CI ran out of minutes mid-build, and nobody invited could ever set a password
The Starting Point
Marathon day. The plan was the next module in the port — a support desk — but the analysis had already told me I couldn't build it yet, because a case's thread is email: replies threaded on a Message-ID, inbound mail matched onto a case. And the email service the plan assumed I'd absorb into core simply didn't exist.
So: build email first, then the desk on top of it. The way attachments got proved by the tracker before being lifted into core.
That was the plan. Then three other things happened.
What Actually Happened
Static analysis failed in CI and passed locally
First thing in the morning. CI came back with 25 errors that didn't appear on my machine.
Cause: packages don't commit lock files (I removed them all weeks ago, for good reasons). So CI installed the static analyser at 3.12 while my local vendor sat at 3.10. The newer version types the view factory parameter as view-string and answers "does this view exist?" by booting a test application — which doesn't include a package's own service provider unless a config file names it.
Every package carries that config now. The result cache doesn't know the file exists, so it needs clearing once.
Two genuine findings came with the upgrade too: a fixture model needed its property docblock, and a source that never returns null was declaring that it could.
This is the exact trade-off I made when I deleted the lock files. Lockless packages mean CI can drift ahead of local. I still think it's right for libraries, but "works locally, fails in CI" is the bill for it and it arrived today.
The email module, and the seam in core it fills
Core gains a small seam: Mailer, Message, SendResult, with a null implementation bound by default. The email module rebinds it when installed.
That's what lets approvals and work requests depend on core alone and still send. An external approver is emailed their link when a mailbox exists, and shown the link to copy when none does. A failed send is a row in the log and a sentence on the screen — never an exception. Same shape as the file storage seam.
Two rules the first transport settled:
- Mail that goes nowhere needs no redirecting. The non-production recipient override leaves the array and log transports alone, otherwise every test suite would have to configure it before it could prove a send.
- Deduplication is the module's job. A poll that overlaps the last one cannot hand the same mail out twice, and mail nobody claims stays visible.
Then a correction, asked for the same day. The recipient override — "while this is on, every message leaving the system goes only to these addresses" — was built but ignored the switch in production. Which sounds safe and isn't: a go-live check on the production instance is precisely where "only these addresses, for the next hour" is wanted.
The switch is honoured everywhere now. What differs by environment is the default: on everywhere but production, off there. So a fresh UAT is safe before anybody opens the settings, and production sends unless somebody deliberately says otherwise.
Three transports, written against REST rather than SDKs
Microsoft 365, Google Workspace and a transactional provider — all against their REST APIs with Laravel's HTTP client rather than the vendor SDKs.
The reasoning: the official Graph SDK is hundreds of megabytes for five calls, and the Google client isn't much better. More importantly, a transport tested with Http::fake() is a transport whose tests say what actually goes over the wire.
The interesting one is Microsoft 365. sendMail returns nothing — not even the Message-ID. So the source could not thread a reply to anything it had sent. Ever.
The fix is to invert the order:
create a draft → this is where Graph assigns the internetMessageId
attach files → onto the draft
send → now we know the ID
A reply finds the original by its Message-ID and calls createReply on it, so Graph sets the threading itself. An original that isn't in the mailbox means an unthreaded reply — same as the source had, but now it's a known limitation rather than the only behaviour.
Google Workspace needs a service-account JWT per impersonated mailbox — about forty lines of openssl_sign. The message is RFC 822 built by Symfony Mime and sent base64url-encoded, so the Message-ID and threading headers are ours rather than assigned by the provider.
Each transport registers only when the environment has something for it to sign in with, and lazily — so a misconfigured provider fails at send time with a sentence rather than at boot with a stack trace. The mailbox form marks the others "not configured", so an administrator doesn't have to read the env file to find out why a mailbox can't send.
Nineteen tests over faked HTTP, down to verifying the JWT signature with a key generated for the test.
Then the Actions minutes ran out
Mid-afternoon. The account's included CI minutes were gone.
So: the four workflows move to a workflows-paused/ directory — the platform only runs from the canonical one — with a README saying what each did and what does it now.
bin/check runs what the test and static workflows ran: formatter, static analysis for every package, every package suite and the reference app, inside the test container so it never touches the dev database. --fast stops after analysis. bin/split does the release-time job from a clean main. The drift check already existed and now runs at the start of a session rather than nightly.
What changed in the guarantee, stated plainly so I don't kid myself:
Nothing runs unless somebody runs it. A green
bin/checkbefore a merge is the same evidence CI gave. A merge without one is a merge with no evidence.
And the invitation flow had never worked
Found on the walkthrough at the end of the day.
An administrator sending a password reset produced a link to a page core never registered. Fortify had no reset-password or forgot-password views bound and no reset action. So nobody invited to the platform could ever set a password. Not "it was awkward" — it was impossible, and had been since the user administration screen shipped a month ago.
While fixing it I also found that Fortify's default feature list had quietly put a /register page on every deployment of an invite-only platform, with nothing behind it. Core owns that list outright now: sign in, reset, second factor.
And this, for the third time on this project from a third direction:
The given role or permission should use guard `` instead of `web`
Core's user routes were bound to core's User class while the app signs in as its own subclass. The permission library found no guard for the base class — and had it got further, the role would have been stored against a model_type that never signs in. Resolved per request rather than at boot, so tooling that boots core with a stock auth model still gets a route table.
Day-one bug. Model factory on day three. Notifications a fortnight ago. Now route model binding. Same root cause every time: a package that defines User and an app that subclasses it.
What Worked
The desk, and the received half of the mail seam
Core already owned the sending half. It now owns receiving: a received-message event with a claim the reader honours. The email module dispatches core's event from its poll; the desk subscribes, matches by reference in the subject or by the message being replied to, raises a case from whoever wrote when nothing matches, reopens a settled case that receives mail, and claims it. First claim wins.
With no email module installed, the desk hears nothing and still works — a reply written then sits on the thread marked not sent. That's the whole point of a seam.
The thread is the desk's own, not an email log: notes staff write to each other and never send, mail in, mail out, and every status or assignment change — one list, in order. The source's thread was email, so an internal note had nowhere to go.
Two kinds of unread, both asked for: the desk's (opening clears it; marking unread is not activity) and personal presence from the same row — "Alice is also viewing", heartbeat every twenty seconds. I had to fix that one immediately: without a heartbeat on load, the person already on the case waited up to twenty seconds to learn somebody else had opened it, which reads as broken rather than slow.
The SLA counts working days against a real calendar — the government's published bank-holiday data ships with the module and a command refreshes it. A case raised on 23 December no longer breaches on Boxing Day.
And the finding the build must not carry, from the analysis: every case message's HTML body and every inline image was served unauthenticated by sequential id in the source, and the authenticated download took a bare identifier. Fifth time this port has found that shape.
A token API in core
Personal access tokens, an ability registry modules declare into, a page where the secret is shown once with minting behind password confirmation, and one middleware group for every endpoint — token guard, then business unit from a header or the person's default.
The nice part: the case-raising endpoint validates against the case record type's own field definitions, option overrides included. So a field added through the settings UI is accepted by the API the same day, with no code change. A field the caller omits takes its declared default.
Document rendering
Nothing server-side in PHP renders HTML/CSS the way a browser does, which is why the source made its PDFs in the browser and lost the bytes. Core now owns a renderer seam — null by default, chosen by environment like the geocoder and the mailer — with a headless-Chromium-in-a-container driver behind a REST API.
A document is one self-contained HTML file, so it renders anywhere, including a container with no route back to the app. A command renders a sample — flex, grid, a table whose header repeats across pages, a fixed footer, the design tokens — through whatever renderer is configured. The test service pins the null renderer so suites never render.
What I Learned
- No lock files means CI can install a newer analyser than your vendor directory. That's the price of lockless library packages, and it will show up as "passes locally, fails in CI" on a day you don't want it.
- A static analyser that resolves view names needs your package's provider booted in its test application, or every view string is an error.
- Graph's
sendMailreturns nothing, not even a Message-ID. Create a draft first if you ever want to thread a reply to your own message. - Write transports against REST with a fakeable HTTP client, not the vendor SDK. Hundreds of megabytes versus five calls, and the tests actually describe the wire format.
- Register a transport lazily and only when it has credentials. Fail at send time with a sentence, not at boot with a stack trace.
- A "safe" environment guard can be the unsafe choice. The recipient override ignoring production sounded careful and removed the one capability you want during a go-live check.
- Fortify's default feature list will put a public
/registeron an invite-only platform. Own the list. - If your package defines
Userand the app subclasses it, route model binding is another place it breaks. That's four distinct failure sites now, all the same root cause. - When CI stops, write down what the guarantee now is. "A merge without a green local check is a merge with no evidence" is the honest version and it's worth saying out loud.
Tech Notes
- Stack: PHP 8.4, Laravel, MySQL/Redis (Docker), SQLite suites, Sanctum, Fortify, PHPStan level 6 via Larastan
- Core seams now: file storage, mailer (send and receive), document renderer, geocoder — all null-by-default, all chosen by environment, all rebindable by a module
- Email transports: Microsoft Graph (draft-then-send for the Message-ID), Google Workspace (service-account JWT, RFC 822 via Symfony Mime, base64url), one transactional REST provider; all tested with
Http::fake() - Every package carries a Testbench config so static analysis boots its provider
- CI paused;
bin/check= formatter + static analysis + all suites, in the test container
bin/check # what CI used to run — required before any merge
bin/check --fast # stop after static analysis
bin/drift-check # now at session start, not nightly
bin/split # release-time package split from a clean main
Zero Focus Dev
Breaking things, learning fast, sharing it all.