For once I applied the rule before making the mistake
The Starting Point
Two things left in this phase: stage gates (a project template stamps out stages, each ending in a go/hold/no-go decision), and an audit of one last area — boards and meetings — that I hadn't looked at yet.
I expected to build both. I built one.
What Actually Happened
Gates and stages, and two silent bugs
The feature itself is a template-and-copy: a project type holds an ordered list of stages, each with a gate and some standard work. Applying it stamps that shape onto a project.
A project type is just a record type. It's configuration rather than delivery, but it's still a list of named things somebody creates, edits, retires and searches. That's exactly what the record framework does. The only screen this module writes is the ordered stage editor, because ordering is the part core genuinely lacks.
That's now the line for the next module that asks: if core's list and form serve it, declare it.
Two bugs that both failed silently, which is becoming a theme I could write a whole post about:
A column in the schema, in the model, and missing from $fillable. create() discarded it without a word, so every copied task arrived unstaged. Same family as the settings required rule from a couple of days back — a mechanism that works and a value that never arrives.
"Move up" nearly swapped sort_order values. Which does absolutely nothing when two stages share one value. It swaps by position and renumbers instead, and the test fixture deliberately gives every stage the same sort order so the naive version fails.
One decision I'm pleased with: the audience for a gate decision is the project's assignee and whoever's assigned work in that stage. The source notifies every user resource. A notification everybody gets is one nobody reads.
Applying copies, and project_type_id is provenance only — nothing reads through it. Deleting a template nulls it and leaves live stages standing. Two tests hold that line: renaming a template stage, and force-deleting the template. That's the third template-and-copy feature in this package now, after work-request checklists and plan version snapshots, and all three would be wrong the other way round.
The audit that ended in "don't build this"
Boards and meetings. Last un-audited area. I wrote the parity table expecting to then build it.
Three reasons not to, in order of weight:
It's unvalidated scope. The feature flag defaults to false and nothing in the source ever sets it true — no seeder, no migration, no deploy script. So whether it's actually live is a fact about somebody's settings table rather than their repository. That's the one question the audit can't answer by itself.
One commit, dated ten weeks ago, and no test anywhere references the models. Two months of heavy use elsewhere in that module produced not one fix here.
A meeting is not a delivery concept. Sales has meetings. Case management has meetings. Account management has meetings.
So: they leave this module's scope entirely. Not deferred — "later, here" and "elsewhere, later" are different things, and only one of them changes what this module is allowed to grow into.
This is the rule that took work requests and approvals out of this package a fortnight ago, applied for the third time. The first two times I applied it after building the thing in the wrong place. Approvals was the worst — the first port took the source's polymorphic owner and narrowed it to a single foreign key, removing the polymorphism and keeping the coupling.
This is the first time it's been applied before the mistake. Which is precisely what writing the parity table first is for.
Two findings I recorded even though nothing's being built:
- The feature flag is the only gate on every route. No permission check anywhere, and
destroy()takes a bare identifier — so any signed-in user could delete any board or meeting by quoting a number. Same shape as the unguarded file download I fixed a couple of days ago. - The data layer spans all three tiers while the tab exists on one page. The exact mirror image of the files finding: the intent is in the schema and the screen never arrived.
Then I checked my own paperwork and it was wrong
I keep two status documents — a markdown one and an HTML dashboard. Several figures had drifted apart between them, and neither was right:
- the package tree listed four packages; there are seven
- the test line read 282 / 40 / 157 "across all four packages"; the real figures are 291 / 142 / 219, and 721 in total
- it claimed 45 architecture decisions (51) and 34 drift changes (44, which the other document had right)
- it said six guard tests and then listed seven; there are eight
None of that is catastrophic. But the whole reason those documents exist is to survive losing context between sessions, and a status doc that's wrong in six places is worse than no status doc, because you'll act on it.
What Worked
The decision structure for a gate, which took a bit of thinking:
- Only an active stage can be decided — enforced in the service rather than the controller, so it's refused however it's reached.
- Go completes the stage and activates the earliest still-pending one.
- Hold and no-go record the decision and leave the stage active, because the decision is the record.
- A held gate can be passed later: one stage, two decisions. Only the latest sits on the row, which is why the card says "last decided" rather than "decided".
That last bit turned out to matter more than I thought, and I came back to it ten days later.
What I Learned
- A column that's in the migration and the model but not in
$fillableis discarded in total silence. No error, no warning, no null — the key just isn't there. Third variant of "the mechanism works and the value never arrives" this fortnight. - Swapping sort values does nothing when the values are equal. Swap by position and renumber. And write the fixture so every row shares a value, or your test can't see the bug.
- "Deferred here" and "moved elsewhere" are genuinely different decisions. One keeps a door open for a module to grow the wrong way.
- An audit is allowed to conclude "don't build it". That's a result, not a failure to deliver.
- A feature flag that defaults off and is never turned on anywhere in the codebase is a question about someone's database, not their code. You cannot answer it by reading.
- Check your own status docs against reality periodically. Mine had six wrong numbers and I'd have believed every one of them next week.
Tech Notes
- Stack: PHP 8.4, Laravel, MySQL (Docker), SQLite in-memory suites, PHPStan level 6, Pint
- Project types declared as a record type rather than hand-built CRUD; only the ordered stage editor is a bespoke screen
- Template application copies rather than references;
project_type_idkept as provenance only, nulled on template delete - Gate decision guard lives in the service, not the controller
- 721 tests green at end of day
Zero Focus Dev
Breaking things, learning fast, sharing it all.