Back to blog
laravel architecture monorepo packages multi-tenant testing

Day one: stock Laravel install to a working modular platform in fourteen hours

The Starting Point

I'm rebuilding a large legacy business platform as a modular product. The old one is ~176k lines of module PHP, 163 models, 189 migrations, 26 modules, and about 40% of it is specific to one industry vertical that got welded into the generic parts over years. The rule for this rebuild is that it's built forward — port module by module — not refactored in place. The old codebase is read-only reference. Never edit it.

Day one had three jobs: survey the thing I'm porting from, decide the shape of the new repo, and get core actually booting.

What Actually Happened

The survey was the useful bit, and it moved a decision I thought was already made.

I read the legacy codebase and counted things. Zero Livewire components against 114 controllers and 224 Blade views — so every module port is a UI rewrite, not a port. That's the biggest estimate risk in the whole project and I'd rather know on day one. 18 circular dependency pairs. The Admin module is 16.8k lines and zero models: 30 controllers, one per other module's settings, plus a central navigation file. It doesn't get ported at all — it dissolves into per-module registration.

That finding shaped everything after it. The old platform grew a settings controller per module because nothing stopped it. So core here gets a NavigationRegistry, a SettingsRepository and a PermissionRegistry, and modules declare into them rather than editing anything central. A heading whose children all get permission-filtered away is removed entirely, because an empty heading leads nowhere.

The monorepo restructure was mechanical and then immediately wasn't.

Root was a stock Laravel app; the target tree has no app at the root. So: move the app into reference-app/, add packages/core, wire path repositories with symlinks. Fine. The interesting constraint showed up straight away — the deploy target bakes the route cache into an immutable image at build time. Which means routes must be registered unconditionally for every installed package. Enablement gets gated by middleware at request time. If a module registered routes conditionally on a database flag, whatever was true during the build would be frozen into the image, with no symptom until routes silently differ between deployments.

I wrote a guard test for that same day: boot the route table with everything disabled, boot it again with everything enabled, assert they're identical.

Then the bugs that tests couldn't see.

Two came out of the identity/permissions work:

  • Core resolved its own User class instead of auth.providers.users.model. Permission guards match against the auth provider, so acting on the base class while the app is configured for a subclass resolved to no guard at all and role assignment just failed. Error in the log: The given role or permission should use guard `` instead of web. — that empty backtick pair is the whole story.
  • The business_unit_user pivot relied on derived table and key names, which come from the class name. Any client overlay subclassing User under a different name would have resolved to a table that doesn't exist.

Two more from the module registry:

  • The registry cached descriptor objects. Works on the array store, fails on any serialising store, and returns __PHP_Incomplete_Class. Every suite passed because Testbench defaults to the array cache store. In production it's worse — the platform shares a cache across releases, so a value written by the previous release gets read by the next one. The log has it: ModuleRegistry::find(): Return value must be of type ?ModuleDescriptor, __PHP_Incomplete_Class returned. Rule now: cached values are plain data, never objects.
  • The registry cache key ignored which installed.json it had scanned, so two registries pointing at different builds shared one cached answer.

And then the one that crashed my browser.

Enabling and disabling a module is queued work. With no queue worker running, the module sat at "Enabling" forever with no way out of the UI. Worse: the page auto-reloaded every 2.5 seconds while anything was transitioning. So a permanently stuck state became an unbounded reload loop. That's not a slow page, that's a browser-killer, and it took Chrome down.

I only found it because I was clicking around in a real browser rather than running the suite. That became a theme.

Also hit Undefined array key "model_morph_key" and a run of SQLSTATE[42S22]: Column not found: 'users.deleted_at' while identity was half-moved into the package. Standard mid-refactor noise, but they cost time.

What Worked

Record types as a framework, not a table. A module declares a record type and inherits list views, filtering, search, sorting, export, custom fields, relationships and reportability. The legacy platform rebuilt that scaffolding 71 times and still applied it unevenly.

Two storage strategies behind one contract: a code-defined type keeps built-in fields in real columns and only runtime additions in a shared value table; a type an administrator invents after the build lives entirely in that table. Adding a field never issues DDL — the hosting is ephemeral, so runtime DDL is out.

The value table stores each value more than once on purpose:

value          -- canonical JSON, so types survive the round trip
value_numeric  -- numbers and currency, indexed
value_date     -- dates, indexed

A single string index would order '10' before '9' and quietly return the wrong rows for every numeric range filter. There's a test that fails without the typed columns.

Two query properties are asserted, not hoped for:

  • the list costs one query however many filters are applied — filters become correlated subqueries inside one statement
  • custom values cost one further query however many rows come back

Three modules proving it. Accounts, contacts and sites ship between them: no controller, no table markup, no filter form, no sort handler, no paginator, no export code. Each declares a record type and inherits all of it. Dependency direction runs one way — contacts and sites depend on accounts and own the key that points at it; accounts never learns they exist.

A guard test for something nobody would remember. The legacy platform declared accounts.user_id and sites.user_id with onDelete('cascade'). So deleting a user deleted every account and site they'd created, cascading onward. Nobody chose that — it's what ->constrained() does by default when the column is named after a user. The intent was "created by", never "owned by".

The new platform was already compliant, but nothing stopped the next migration breaking it. So:

// UserDeletionGuardTest reads the LIVE schema across every installed package,
// finds each foreign key pointing at users, and fails on any that cascades
// unless it's on a named allowlist with a stated reason.

Reading the schema rather than the migrations is what catches a column added next year by a module that doesn't exist yet. And I proved the guard fails when violated — created a probe table with a cascade and watched it go red — rather than trusting that it passes today.

The stuck-module fix, at the level it went wrong: a transition left longer than two minutes gets reconciled to Failed with a reason worth acting on, and reconciliation happens when the page renders, so recovery needs no worker and no console access. Polling is bounded to about a minute, then the page stops and offers a link. Two minutes because it's far longer than seeding takes and far shorter than anyone will sit watching a page.

Ended the day at 183 core tests, 59 app tests, PHPStan level 6 clean, and a STATUS.md written specifically to survive losing conversation context — everything a fresh session needs is on disk rather than in a thread.

What I Learned

  • Green suites hide missing surfaces. Every bug worth writing down today was found by using the running app, not by the tests. The mechanism was built and covered; the surface was missing or broken.
  • Testbench's array cache store is a liar. It'll pass anything you throw at it that a real serialising store would reject. If you cache objects, you won't find out until production.
  • ->constrained() guesses your intent from the column name. A user_id that means "created by" gets a cascade it never asked for. Guard it with a schema-reading test, not a code review habit.
  • Assert the property, not today's behaviour. The route-registration guard, the FK cascade guard and the query-count assertions all protect things that are invisible until they break badly.
  • Prove a guard can fail. A guard you've never seen go red is a guard you're trusting on faith.
  • Writing rationale into commit bodies rather than a wiki has already paid for itself twice — I've gone back to "why is this two minutes" and "why is the reference a separate field" and the answer was right there.

Tech Notes

  • Stack: PHP 8.4, Laravel, Blade, Tailwind 4 (CSS-first tokens), Orchestra Testbench, PHPUnit, PHPStan level 6, Pint
  • Key packages: spatie/laravel-permission (teams mode, pointed at business unit), spatie/laravel-activitylog, Fortify for auth + TOTP
  • Structure: monorepo with packages/* (Composer packages) + reference-app/ (integration harness, resolves packages via path repositories with symlinks)
  • CI added: package tests standalone, a MySQL leg that migrates a clean database (suites run SQLite for speed, deployments run MySQL), static analysis, and a subtree split of each package to a read-only repo on merge
  • Pint is configured twice on purpose — root preset for the monorepo, a stricter pass over packages/* enforcing declare(strict_types=1) and ordered imports. Applying strict types to Laravel's stock config would just make framework upgrades noisier.
composer test        # package tests then app tests
composer test:core   # core standalone, no host app
composer analyse     # PHPStan
composer lint:test   # format check without writing
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.