DEV Community

Cover image for Dev Log: 2026-08-19 — the bugs that never threw
Nasrul Hazim
Nasrul Hazim

Posted on

Dev Log: 2026-08-19 — the bugs that never threw

Thirteen commits across five repos today, and once I lined them up in the evening the pattern was uncomfortable: almost none of them were bugs that threw an exception. No stack trace, no 500, no failing test. Every one of them was a system cheerfully reporting success while doing the wrong thing.

That's the expensive category. An exception gets logged, alerted, fixed on Tuesday. A silent wrong answer gets discovered by a user in month four.

Here's the roll call.

1. A comparison that was never true

A password-reset token table had an isExpired() check. Something like:

return now()->diffInMinutes($this->created_at) > $ttlMinutes;
Enter fullscreen mode Exit fullscreen mode

Reads fine. Passed review. Ran in production for months. And never expired a single token — an eleven-day-old reset link still worked.

Carbon 3 made diffIn*() signed. now()->diffInMinutes($past) is negative. Negative is never greater than sixty. The condition simply cannot fire, and nothing anywhere complains about a comparison that's always false.

The fix is to stop doing arithmetic on directions you have to reason about:

public function isExpired(): bool
{
    return $this->created_at === null
        || $this->created_at->addMinutes($this->ttlMinutes())->isPast();
}
Enter fullscreen mode Exit fullscreen mode

No sign, no subtraction, no argument-order trivia. "The deadline has passed" is a question with one obvious reading.

If you upgraded to Carbon 3 and didn't audit for this, go grep diffIn in anything time-sensitive right now. Expiry checks, rate windows, "is this stale" guards. Same trap fits all of them, and none of them will tell you.

While in there, both reset endpoints picked up throttles — 5 per 15 minutes per email+IP on the request form, 10 per 15 minutes per token+IP on the submit. A reset endpoint with an unlimited budget is an account-enumeration oracle even when everything else is correct.

2. The checklist that lied in green

Same auth flow, different silence. The reset screen showed a requirements panel — "At least 8 characters" — with checkmarks rendered statically green. Always green. Decorative.

The enforced policy was minimum twelve.

So people typed a nine-character password, saw a wall of green ticks, hit submit, and validate() bounced them with an error they didn't connect to the panel that had just told them they were fine. The audit trail was a beautiful piece of evidence: N reset emails clicked, zero password-attempt rows. People were clicking through and giving up at the last step, and nothing in the logs looked like a failure.

Two changes. The panel now mirrors the enforced rule set exactly, and it validates live per keystroke instead of being a picture of a checklist.

The general rule I wrote into the project notes: if the displayed policy and the enforced policy live in two files, they will diverge, and the divergence will be invisible. Either derive the display from the rules, or change both in the same commit — but never let a UI assert something it isn't asking anyone.

3. A dual write inside a swallowed try/catch

The big one. An identity system stores verified personal emails locally and pushes them into an external system of record that other, older applications read from. Classic dual write, and the second half was wrapped like this:

try {
    $this->syncToExternalStore($user, $email);
} catch (Throwable $e) {
    Log::error('sync failed', ['error' => $e->getMessage()]);
}
Enter fullscreen mode Exit fullscreen mode

Which means: local row says verified, external store says nothing, and the user is shown a green success message. Two systems disagreeing, zero signal, and the log line rotates out in a fortnight.

Then a second hole underneath it. The "does this already exist" guard checked only that a row existed for this identifier — not that the row was actually flagged verified. So a row written earlier by a different system, sitting there unverified, made the sync decide there was nothing to do. The flag never got set.

Nothing in the app itself ever read the external store, so nothing in the app could ever notice. The consequence surfaced somewhere else entirely — a separate legacy screen resolving people to a stale identity, months later, via a support ticket.

Three fixes, and they're the three you generally want for any dual write:

  1. Make the write an upsert, not a conditional insert. ensureVerified() flips an existing-but-unverified row instead of skipping it. Conditional inserts that check for presence rather than for the state you actually want are a recurring bug shape.
  2. Fail loud. The catch still catches — the local write shouldn't roll back because a remote system is down — but it now sends an admin notification instead of only logging. If a swallowed error has no path to a human, it isn't handled, it's hidden.
  3. Ship the reconciler with the fix. A console command that finds rows where local and remote disagree and backfills them, with --dry-run, --limit, and a single-record --identifier mode. Because fixing the write path does nothing for the divergence you already have — and once you accept that a dual write can drift, a reconciler stops being cleanup and starts being infrastructure.

There was a nice testing detour too. The remote tables are addressed with a dotted schema prefix, which sqlite handles via ATTACH DATABASE ':memory:' AS <schema>. That can't run on the default connection — RefreshDatabase already has a transaction open there — so the test uses a dedicated connection for the attach. Worth knowing if you've ever given up on testing a schema-qualified legacy table and mocked the whole repository instead.

4. Engagement is not a reply

An automation engine had a "stop when the contact replies" condition. It matched any inbound activity.

In production, that meant a form submission with a message attached created an inbound note — which immediately stopped the very automation that submission had just triggered. Enrollment created, enrollment halted, before a single email went out. Nobody complained, because the failure mode of a drip campaign is silence, and silence looks exactly like a drip campaign that hasn't reached step one yet.

Open and click tracking counted too. Opening an automation's own email was recorded as replying to it.

$replied = Activity::query()
    ->where('contact_id', $contact->id)
    ->where('direction', ActivityDirection::INBOUND->value)
    ->whereIn('type', [
        ActivityType::EMAIL->value,
        ActivityType::WHATSAPP->value,
        ActivityType::CALL->value,
    ])
    ->where('occurred_at', '>=', $enrollment->enrolled_at)
    ->exists();
Enter fullscreen mode Exit fullscreen mode

The lesson isn't the whereIn. It's that "inbound" and "a human answered you" are different concepts that happened to share a column. Direction is a transport property. Reply is a semantic one. Once tracking pixels, system logs and capture notes all live in the same activity table, direction stops carrying the meaning you're asking it to carry.

Which leads directly to the next commit.

5. Make the silence legible

The analytics page had an "Exited" card showing a count. Just a number. The reason each enrollment stopped was visible nowhere except by opening each contact individually.

So the reply bug above was, in principle, visible in the data the whole time. Sixty exits, all with the same reason, all before step one. Nobody could see it because the UI aggregated the one field that mattered into an integer.

That page now gets reason chips ("Contact replied × 2") and a detail table: who, why, where — which step actually executed, or explicitly "before step 1, nothing sent" — and when. The reason labels moved onto the model as stopReasonLabel() / stopReasonDescription(), shared with the contact page, so the raw enum string stops leaking into two different views with two different formattings.

"Before step 1 — nothing sent" is the phrase I'm happiest with. That's the sentence that would have caught the bug in a glance.

If you have a counter for a bad outcome, you owe yourself a breakdown of it. A number tells you something is wrong. Only the breakdown tells you it's always the same wrong.

6. The cache that made a route disappear

Deploy script cleared config caches and view caches, and — for however long it had existed — not the route cache.

So the moment a deploy added a new route, production kept resolving against the stale cached route table. Any view calling route() for the new name threw RouteNotFoundException, which surfaced as a 500 on pages that had nothing obviously to do with the new feature.

  php artisan config:clear
+ php artisan route:clear
  php artisan view:clear
Enter fullscreen mode Exit fullscreen mode

One line. The only reason it's in this write-up is that it's the one bug today that did throw — and it still took a while to place, because the exception surfaced three screens away from the change that caused it. Cache invalidation gaps are like that: the error appears wherever the stale data is read, never where it was written.

Check your deploy script covers config, routes, views, and events. If it clears two of the four, the missing two are a bug waiting for the right commit.

7. Two small ones on the marketing side

DevHub's site grew a proxy rule so /dl/* on the main domain serves ebook PDFs that live on a completely separate, unlinked deploy — one line in _redirects, 200 status so it proxies rather than redirects, and the SPA fallback stays last (order matters; a catch-all above it eats everything below).

The reason for the split is worth a sentence: the files shouldn't be listed, crawled, or discoverable from the site, but the links people receive should still sit on the main domain rather than looking like a random third-party host. Proxy gives you both — one origin for the reader, separate lifecycle for the assets.

The store behind it got tidied the same day: everything that's actually live separated from everything that's parked, so "what's shippable right now" is a directory listing rather than a memory test. Small change, but the thing that decides whether you send the right link at 11pm.

What today actually taught me

Six of today's seven items shipped a wrong result while reporting success. That's not bad luck, it's a category — and it has a shape:

  • Comparisons that can't fire. Signed diffs, always-false guards. Nothing is thrown when a condition is simply never true.
  • Displays that don't derive from the rule they describe. The checklist, the exit counter. A UI asserting something nobody verified against the source.
  • Errors caught with no route to a human. A Log::error in a catch block is not error handling if nobody reads the log.
  • Fields carrying meaning they were never given. direction doing duty as "replied".

None of these are caught by tests you'd think to write, because you'd have to already suspect the answer is wrong. What catches them is making the outcome visible — the exit-reason breakdown, the admin notification, the reconciler's dry-run report. Observability isn't only for outages. It's mostly for the failures that never page anyone.

Tomorrow: the reconciler runs against real divergence for the first time. I'm expecting a number I won't enjoy.

Top comments (0)