Back to blog
laravel porting refactoring testing eloquent sqlite

I'd been porting the schema and calling it the feature

The Starting Point

Starting the biggest module in the port: ~19k lines, 45 models, 35 migrations in the source. The delivery-management domain — hierarchies, plans, risk/issue tracking, approvals.

I'd read the analysis and the migrations. That felt like enough. It was not.

What Actually Happened

The morning went fine, which is how these things start

Built the first tier — three record types, one service provider, no controllers, no list views, no filters, no exports. All of it comes from registering the types. Eleven tests, standalone, in a suite that's deliberately a deployment with no customer region at all — no accounts table, no foreign key, everything still works.

Two core bugs fell out immediately from creating the first record through the interface:

A declared field default was ignored by the writer. ->default(0) pre-filled the form and nothing more. Submit with that input empty and the record was written with null, because blank input went straight to null without consulting the declaration. Fine — until a column is NOT NULL with a default of its own, which is the ordinary shape for a counter or a percentage:

SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'sort_order' cannot be null

And the error names a column the user never saw rather than the field they left blank. The module's own suite could never have caught it, because it exercises the model rather than the form.

SQLite wasn't enforcing foreign keys in four of five suites. SQLite doesn't enforce FKs unless you ask. The reference app asked; none of the four package suites did, because each defines its testing connection inline and the option was never carried across.

So a package could declare a cascade or a restriction in a migration that no test could ever exercise. The constraint written down and never applied, which is worse than not having it, because the migration reads as though the protection is there.

Found it while writing a test that asserted deleting a parent is refused. It passed the assertion by not raising anything at all.

All five suites stayed green with enforcement on, which was the reassuring half.

Then I clicked "Plan" and saw plan history

I'd built plan versioning — snapshots, drift, locking, rebaselining — and the page showed version history and called itself Plan. It answered "what did we say" without ever answering "what does it say now".

Caught by looking at it. Clicked Plan, saw paperwork.

Then it got worse, in a useful way

I was asked to look at the reference code again. I'd built the top two tiers of the hierarchy and called it a plan. The source keeps the whole hierarchy in one self-nesting table — six levels down to milestones — and the plan is that tree. I'd shipped the browsable records and missed the entire plan layer beneath them.

Not caught by anything I ran. Caught by re-reading the source.

Building it properly surfaced a bug I'm slightly embarrassed by. Items can depend on each other, and a dependency that would close a loop has to be refused. I got the direction of the cycle check backwards. Adding "item waits for target" closes a loop when item is already reachable from target — and asking it the other way round always answers no. So the first version happily saved a cycle, which the interface then rendered as two items politely waiting for each other forever. Tested in both directions now.

Some other errors from the day, mostly self-inflicted and all instructive:

Route [projects.plan] not defined.
Method PlanController::authorize does not exist.
PlanItemController::resequence(): Argument #3 ($beforeId) must be of type ?int, string given
Declaration of TrackerItemUpdateAttachment::update(): BelongsTo must be compatible with Model::update()
Setting [brand.record_layout] is not defined for module [core]. Declare it with define().

That third one is a real gotcha: an integer validation rule accepts a numeric string and hands it straight through. Perfectly fine until there's a typed signature on the other side of it.

The fourth is funnier — I named a belongsTo relationship update(), which collides with Eloquent's own update(). PHP catches it as a signature incompatibility, which reads like a much scarier problem than "rename your relationship".

The audit, which is the actual story of the day

So I did the thing I should have done first: read ProjectTaskController properly, line by line, and wrote down every behaviour against what I'd built.

Six behaviours never carried across. Not dropped, not deferred — missed:

  • move (reorder and re-parent, with sibling resequencing and a descendant guard)
  • duplicate (subtree copy that deliberately leaves dependencies behind)
  • updateStatus
  • the assignee picker spanning two different person types
  • a dependency picker that excludes cycle-creating candidates before offering them
  • per-type status defaults

Plus: subtask as a distinct type, priority, a reference per item, custom fields scoped to the item type, effort recording, and an export.

That's an interpretation of the source rather than a port of it, which is precisely the thing this project exists to avoid.

Then I audited everything I'd already built the same way, and found the same failure everywhere:

  • The tracker has an escalate toggle with no button, a closed_at column nothing stamps, and an update model that logs what changed rather than being the thing that changes it.
  • Plan versions can't be cleared or deleted — once anything is captured there's always an active comparison and no way to remove a mistaken one.
  • Two of the three hierarchy tiers have no assignee, from a migration I read while writing the analysis and never carried across.

Three of these are columns that exist with nothing setting them. That's the tell. The schema went across and the behaviour did not.

What Worked

The parity table. One row per behaviour from the source, and no row is allowed to be blank or absent — every row is Ported, Partial, Deferred, Dropped or Open. Written before the code for every remaining area, not after.

The process correction written down with it: read the controllers, not only the schema. A table says what can be stored; a controller says what the feature does. Six of these were invisible in the migrations and obvious in the controller.

Plan versioning, once it was actually right. Every rule exists because the alternative lets a project quietly never be late:

  • A snapshot is stored, not referenced. Joining back to live rows would rewrite history every time somebody renamed something — the one thing a baseline exists to prevent.
  • Locking is one-way. An unlock makes the lock decorative; the value is that replanning means capturing a new version, visibly.
  • Rebaselining leaves the original intact, because the argument it settles is whether the first commitment stands.
  • Exactly one version active per project, enforced in code rather than by a unique index — "at most one row where is_active" is a partial index, which Postgres has and MySQL doesn't, and this runs on both.
  • Drift reports only what moved. A page of zeroes is a page nobody reads.

And a line with no baseline reads as "—" rather than "on time". Reporting on-time against a baseline nobody set is inventing an answer.

Themes without a build step. The half that mattered already existed: not one of the platform's 42 views names a colour. They name bg-surface, text-content-muted, text-brand-600. So a theme is a set of token values and no module needs to know one exists.

A client picks one colour and eleven shades are derived from it, keeping the shipped lightness curve and moving only the hue. That's the reason not to ask for eleven values: brand-600 is the same lightness for everyone, so white text on a brand button has the same contrast in every deployment and nobody can pick a colour that makes the UI unreadable. Tokens were already OKLCH, which is what makes that honest rather than approximate — lightness there is perceptual, so holding it constant holds contrast constant. The test worth having: the maths reproduces the shipped brand colour from the hex it came from.

Status colours are deliberately not themed. A client whose brand is red still needs a red that means "this went wrong".

Tabs as a theme choice, and tabs that stay tabs. The subtle bit: a button says "you are going elsewhere", a tab says "you are staying on this record". The plan page rendered its own shell with a back-link, so a Plan tab would have navigated away and taken the tabs with it — a worse promise broken than a button ever makes. The tab strip is now built by a service either page can call.

Tabs are URL-driven rather than hiding markup already fetched. The controller used to build every panel's rows on every view; it now loads the section being read and counts the rest, so a record with eight relationships stops paying for seven. The tabbed page is cheaper than the page was before, and it works with no JavaScript.

Deliberately not role="tablist" — ARIA's tab pattern promises arrow-key switching without a page load. These navigate. So they're links carrying aria-current, which is what assistive tech can actually act on.

What I Learned

  • Read the controllers, not the schema. The schema tells you what can be stored. Only the controller tells you what the feature does. I lost a day to this and I'd have lost a month if the audit had come later.
  • A column with nothing writing to it is the reliable tell of a half-port. I've now found this pattern four separate times.
  • SQLite doesn't enforce foreign keys unless you ask. Check every suite, not the one you remember configuring. A constraint that no test can exercise is worse than no constraint.
  • Get your cycle-detection direction right, and test both directions. Reachability checks are trivially easy to write backwards and they fail silently by allowing exactly what they exist to prevent.
  • Laravel's integer validation rule passes numeric strings through. Typed signatures downstream will catch it, loudly, at runtime.
  • Don't name an Eloquent relationship update(). Or delete(), or save().
  • Deriving a palette from one hue and a fixed lightness curve is a genuinely good trade — clients get a brand, and nobody can ship an unreadable button.
  • Assert the journey, not the artefact. "The plan page shows the plan" is a test I had to write after clicking the thing.

Tech Notes

  • Stack: PHP 8.4, Laravel, MySQL (dev, in Docker), SQLite in-memory (suites), Tailwind 4 with OKLCH tokens
  • Enable FK enforcement in every package test suite — it is not on by default in SQLite and each suite that defines its own connection needs it explicitly
  • Plan-version active flag enforced in application code, not a partial unique index (MySQL doesn't have them)
  • Tree pages: carry depth from the model and eager-load; walking lazily is one query per line and a plan is mostly lines
  • Ended the day at ~510 tests, 45 recorded decisions, and 6 open parity gaps written on the dashboard where they can't be ignored
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.