DEV Community

Cover image for Dev Log: 27 August 2026 — Everything That Reported Success and Wasn't
Nasrul Hazim
Nasrul Hazim

Posted on

Dev Log: 27 August 2026 — Everything That Reported Success and Wasn't

Thirteen repos moved today, sixty-nine commits. One is public — laravel-billing — and got its own post; the rest is private work, so what follows is the reasoning with the specifics filed off.

Reading the day back, nearly everything landed on one theme, and it's an uncomfortable one: the system reported success and wasn't successful. Not crashes. Not exceptions. Green ticks, happy log lines, confident UI copy — all sitting on top of something that hadn't actually happened.

That class of bug is expensive because your instinct for finding bugs is trained on things going wrong. These went right, loudly.


A green tick over an empty release

The public one first, since it sets the tone. I tagged a major release against the wrong commit. Deleted the tag, re-pushed it at the right one, moved on.

composer require kept installing the original archive — the empty one — because the package registry caches a tag's dist and re-pushing a corrected tag doesn't invalidate it. composer show reported the new version quite happily. composer clear-cache does nothing; the stale artifact isn't local.

The only fix is to supersede with a new version and delete the bad one so nothing can resolve to it. Full write-up in the other post.

Absence read as permission

The sharpest one of the day, from a multi-tenant SaaS.

Feature resolution was written as:

$tenant->plan?->grants($feature) ?? $feature->enabledByDefault()
Enter fullscreen mode Exit fullscreen mode

and eleven of the twelve feature cases default to ON. Read that line again with a lapsed customer in mind. Clearing an organisation's plan — the obvious, intuitive way to cut off a non-payer — handed them everything the top tier buys, with every quota resolving to unlimited. Deleting a plan did the same thing to every organisation on it, because the relation resolved to null through the soft-delete scope.

The trap here is that the defaults weren't wrong. On-premise there are no plans at all; the customer bought the whole product, so ON is correct. The bug wasn't the fallback, it was expressing restriction as absence — two very different situations collapsed into one null.

The fix has three parts worth stealing:

1. Make the baseline depend on the deployment, not on the null. On-premise, absence still means "everything" — byte-for-byte the pre-plan behaviour. On SaaS, the plan is the entitlement, so its absence is not a licence: baseline denies, and an organisation that loses a plan fails closed rather than open.

2. Restriction gets an explicit row. A seeded lapsed plan that denies every feature, every quota zero, is_active = false so it never shows on the pricing page and never appears as an upgrade suggestion. Lapsing is now something you assign, not something you remove.

3. Quotas needed the mirror-image fix and a different answer. Null meant unlimited, so taking a plan away made limits infinite. But setting an unentitled org's limit to zero would make the members it already has a violation. So it freezes at current usage instead — refuses growth, leaves what exists alone. Which is exactly what a downgrade already does, so the behaviour was already in the product; it just wasn't reachable.

Two smaller things fell out of the same audit, and both are the same shape:

  • One screen had its own copy of the plan → baseline fall-through, which would have shown an operator a grant that resolution then denied. Resolution lives in one method now, and every surface asks it.
  • The plan seeder was forcing is_active on update, and it runs every deploy. So a plan an operator had deliberately pulled from sale was back on the pricing page after the next deployment. The product owns the grants map and the prices; the operator owns whether it's sold. Seed that on create only.

Registered twice, so it fired twice

Same app. Every listener was registered twice, and had been for a while.

Laravel registers its own EventServiceProvider alongside yours. Left to itself, it discovers every class in app/Listeners and registers it a second time as Listener@__invoke, on top of your $listen entry. Six events, every declared one, firing double.

It hid for months because almost everything downstream was idempotent — the action that issues a member card returns the existing card, so nobody ever got two cards. Three mail listeners have no such guard. Applicants had been receiving duplicate submitted, approved and activated emails.

The fix is one line in bootstrap/app.php:

->withEvents(discover: false)
Enter fullscreen mode Exit fullscreen mode

Two things I'd flag before anyone copies that:

  • Check every listener on disk is actually declared before you disable discovery. All thirteen were here. Had one been discovery-only, this change would have silently stopped it firing — which is strictly worse than a duplicate email. Turning a loud bug into a quiet one is not a fix.
  • The shouldDiscoverEvents() override on your own provider does not do this, and reads exactly as though it does. Discovery consults the framework's provider, not yours. I annotated it rather than deleting it, so the next person doesn't rediscover this at the same cost I did.

Idempotency saved us on the writes and exposed us on the sends. Worth asking of any listener you have: if this ran twice, would anyone notice? If the answer is "no", you have less test coverage than you think.

A sweep that moved the leak instead of closing it

A different SaaS, same shape. Yesterday's work added a sweep that marks an expired subscription PastDue. Shipped, tested, done.

Except PastDue grants access — deliberately, so a provider hiccup doesn't cut off a paying customer mid-period. So the sweep didn't close the leak it was written to close. It moved it from "Active forever" to "PastDue forever". A customer whose renewal never arrived kept a paid plan indefinitely, for free, exactly as before.

The status enum had documented PastDue → Canceled (retry grace period exhausted) since the day it was written. Nothing implemented it. A documented transition with no code behind it is a comment, and comments don't run.

Now the billing tick makes that transition after a configurable grace window past the period end. Covered by a test that walks one subscription through the whole lifecycle — active, lapse, past-due with access intact, grace exhausted, cancelled, workspace back to free — because each step passing in isolation is exactly what let the gap through in the first place.

Copy that promised something the code didn't do

Same app, and this one is the theme in its purest form.

The marketing site had been selling every paid tier with "Start 14-day trial, no card required". The app charged immediately. The Trialing status was only ever set by an admin screen and a console command, which meant the trial reminder and expiry sweeps had never fired for a single self-serve customer — the machinery existed and had nothing to act on.

So the first paid-plan selection now genuinely starts a trial: no provider round trip, no charge. One per workspace ever, keyed on "has this workspace ever had a subscription row" including soft-deleted ones, because deleting a lapsed subscription and starting over must not mint a second free fortnight.

Ending the trial splits on whether the customer did the thing the trial existed to prompt:

  • Live mandate → autopay is set up, only the first collection is outstanding → PastDue, access intact, grace window covers the collection.
  • No mandate, or one still waiting on the bankCanceled, back to free.

Neither branch rolls the billing period or issues an invoice, and that's load-bearing rather than an omission: the period has to stay ended so the first collection callback reads as paying for the next one. Roll it there and the webhook's duplicate guard swallows the genuine charge — customer billed, no invoice to show for it.

There's a mandate reconciliation step in the same sweep, running first so everything after it decides on fresh state. The provider's approval callback isn't guaranteed to arrive, and a mandate stuck at "waiting approval" tells a customer who did set up autopay that they didn't — then downgrades them when the trial ends. Now the sweep asks the provider directly for any mandate not in a final state.

Then the small one that closes the loop: every plan card still read "Choose Solo" while the first click no longer checked out. A button that promises one thing and does another is the same defect whichever direction it runs in — it doesn't stop being a lie because the surprise is pleasant. Label follows eligibility now, with a test asserting both states, because the failure mode here is copy drifting away from behaviour and nothing catching it.

A knob nobody can find may as well not exist

Same app, different flavour of the same theme. An audit of every env() read in config/ against .env.example found 24 of the app's own keys documented nowhere an operator would look — including the master switch that controls every plan limit and whether the billing section exists at all. Deploying the hosted mode meant reading the config file to discover it.

The fix isn't the documentation, it's the guard:

it('documents every env key the config reads', function () {
    $used = collect(File::files(config_path()))
        ->flatMap(fn ($f) => str($f->getContents())
            ->matchAll('/env\(\s*[\'"]([A-Z0-9_]+)[\'"]/')
        )
        ->filter(fn ($k) => str($k)->startsWith(['APP_', 'BILLING_', 'AUDIT_']))
        ->unique();

    $documented = str(File::get(base_path('.env.example')))->toString();

    expect($used->reject(fn ($k) => str_contains($documented, $k))->all())
        ->toBeEmpty();
});
Enter fullscreen mode Exit fullscreen mode

Config drift only ever gets worse, and it's the cheapest possible thing to pin.

Two audit findings were false alarms, and I wrote those down too — so nobody "fixes" a command that belongs to the customer's app, or a setting that only appears in a superseded ADR and is meant to read as history.

The deploy that reported nothing and served 502

Infrastructure product. A deploy step emitted a console command with a --force flag. --force is a migrate flag; the command in question doesn't define it, so Symfony Console aborts with The "--force" option does not exist. before running anything.

On an ordinary hook, that's a warning in a log. This was a systemd ExecStartPre. Pre-start failing means the service never starts, so the deploy never links current, so the PHP-FPM pool has no working directory — and the reverse proxy answered 502 on every request until somebody read the journal. Migrations had already applied by then, so the database was ahead of the code that was serving nothing.

The deploy script has had the correct invocation since the day it was written, with a comment saying exactly why. The line was copied without the comment being read.

I have no clever architectural fix for that one. The honest lesson: a pre-start hook is a gate, not a log line. Anything you put there needs to be as reliable as the service itself, and "it worked when I ran it by hand as my user" is not that.

Elsewhere in the same product today: zero-downtime releases went in — blue-green on the container runtime, readiness-gated on the orchestrator — plus per-environment mail sandboxes for non-production, and DNS work that now checks which zones the API token can actually edit before the deploy, then tells you whether it'll be automatic or manual. Two more of the same family:

  • A caching resolver returning nothing is not evidence about a zone. Silence isn't a negative answer.
  • A post-install step has to be the account that owns the file tree, not merely have permission to write to it. Close enough works right up until it doesn't.

And a subtraction I'm pleased with: three tabs and a cost estimate got withdrawn from the deployments screen. They rendered plausible numbers that weren't real. A UI that shows you a confident wrong figure is worse than a UI that shows you nothing, because you'll act on it. Shipping less is allowed.

Renaming a product across four repos

Lighter, but instructive. A consumer app I'm building got renamed — docs, mobile app, marketing site, API, all four.

A product name is never just a string. It's in the Android application id and the manifest, the iOS bundle identifier and Info.plist, the native build config, the icon assets and the maskable variants, the OG card, the web manifest, the favicon route, the SEO config, a Blade brand component, and the integration tests that assert on branding. Rename the display string only and you've renamed the label, not the product.

Two things that made it survivable: the branding tests failed on the rename rather than passing over it, and the screenshots regenerate from a script rather than being captured by hand. A rename is a great stress test for whether your assets are generated or curated. Mine were mostly generated. Mostly.

Two UI bugs surfaced in the same pass, both invisible-by-design:

  • The dark theme's container colour was the same value as the card fill, so every card boundary was invisible in dark mode. Not an error, not a warning. Just nothing where a shape should be.
  • The wide layout only composed at certain window sizes. Fixed to compose at any size, which is the only version of that statement that's testable.

Also added the operating entity's registered name, registration number and address to the privacy policy — boring, mandatory, and much easier to do before a store review than during one.

A new project, scaffolded properly

One genuinely new thing today: a fitness product went from nothing to a Phase 1 SDLC doc set, a marketing and legal site, and a first app commit with a workspace, an exercise catalogue and a rep-counting state machine.

The rep engine being an explicit FSM from commit one is the choice I'd defend. Rep counting is all about ambiguous in-between states — half-reps, pauses, the user putting the phone down mid-set. A state machine makes every one of those a named state you can test in isolation, instead of a pile of booleans that agree with each other most of the time.

The docs got a same-day correction too: the design doc was synced to what was actually built, not left describing the plan. A design doc that's drifted from the code is worse than no design doc, for the same reason as everything else in this post — it's a green tick over an empty release.


The thread

Seven independent bugs today, and the same sentence describes every one:

Something reported success, and the report was the only evidence anyone checked.

An empty package that composer show vouched for. A null plan that resolved to full access. A listener registered twice with no complaint from anywhere. A sweep that set a status that didn't do what the status name suggested. Copy that sold a trial the code never started. A pre-start hook that aborted before doing anything, with a 502 as the only symptom. A card boundary rendered in exactly the colour of the card.

None of these throw. That's the whole problem, and it points at one practical habit: test the transition, not the state. Every one of these had a test suite that was green because each individual state was correct in isolation. The subscription lifecycle test that walks a single row from active to cancelled found what five status-level tests couldn't. The .env.example guard checks a relationship between two files, not the contents of either.

Assert on the thing that has to stay true across the seam. That's where these live.

Top comments (0)