Development
Everything runs in Docker; just --list shows every recipe.
just up # start nginx, php-fpm, postgresjust composer install # composer inside the container — never on the hostjust migrate-run # set up the databasejust exec bin/console app:dev:seed # dev@loupe.test or admin@loupe.test / passwordjust up publishes no host port and needs a reverse proxy — see
Reverse proxy before the first command.
Day to day
just shell # bash inside the php-fpm containerjust composer # e.g. just composer require foo/barjust worker # foreground messenger consumer, and the scheduler with itjust tailwind # CSS watch mode (already running in dev)Tailwind rebuilds automatically in the dev container. Do not run
tailwind:build by hand after editing templates — wait a second and re-check.
The same goes for cache:clear.
AssetMapper can retain old CSS when its cache and the watcher output change within the same second.
Its freshness check compares whole-second modification times.
Compare the served stylesheet with var/tailwind/app.built.css inside the container before attributing a visual failure to source code.
If the generated CSS is current but the served CSS is stale, update only the generated file’s timestamp:
just exec touch var/tailwind/app.built.cssConfirm that a fresh page response uses a new stylesheet hash before rerunning the browser check.
Run one unit test with just phpunit --filter TestClassName, one JavaScript
test with just js-test tests/js/<name>.test.js, one e2e spec with
just e2e tests/<area>/<spec>.spec.ts.
just phpunit runs PHPUnit through ParaTest on 4 processes. Set
PHPUNIT_PROCESSES on the host to change the count, for example
PHPUNIT_PROCESSES=8 just phpunit tests/Module/Board. Each process uses its own
test database, app_test<TEST_TOKEN>__p<n>.
just js-test runs Vitest over tests/js/. It needs Node alone, with no
container, no database and no browser. It is the fastest way to cover front-end
logic, so put a test there whenever the behaviour is reachable without a booted
application. The suite runs in Node by default. A file that needs a DOM asks for
one with /** @vitest-environment jsdom */ on its first line.
The gate
just cs # apply formatter + Rector fixesjust phpstan # whole projectjust arkitect # whole projectjust gamache # whole projectjust phpunit tests/<path> # the tests for what changed, or --filter <name>just js-test # when JavaScript changedjust cli-test # when cli/ or hooks/ changedjust e2e # Playwright end-to-endCI’s required checks are the full gate. Read them on the pull request, and fix
every failure, including ones that pre-date the change. just ci is the full
check-only run in one command. A person can run it in a terminal, and a hook
refuses it for an agent.
e2e runs against a dedicated target, never the dev host. just e2e-up
creates a disposable database and a sidecar serving this checkout; just e2e
refuses to start without it. The suite is destructive by design — one project
truncates every table — so pointing it at your development database wipes it.
No messenger consumer is needed. Messages dispatched during a request carrying
X-Playwright: 1 are handled inline, so specs that assert on mail or a download
link work with nothing draining the queue.
The suite cannot be parallelised: Mailpit is shared, so mail-asserting specs across concurrent runs read each other’s messages.
Mutation testing
just mutation-diff # only the src/ files this branch changed — run this onejust mutation # all of src — the weekly Action already does thisInfection changes the source code one edit at a time and reruns the tests. A
change that no test catches shows that the test asserts nothing. infection.json5
holds the scope, which is all of src apart from Kernel.php.
Read var/infection/infection.log after a run. Escaped mutants name a test that
asserts nothing. Some are harmless, so judge each one. Not-covered mutants name a
line no test reaches, which --with-uncovered in both recipes keeps visible.
just mutation-diff is the one to run while you work. It mutates the src/ files
you added or changed against origin/main, which takes minutes. Pass another base
as its argument, such as just mutation-diff main~5.
A weekly GitHub Action named “Mutation testing” runs the full pass and attaches its log as an artifact. It is informational, not a required check. That run takes 15 to 25 minutes on a hosted runner and much longer on a development machine, for the reason below.
Why a local timing is not a CI timing
A workload that starts one process per unit of work runs about 8 times slower
here than on a hosted runner. Every process autoloads vendor/ across the
bind mount, and Docker Desktop on macOS serves that mount through a virtual
filesystem. The same suite inside one process is only about 2 times slower.
This holds on an idle machine, so it is not contention. Measure a process-per-unit tool on CI before you judge how long it takes, or you will overstate it by an order of magnitude.
Neither is a just ci leg. Run either one alone: it starts one PHPUnit process
per mutant against this checkout’s test database, and a second test run in the
same checkout crashes on the shared schema.
Coverage
just phpunit-coverage # HTML at var/phpunit-coverage/html, summary on stdoutjust open-phpunit-coverage # open that reportThis covers the PHPUnit suites. just e2e-coverage covers the e2e suite instead,
and it owns var/coverage, which it deletes at the start of every run. The two
reports keep separate trees for that reason.
Both recipes take PHPUnit arguments, so just phpunit-coverage tests/Module/Review
reports on one directory.
Reports from CI
just ci-report mutation # the weekly mutation runjust ci-report phpunit-coverage # the weekly PHPUnit coverage runjust ci-report e2e-coverage # the weekly e2e coverage runjust ci-report e2e-timing [RUN] # time per e2e project, from any CI runjust ci-report phpunit-timing [RUN] # slowest PHPUnit classes and tests, from any runThe recipe downloads the artifact into var/ci-reports/ and prints its summary.
With no run id, it takes the newest run on main that holds the artifact.
The e2e job runs on eight runners, so e2e-timing prints one summary per leg.
Count the flakes of a spec
The e2e flake hunt workflow runs chosen specs many times on a CI runner and prints the failure rate of each test. It does not stop at the first failure. Start it from the Actions tab, or with gh workflow run e2e-flake-hunt.yml -f specs='tests/review/review-loop.spec.ts' -f repeat=20 -f workers=4. Run it again with -f workers=2. Fewer failures with two workers means the runner is overloaded. The same failures means the test is at fault. A spec that registers one fixed account in several tests can collide with its own repeats, and the collisions then show as failures. Check that a spec gives each test its own account before you trust its rate.
GitHub starts a workflow only from main, so the workflow must be on main first.
A failed e2e job uploads nginx-log-<leg>. The last field of each line is the request time in seconds.
Secrets
just secrets-scan # gitleaks + trufflehog over the whole historyNot part of ci — it needs host tooling and outbound network. Run it before
publishing anything.