Quality
The four gates
Section titled “The four gates”| Script | Runs |
|---|---|
bin/composer test |
artisan test --compact --parallel (Pest 5) |
bin/stan / bin/composer analyse |
PHPStan with Larastan, level 8 |
bin/pint / bin/composer lint:check |
Pint fix / report |
bin/rector / bin/composer refactor:check |
Rector apply / dry run |
All four must be green, and the suite must be green on both database engines, before anything is merged.
Two more run beside them, and both are green before a merge too:
| Command | Runs |
|---|---|
bin/artisan hilms:theme:a11y |
The accessibility contract of the active theme: every contrast pair of its chain’s tokens in both colour schemes, and its own Blade through MarkupGuard. Exits 1 on any finding |
npm run a11y (on the host) |
axe-core over the split design export — bin/artisan hilms:design:export --split first, and `–style=<slug |
And two more for the stylesheets. Whenever a change promises not to move a pixel — anything in the tokens, the generated sheets or the stylesheets around them — and whenever it touches which declaration wins:
| Command | Runs |
|---|---|
npm run visual (on the host) |
Every document of the split export (hilms:design:export --split --at="2026-01-01 10:00:00") at 390, 768 and 1280 px, compared with a baseline taken before the change by node bin/visual.mjs --baseline; Identical. or a list of what moved, with actual/ and diff/ pictures beside the baseline. The export is drawn in the theme’s own values whatever style the installation is in, so the baseline never depends on the database; a document the baseline lacks — a new view, a new value of an option or a variant — is listed as “no baseline”, and then the run does not say Identical.: it passes when nothing else is listed. Never in CI (Design tokens) |
npm run cascade (on the host) |
After bin/artisan hilms:design:cascade: 26 cases in Chromium — a style’s sheet after and before the theme’s in light, dark and both halves of system, each with no preference, more contrast and less motion (24), and inside a panel block preview in light and dark (2) — each probed custom property compared with what the token model says it must compute to, two of them inside a block given a look, and outside the preview the panel’s own spacing, which a theme must never replace. For a token sheet’s selectors, the preview build, a11y.css or the style compiler. It compiles a style, which reads the fonts table, so on an installation not yet upgraded run it against a migrated database (DB_DATABASE=hilms_testing_<tree> bin/artisan hilms:design:cascade). Never in CI (Styles) |
Chapter 17 is what they check and why.
Pest 5 with the Laravel and Livewire plugins, with RefreshDatabase against the tree’s own test database:
bin/pest MySQL 8.4, six processesbin/pest-mariadb MariaDB 11.8bin/pest --no-parallel one process, for debuggingbin/pest --processes=4 another worker count (or HILMS_TEST_PROCESSES=8)bin/pest app-modules/learning/tests/Feature/EnrolmentTest.phpbin/pest --filter=grants drops to one process by itselfSix of the twelve cores is the default: this machine runs out of memory before it runs out of cores, and the last processes buy minutes of nothing. A full run is about five and a half minutes an engine. Measured in October 2026 over some 3,100 tests: half of the time goes to the panel tests, which drive a Filament page through Livewire’s harness in PHP (no browser) and redraw the whole form on every simulated action — the style editor’s hundreds of token fields cost 6–14 seconds a test; every test also pays about 100 ms for booting the application, about 200 ms more where it seeds roles and permissions, and each worker builds its database from the migrations first. Speeding it up was weighed and left for another day.
Suites in phpunit.xml: Unit and Feature in tests/, Modules for app-modules/*/tests. The test environment uses the array cache, the array mailer, the sync queue and Redis-free sessions, and switches Pulse, Telescope and the health token off.
Helpers in tests/Pest.php:
expectQueries(int $count)asserts an exact query count for the whole test.countQueries(Closure $callback)returns the number of queries a closure ran. Every listing carries an invariance test built on it: warm the page once, then assert that the same request against ten times the data runs the same number of queries.toBeAccessibleDocument($kind)parses a rendered document — or the response holding one — and names everything wrong with it in one list (Accessibility).
Notifications, events, the queue, the filesystem and Socialite are faked with the framework fakes. Live language-model calls never happen in the suite: the AI tests fake the SDK.
Two engine differences worth remembering:
- MariaDB’s native
uuidcolumn reformats values and rejects anything that is not canonical, so an identifier compared as a string is declaredchar(36). - A
CHECKconstraint written withINneeds itsIS NOT NULLfirst: a NULL compared withINis unknown, and a constraint only refuses what is false.
Two traps that make a test pass for the wrong reason:
- A
livewire()test does not boot the panel. Filament hangs panel-wide configuration inPanel::boot(), which a request reaches through the persistentSetUpPanelmiddleware; a mounted component has none of it, soFilamentTimezoneanswersconfig('app.timezone')and aDateTimePickersilently works in UTC. CallFilament::bootCurrentPanel()in the setup, or drive the page over HTTP. It is the same family as the nested resource page, which reads its parents from the current request’s route parameters and cannot be mounted throughlivewire()at all. - A test of which clock is read must freeze far from the real year.
date()andtime()ignoretravelTo(), so code that wrongly calls one of them still agrees with the frozen clock whenever the two fall in the same year.BrandingTestfreezes at 2099-12-31 23:30 UTC with the installation on a clock already into 2100 (Time and clocks).
Browser tests are not part of the suite; manual checks use the Playwright MCP against the Lando site.
Which database the suite talks to, and why it is guarded twice
Section titled “Which database the suite talks to, and why it is guarded twice”RefreshDatabase migrates from nothing while .env names the live local installation, so the suite is stopped twice before a test exists:
bin/pestexportsDB_DATABASEitself:hilms_testingin the main tree,hilms_testing_<tree>in a sub-tree. Laravel’s parallel runner adds_test_<token>per worker.tests/bootstrap.phpruns in the parent process and in every parallel worker and refuses aDB_DATABASEthat does not start withhilms_testing.Tests\TestCase::createApplication()then asks the booted application what database its default connection really holds and refuses the same way. The environment alone is not enough:LoadConfiguration::bootstrap()readsbootstrap/cache/config.phpwhateverAPP_ENVsays, so a site warmed bylando artisan optimizewould otherwise hand the suite its own database.
Both refusals come from Tests\Support\TestDatabase, name the database and the wrappers, and end the process rather than failing one test.
The suite never reaches a storage bucket either. The AWS SDK brings its own HTTP client, which Http::preventStrayRequests() never sees, and MediaConfiguration::apply() forgets a faked disk whenever the configuration changes, so a write after apply() once went out to a made-up R2 host. Tests\Support\NoRealBuckets::guard(), called from createApplication() beside the database guard, gives every s3 disk an http_handler that throws “A test reached the [disk] bucket over the network”. Signing an address is local and keeps working; a test that drives the SDK on purpose passes a handler of its own; a test that writes after apply() fakes the disk again. tests/Feature/NoRealBucketsTest.php proves the guard trips.
The suite also reads none of the site’s caches. bin/pest hands bin/host HILMS_CACHES=testing, and phpunit.xml says the same for a run that bypasses the wrappers, so APP_CONFIG_CACHE, APP_ROUTES_CACHE, APP_EVENTS_CACHE, APP_MODULES_CACHE and APP_ICONS_CACHE all point at bootstrap/cache/testing/, a directory that never exists: configuration, routes, events, modules and icons are loaded fresh, and a stray write fails loudly instead of going stale. tests/Unit/TestDatabaseTest.php holds all five to it. services.php and packages.php stay shared, because they are Composer’s artifacts rather than the environment’s and are always there, so no parallel worker rebuilds them. Nothing needs optimize:clear before a run (Environment has the host’s side of the same split).
A test that writes into the checkout is an isolation bug under parallel runs, not a reason to run serially, because every worker boots the application from those same files. Each test that has to write somewhere gets a directory of its own: MakeModuleCommandTest hands the generator a ModuleRegistry of its own through a contextual binding, so it scaffolds under storage/framework/testing; MakeSettingsCommandTest scaffolds into a module directory and a copy of config/settings.php of its own; and ThemeOpsTest links its fixture themes into a public directory of its own rather than the checkout’s public/themes.
A run killed with timeout leaves PHP processes alive holding a MySQL metadata lock, so every later run blocks on RefreshDatabase and looks like a fresh hang. Clear it with pkill -9 -f 'vendor/bin/pest', then kill the leftover connections listed in information_schema.processlist for the tree’s test databases.
Sub-trees
Section titled “Sub-trees”Every branch other than main is developed in a git worktree under ../hi-lms-trees, managed by bin/tree:
bin/tree new batch/19-something creates the tree and makes it ready for the gatesbin/tree status every tree, its branch, its dirty files, its last commitsbin/tree listbin/tree rm 19-something [--force] drops the tree, its worktree entry and its databasesbin/tree new copies .env, installs the Composer and npm dependencies on the host, builds the assets, generates Passport keys, publishes Livewire’s assets, creates the tree’s database on both engines and indexes the tree — about half a minute. Each tree has its own vendor/, node_modules/, public/build and bin/, so a wrapper run inside a tree is that tree’s and needs no argument.
Read bin/tree status before starting work anywhere and again before merging a sub-tree into the main tree. nginx and the browser still serve the main tree only.
Static analysis
Section titled “Static analysis”phpstan.neon analyses app, app-modules (without tests and resources), database and routes at level 8. Larastan infers model attributes from database/migrations and every module’s migrations, and parseModelCastsMethod reads each model’s casts() so enum and date casts are typed. Test files are excluded on purpose: Pest’s closure-bound $this is not analysable. Two published package migrations are excluded as package internals.
Formatting and refactoring
Section titled “Formatting and refactoring”pint.json is the Laravel preset plus declare_strict_types. bin/pint answers --dirty itself, because Pint looks for a .git directory and a worktree has a .git file.
rector.php runs over app, app-modules, config, database, routes and tests with the PHP sets, the Laravel set, dead code, code quality and type declarations, and skips arrow-function return types and the two #[Override] rules to keep the code lean. Rector is what moved commands onto #[Signature] and models onto #[RouteKey]; run it after a framework upgrade.
One trap: spatie’s health Status is a spatie/enum, not a PHP enum. Compare $result->status->value with a string, or Rector rewrites the factory call into a constant that does not exist.
Before a push: bin/gates
Section titled “Before a push: bin/gates”bin/gates Pint, Rector, PHPStan, the suite on MySQL, the suite on MariaDBbin/gates --check whether HEAD's content has passed, without running anythingBoth engines are tested on the developer’s machine and nowhere else, so bin/gates is what every push needs: it runs the five checks one after another (about eleven minutes), on a clean, committed tree, and records the pass against the commit’s content — its tree hash, in hilms-gates/ under the repository’s common git directory, which every sub-tree shares. .githooks/pre-push, which bin/gates installs as the checkout’s core.hooksPath, refuses a branch or a tag whose content has not passed, or differs from a passed one in more than Markdown or the books’ site under docs/, so the books can change after the gates without running them again — except a chapter a test reads (docs/install/requirements.md, docs/dev-book/17-accessibility.md), which the hook finds by asking the tests, comparing with renames off so code moved into docs/ still counts where it left. Run it in the sub-tree after the last code commit; a --no-ff merge into a main that has not moved has the same content and passes. PrePushGateTest holds the hook to that against repositories of its own. Pushing past it with --no-verify is the owner’s call, never a way round a red gate.
Continuous integration
Section titled “Continuous integration”GitHub runs no test on a push or a tag: the Actions minutes are scarce, and both engines are held by bin/gates instead. .github/workflows/ci.yml is started by hand from the Actions tab now and then, as the check a developer’s machine cannot give — a clean checkout, a fresh install and a newer PHP than the host’s: the four quality scripts and the whole suite against MySQL 8.4, on PHP 8.5 with Redis, a real npm run build and generated Passport keys (MariaDB’s entry waits in a comment). The environment comes from .env.ci, with DB_HOST, DB_PORT and PHP_INI_SCAN_DIR passed as real environment variables — the first two point at the runner’s service containers rather than the loopback ports phpunit.xml names, the third gives the runner the project’s .lando/php.ini.
.github/workflows/release.yml builds the production image for a v* tag — without testing it again, because the tag could only be pushed once bin/gates had passed its content — and pushes it to ghcr.io/pipejesus/hilms tagged with the version, the minor series and latest. HILMS_VERSION is baked in as the same string the image is published under, so the version a container reports and the tag it was pulled as always agree.
Guard tests
Section titled “Guard tests”Several tests exist to stop a whole class of mistake rather than to cover a feature. They fail loudly and the fix is always to update the source of truth, never the test:
| Test | Refuses |
|---|---|
tests/Feature/PermissionsTest.php |
A seeded permission no policy consults, or a policy consulting one nobody seeds |
tests/Feature/TranslationParityTest.php |
A module’s pl and en files holding different keys, an English file with no Polish one beside it, lang/pl.json restating a key instead of translating it, a Polish plural short of its three forms (1 plik, 2 pliki, 5 plików) where English has two, or choosing a form Polish grammar would not (22 as 2, 12 and 25 as 5) |
tests/Feature/TranslationKeysTest.php |
A phrase the code asks for that a language does not have: every literal key handed to __(), trans(), trans_choice() or Lang::get(), read with PHP’s own tokenizer from the PHP files and from Blade as the framework compiles it, asked of the translator in English and Polish — and every case of every enum the panel labels (Languages) |
PanelDatesTest |
Panel code calling Filament’s date(), dateTime() or time(), which write one PHP pattern — an American order — in every language (Time and clocks) |
tests/Feature/ListingIndexesTest.php |
A panel listing over a table that grows with its users opening sorted by a column no index leads with |
ErrorPagesTest |
An error page that queries when warm, fails to draw cold with the database out of reach, or is not an accessible document in either language |
ThemeOptionsTest |
An option, variant or value of the default theme with no words in one of the two languages |
LoadDatasetTest |
The load dataset writing a block the editor could not have saved |
app-modules/theme/tests/Feature/ViewContractTest.php |
A core view that is rendered but not declared, or declared but missing from the default theme |
ThemeStyleGuardTest |
A raw palette utility or a dark: variant in the default theme, a theme view that formats a date for itself instead of going through <x-ui.date>, the bare max-w-prose anywhere in a theme or a generator stub, which Tailwind reads as its own 65ch rather than the reading-width token, the named shadow utilities (shadow-box, shadow-raised, shadow-control), whose value Tailwind inlines out of a style’s reach, an atom going back to a utility it drew with before its component tokens, a box-like block that stops drawing a token of its own group or falls back to a foundation behind its look (BLOCK_TOKEN_GROUPS), and a heading without wrap-break-word or reading text without overflow-wrap, which would push a phone’s page sideways |
TokenSheetTest |
A colour of the contract with no dark value, a system-dark block that differs from the chosen-dark one, accessibility tokens below the values a control is built on |
ThemeTokensTest |
A foundation or component token of the contract the default theme does not define, or defines with another type, in either direction; a chain that stops laying a child’s tokens over its parent’s |
ChildTokensTest |
A child changing a colour without the dark value its parent changes, or turning a token into another kind |
ThemeTokensCommandTest |
A generated token sheet in themes/ — tokens.css or tokens.preview.css — that is out of date, a sheet edited by hand, and a preview sheet that writes anything on :root |
TokenModelTest |
A value that does not survive being read and written again, and a token file the model should refuse but does not |
PreviewStylesheetTest |
A block preview stylesheet that no longer carries the atoms’ utilities, or declares a token of the theme on the panel’s :root; a theme without a build handing the panel more than its scoped token sheet |
BlockLookTest |
A surface offered to a block’s look whose text, secondary text, headings or links ContrastPairs does not judge on it |
DesignExportStyleTest |
An export without --style that depends on the style the installation is drawn in |
RailTest |
The shell’s two modes drifting apart: a rule that positions the panel outside one of the two media queries, or a closed panel left in the accessibility tree |
ShellContractTest |
A layout whose rendered page stops being a page: no main#main, no skip link to it, a footer outside the shell or not exactly one, a rail panel that is unnamed or has no toggle naming it |
MediaAssetConstraintTest |
The live media_assets check constraints drifting from AssetKind and AssetVisibility |
PanelAssetsTest |
A panel asset whose address does not change when the file does |
BlockCanvasTest |
The panel stylesheet’s :has() chains drifting from the markers the canvas draws |
BlockValidatorTest |
The away-from-the-panel validator and the page editor disagreeing, word for word |
MenuBuilderTranslationsTest |
The menu package gaining an English string with no Polish one beside it |
tests/Feature/SettingsStructureTest.php |
A settings page that is not a SettingsSection, a registered settings class edited on no section or on two, a class that does not load on a fresh database, a section with no group or description, a Settings sub-navigation other than the list it spells out (Settings) |
tests/Feature/SettingsAccessTest.php |
Any of the eleven sections of Settings opening to somebody other than an administrator |
app-modules/ops/tests/Feature/RequirementsTest.php |
The install guide and Requirements drifting apart, a compose.yaml variable missing from .env.production.example, a service publishing a port on anything but 127.0.0.1 |
GenerationKindConstraintTest |
The live ai_generations check constraint drifting from the enum |
| The scheduler test | A scheduled command whose cron expression changed |
PatternRegistryTest, InsertPatternTest |
A registered pattern that does not save exactly as it arrives, except on the block types it declares in awaits() |
Staying current
Section titled “Staying current”bin/outdated lists, first, every advisory against what is installed (composer audit, npm audit), then every direct Composer and npm package with a newer release, marked semver-safe or major. --daily is the form a SessionStart hook in the owner’s local settings runs at the start of the first session of a day. The rule that goes with it: an advisory is fixed in the session that sees it; semver-safe updates are applied in groups, one commit each, with the gates on both engines and a live look at what they touch; a major is read first — its upgrade guide and changelog against our code — and proposed to the owner with what it would change.
Performance at size
Section titled “Performance at size”The suite’s query-count tests hold each listing’s queries constant as its data grows tenfold; how fast a page is at size is measured by hand, on the load dataset in a scratch database of its own (its own CACHE_PREFIX and Redis databases, so a developer’s site is never touched), through the real HTTP kernel, three rounds per page with the session cookie carried forward. At 500 courses, 6,000 lessons, 5,000 people and 15,000 seats (October 2026): visitor pages ran 8–16 queries in 70–95 ms warm, a student’s 11–20 in 60–100 ms, every one a keyed lookup under 2.2 ms; the panel’s lists 5–11 queries in 100–260 ms, most of it Filament drawing. Database sessions cost two queries a request — a read and an update of 1–11 ms — and an insert on a first visit. Six lists were sorting their whole table to show ten rows; they have their indexes now, and ListingIndexesTest holds them with the seventh, the library’s, which had one already. The site’s stylesheet is 77 kB (13 kB compressed) and its script 20 kB (6 kB); Livewire’s CSP build, 304 kB (100 kB compressed), loads only on pages with a component — the catalogue and the player.
AI tooling
Section titled “AI tooling”Laravel Boost provides guidelines (AGENTS.md), skills in .claude/skills (ignored and regenerated by boost:update on composer update) and an MCP server. .mcp.json runs that server on the host through bin/host, so it serves the tree the session was opened in. boost.json stores the chosen agents and features. Durable project rules recorded through Boost land in .ai/rules and are committed.
HiLMS also is an MCP server: bin/artisan mcp:start hilms-authoring exposes the authoring tools to an editor assistant on the machine itself (see API and agents).
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.