Twelve gaps in one walkthrough, and every one was a finished feature with no door on it
The Starting Point
Closing out the phase. The plan was: finish three known gaps in the morning, then hold the gate — sit down with a rebuilt database and actually use the thing, screen by screen, the way a client would.
The morning took until lunch. The walkthrough took the rest of the day and produced twelve more items, all of which got fixed before sign-off.
What Actually Happened
The morning: three gaps, each hiding a second one
Effort entry. The costing service was finished and nothing reached it — I knew that from last week's triage. What I didn't know: the rate card it prices against had no screen either, so template and project rates could only ever be seeded. Two missing surfaces stacked on one working mechanism.
Also, the source's delete took a bare identifier — destroy(ProjectTaskEffort $effort) with no check that the effort belongs to the project in the URL. Fourth time this audit has found that exact shape.
Cycle exclusion in the dependency picker. There was no picker. addDependency had a route and nothing posted to it, so dependencies could be removed from the plan and never added.
Each row has one now: server-filtered, no JavaScript, with anything that would close a loop left out rather than offered and then refused. Reachability comes from one query on the link table, precomputed per item, rather than walking the relation per row.
Date rollup, built from model events rather than service calls — deliberately. Seven different paths write a plan item, and a rollup that's wrong because one of them forgot to call it is a failure nobody sees.
Dates roll up through phases, tasks and subtasks and stop at the plan: a project due in June whose plan runs to April has slack, not an April deadline. Progress rolls up all the way as the mean of the leaves beneath. Milestones contribute to neither.
And one test bug I want on the record, because it cost me an hour of staring at working code:
[defaults] + $attributes // keeps the LEFT-hand key
PHP's array union operator keeps the left operand's value on a key collision. So every date passed into my test helper became null, and eleven tests failed against a rollup that was working perfectly.
The walkthrough: twelve gaps
Rebuilt database, signed in, started clicking. Every item below is something I found by using it.
"Edit a task's due date" — first step, and there was no way to. update() and destroy() both existed, both had routes, and nothing in the view posted to either. A plan item could be added, moved, duplicated and made to wait for another, but never changed and never removed. Fifth missing surface over a finished mechanism in one day.
"I don't see the option to switch from dark to light" — there wasn't one. Dark mode was the operating system's decision and nothing in the app could overrule it.
Creating a rate threw an integrity violation:
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'sort_order' cannot be null
sort_order declared as an optional number with no default, so a blank field was written as null against a NOT NULL column. The project type had the same field declared the same way and was next in line.
A whole number of hours was rejected by the browser. This one's genuinely nasty: an HTML step is counted from min. So min="0.01" with step="0.25" makes 1 invalid and 1.01 valid. The browser refused every whole number while the server would have happily accepted them. step="any" now; the server's min:0.01 still stands.
"I don't see Stages and tasks." In the tabbed layout the header's action buttons are suppressed, because the tab strip is where they live — and the strip was only drawn when there were relationship panels. A project type declares one action and no relationships, so its one screen was reachable from nowhere at all. Gated on sections rather than panels now.
"Where is this?" — an external approver's decision link was rendered as a truncated grey line. Unreadable, unclickable, easy to mistake for a caption.
"Where can I add approvers?" The list's New button went to the generic record form, which made an approval with no approvers and no creator — a request nobody could ever decide, with no way to add anybody afterwards. The Approvers card was an empty box.
"Approvals should allow the uploading of documents." They did. Attach, download and detach were built, routed and tested. No view rendered any of it. The parity row said Ported.
Worse: the signed-in download's own comment promised every approver could read the deliverable "including one with no account at all" — and the route sat behind auth middleware. So an external approver following the link they'd been sent got a 403 from the document they were being asked to sign off.
The tracker panel showed titles alone, which for a risk register is the one column nobody needs.
That's the pattern, twelve times over. Which produced the rule I should have had a month ago:
A parity row may not be marked Ported without naming the screen it is reached from.
Some self-inflicted noise along the way
Undefined variable $uploads
SQLSTATE[42000]: Can't DROP 'approval_attachments_approval_comment_id_foreign'; check that column/key exists
And a test correction worth naming: fresh() runs without global scopes and returns a soft-deleted row, so it cannot be used to assert that a delete happened. It'll happily hand you back the thing you just deleted.
What Worked
Theming, rewritten around light-dark()
The tokens are now one definition each, resolved by light-dark() against the root's color-scheme. By default that follows the system; a person pins it with Auto / Light / Dark in the top bar, which stamps data-theme on the root.
No token is defined twice, so the two modes cannot drift apart and there's no second block to forget when a token is added. (Which matters — a fortnight ago I shipped a dark-mode contrast bug for exactly that reason.) Lightning CSS polyfills light-dark() for older browsers and the polyfill honours the attribute.
A cookie rather than a column: it's a preference about this screen — the shared laptop, the phone at night — and the sign-in page needs it before anybody is signed in. "Auto" removes the cookie rather than storing a word that means "no cookie".
Approvals grew up
Asked for in one sentence on the walkthrough: multiple approvers, adding some even after rejection, a re-submit with dialogue in between, and a quorum such as two of three. The source has none of it — every approver must approve, one rejection sinks the request, and a rejected request is a dead row.
The outcome rule now: approved when enough have said yes, rejected when enough can no longer say yes.
With two of three required, one rejection leaves two who can still get there, so the request stays pending. Two rejections make it unreachable, so it's rejected then. The one person who always says no doesn't get a veto they were never given. null for the quorum means everyone — the source's rule, and still the default.
Decisions, comments and re-submissions are one table, because "why was it rejected and what changed before it was approved" is one question, not three.
And RecordType::createdAt(route) — one declaration core gained. A type whose records cannot be made by filling in columns says where they are made instead. The list's New button goes there, the generic create redirects there, and the generic store refuses. The portability guard now follows that redirect and asserts the declared form renders, because a declared route that 404s is a New button that goes nowhere.
Keeping every gate decision
Predicted by the parity table when I wrote it: "what did we decide last time, and why" is a question people ask of the record, not of the audit log. The source keeps only the latest decision on the stage row and leaves the rest to free-text log entries nobody can query.
Append-only table now: decision, notes, who, when. The stage row still carries the latest, because that's what a list of stages needs. A gate held in March and passed in April is one stage with two entries.
What I Learned
- PHP's
+on arrays keeps the left-hand key.[defaults] + $attributessilently ignores everything the caller passed. Usearray_merge()or$attributes + [defaults]. fresh()ignores global scopes and will return a soft-deleted model. It cannot assert a deletion.- HTML
stepis counted frommin.min="0.01" step="0.25"makes every whole number invalid in the browser and valid on the server. Usestep="any"unless you genuinely want a grid. - Define theme tokens once with
light-dark(). Two blocks is two places to forget, and I'd already shipped a bug from exactly that. - A theme preference belongs in a cookie, not a column — it's about the screen, and the sign-in page needs it before there's a user.
- "Ported" must name the screen. Twelve gaps in one afternoon, all of them code that was built, routed, tested and completely unreachable.
- A rejection quorum isn't the mirror of an approval quorum. "Rejected when enough can no longer say yes" is the correct rule; "rejected on first no" hands a veto to whoever answers first.
- An hour of clicking found twelve things that ~740 green tests did not. Second time this has happened on this project and I've stopped being surprised.
Tech Notes
- Stack: PHP 8.4, Laravel, MySQL (Docker), SQLite suites, Tailwind 4 + OKLCH tokens, Lightning CSS
- Theme: single-definition tokens via
light-dark(),data-themeon root, cookie-backed, Auto deletes the cookie - Date rollup driven by model events (seven write paths; a missed service call would be invisible)
- Dependency picker: reachability precomputed from one query on the link table, cycle-creating options omitted rather than refused
RecordType::createdAt(route)for types that can't be created from a generic form; portability guard follows the redirect- 746 tests green after the morning, PHPStan level 6 and Pint clean
Zero Focus Dev
Breaking things, learning fast, sharing it all.