The notification row was written, the count stayed zero, and eight tests passed the whole time
The Starting Point
Started from a small question: there was no files tab anywhere in the UI, and files could only ever hang off the bottom tier of the hierarchy. That's it. That was the whole plan.
It turned into fourteen hours, four architecture decisions, and the single best bug of the project so far.
What Actually Happened
One controller, three tiers, and route parameters that don't bind how you think
The source hangs files off the bottom tier alone, and my first port inherited that without ever naming it as a choice. But the tracker already spans all three tiers, and a mid-tier contract or a top-tier mandate are both real documents somebody needs to attach.
So: one controller and one view serving all three. Three near-identical controllers is exactly how the source ended up with a download that checked nothing.
Then this, over and over, at half ten in the morning:
ProjectFileController::download(): Argument #3 ($file) must be of type ProjectFile, string given
ProjectFileController::destroyFile(): Argument #3 ($file) must be of type ProjectFile, string given
Route parameters bind positionally. The tier is a route default and the record is a URL segment, so they arrive as record-then-type. My signature named $type first, so it silently received the record's identifier and the record parameter got the string.
The symptom was a 404. Which is fine, except two guard tests were expecting a 404 — they were asserting that you can't reach a file across tiers. So both passed for entirely the wrong reason. The guard wasn't working; the bug was producing the same status code the guard was supposed to.
Reading the tier by name instead of by position fixes it, and reordering can't break it again.
I'd also had a run at this near midnight the night before and got nowhere:
Method Illuminate\Routing\RouteRegistrar::defaults does not exist.
Plus the usual trail of me flailing at it:
Undefined variable $type
Method ProjectFileController::tier does not exist
Column not found: 1054 Unknown column 'portfolio_id' in 'where clause'
The test that asserted a link and not a page
This one's my favourite of the day.
I had a test called "a mid-tier and a top-tier record offer one too". It checked that the record page rendered a link to the files area. It passed.
Both pages behind those links returned 500:
ProjectFiles::bin(): Argument #1 ($project) must be of type Project, Programme given
ProjectFiles::bin(): Argument #1 ($project) must be of type Project, Portfolio given
bin() still carried a bottom-tier type hint from when files only existed there. So the link rendered perfectly and went nowhere.
Asserting a link exists is not asserting it goes anywhere. This is the second time this exact class of thing has bitten me — a few days back it was assertRedirect() asserting where a journey points rather than that it arrives. Both tests are now opening the page.
migrate:fresh ate my deployment state. Twice.
Amending the file migrations meant a migrate:fresh. Which took the demo data, the appearance setting and the baseline roles with it.
SQLSTATE[42S02]: Base table or view not found: 1146 Table 'app.sessions' doesn't exist
The tabs disappeared and I put them back by hand. Then it happened again and I put them back by hand again, which is the point at which it became a bug rather than an inconvenience.
Baseline roles and the record page layout are not schema, so migrate:fresh takes them. The seeder now calls the role catalogue's ensure() and sets the layout, so a rebuild returns to a working deployment rather than a bare database.
The seeder also predated the whole delivery module, so a freshly seeded database opened every screen built this phase completely empty — a plan with nothing in it, a tracker with nothing on it, a files area with nowhere to put anything. It now seeds a full hierarchy with a plan, a phase, a task, a milestone, an escalated risk, finance lines and rate cards.
I lost the actual demo data and couldn't restore it, which is written into the status doc honestly rather than quietly.
A dark mode bug the test suite structurally cannot see
On the appearance screen, the label of the option you had selected was the one thing you couldn't read.
The cause is a direct consequence of a design decision I'm still happy with. The brand ramp holds lightness fixed across themes — brand-50 is lightness 0.97 in both light and dark, deliberately, because that's what makes contrast predictable for every client's chosen hue. But it was paired with text-content, which does flip. Near-white text on a near-white background.
Every other selected state in the platform already pairs the two ramp colours — bg-brand-50 with text-brand-700 — in tabs, badges, the sidebar. The appearance screen's two choosers were the only exception.
I swept the rest of the views for the same shape and found no others. It's recorded as a rule rather than a fix, because the trap is easy to walk into again:
A fixed-tint background takes fixed-tint text. Mixing the two kinds is the bug, rather than either colour being wrong.
No test in the suite could ever have caught this. Contrast between two token values in two themes isn't something a feature test looks at.
Attachments: my first instinct was wrong
Approvals became its own package a couple of days ago, depending on core alone. Then it needed a document to review — and it can't point at the delivery module's file table, by design.
First instinct: a shared attachments table in core.
Then I looked at what the tracker's attachments and the file area actually duplicate, and it isn't the five columns. Columns are cheap. What they duplicate is:
- writing bytes outside the transaction and undoing them by hand
- streaming through the application rather than linking to a disk
- treating a failed delete as waste rather than as a failed request
That's behaviour, not schema. So core owns those three things and each module keeps its own table. A shared table would also have needed a polymorphic morph, and this platform has taken named foreign keys over morphs everywhere else.
Both existing callers refactored onto it, and their rollback tests still pass through the shared service rather than three copies of it.
One rule deliberately stays with the caller: whether this reader may have this file. Every caller answers it differently. An approval's answer is "are you an approver" rather than a permission at all — an approver is frequently somebody with no other reason to be in the system, and gating the document behind a view permission would lock out exactly the person being asked to read it.
That's the same mistake this module already made once, on the decide route, caught then by a test. Avoided here by design, which is progress.
What Worked
Notifications, and the bug that made the whole day worth it
Five separate parity tables had recorded the same block, each in its own words: there is no notifications module. Approvals requested and decided, work requests decided and completed, report comments, gate decisions. Five callers. This project has twice used a two-caller threshold to decide something deserves its own package, so this was well past arguing about.
But it goes in core, not a package. The rule I've been applying says a concept needed by more than one domain becomes its own package. Notifications break that rule on purpose, because they're a mechanism rather than a domain. The test that separates them:
You could run a deployment without approvals. A deployment with notifications off is one where five workflows silently do nothing.
That's much closer to the audit logger — also in core, also called by everything.
One notification class, not one per event. The source has 1,318 lines of configurable triggers behind its notifications; I built the sending and left the rules out until somebody actually wants a rule.
And nobody is told what they did — every send drops the actor, in the notifier rather than in five callers, because forgetting it is invisible.
Now the bug.
Recipients were loaded as core's own User class. Laravel stores a notification against the concrete class of the notifiable. Every deployment subclasses core's user. So loading recipients as the core class addressed every notification to a type that nothing ever queries.
The row was written. The count stayed zero. The page stayed empty. And eight unit tests passed throughout.
They passed because no package suite can possibly catch this — a package has no host application, so there's nothing to subclass anything. The bug only exists in an assembled deployment.
Fix is one line — resolve through config('auth.providers.users.model'), never core's own class — and it's the third time this exact failure has appeared on this project from three different directions: permission guards on day one, a model factory on day three, and now notifications.
The tests that matter now live in the integration harness and assert the last mile: sent, then read back through an authenticated request. Not "a row exists".
And one more, hiding underneath it. packages/core/phpunit.xml listed three test directories by name. So tests/Notifications — a whole directory — was silently skipped. It now points at tests.
A test suite that doesn't include your tests is green. Same failure family as the CI matrix that was missing three packages yesterday: green by omission looks exactly like green.
Ended at 691 tests, PHPStan level 6 and Pint clean.
What I Learned
- Laravel stores notifications against the concrete notifiable class. If your package defines a
Userand the app subclasses it, load recipients through the auth config or your notifications go to a type nothing queries. Silent. Completely silent. - Route parameters bind positionally, and route defaults come after URL segments. Read them by name. And beware guards whose failure mode is the same status code they're testing for — mine passed for the wrong reason.
- A test that asserts a link is not a test that the page opens. Second time this month I've been caught by asserting a signpost.
migrate:freshdestroys everything that isn't schema — roles, settings, layout choices. Make the seeder restore a working deployment, not just rows.- Check what your
phpunit.xmlactually includes. Naming directories explicitly means every new directory is opt-in, and nothing tells you. - Fixed-lightness colour ramps can't be paired with theme-adaptive text. The mixing is the bug, not either colour. And no automated test in a normal suite will see it.
- Look at what duplicated code actually duplicates. I nearly built a shared table when the real duplication was three behaviours. Columns are cheap; rollback semantics are not.
- A mechanism isn't a domain. "Could a deployment sensibly run without this?" is a sharper test for package-vs-core than counting callers.
- A column nothing reads or writes is noise, not data. Found the fifth one today, documented as "reserved for later" and never used. Dropped it.
Tech Notes
- Stack: PHP 8.4, Laravel, MySQL (Docker), SQLite in-memory (package suites), Tailwind 4 with OKLCH tokens
- Notifications: in core, one generic notification class, recipients resolved via
config('auth.providers.users.model'), actor always dropped from their own sends, unread count in the shell composer - Attachments: behaviour in core (transactional write, app-streamed download, forgiving delete), tables stay with the calling module, authorisation stays with the caller
- Seeder now restores deployment state (baseline roles + record page layout) as well as demo rows, so
migrate:freshis recoverable - Colour rule:
bg-brand-50pairs withtext-brand-700; never pair a fixed-lightness tint with theme-adaptive text
<!-- this silently skipped an entire test directory -->
<directory>tests/Feature</directory>
<directory>tests/Records</directory>
<directory>tests/Modules</directory>
<!-- now just: -->
<directory>tests</directory>
Zero Focus Dev
Breaking things, learning fast, sharing it all.