Back to blog
porting laravel testing ci architecture

I spent an evening porting from the wrong repository

The Starting Point

Morning after building a whole reporting engine. First thing in the session, as always now, I run the drift check — the CI script that verifies changes landing on the legacy system are recorded in my drift log.

It refused to run.

What Actually Happened

The wrong repository

The drift check is built to fail loudly when it cannot verify, rather than skipping. I wrote that rule weeks ago and have been quietly proud of it. This morning it earned every word.

It refused because the checkout it was pointed at was not the repository I'm porting from.

Here's the trap. There are two products that share an ancestor. Same Modules/ layout. Same file names. Recognisably the same code — a fork that diverged a long time ago and grew in different directions. The analysis for reporting was written from the correct checkout. The build was not.

So an entire evening's work was ported from a sibling product.

What was actually wrong

Not everything — much of the shared ancestry is genuinely identical, which is exactly what makes it dangerous. But:

The parameter syntax. This is the one that mattered most. The real source uses :start_date, resolved from the reader, then the report's own config defaults, then the built-ins, and only inside the filter tree. What I had built was invented — {{param.x}} and eight token names of my own devising.

No stored config from the actual target would have resolved at all. Every report anybody had ever saved would have silently stopped working at cutover.

The drilldown. Reached by different query parameters, structured differently.

The record modal, built from a stored config rather than the row already on screen.

A third of the widget renderer — link columns, empty flags, variance flags (which mark whichever side carries the excess), row actions, target lines, KPI subtitles, stacked charts, metrics tables.

And three widget shapes nobody had counted. My analysis said six. There are nine — a map, a filter card and a schematic.

And the layout grid. This one hurt: I was storing layout as a width word — third, half, full — where the real source stores {x, y, w, h} on a twelve-column grid. An imported page would have arrived with every item full-width in sort order, and the arrangement somebody spent an afternoon making quietly gone.

Same family as the parameter syntax: a stored shape invented rather than read.

Re-porting, with one guard the source didn't need

Row actions are carried with one addition: the URL must be a path on this deployment. The source's pages only ever posted to themselves, so it never needed the check. A generic platform does.

Also fixed while in there: a column's enum values never reached the result, so a filter card could not have listed the options a field defines.

Not built, and recorded rather than quietly skipped: a map marker's drill opening a named second report in a modal. That's a report runner inside a widget and wants its own decision.

Two things found while looking for a missing menu

Unrelated, and both bad:

The shell has no navigation below 1024px. One sidebar, hidden lg:flex, and nothing else. So on a narrow window the entire platform was reachable only by typing URLs. Open since the shell was written.

Roles are granted per business unit — and my dev admin belonged to three units and held roles in one. So switching unit emptied the sidebar instead of narrowing it. Which looks exactly like a broken menu.

Then: record types an administrator invents

The rest of the day was the next step, and it turned out to be mostly a matter of finding a caller.

RecordType::userDefined(), the shared records table and the dual storage path have been in core since the framework was written, with nothing calling them. Exactly the state the datasource contract was in before reporting.

So a record_types row becomes a record type in the same registry modules declare into — and everything downstream works without knowing where the type came from: lists, forms, search, pickers, import, export, audit, unit scoping, delete guards, and reporting.

The headline from the analysis: this is not an assets module. Most of what the source's 8,369 lines do, core already does for every record type.

A stack of small bugs, every one found by clicking

Chris opened a vehicle he'd just invented. Every field read as an em dash, and the Inspections tab said 1 with nothing under it.

The values: the page loaded its record through the query builder rather than through the query object — and only the query attaches custom field values. So a type whose fields are all custom showed nothing at all. Worse, a module's custom fields were quietly doing the same on every record page in the platform; nobody noticed because every module type also has real columns.

The empty tab: the active tab was chosen from declared relationships only. A type that has none — a vehicle, whose only panel is the inspections pointing at it — had no active tab, and the view renders the panel whose key matches. The strip showed a tab; the body rendered nothing.

Then: "I assumed Reference would auto-populate using the numbering rules?" It should have. The type form took a prefix and stored it as a string nothing read. Two halves were missing — the shared record model didn't draw from a sequence at all, and giving a prefix didn't configure the allocator.

And underneath that, two older defects:

  • A reference was allocated only for a type saved through the admin screen. So a type made by a seeder, an import or a test had a prefix and no numbering.
  • next() locked a row it had not ensured existed, found nothing, and returned null — so the first allocation for any unconfigured type silently produced no reference at all.

And a third in passing: the writer nulled the title whenever the naming field was absent from the payload, so a partial update from an integration blanked the record's name on the way past.

Two framework gotchas

// a `from` scope on an Eloquent model is never reached —
// the query builder has that method and silently changes the table instead

And four new columns dropped in silence by mass assignment until they were made fillable. Third time $fillable has bitten me on this project.

Also: Blade's inline @php(...) gives up on an expression with enough nesting and emits an unclosed tag, after which the compiled view reads the next HTML attribute as PHP. Use the block form.

What Worked

"Named by" defaults to the reference. Chris's call and the right one: a reference is the one thing every record of a numbered type has from the moment it exists, it's unique, and it's already what people quote to each other. A type nobody has finished configuring reads VEH-0001 rather than #41.

Rejected on the way: "the first field" implemented properly — unstable, because adding a field that sorts first would silently rename every record.

A link between record types is a row in one table, and the inverse panel is derived rather than declared. Asking for the reverse to be configured as well is asking for two statements of one fact, and the second goes stale.

The source needed two link tables — one for custom-to-custom and a polymorphic one for everything else — because its assets were a different kind of thing from its accounts. Here a record type is a record type whoever declared it, so a link is a field, a record, and a record it points at.

An upsert over the API, which is what a nightly feed actually needs: it names the field to match on, so the same call creates the first time and updates every time after, and says which it did. Without it a feed produces three hundred duplicates by the end of the month.

A spreadsheet column of registrations becomes links, matched against the field the link field already named. And a row naming something nobody has is a problem said out loud, not a silently empty link.

A multi-select control that isn't a native multiple. A native one is not a shorter version of a picker, it's a worse and different thing: no filtering, no visible list of what's chosen, and a "hold ⌘ or Ctrl" instruction which is the interface admitting defeat.

What I Learned

  • A check that refuses to run is worth more than a check that passes. This one caught an entire evening of wrong work by being unable to verify rather than by finding a difference.
  • Two forks of the same codebase are the most dangerous thing to have on disk. Same paths, same filenames, recognisably the same code — and the syntax of stored user configs is completely different. Pin the checkout, and check it.
  • Porting a stored shape is not the same as porting a behaviour. My invented parameter syntax and invented layout model both worked perfectly. They'd have read exactly zero existing configs.
  • A from() scope on an Eloquent model is unreachable — the query builder owns that method and silently changes the table.
  • Blade's inline @php(...) breaks on deeply nested expressions and the error surfaces nowhere near the cause.
  • "It's been in core since the framework was written with no caller" is a recurring shape — that's twice now, and both times the code was correct and untested against reality.
  • The thing that finds bugs is still somebody opening a page and looking at it.

Tech Notes

  • Stack: PHP 8.4, Laravel, MySQL (Docker), SQLite suites
  • Custom record types: one links table, a field declaring target/multiplicity/match-field/reverse-label, permissions per type granted in the same request as creation
  • API: POST /api/records/{type} validates against the type's own field definitions; PUT with match upserts on a named field; one ability per type
  • Runtime fields became report columns via a join pinned per field id — read from the plain copy, not the JSON one, or a registration reports with its quotes
  • 1,114 tests at end of day, thirteen packages
bin/drift-check   # refuses to run against the wrong repository — which is the point
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.