Twenty-three commits across four repos today. Looking back at them, most had the same shape underneath: something that was empty, and the question of what empty was supposed to mean.
Empty, none, and unknown are three different answers
I built a card on an integrations screen that shows which permission scopes a stored API credential actually needs. Straightforward — until you hit a provider where you don't know.
The naive model has two states: a list of scopes, or an empty list. That's wrong, because an empty list on screen reads as "this credential needs no special permissions" — which is a confident, specific, and possibly false claim.
There are three states:
- documented — we know the exact scopes, because we know which API calls we make. Show them.
- none — the provider has no scope model at all. It's a plain key that works or doesn't. Saying that is useful.
- undocumented — nobody has written them down yet. Show nothing at all, not an empty list.
final readonly class ScopeGuidance
{
public static function of(array $scopes, ?string $note = null): self { /* documented */ }
public static function none(string $note): self { /* no scope model */ }
public static function undocumented(): self { /* not written down */ }
}
Same rule a vulnerability scanner has to follow: an image that couldn't be scanned records nothing, and unscanned must never render like scanned-and-clean. The absence of a finding is not a finding of absence, and if your data model can't tell those apart, your UI will confidently lie.
While I was in there I also learned that a cloud provider token needs tag:create and not just tag:read if you intend to tag the resources you create — the kind of thing that fails at exactly the wrong moment. That note now lives on the card, above the list, because a scope nobody would guess deserves its reason next to it rather than in a comment nobody reads.
A count that was always zero
Same theme, uglier version: a dashboard tile showing open incidents that read 0 regardless of reality. A zero that's wrong is worse than a tile that's missing, because a wrong zero is reassuring.
The neighbouring tile was rendering a raw enum case name straight into the page — awaiting_promotion where a human wanted "Awaiting promotion". Which is the argument for enums carrying label() and color() rather than the Blade template doing Str::headline() on the way out. Put the presentation on the enum and every surface gets it right; leave it to templates and every surface gets it right separately, until one doesn't.
/storage is already taken
My favourite bug of the day. I added a new section to a Laravel app under the URL prefix /storage. Every test passed. In production, every page under it returned 403.
public/storage is Laravel's own storage:link symlink and it exists on essentially every Laravel deployment. A web server configured with try_files $uri $uri/ matches /storage as a real directory and answers before the request ever reaches PHP. The router never sees it.
The test suite never catches this, because the test suite doesn't go through a web server at all. That's not a gap in the tests, it's a category of bug tests of that kind structurally cannot see.
The fix was a different prefix. The actual fix was a test that walks every registered route prefix and fails if one collides with something that exists in public/:
it('does not register a route prefix that public/ shadows', function () {
$shadowed = collect(File::directories(public_path()))
->map(fn (string $path) => basename($path));
$prefixes = collect(Route::getRoutes())
->map(fn ($route) => Str::before(ltrim($route->uri(), '/'), '/'))
->unique()
->filter();
expect($prefixes->intersect($shadowed))->toBeEmpty();
});
Route names can stay whatever reads well in the sidebar. It's the URL that has to dodge the framework's own conventions.
Refusing loudly beats hiding the button
I shipped a "clone this application" feature — take a running app's code and shape and stand up a copy somewhere else. Then, in the follow-up commit, I gated it: only an application that has deployed successfully at least once may be copied.
An app that has never deployed isn't worth copying. Its environment was never proved to boot anything, its build never produced an artifact, and a copy inherits all of that while looking like a copy of something that works.
The interesting part is how the gate surfaces. Two options:
- Hide the Clone button when the app can't be cloned.
- Show it, and render the reason when it's refused.
I went with 2, and I'd argue it's almost always right. A hidden button is indistinguishable from a feature that doesn't exist, and the user's next move is to ask you why it's missing. A refusal that says "this application has not deployed successfully yet — deploy it once, then it can be copied" is a hidden button that answers its own support ticket.
The shape that makes it work:
/**
* Why this cannot be cloned, or null when it can.
* For rendering, never for deciding.
*/
public function refusal(Application $app): ?string;
refusal() renders. The action enforces the same rule itself, independently. A gate that only lives in a Livewire component is not a gate — it's a suggestion with a nice tooltip.
Naming things for whose thing it is
Two smaller ones from a multi-tenant membership platform, both the same mistake in different clothes.
The first: its MCP server was announcing itself with the name of the toolkit it was built on — the same name for every organisation running it. If an assistant is holding connections to several of these, "which one is this?" is not a question it can answer from a shared name. Now the server names itself after the organisation it belongs to.
The second: the public homepage of each organisation was written in the voice of a software vendor — features, capabilities, the case for the platform. But the person landing there is a member, not someone shopping for member-management software. They want to know how to renew, what's coming up, and who to contact. Rewriting that copy changed no logic at all and was probably the highest-value change of the day.
Both are the same bug: content written from the builder's seat instead of the reader's.
Where the ceiling goes
Last one, from a chess app for kids. I added optional accounts, and with them a question: what does a signed-out player not get?
The easy answer is "a trial" — cap everything, nag for signup. I went the other way. Puzzles, lessons, the journal, cosmetics — all uncapped for a guest. Guest play is the primary path, not a funnel. The ceiling sits on exactly two things: the upper rungs of the engine ladder, and anything that connects you to another human.
Then the part I actually want to note, which is where the rule lives:
abstract final class AccountAccess {
static const int guestLevelCeiling = 3;
static bool allowsLevel(EngineLevel level, {required bool signedIn}) =>
signedIn || level.index <= guestLevelCeiling;
static bool allowsConnectedPlay({required bool signedIn}) => signedIn;
}
One place, unit-testable, consumed by both the level picker and the play controller. A ceiling nobody can unit-test is a ceiling that quietly moves — someone adds a third surface, reimplements the check slightly differently, and now your product rule has two versions of itself.
And locked levels stay visible, greyed with "Make an account to play this one". A child should be able to see the ladder they're climbing, not wonder where it ends.
The through-line
Every one of these was about a value that was empty or absent, and what the system chose to say about it. Empty scope list. Zero incidents. Missing button. Missing account.
The failure mode is always the same: absence gets rendered as a confident negative. No scopes needed. No incidents open. Feature doesn't exist. Nothing above level three.
None of those were true. They were just what empty looked like when nobody decided what empty should mean.
Top comments (0)