One commit, two bugs I wasn't looking for
The Starting Point
Short session. One thing on the list: status vocabularies were fixed in code. A client who calls a prospect something else had to wait for a release. That's exactly the kind of thing that should never need a deploy on a product platform.
What Actually Happened
The feature itself was straightforward — a module marks a select field's options as configurable, and administrators change the choices from the field admin screen. Resolution order is business unit, then deployment-wide, then the module's declared default.
The discipline is the interesting part, and I wrote it down as a hard rule: nothing may branch on a configurable value. If code does if ($status === 'active'), that field is simply not declared configurable. There's no clever mechanism, no escape hatch — the whole rule is the declaration.
Retired values are counted and reported, never rewritten. Deciding what a closed record's old status becomes is a judgement call, and guessing loses the only record of what the value was.
Then two bugs fell out that I hadn't gone looking for.
One: the settings cache only cleared the scope it wrote.
SettingsRepository cleared the cache for whichever scope you'd just written to. So changing a deployment-wide value left every business unit still reading a stale one. Nothing errored. The value just didn't change for anyone who had a unit in context — which is everyone in a real deployment.
Two: WithoutModelEvents was eating record identifiers.
The scaffolded DatabaseSeeder used WithoutModelEvents. That suppresses the creating hook that the UUID trait assigns identifiers from. Here it failed loudly because the column is NOT NULL:
SQLSTATE[HY000]: General error: 1364 Field 'uuid' doesn't have a default value
Which is the good outcome. On a nullable column it would have silently seeded a pile of records that nothing could match across an export or a cutover — and nobody would notice until the data migration.
I also found the shipped option lists were plain wrong. The demo data was already using values the declared options didn't offer. The seeder was right and the code wasn't.
What Worked
The configurable-options resolution, with each layer only overriding what it actually sets:
business unit value → deployment value → module's declared default
And options are entered one-per-line as Label or value|Label. No client-side machinery, quick to type, quick to paste into from a spreadsheet, which is where these lists actually come from.
What I Learned
- A cache invalidation that's scoped like the write is usually wrong. Writes are scoped; reads fan out. Clear everything the write could possibly affect.
WithoutModelEventsin a seeder is a trap if any of your models do work increating/created. It's the default in Laravel's scaffolded seeder and it silently disables behaviour you built on purpose.- A NOT NULL column just saved me from a silent data problem. Nullable-by-default is a lot more expensive than it looks.
- Demo data disagreeing with declared options is a useful smell — it means one of the two was written from reality and the other from memory.
Tech Notes
- Stack: PHP 8.4, Laravel, MySQL (dev), SQLite (test suites)
- Settings resolution: three-tier (unit → deployment → module default), cache cleared across all scopes on write
- Field options entered as
Labelorvalue|Label, one per line
Zero Focus Dev
Breaking things, learning fast, sharing it all.