Back to blog
ci github-actions composer monorepo testing laravel

CI on a clean checkout found four things my machine never could

The Starting Point

The plan for the day was the "porting contract" — make the properties that let a module be ported in a day executable rather than remembered. Plus a searchable relationship picker and a map view.

What actually dominated the day was CI running for the first time on a clean checkout, and a near-miss with the package split workflow that would have been genuinely bad.

What Actually Happened

The near-miss first, because it's the one that made my stomach drop.

The split workflow force-pushes each packages/* subtree over its own read-only repository on merge to main. I was checking whether the split targets even existed and found that one of them — the target for the core package — redirected to the monorepo itself. That name had been the monorepo's before a rename, so GitHub was still resolving it.

So merging to main would have force-pushed the core package over the entire monorepo. Every other package, the reference app, the whole architecture doc set, gone.

The fix is a guard in the workflow: it refuses to split when a target is missing, redirects, or resolves to this repository. Creating the real target repo also deliberately broke the redirect, which is what actually closed it.

Then CI ran on main for the first time and found four things a working copy can never find.

  1. PHP 8.3 was never supported. Every package declared ^8.3 while its dependencies already required 8.4 — the activity log package needs ^8.4, Symfony 8.x needs >=8.4.1. A client installing on 8.3 would have hit an unresolvable dependency set. The matrix was claiming to test something that couldn't work.

  2. packages/core/tests/Unit was an empty directory. It existed on my machine and nowhere else, because git doesn't track empty directories. PHPUnit exits 2 on a testsuite whose directory is missing.

  3. The reference app had no APP_KEY outside my local .env. Every one of its tests failed on a fresh checkout. Each package's TestCase already supplied a throwaway key; the app didn't.

  4. Pint at the root covers the reference app, and I'd only been running it inside packages/*. So the app had never actually been formatted.

And I removed the committed lock files.

They pinned sibling packages to dev-feat/phase-1b-record-types — a branch that no longer existed — because a path repository resolves @dev to whatever branch the monorepo is on. Every branch would have needed a lock refresh forever. The four packages are libraries whose consumers never read a lock anyway, and the reference app is a harness rather than a deployed thing. That diff is about −54,000 lines, which felt great.

Two more bugs, both found by driving the import feature rather than testing it:

  • Required fields weren't validated when a create named no fields at all. The form always declares what it rendered, so the UI never hit it — but anything else posting there got a 500 off a NOT NULL column, and a blank record if the column had been nullable.
  • App\Models\User::factory() was building core's User, not the app's subclass. Core's User::newFactory() is inherited and hard-coded the model. The app's own factory override could never have fixed it, because Laravel asks modelName(), so a $model property on a child class is never consulted. Everything looked fine right up until a permission guard had to resolve, which matches the model against the auth provider and found no guard at all. Same failure shape as day one, arriving from a completely different direction.

Also: SQLSTATE[HY000]: General error: 1364 Field 'name' doesn't have a default value while wiring the import — another NOT NULL column doing me a favour.

What Worked

The porting contract as one assertion.

// Each package's suite makes the claim in one line:
$this->assertModuleIsPortable('sites');

It checks that the module declares itself, registers the permissions it advertises, has a table and a column behind every declared field, points its navigation at routes that exist, and leaves nothing dangling waiting for a record type that never arrives. Core holds itself to it too.

I checked the assertions can fail before trusting them — an advertised permission that nothing registers, and a typo in an extended type key, both turn the suite red.

The picker, rewritten. The 500-option cap lived in two controllers, rendered all five hundred into the page, and dropped the five hundred and first without saying so. A record that exists, that the user can see in a list, and that simply couldn't be chosen.

Now one RecordPicker decides from a count: plain <select> up to a hundred, search box above that. Not everything becomes a search box — a select is better when it fits, no JavaScript, and keyboards and screen readers already know what to do with it. The threshold costs one indexed count(), which is cheaper than rendering 500 options was.

The search half has four non-obvious requirements, each because the naive version is broken:

  • the current selection is named explicitly rather than hoped for among the first matches
  • superseded requests are aborted, or a slow answer to "me" lands after a fast one to "meridian"
  • typing without choosing reverts, so an abandoned search can't leave a stale ID to be saved
  • records are named by their display fields, not plucked from one column

And the endpoint isn't a second read path — it goes through the same query layer and authorises the type's own view permission, so a form control never becomes a way to enumerate records you can't read.

Maps turned out to be the list's own query with a different renderer. A type declares ->coordinates('latitude','longitude') and gets a map route taking the same filters. The coordinates are ordinary declared fields, so they're already on the form, in the list, in the export and in the import — none of which was written for them.

Building it closed a real gap I didn't know about: the query layer couldn't filter on a relationship at all, so the list couldn't filter by parent either and was silently returning everything.

Two things default safely: geocoding defaults to doing nothing (turning it on sends a client's data to somebody else's server — that's a deployment's decision), and unplaced records are counted and reported. A map showing 40 of 60 sites with nothing saying the other 20 exist is worse than no map.

Leaflet is vendored and served by core rather than pulled from a CDN or built by the app — a client repo must not have to know the platform draws maps, and the host doesn't persist deploy-time filesystem changes. Markers are drawn circles, so none of Leaflet's images are needed and the whole dependency is two text files.

What I Learned

  • A working copy is not a fresh clone, and you will not find out until CI says so. Empty directories, local-only env, unformatted paths outside where you habitually run the formatter — all invisible locally.
  • Check your split/deploy targets actually resolve to what you think. A GitHub repo rename leaves a redirect, and a force-push follows redirects.
  • Committed lock files in a path-repository monorepo are a trap. @dev resolves to the current branch, so every branch switch invalidates every lock.
  • Declared PHP constraints drift from what your dependencies actually need. Nobody notices until a client can't install.
  • newFactory() is inherited and hard-codes the model. If you subclass a package's User, the factory silently builds the wrong class and only a guard resolution will tell you.
  • Truncating a list "for performance" without saying so is a bug wearing an optimisation costume.

Tech Notes

  • Stack: PHP 8.4 (8.3 support formally dropped), Laravel, GitHub Actions, PHPUnit, PHPStan L6, Pint
  • Added: Leaflet vendored into core (~2 files, drawn-circle markers so no image assets), a postcode geocoder driver that defaults to off and returns null rather than throwing when the service is down
  • Removed: all committed composer.lock files from packages and the reference app (−54k lines)
  • CI guards: split workflow refuses to run when a target repo is missing, redirects, or resolves to the monorepo
# prove a portability guard can actually fail before you trust it
# (advertise a permission nothing registers, then run the suite)
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.