Back to blog
docker laravel ci github-actions testing security maps

A green suite, six broken journeys, and a drift log that was empty because nothing was writing to it

The Starting Point

Long day. Four separate goals, and they turned out to be connected:

  1. Get local dev off the shared web stack on my machine and into Docker.
  2. Build the drift check — CI that verifies changes landing on the legacy system are actually recorded, rather than trusting a markdown file.
  3. Run the gate walkthrough: sit down and use the platform end to end as a client would.
  4. Start the analysis for the biggest module in the port.

What Actually Happened

Docker, and two things that look like config mistakes until you know better

The shared local stack (MAMP) serves other sites on this machine, so the platform now brings its own containers: web, queue worker, MySQL, Redis, on a named volume and on ports that can't collide (app 8080, MySQL 33061, Redis 63791).

Two things fought back:

artisan serve passes only a whitelist of environment variables to its subprocess. There's literally a ServeCommand::$passthroughVariables array. So the container's DB_* never reached the web tier and it fell back to the .env — which points at the shared stack's unix socket. Hence a pile of these in the log:

SQLSTATE[HY000] [2002] No such file or directory
(Connection: mysql, Socket: /Applications/MAMP/tmp/mysql/mysql.sock, Database: ...)

That reads like a database problem. It is not a database problem. The fix was to run PHP's built-in server directly through a small router.php instead, which inherits the whole environment. Now the compose file is the single source of truth inside Docker and the .env still works outside it.

Tests need their own service, not the app service. Laravel reads $_SERVER before PHPUnit's putenv(), so anything the app service sets for APP_ENV and DB_* overrides phpunit.xml. The suite would have run against the live dev database with CSRF active. The test service states the testing values explicitly. That's the only reliable fix — there's no ordering trick that saves you.

The queue worker is part of up rather than an afterthought, because enabling a module is queued work and without a worker nothing toggles. (Learned that one the hard way ten days ago.)

The drift log was empty, which I'd been reading as "no drift"

The whole point of the drift log is to record changes landing on the live legacy system during the rebuild, so they get forward-ported. It was empty. I'd been treating that as reassuring.

It was empty because nothing was writing to it.

Six commits had landed since the survey baseline and none were recorded. I found them by diffing against the baseline while scoping the CI check — which is a fair demonstration of why the check needed to exist.

So I built bin/drift-check: it fails when a commit touching the legacy modules directory has no row in the log, and a nightly workflow runs it against a clone. The design point that matters:

A run that cannot reach the legacy repo exits non-zero rather than skipping. A green tick that checked nothing is exactly the false confidence the whole thing exists to prevent. A missing token, an unusable checkout, a baseline that doesn't describe its branch — all failures, not skips. I checked it against deliberate breakage before trusting it: removing a recorded row exits 1, each unverifiable case exits 2.

Running it found something much bigger than six commits.

The two live branches have diverged, and the survey only ever saw one of them. They last shared history two months before the survey. Since then one has taken 141 commits and the other 30, and neither contains the other's. The survey baseline is on one branch and isn't an ancestor of the other, so it cannot measure it at all — the check refuses that comparison rather than reporting the entire history of one branch as drift.

So there are two baselines now, and a real open question about what the port is being written against. I resolved it by deciding to target the development branch, with the costs written down explicitly rather than assumed away: ten commits of live behaviour sit outside the target and must be read before cutover; four modules exist only on that branch and no client has run them, so they'd be ported as though validated unless someone checks.

Also had to solve credentials: the legacy repo is in a different org, so the workflow's own GITHUB_TOKEN can't see it. I went with a read-only deploy key rather than a fine-grained PAT, and the deciding factor is expiry. A PAT works today and lapses in some future month, and it fails with "drift cannot be checked" — a message that reads like a drift problem rather than a stale credential. This check is built to fail loudly when it can't look, so scheduling a false alarm into it would teach everyone to ignore the one alarm that matters.

The clone moves to SSH with the host key pinned, rather than ssh-keyscan. Keyscan trusts whatever answers first, which is the single thing a known_hosts file exists to prevent.

The gate walkthrough: six gaps, none of which a green suite showed

This is the part worth the whole post. I sat down with a database rebuilt from empty and used the platform as a client would. 21 migrations clean, ~398 tests green.

Six gaps in about two hours. Every single one had the same shape: the mechanism built and covered by tests, the surface missing.

  • No page to enrol in MFA. Two-factor was built, tested, and reachable by administrators (who can reset it) and by nobody else. So a second factor was a feature no deployment could actually turn on.
  • No way to create a role. A deployment had exactly one role — the vendor superuser, which a client administrator can't assign — and no screen to make another. So every real user was either a full superuser or had no role at all. The user form literally said "No roles have been created yet" and offered no way to create one.
  • Password confirmation redirected into a 500. Every route behind password.confirm redirected to the confirm-password page and died there:
Target [Laravel\Fortify\Contracts\ConfirmPasswordViewResponse] is not instantiable.

Fortify binds that response only when a view is registered, and core had never registered one because nothing used password confirmation until the account security page did. So the page I'd added the day before guarded itself into a 500.

  • The UUID was rendered nowhere. Every record has carried an immutable UUID for weeks. Reading one meant opening a database client — which is a poor answer when the UUID is precisely the identifier worth quoting while diagnosing across environments.
  • Reference numbering was settable only from a seeder. The decision doc had said "configured by an administrator" since August. It wasn't true of any administrator.
  • A contact's page listed accounts without their tags — dropping exactly the half that makes the relationship shape worth having.

The test-gap lesson is the more useful half of the 500. Two tests asserted assertRedirect('/user/confirm-password') and neither followed it. So the suite stayed green while the journey was broken end to end. A redirect assertion says where a journey points, not that it arrives. The new tests walk blocked → confirm → through.

That's now written into the porting playbook as a five-question check at every gate, the last of which is the whole test: can a client do this without us? A capability only we can exercise isn't finished, it's a capability with a support ticket attached.

A licensing problem hiding in a default

The map's default tile provider was OpenStreetMap, whose usage policy doesn't cover commercial use. Shipping that as the default meant a deployment could breach the policy without anyone ever choosing it — the worst shape a licensing problem can have, because nothing surfaces until someone notices.

Swapped to a commercially licensed provider that drops into the existing {z}/{x}/{y} driver with no architectural change, and there is deliberately no working default without a key. An unconfigured map renders a notice naming the two variables instead of a broken tile layer.

I considered and rejected the obvious big-name alternative on two grounds worth knowing:

  • Its geocoder can't be used here at all. The terms permit caching coordinates for 30 consecutive days, after which they must be deleted. This platform stores coordinates as ordinary persisted fields, so anything derived from that provider sitting in the column past 30 days is a straight breach. Working around it means storing place IDs and re-resolving on every render — a different design, and a permanent hard dependency in the render path.
  • Its tiles can't go in this driver. The terms forbid its tiles in a non-native map library, so Leaflet is out and it needs their own JS SDK. That's a different rendering engine, not another URL.

Also documented the thing that's easy to misread: the tile key is public by design. Tiles are fetched by the browser, so the URL — key and all — is rendered into the page. That's how every client-side tile provider works and it isn't a leak. But it does mean the key is protected only by an origin restriction resting on a header anyone can forge. I probed the restriction rather than assuming it: localhost allowed, absent or unrelated Referer refused, and — the case that actually matters — the origin-only Referer a browser really sends on a cross-origin image load is accepted. A full-path curl would have "passed" without proving anything.

What Worked

File storage with the disk chosen by an administrator. Modules never name a disk; they call FileStorage, and the deployment decides whether that means this server or object storage, chosen from an admin screen rather than baked into a release.

The choice is stored and the credentials are not. Keys and buckets stay in the environment with every other secret, because a value in the settings table is a value in every database backup, in the audit trail of whoever changed it, and in any export of that table. The screen says so explicitly, since editable credentials there is the obvious next feature and the wrong one.

Unusable disks are shown rather than discovered: available() reports whether each is actually configured, object storage renders "Not configured" without a bucket, and selecting it is refused.

Stored files are addressed by a generated UUID. A filename is user input and arrives with path separators and worse in it; the original is kept for display and never used to locate anything. Tested with a traversal attempt.

One assumption I got wrong and the test now records: I'd assumed temporaryUrl() returning null meant "the local driver can't". It doesn't — the local driver can when serving is on, and the fake always can. The property is the disk's configuration, not its driver.

A markdown field type, because textarea renders through {{ }} so anything longer than a note arrived as one run-on block with its blank lines shown literally. It's stored, validated, cast, filtered, imported and exported exactly as a string — only the two render points know the difference, so nothing downstream gains a second code path.

The render is the only place in the platform that emits unescaped HTML derived from user input, so it's the part that earns scrutiny. The converter strips HTML outright rather than sanitising it — there's no allow-list to get subtly wrong — and refuses unsafe link schemes. Being subtly wrong about an allow-list here is stored XSS.

It also broke an old comment: a custom-stored value is searched through an index column capped at 191 characters so MySQL can index it. Markdown is the first field type that can genuinely exceed that. The comment used to say "no field type here uses long text". Now it says what's actually true and a test pins the limit.

Self-service MFA, with the QR rendered in the deployment rather than by a remote service — a remote QR service would be handed the shared secret in a query string, and that's the one part of enrolment that must not leave the building. It also means no JavaScript and it works offline.

Roles a client can actually create. Three ship: Admin, Editor, Reader. And a module declares where its own permissions sit, right next to where it registers them. Core holds no table mapping roles to permissions — one would need editing every time a module landed, which is the exact coupling the registry exists to remove.

Defaults apply on two triggers and only two: a newly created role gets everything declared for it, and a newly created permission reaches the roles that asked for it, including roles that already exist. On a plain redeploy neither fires, so an administrator who took a permission away from Editor doesn't get it handed back. That's the difference between a default and a policy.

The first implementation had only the permission trigger and created all three roles empty, because the vendor-admin sync runs first and nothing was new by the time the catalogue ran.

What I Learned

  • An empty log and an unverified log are indistinguishable, and only one of them is reassuring. If you keep a manual record for a real purpose, put a check on it that fails loudly when it can't verify.
  • A CI check that skips when it can't reach something is worse than no check. Exit non-zero. Every time.
  • assertRedirect() proves where a journey points, not that it arrives. Follow the redirect in the test or you're asserting a signpost.
  • The gap between "built and tested" and "usable" is enormous and invisible from inside the suite. Six for six on one afternoon.
  • artisan serve filters environment variables to a whitelist. If you're containerising, this will look like a database config error.
  • Laravel reads $_SERVER before PHPUnit's putenv(). Your test container must state its own env, not inherit the app's.
  • Defaults carry liability. A tile provider whose licence doesn't cover commercial use is fine as an opt-in and dangerous as a default, because nobody ever consciously chose it.
  • A client-side map key is public by design — but probe the origin restriction with the request a browser actually sends, not with curl.
  • Deploy keys beat PATs for a check that must never cry wolf: no expiry, read-only at the provider's end, scoped to one repo, owned by the repo rather than a person.

Tech Notes

  • Stack: PHP 8.4, Laravel, Docker Compose (web / queue / MySQL / Redis), GitHub Actions, PHPUnit, PHPStan L6
  • Added: a markdown field type (CommonMark, HTML stripped not sanitised, unsafe link schemes refused); bacon/bacon-qr-code for local QR rendering (already a Fortify dep); a FileStorage contract with admin-selected disk
  • Docker gotchas: run PHP's built-in server via a custom router.php instead of artisan serve; give tests their own compose service with explicit env
  • Ports: app 8080, MySQL 33061, Redis 63791 — chosen to never collide with the shared local stack
  • Node is a one-shot behind an assets compose profile; asset builds are occasional and don't belong in the always-on set
  • Ended the day at ~410 tests and 40 recorded decisions
bin/drift-check          # exits 1 on unrecorded drift, 2 when it cannot verify
docker compose up        # web + queue worker + mysql + redis
docker compose run test  # separate service, explicit testing env
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.