<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nasrul Hazim</title>
    <description>The latest articles on DEV Community by Nasrul Hazim (@nasrulhazim).</description>
    <link>https://dev.to/nasrulhazim</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F47230%2Fc062b1f5-2c98-4750-8877-6991f248b4bf.jpg</url>
      <title>DEV Community: Nasrul Hazim</title>
      <link>https://dev.to/nasrulhazim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nasrulhazim"/>
    <language>en</language>
    <item>
      <title>Dev Log: 3 September 2026 — Everything I Fixed Today Was Already Written</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:40:37 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-3-september-2026-everything-i-fixed-today-was-already-written-1emi</link>
      <guid>https://dev.to/nasrulhazim/dev-log-3-september-2026-everything-i-fixed-today-was-already-written-1emi</guid>
      <description>&lt;p&gt;Six commits across two codebases today — a deployment platform and an identity system. Different stacks, different problems. Reading them back in one sitting, the same shape six times:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The feature was written. Someone just couldn't reach it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not one of these was a missing implementation. The role management existed. The setup-command runner existed. The delivery tracking existed. The "already verified" message existed. All of it shipped, tested, working — and unreachable, because of a default, a branch, a missing entry point, or a token that had already been spent.&lt;/p&gt;

&lt;p&gt;That's a genuinely different category of bug from "we haven't built it yet", and it's much harder to see, because every code review of the feature passes. The feature is fine. The path to it isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The badge in the &lt;code&gt;@else&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Start with the smallest one, because it's the clearest.&lt;/p&gt;

&lt;p&gt;A team page shows members and lets an owner change their roles. The component had &lt;code&gt;addMember&lt;/code&gt;, &lt;code&gt;changeRole&lt;/code&gt; and &lt;code&gt;removeMember&lt;/code&gt; all along. But the page read as though role management didn't exist.&lt;/p&gt;

&lt;p&gt;The badge naming a member's &lt;strong&gt;current&lt;/strong&gt; role was inside the &lt;code&gt;@else&lt;/code&gt; branch of &lt;code&gt;@can('manageMembers')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sit with what that means. A plain member saw everyone's role and could change nothing. An owner could change any role and &lt;strong&gt;never saw what it currently was&lt;/strong&gt; — and the dropdown offered "Set as Lead", "Set as Member", "Set as Viewer" identically, with no marker on the one already true. The page never named a role to the one person allowed to act on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- Before: the person with the power was the one kept in the dark --}}
@can('manageMembers', $team)
    &amp;lt;x-dropdown&amp;gt;...&amp;lt;/x-dropdown&amp;gt;
@else
    &amp;lt;x-badge&amp;gt;{{ ucfirst($member-&amp;gt;pivot-&amp;gt;role) }}&amp;lt;/x-badge&amp;gt;
@endcan

{{-- After: the badge is information, not a permission --}}
&amp;lt;x-badge&amp;gt;{{ $roleLabels[$member-&amp;gt;pivot-&amp;gt;role] }}&amp;lt;/x-badge&amp;gt;
@can('manageMembers', $team)
    &amp;lt;x-dropdown&amp;gt;...&amp;lt;/x-dropdown&amp;gt;
@endcan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two follow-ons worth naming:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The label came from &lt;code&gt;ucfirst()&lt;/code&gt; on a raw pivot value.&lt;/strong&gt; Untranslated, and it disagreed with the labels in the dropdown sitting &lt;em&gt;right next to it&lt;/em&gt;, which came from the enum's &lt;code&gt;label()&lt;/code&gt;. If you've got an enum with &lt;code&gt;label()&lt;/code&gt;/&lt;code&gt;color()&lt;/code&gt;, that enum is the only place a role should ever be turned into words. I pass a &lt;code&gt;[value =&amp;gt; label]&lt;/code&gt; map in from &lt;code&gt;render()&lt;/code&gt; rather than resolving the enum in the Blade file — an inline &lt;code&gt;@php()&lt;/code&gt; holding a nested static call is its own landmine in a component view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three of four confirmations were arriving a page late.&lt;/strong&gt; The toast component lives in the layout and &lt;em&gt;pulls&lt;/em&gt; session &lt;code&gt;toast.*&lt;/code&gt; keys as it renders. So a &lt;code&gt;session()-&amp;gt;flash()&lt;/code&gt; from a Livewire action that doesn't redirect surfaces on the next full page load — which, for &lt;code&gt;addMember&lt;/code&gt;, &lt;code&gt;removeMember&lt;/code&gt; and &lt;code&gt;changeRole&lt;/code&gt;, means never at the moment it meant anything. Those dispatch a browser event now. &lt;code&gt;delete()&lt;/code&gt; still flashes, because it redirects, and that's what the flash mechanism is actually for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you're mixing Livewire actions and a layout-level toast: &lt;strong&gt;flash is for redirects, dispatch is for stays.&lt;/strong&gt; Getting that backwards produces a bug where the user's action appears to do nothing and then a stale confirmation appears somewhere unrelated.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And a third: &lt;code&gt;removeMember()&lt;/code&gt; refuses to remove the organisation owner, and reported that refusal via &lt;code&gt;addError('memberEmail')&lt;/code&gt; — a field on the &lt;em&gt;add member&lt;/em&gt; form. So clicking Remove put a validation error on an input the operator wasn't touching, and said nothing about what actually happened. It's a dispatched error toast now, and the menu item is disabled for the owner instead of offered and then refused. The server-side guard stays, obviously: &lt;strong&gt;a disabled control is not an authorisation check.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The runner with no door
&lt;/h2&gt;

&lt;p&gt;Same shape, one level up.&lt;/p&gt;

&lt;p&gt;There's an action and an MCP tool for running an application's declared post-install commands. Both have existed for weeks. What didn't exist was any way to reach them from the application's page in the UI.&lt;/p&gt;

&lt;p&gt;So the workaround for "I need to run a seeder on this host" had become: &lt;strong&gt;push an empty commit&lt;/strong&gt; to trigger a deploy. A workaround with a commit in it. That's the tell that something's unreachable rather than unbuilt — the workaround is absurd and everyone's fine with it.&lt;/p&gt;

&lt;p&gt;The panel I added is mostly wiring, but two decisions in it are worth writing down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The list of commands is read from the same config the deploy reads.&lt;/strong&gt; Not a copy. Two lists drift, and the drift doesn't arrive as a diff — it arrives as "it worked when I ran it by hand and failed on deploy", six weeks later, on someone else's shift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The refusal order matters, and it's the opposite of the obvious one.&lt;/strong&gt; My deployment policy already refuses while an application is provisioning or being destroyed. So authorizing first handed an operator who is mid-deploy a bare &lt;code&gt;403&lt;/code&gt; — for something that is not a permission problem at all. The status check runs first now, and says what's actually true:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RunSetupRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Application&lt;/span&gt; &lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Status BEFORE authorization: a mid-deploy operator is not&lt;/span&gt;
    &lt;span class="c1"&gt;// unauthorized, and telling them so sends them to the wrong person.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;canRunSetup&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;back&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setupBlockedReason&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'runSetup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual objection to ordering it this way is information disclosure — you've told an unauthenticated-ish caller something about the resource before checking their rights. Worth checking every time, and here it's fine: the page is already tenancy-scoped, so anyone who can read that status could read it from the deployment list beside it. &lt;strong&gt;If that weren't true, authorize first and accept the worse error message.&lt;/strong&gt; That trade-off is real, and it goes the other way often enough that it's worth naming rather than assuming.&lt;/p&gt;

&lt;p&gt;Two smaller things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The output is kept and shown.&lt;/strong&gt; The reason an operator reached for this is that the app was refusing to start. "Setup completed" with no output leaves them exactly as unable to distinguish a seeded database from a silently skipped seeder as they were before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The force checkbox carries a sentence, not the word "force".&lt;/strong&gt; These commands are first-install by nature and mostly &lt;em&gt;not&lt;/em&gt; idempotent — a seeder calling &lt;code&gt;User::create()&lt;/code&gt; fails the second time, and re-provisioning a tenant database is worse than failing. The label says that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It runs what the application already declared, in order, once. It is not a shell and not a step toward one. An arbitrary-command surface on a customer's production node is a different feature with a different threat model, and I'd want to design it as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tracking that was complete, and off
&lt;/h2&gt;

&lt;p&gt;Over in the identity system, every message on the mail-history detail screen read &lt;strong&gt;"No delivery events recorded"&lt;/strong&gt; — including the account-verification mail whose delivery we're asked to prove more often than any other.&lt;/p&gt;

&lt;p&gt;The package was complete. The &lt;strong&gt;defaults&lt;/strong&gt; weren't. The config declared tracking as &lt;code&gt;env('MAIL_HISTORY_TRACK_OPENS', false)&lt;/code&gt;, and production's &lt;code&gt;.env&lt;/code&gt; carried neither key. So the tracking routes were never registered and no pixel was ever injected. I probed it live before touching anything: both the open and click endpoints answered &lt;code&gt;404&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/mailhistory.php&lt;/span&gt;
&lt;span class="s1"&gt;'track_opens'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_HISTORY_TRACK_OPENS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="s1"&gt;'track_clicks'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_HISTORY_TRACK_CLICKS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flipping a default is a one-line diff and a real decision. &lt;strong&gt;There is deliberately no UI switch for it&lt;/strong&gt; — whether an activation email was opened is not an operator preference, it's an audit question. And defaulting to &lt;code&gt;true&lt;/code&gt; means no environment needs an &lt;code&gt;.env&lt;/code&gt; edit to be correct, which matters when the environment you most need it in is the one you touch least.&lt;/p&gt;

&lt;p&gt;The consequence you have to think about: click rewriting genuinely starts running in production for the first time. So Laravel's &lt;strong&gt;signed&lt;/strong&gt; email-verification URL joins the exclusion list, because its &lt;code&gt;?expires=...&amp;amp;signature=...&lt;/code&gt; is exactly the escaped-ampersand round trip that breaks under rewriting — and a broken signature locks an account out of verifying itself. &lt;strong&gt;Turning on a feature that has never run in production is a deploy, not a config change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two counting rules that went into the statistics service, both of which are the kind of thing that silently produces a plausible wrong number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engagement is counted from the events table, never from &lt;code&gt;status&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;status&lt;/code&gt; holds only the &lt;em&gt;latest&lt;/em&gt; state, so a click hides the open that preceded it. Count events, not states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count distinct messages, not event rows.&lt;/strong&gt; One recipient opening four times must not push an open rate past 100%. If a percentage can exceed 100, the denominator is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then a set of surfaces on top: summary cards computed &lt;em&gt;inside&lt;/em&gt; the Livewire component so they follow the active filters instead of silently contradicting them; an empty state that distinguishes &lt;strong&gt;"nobody opened it"&lt;/strong&gt; from &lt;strong&gt;"nothing was ever measured"&lt;/strong&gt; (they used to render identically, which is how the original bug hid for so long); a report page with period, status and origin breakdowns; and three MCP tools behind the same permission as the screens.&lt;/p&gt;

&lt;p&gt;The MCP tools never return a message body or a clicked URL — only the destination host. Verification and reset links live in there, and a read-only reporting tool is not a place to hand them out.&lt;/p&gt;

&lt;p&gt;Every surface now reports the tracking state next to the figures. A number whose collection was off for an unknown period is not a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token that was already spent
&lt;/h2&gt;

&lt;p&gt;The best one, and the one with real user pain attached.&lt;/p&gt;

&lt;p&gt;A personal-email verification link works once. The token is nulled the moment it's spent — correctly, that's the point. But the lookup is &lt;strong&gt;by that token&lt;/strong&gt;. So a second click on a link that had &lt;em&gt;worked perfectly&lt;/em&gt; fell into the &lt;code&gt;Invalid or expired verification link&lt;/code&gt; branch. The &lt;code&gt;already_verified&lt;/code&gt; branch sitting right below it was &lt;strong&gt;unreachable by construction&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Subject&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'verification_token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;invalidLink&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ← every second click lands here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;personal_email_verified_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;alreadyVerified&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ← unreachable: token is gone by now&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users whose accounts had verified flawlessly were being told their account was broken. That's a support ticket, then six more.&lt;/p&gt;

&lt;p&gt;Nulling the token stays. What changes is the response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Post/Redirect/Get.&lt;/strong&gt; On success it redirects to a landing route carrying the subject id in the session, so a refresh no longer replays a spent token URL. Verification is a state change; it should not be sitting behind a re-runnable GET in someone's history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unresolvable token redirects to that same landing page&lt;/strong&gt;, which reports &lt;em&gt;"Email Already Verified"&lt;/em&gt; rather than a red failure. And here's the honest part: &lt;strong&gt;once the token is gone, the two cases genuinely cannot be told apart.&lt;/strong&gt; A never-issued token and a spent one look identical. So the page says what's true for the overwhelming majority, and keeps a quiet "never verified?" hint for the rest. When you can't distinguish two cases, pick the message that's right most of the time and leave a door for the exception — don't show a failure page to be safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mail no longer claims the link "will expire in 24 hours".&lt;/strong&gt; There's no expiry column, and the verification never checked the token's age. The claim was fiction — and it's precisely what made the old failure page read as &lt;em&gt;plausible&lt;/em&gt;. A user clicks their link twice, sees "invalid or expired", remembers the email said 24 hours, and concludes the system is right and they're late. &lt;strong&gt;A false reassurance in an email is what turns a confusing error into a believed one.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The two dead failure views are deleted, along with the branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one that wasn't already written
&lt;/h2&gt;

&lt;p&gt;One bug today was mine, fresh, and shipped an hour earlier: a baseline of security headers I'd added to every generated nginx vhost was &lt;strong&gt;appending&lt;/strong&gt; rather than deferring, so applications that set their own &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt; were answering with &lt;code&gt;DENY&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;SAMEORIGIN&lt;/code&gt;, which browsers read as neither.&lt;/p&gt;

&lt;p&gt;That one got its own post, because the nginx mechanics deserve the space — &lt;em&gt;"nginx add_header appends. It doesn't override."&lt;/em&gt; (companion post, link at review time). Short version: &lt;code&gt;add_header&lt;/code&gt; can't ask whether the upstream already sent a header, a &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt; can answer before you ask, and a security header added carelessly is a downgrade rather than an addition.&lt;/p&gt;

&lt;p&gt;Worth noting &lt;em&gt;how&lt;/em&gt; it was found, though, because it fits today's theme from the other side: I went and checked the live response headers right after claiming they were proven. The test suite was green. The suite verified the config generator, which was working exactly as written. &lt;strong&gt;Nothing in CI could have caught it&lt;/strong&gt;, and I'd have believed CI for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Six bugs, one shape. If I had to write the search query for tomorrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anything inside an &lt;code&gt;@else&lt;/code&gt;.&lt;/strong&gt; Ask who's in the other branch and what they lose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any branch that can't be reached&lt;/strong&gt; given how the state above it is mutated. The &lt;code&gt;already_verified&lt;/code&gt; check was dead code that looked like a safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any &lt;code&gt;env(..., false)&lt;/code&gt; default&lt;/strong&gt; in a config for a feature you assume is on. Then go probe the endpoint in production instead of assuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any action reachable only from an MCP tool, a console command, or a deploy.&lt;/strong&gt; If the workaround involves an empty commit, the door is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any two states that render identically.&lt;/strong&gt; "No events recorded" meaning both &lt;em&gt;nobody engaged&lt;/em&gt; and &lt;em&gt;nothing was measured&lt;/em&gt; is how an off switch hides for months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these show up as failing tests, because the code under test is correct. They show up when you use the thing as the person it was built for — which is the cheapest verification step available and the one easiest to skip.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Agent Skills All Said How to Start. None Said When to Stop.</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:37:55 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/my-agent-skills-all-said-how-to-start-none-said-when-to-stop-4a72</link>
      <guid>https://dev.to/nasrulhazim/my-agent-skills-all-said-how-to-start-none-said-when-to-stop-4a72</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I read someone else's skill library, went back to my own 32 skills, and found every one of them was a tutorial. They all explained how to begin a task. Not one explained how to know you were finished, or what a wrong turn looks like on the way. &lt;a href="https://github.com/nasrulhazim/claude" rel="noopener noreferrer"&gt;Claude Toolkit&lt;/a&gt; 2.5.0 adds three sections — Common Rationalizations, Red Flags, Verification — to the nine skills where being wrong is expensive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;A skill file for a coding agent is a strange document. It's not documentation, because nobody reads it for reference. It's not a prompt, because it's loaded conditionally. It's closest to a &lt;strong&gt;playbook you hand a competent contractor on their first day&lt;/strong&gt;: here's how we do this, here's what we care about, here's what will get you a phone call.&lt;/p&gt;

&lt;p&gt;Mine were only ever the first third of that. Take the deploy skill before this release. It knew the deploy command, the SSH flow, the order of operations, how to run migrations. Everything about starting.&lt;/p&gt;

&lt;p&gt;It said nothing about the moment that actually matters, which is when the agent — or a tired human — decides the deploy is done. &lt;code&gt;bin/deploy&lt;/code&gt; exited 0. Is that success? The skill had no opinion. So the answer became whatever the model felt like, which on a good day is thorough and on a bad day is "the command exited 0, deploy complete."&lt;/p&gt;

&lt;p&gt;That's not a model problem. That's a missing section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sections, and why each exists
&lt;/h2&gt;

&lt;p&gt;I added the same three headings to nine skills. Each one targets a different way work goes wrong, and the order matters — they run at different moments.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;## Common Rationalizations&lt;/code&gt; — the arguments you'll make to yourself
&lt;/h3&gt;

&lt;p&gt;A table. Left column: the excuse. Right column: what's actually true.&lt;/p&gt;

&lt;p&gt;This is the one I expected to feel patronising and it's turned out to be the most useful. Bad engineering decisions are rarely made in ignorance. They're made with a &lt;em&gt;reason&lt;/em&gt; — a sentence that sounds fine when you say it in your head at 6pm. Writing the sentence down next to its rebuttal takes away the "well, in this case…" move, because the case is right there in the table.&lt;/p&gt;

&lt;p&gt;From the code-quality skill:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rationalization&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Just baseline it, it's legacy code"&lt;/td&gt;
&lt;td&gt;A baseline entry is a permanent, invisible ignore. Baseline to unblock CI, then file the issue — a baseline that only ever grows is a broken quality gate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Level 5 is good enough for now"&lt;/td&gt;
&lt;td&gt;Levels only ever ratchet up when someone forces them. "For now" has been the level for two years in most codebases.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Pint fixed it, so the code is reviewed"&lt;/td&gt;
&lt;td&gt;Pint fixes spacing and ordering. It has no opinion on the god class it just reformatted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Adding &lt;code&gt;@var&lt;/code&gt; fixed the error"&lt;/td&gt;
&lt;td&gt;It silenced the error by lying to the analyser. If the annotation is wrong, you have made the codebase less safe, not more.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That first row cost me a real bug this week, in a different repo. Static analysis had flagged two classes as used zero times. Someone baselined the warning rather than deleting the files — and one of those "unused" classes was supposed to be doing PII redaction. The baseline hid it for months.&lt;/p&gt;

&lt;p&gt;The rebuttal has to be &lt;strong&gt;specific and mechanical&lt;/strong&gt;, not moral. "Don't be lazy" changes nothing. "A baseline that only ever grows is a broken quality gate" is an argument.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;## Red Flags&lt;/code&gt; — signals visible &lt;em&gt;during&lt;/em&gt; the work
&lt;/h3&gt;

&lt;p&gt;A flat list of observable things. Not principles — sightings. If you can see it in a diff, a terminal, or a PR, it belongs here.&lt;/p&gt;

&lt;p&gt;From the debugging skill:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sleep()&lt;/code&gt; or &lt;code&gt;--retry&lt;/code&gt; added to make a test pass&lt;/li&gt;
&lt;li&gt;The same bug fixed in three different callers in one PR&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;-&amp;gt;skip()&lt;/code&gt; or &lt;code&gt;-&amp;gt;markTestIncomplete()&lt;/code&gt; added during a bug fix&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dd()&lt;/code&gt;, &lt;code&gt;dump()&lt;/code&gt;, &lt;code&gt;ray()&lt;/code&gt; or &lt;code&gt;Log::debug('here')&lt;/code&gt; left in the diff&lt;/li&gt;
&lt;li&gt;A diff that touches files unrelated to the reported failure&lt;/li&gt;
&lt;li&gt;Blaming the framework, the database or the browser before reading your own stack trace&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And from deploy-app, where the stakes are higher:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deploying without a fresh, size-checked backup&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;migrate:fresh&lt;/code&gt;, &lt;code&gt;db:wipe&lt;/code&gt;, or &lt;code&gt;--seed&lt;/code&gt; typed against a production host&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;.env&lt;/code&gt; keys added to the repo but never set on the host&lt;/li&gt;
&lt;li&gt;A deploy that also contains a refactor&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;APP_KEY&lt;/code&gt; in the diff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice the second entry in the debugging list. "The same bug fixed in three different callers" is not a style complaint — it's near-proof that the fix is at the wrong layer. That's the value of the format: a red flag encodes a diagnosis, compressed into something you can &lt;em&gt;spot&lt;/em&gt; rather than something you have to &lt;em&gt;reason about&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;## Verification&lt;/code&gt; — the exit gate
&lt;/h3&gt;

&lt;p&gt;A checkbox list run before the work is called done. Not a summary of the skill; a set of conditions that can each be false.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Verification&lt;/span&gt;

Before calling a bug fixed:
&lt;span class="p"&gt;
-&lt;/span&gt; [ ] The original failure reproduces on demand (or its impossibility is documented)
&lt;span class="p"&gt;-&lt;/span&gt; [ ] The root cause is stated in one sentence, and it is a cause, not a symptom
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Every caller of the changed function was checked, not just the reported path
&lt;span class="p"&gt;-&lt;/span&gt; [ ] A regression test exists, and it fails when the fix is reverted
&lt;span class="p"&gt;-&lt;/span&gt; [ ] The full test suite passes, not just the new test
&lt;span class="p"&gt;-&lt;/span&gt; [ ] No debug output (&lt;span class="sb"&gt;`dd`&lt;/span&gt;, &lt;span class="sb"&gt;`dump`&lt;/span&gt;, &lt;span class="sb"&gt;`ray`&lt;/span&gt;, &lt;span class="sb"&gt;`Log::debug`&lt;/span&gt;) remains in the diff
&lt;span class="p"&gt;-&lt;/span&gt; [ ] The diff contains the fix and its test — nothing else
&lt;span class="p"&gt;-&lt;/span&gt; [ ] If the bug reached production, the blast radius is stated: who was affected, what data, over what window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"A regression test exists, and it fails when the fix is reverted."&lt;/strong&gt; If I could keep one line out of this entire release, it'd be that one. A test that passes both with and against your fix is a test of something else. Almost nobody checks, because checking means deliberately breaking working code, and it feels like a waste of ninety seconds. It's the only thing that distinguishes a guard from a decoration.&lt;/p&gt;

&lt;p&gt;Same energy in the last box. "The blast radius is stated" is not a coding task at all — it's the part of incident work that gets skipped because the code is already fixed and the adrenaline is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the end of the file, not the top
&lt;/h2&gt;

&lt;p&gt;The obvious objection: an agent reads the whole skill, so why does placement matter?&lt;/p&gt;

&lt;p&gt;It matters because &lt;strong&gt;the failure isn't at the entry, it's at the exit.&lt;/strong&gt; Instructions at the top of the file compete with the user's actual request and lose. A checklist under a &lt;code&gt;## Verification&lt;/code&gt; heading is a thing you can be &lt;em&gt;asked to run&lt;/em&gt; — "run the verification section" — and the answer is auditable, box by box. It's a different mode: not guidance, but a gate.&lt;/p&gt;

&lt;p&gt;That's the reframe I'd offer anyone writing skills for a coding agent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A skill is a &lt;strong&gt;policy document&lt;/strong&gt;, not a tutorial. The tutorial part is the cheap part — the model mostly knows the framework. What it doesn't know is your organisation's definition of &lt;em&gt;done&lt;/em&gt;, and which shortcuts you've been burned by.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The happy path is where the model is already strong. The failure modes are where it needs you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage, hygiene, and the boring half
&lt;/h2&gt;

&lt;p&gt;Three other things landed in 2.5.0, and two of them are only interesting as process lessons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two agents had no backing skill.&lt;/strong&gt; The role personas — code reviewer, QA engineer, DevOps engineer and so on — load skills as their playbook. Two were pointing at nothing. So the release adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;debugging&lt;/code&gt;&lt;/strong&gt; — the five-step triage (reproduce, localise, reduce, fix at the root, guard), a table of Laravel-specific traps (N+1 masked as a timeout, queue jobs failing silently on the wrong connection, config cache serving stale values, Livewire state desync), flaky-test diagnosis, and two reference files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;security-hardening&lt;/code&gt;&lt;/strong&gt; — defensive OWASP-in-Laravel, authorization coverage auditing, uploads, secrets, production config, CI/CD hardening. It's the proactive counterpart to the reactive soc-analyst and log-monitor pair I already had.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Step 1 of that triage is blunter than I'd usually write:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You have not started debugging until the failure happens on demand.&lt;/strong&gt; … If you cannot reproduce it, say so plainly and stop. A fix for a bug you never saw fail is a guess wearing a diff.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;My own convention file was being violated by my own skills.&lt;/strong&gt; The repo's &lt;code&gt;CLAUDE.md&lt;/code&gt; says every skill ships a &lt;code&gt;references/&lt;/code&gt; directory and a Reference Files table. Two skills had neither, and both were orphaned — no agent loaded them. Fixed, and both are now wired to an agent.&lt;/p&gt;

&lt;p&gt;That's the unglamorous lesson and it applies well beyond agent skills: &lt;strong&gt;a convention nobody checks is a preference.&lt;/strong&gt; Mine had drifted in the exact way today's other repo drifted, where a hand-maintained &lt;code&gt;SECURITY.md&lt;/code&gt; mirroring &lt;code&gt;composer.json&lt;/code&gt; had gone wrong in every single entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Companions, installed not vendored.&lt;/strong&gt; Two external tools are now referenced from the skills that benefit — a codebase knowledge-graph tool for research and architecture work, and a minimalism-ladder plugin for the code-review and refactoring skills. Both are installed from upstream, never copied into the repo. Both are optional: the installer reports a skip when prerequisites are missing, and &lt;strong&gt;every referencing skill states its fallback&lt;/strong&gt;, so a missing companion degrades the skill rather than breaking it. New &lt;code&gt;--no-companions&lt;/code&gt; flag for anyone who'd rather not.&lt;/p&gt;

&lt;p&gt;Copying someone else's tool into your repo is how you end up maintaining a fork of it by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  And a smaller thing that changed every response
&lt;/h2&gt;

&lt;p&gt;The default output format is now &lt;strong&gt;TLDR + tables&lt;/strong&gt;, written as a delimited managed block into &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; so it applies to every prompt. Replaced — never duplicated — on reinstall, and the user's own instructions are untouched.&lt;/p&gt;

&lt;p&gt;A new &lt;code&gt;/output&lt;/code&gt; command switches mode per session: &lt;code&gt;tldr&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;verbose&lt;/code&gt;, &lt;code&gt;bullets&lt;/code&gt;, &lt;code&gt;narrative&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;raw&lt;/code&gt;. Three rules keep it from becoming a footgun:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A one-off instruction in the prompt always beats the session mode.&lt;/strong&gt; "explain in detail" wins for that reply, then the mode returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never drop required content to satisfy a format.&lt;/strong&gt; A safety caveat, a failing test, an unmet requirement or a stated assumption gets reported in every mode — &lt;code&gt;tldr&lt;/code&gt; and &lt;code&gt;json&lt;/code&gt; included.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;tldr&lt;/code&gt; is not permission to omit; &lt;code&gt;verbose&lt;/code&gt; is not permission to pad.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That middle rule is the same idea as the verification section, honestly. A format is allowed to shape the prose. It is not allowed to quietly delete the bad news.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you're writing skills, playbooks, or &lt;code&gt;CLAUDE.md&lt;/code&gt; files for a coding agent, the highest-leverage paragraph you can add isn't another example of the happy path. It's the answer to two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;What excuse will be made for cutting this corner, and what's the rebuttal?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What has to be true before this is done — as boxes that can each be false?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model already knows the framework better than your docs do. What it can't know is which shortcut burned you in March.&lt;/p&gt;

&lt;p&gt;34 skills, 20 agents, 12 commands. MIT, and it's all at &lt;a href="https://github.com/nasrulhazim/claude" rel="noopener noreferrer"&gt;github.com/nasrulhazim/claude&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next on the list: making the nine verification sections runnable as an actual gate rather than a prompt the agent is trusted to honour. A checklist you can skip is, by today's standard, a decoration.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>architecture</category>
      <category>laravel</category>
    </item>
    <item>
      <title>nginx add_header Appends. It Doesn't Override — And That Can Remove Protection.</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:35:37 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/nginx-addheader-appends-it-doesnt-override-and-that-can-remove-protection-54pf</link>
      <guid>https://dev.to/nasrulhazim/nginx-addheader-appends-it-doesnt-override-and-that-can-remove-protection-54pf</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I shipped a baseline of security headers into every generated nginx vhost, meaning it as a &lt;em&gt;fallback&lt;/em&gt; for applications that send none of their own. Within the hour, production was answering with two &lt;code&gt;X-Frame-Options&lt;/code&gt; values: &lt;code&gt;DENY&lt;/code&gt; from the app and &lt;code&gt;SAMEORIGIN&lt;/code&gt; from my baseline. Browsers treat a contradictory set as &lt;em&gt;no directive at all&lt;/em&gt;, so a header added to increase protection had removed it. The fix is a &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt;, because &lt;code&gt;add_header&lt;/code&gt; has no way to ask a question.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I maintain a system that generates nginx server blocks for applications it deploys. The applications aren't mine. Some are well-built Laravel apps that set their own security headers in middleware; some are ten-year-old PHP that sets nothing at all and is being moved off a managed hosting panel that used to set headers on its behalf.&lt;/p&gt;

&lt;p&gt;So the requirement is easy to state: &lt;strong&gt;give every vhost a sane baseline, without stepping on an application that already knows what it wants.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first cut looked like this, rendered into every server block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server_tokens&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests green. Config valid. Deployed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What production actually answered
&lt;/h2&gt;

&lt;p&gt;I went to check the thing I had just claimed was proven — a habit that has earned its keep — and curl gave me this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;x-content-type-options: nosniff
x-content-type-options: nosniff
x-frame-options: DENY
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
referrer-policy: strict-origin-when-cross-origin
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every header, twice. The application's own middleware was setting &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;. My baseline had added &lt;code&gt;SAMEORIGIN&lt;/code&gt; beside it.&lt;/p&gt;

&lt;p&gt;Here's the thing I want to be blunt about, because I think it's the part people assume their way past:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two contradictory &lt;code&gt;X-Frame-Options&lt;/code&gt; values is not "the stricter one wins."&lt;/strong&gt; There is no merge rule. Browsers that receive an unparseable set of directives for that header treat the header as absent. So &lt;code&gt;DENY&lt;/code&gt; — the strongest possible value, which the application had correctly set — became nothing, because I added a weaker value next to it.&lt;/p&gt;

&lt;p&gt;On that particular host, &lt;code&gt;Content-Security-Policy: frame-ancestors 'none'&lt;/code&gt; still covered the gap; CSP takes precedence over &lt;code&gt;X-Frame-Options&lt;/code&gt; in every modern browser. But the applications this baseline was &lt;em&gt;written for&lt;/em&gt; are exactly the ones relying on &lt;code&gt;X-Frame-Options&lt;/code&gt; alone. For all of them, running my "hardening" left them &lt;strong&gt;worse off than before it ran&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three nginx facts worth internalising
&lt;/h2&gt;

&lt;p&gt;The bug wasn't a typo. It was three separate properties of &lt;code&gt;add_header&lt;/code&gt;, none of which I had actually sat with.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;add_header&lt;/code&gt; appends. Always.
&lt;/h3&gt;

&lt;p&gt;The name is honest and I read it as if it said &lt;code&gt;set_header&lt;/code&gt;. It adds a field to the response. It does not replace an existing one, and there is no &lt;code&gt;add_header ... if_not_present&lt;/code&gt; flag. Think of it as &lt;code&gt;$response-&amp;gt;headers-&amp;gt;set()&lt;/code&gt; versus &lt;code&gt;-&amp;gt;add()&lt;/code&gt; — nginx only gives you &lt;code&gt;-&amp;gt;add()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;add_header&lt;/code&gt; cannot ask a question.
&lt;/h3&gt;

&lt;p&gt;This is the real constraint. Inside a &lt;code&gt;location&lt;/code&gt;, there is no expression available to &lt;code&gt;add_header&lt;/code&gt; that means "unless the upstream already sent this." &lt;code&gt;if&lt;/code&gt; won't help you — &lt;code&gt;if&lt;/code&gt; inside &lt;code&gt;location&lt;/code&gt; is famously &lt;a href="https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/" rel="noopener noreferrer"&gt;not what you want&lt;/a&gt;, and it can't inspect response headers anyway, because at request-evaluation time the response doesn't exist yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;add_header&lt;/code&gt; does not merge across levels — it replaces the whole set.
&lt;/h3&gt;

&lt;p&gt;One &lt;code&gt;add_header&lt;/code&gt; in a nested block silently discards &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;add_header&lt;/code&gt; inherited from the parent. Not the matching one. All of them.&lt;/p&gt;

&lt;p&gt;This one bit me in a place I would never have looked. My generated config has a fallback location that returns a friendly 503 while an application is starting up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;@app_unavailable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Retry-After&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;!doctype&lt;/span&gt; &lt;span class="s"&gt;html&amp;gt;...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;Retry-After&lt;/code&gt; wiped the entire inherited baseline. Which means the response an operator is &lt;em&gt;most&lt;/em&gt; likely to see with their own eyes — the "just a moment, starting up" page during a restart — was the one response on the whole node with no protection headers on it. The fix is to repeat the set inside the location, with a comment explaining that the repetition is load-bearing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;@app_unavailable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Repeated, not inherited: nginx replaces the whole add_header set at the&lt;/span&gt;
    &lt;span class="c1"&gt;# deepest level that defines one, so Retry-After above would otherwise&lt;/span&gt;
    &lt;span class="c1"&gt;# strip the baseline.&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Retry-After&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xcto&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xfo&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="nv"&gt;$fallback_rp&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;!doctype&lt;/span&gt; &lt;span class="s"&gt;html&amp;gt;...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And while we're here: &lt;code&gt;always&lt;/code&gt; matters. Without it nginx omits the header from 4xx and 5xx responses — protecting only the responses that were never the risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;add_header&lt;/code&gt; can't ask a question, but a &lt;code&gt;map&lt;/code&gt; can answer one before the question is asked. Two behaviours combine into exactly what I needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$upstream_http_&amp;lt;header&amp;gt;&lt;/code&gt; holds the value the upstream sent for that header, or the empty string if it sent nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;nginx omits an &lt;code&gt;add_header&lt;/code&gt; whose value evaluates to an empty string.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those together and the fallback writes itself. In &lt;code&gt;http&lt;/code&gt; context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The baseline security headers are a FALLBACK: each value is empty when the&lt;/span&gt;
&lt;span class="c1"&gt;# application already sent that header, and nginx skips an add_header with an&lt;/span&gt;
&lt;span class="c1"&gt;# empty value.&lt;/span&gt;
&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$upstream_http_x_content_type_options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xcto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;"nosniff"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$upstream_http_x_frame_options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$upstream_http_referrer_policy&lt;/span&gt; &lt;span class="nv"&gt;$fallback_rp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the vhost uses the variables instead of literals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server_tokens&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xcto&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xfo&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="nv"&gt;$fallback_rp&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An application that sets its own headers keeps &lt;strong&gt;exactly&lt;/strong&gt; what it set — no duplicate, no contradiction. An application that sets none gets the baseline. That's the behaviour the requirement asked for, and it's what the first cut didn't do.&lt;/p&gt;

&lt;p&gt;Two properties of &lt;code&gt;$upstream_http_*&lt;/code&gt; that made me comfortable shipping this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's populated for &lt;strong&gt;fastcgi&lt;/strong&gt; and &lt;strong&gt;uwsgi&lt;/strong&gt; upstreams too, not just &lt;code&gt;proxy_pass&lt;/code&gt;. So a PHP-FPM vhost behaves the same as a reverse-proxied one.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;empty when nginx serves the file itself&lt;/strong&gt;, which is the correct answer for a static vhost: nothing upstream spoke, so apply the fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The deployment trap that nearly cost more than the bug
&lt;/h2&gt;

&lt;p&gt;Here's the operational edge case, and it's the one I'd want a reviewer to catch if I'd missed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A vhost that references &lt;code&gt;$fallback_xfo&lt;/code&gt; when no &lt;code&gt;map&lt;/code&gt; defines it is a config nginx refuses outright.&lt;/strong&gt; Not a warning. Not that vhost failing. &lt;code&gt;nginx -t&lt;/code&gt; fails, the reload is rejected, and if a deploy pushes the vhost without the map, you have taken down &lt;em&gt;every site on the node&lt;/em&gt; — including the ones you weren't touching.&lt;/p&gt;

&lt;p&gt;So the maps and the vhosts have to land together, in all three of the config-writing paths in my system (a systemd driver writing to disk, a container-oriented writer, and a Kubernetes ConfigMap). All three render vhosts from the same shared trait, so all three needed the maps added — the shared trait is exactly what makes it easy to forget, because the change &lt;em&gt;looks&lt;/em&gt; like one place.&lt;/p&gt;

&lt;p&gt;If you're adding this to an existing setup: deploy the &lt;code&gt;map&lt;/code&gt; block first, on its own, and reload. Then deploy the vhosts. A map nothing references is harmless. A reference with no map is a node-wide outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't copy
&lt;/h2&gt;

&lt;p&gt;The panel these applications are migrating from also sends &lt;code&gt;X-XSS-Protection: 1; mode=block&lt;/code&gt;. I deliberately left it out. Every current browser has removed the XSS auditor that header names, and on the browsers that kept it, the header introduced a cross-site information leak of its own. &lt;strong&gt;Matching the old platform's protection level is the goal; matching its config file is not.&lt;/strong&gt; Worth writing that distinction into a comment, because "the old system sent it" is a very persuasive argument at review time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing this honestly
&lt;/h2&gt;

&lt;p&gt;The config generator is unit-testable, and the test is worth writing because the &lt;em&gt;shape&lt;/em&gt; of the output is the whole contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'renders baseline headers as a fallback, not a second opinion'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;renderVhost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'server_tokens off;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header X-Frame-Options $fallback_xfo always;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// The literal is the bug: a hardcoded value cannot defer to the app.&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header X-Frame-Options "SAMEORIGIN"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Not copied on purpose — the auditor it names no longer exists.&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-XSS-Protection'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'repeats the baseline inside the unavailable location'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// One add_header at a deeper level discards the entire inherited set,&lt;/span&gt;
    &lt;span class="c1"&gt;// so the 503 page needs its own copy or it ships bare.&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;renderVhost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header Retry-After 5 always;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header X-Frame-Options $fallback_xfo always;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But be clear-eyed about what that proves. &lt;strong&gt;It asserts a directive was emitted. It cannot assert what a browser does with the response.&lt;/strong&gt; The original bug passed a green suite. A test over generated config verifies the generator, not the behaviour — and the difference between "CI-proven" and "live-proven" is exactly the gap this bug lived in for an hour.&lt;/p&gt;

&lt;p&gt;The only thing that caught it was hitting the live host and reading the response headers, right after shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;add_header&lt;/code&gt; appends, can't be conditional, and replaces the entire inherited set at the deepest level that defines one. All three of those will surprise you at least once.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt; plus nginx's empty-value rule is the idiomatic way to express "only if the upstream didn't". No &lt;code&gt;if&lt;/code&gt;, no Lua, no duplicated header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A security header added carelessly can be a downgrade, not an addition.&lt;/strong&gt; "More headers" isn't a safety property.&lt;/li&gt;
&lt;li&gt;Ship the &lt;code&gt;map&lt;/code&gt; before the reference. A dangling variable in a vhost fails the whole config, not just that site.&lt;/li&gt;
&lt;li&gt;Verify the claim you just made, against the live thing, in the hour you made it. A green suite is evidence about the generator. It is not evidence about the response.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Dev Log: 2 September 2026 — Every Bug Today Was a Claim Nobody Checked</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:34:42 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2-september-2026-every-bug-today-was-a-claim-nobody-checked-5ghh</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2-september-2026-every-bug-today-was-a-claim-nobody-checked-5ghh</guid>
      <description>&lt;p&gt;Twenty-two commits across four repos today. Different stacks, different problems, and — reading them back in one sitting — the same bug wearing four costumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something asserted a state. Nothing verified it. The assertion was wrong, and it had been wrong for a while.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A compliance control was green on evidence that didn't exist. A kitchen screen said there was nothing to cook while paid orders were sitting there. A dashboard reported a 97.8% success rate directly above thirteen failed rows. A service worker claimed to be serving today's CSS and was serving the first CSS it ever saw.&lt;/p&gt;

&lt;p&gt;None of these threw. That's the shared trait. A stack trace is a gift — it tells you exactly where to look. A confident wrong number tells you nothing, and it keeps telling you nothing until someone counts by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  The control that was green on nothing
&lt;/h2&gt;

&lt;p&gt;Start with the worst one, because it's the cleanest illustration.&lt;/p&gt;

&lt;p&gt;A compliance reporter generates control evidence automatically — for each control, a line saying &lt;em&gt;why&lt;/em&gt; the system considers it satisfied. One data-minimisation control cited two mechanisms: encrypted casts on the sensitive columns, and a redaction trait applied to the audited models.&lt;/p&gt;

&lt;p&gt;The first half was true. The second half was not. The trait existed. It was applied to &lt;strong&gt;no model at all&lt;/strong&gt;. The control had been permanently green on a citation that pointed at nothing.&lt;/p&gt;

&lt;p&gt;And it wasn't simply forgotten. As written it &lt;em&gt;could not&lt;/em&gt; be applied to the main user model — the audit package it needed to cooperate with declares a method of the same name, and two traits declaring the same method in one class is a fatal composition error in PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Auditable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RedactsPiiInAudit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;RedactsPiiInAudit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;transformAudit&lt;/span&gt; &lt;span class="k"&gt;insteadof&lt;/span&gt; &lt;span class="nc"&gt;Auditable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;insteadof&lt;/code&gt; is doing real work. Without it the class doesn't compile. Descendants of a base class that had one of the two were fine — a trait on the child beats a method inherited through the parent — so the conflict only appeared on the one model that mattered.&lt;/p&gt;

&lt;p&gt;Two things I'd generalise from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated evidence needs a test that the evidence is reachable.&lt;/strong&gt; A reporter that emits &lt;code&gt;evidence_source: "...trait X"&lt;/code&gt; should be able to answer "which classes actually use X?" and fail when the answer is none. Otherwise the report is a string, and strings don't know if they're true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration on a trait belongs in a method, not a property.&lt;/strong&gt; A trait and its consumer cannot both declare the same property with different defaults — also fatal. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;trait&lt;/span&gt; &lt;span class="nc"&gt;RedactsPiiInAudit&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Overridable. A property here would be a fatal clash the first time&lt;/span&gt;
    &lt;span class="c1"&gt;// a consumer wants its own list — and a consumer that forgets to&lt;/span&gt;
    &lt;span class="c1"&gt;// declare one would have no default at all.&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;redactedAuditAttributes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ic_number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'address'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bonus find, which is the part I'd actually lose sleep over: &lt;strong&gt;password hashes were being written into the audit trail.&lt;/strong&gt; An audit table is immutable by design and retained for a year. A hash written there can't be withdrawn. Masking is the right treatment for PII that has to stay recognisable in an audit — a credential is never in that category, so credentials and 2FA secrets are now excluded outright, not masked.&lt;/p&gt;

&lt;p&gt;The same commit deleted three classes referenced from nowhere. Static analysis had flagged two of them as used zero times, and someone had &lt;strong&gt;baselined the warning instead of deleting the files&lt;/strong&gt; — which is its own version of today's theme. A baseline entry is a permanent, invisible ignore.&lt;/p&gt;

&lt;p&gt;And a &lt;code&gt;SECURITY.md&lt;/code&gt; that hand-mirrored &lt;code&gt;composer.json&lt;/code&gt; had drifted in every single entry: it named a framework version two majors behind, and listed four dependencies the project doesn't have. A hand-maintained copy of a lockfile does not stay true. It now points at &lt;code&gt;composer.json&lt;/code&gt; and names the audit commands instead. A stale security document is worse than no security document, because it looks like diligence.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two screens that contradicted themselves
&lt;/h2&gt;

&lt;p&gt;Same shape, two different products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An audit dashboard&lt;/strong&gt; rendered its summary cards from the controller using an unfiltered count, while the table below them came from the datatable's filtered query. Filter to "failed, last four days" and you got 1,949 attempts and a 97.8% success rate printed directly above thirteen failed rows. Both halves of one screen, disagreeing — and the reassuring half was the wrong one.&lt;/p&gt;

&lt;p&gt;The fix is architectural, not arithmetic: the cards moved &lt;em&gt;inside&lt;/em&gt; the Livewire component and are computed from the same &lt;code&gt;baseQuery()&lt;/code&gt; that produces the rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Whatever narrows the rows must narrow the summary. One source, two renders.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;baseQuery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'total'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;clone&lt;/span&gt; &lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'failed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;clone&lt;/span&gt; &lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a number and a list on the same screen come from two different builders, they will eventually disagree. Not might — will.&lt;/p&gt;

&lt;p&gt;The rebuilt report also added a period selector, because all-time was the only view and a large intake of successful records pinned the headline near 98% no matter what broke today. A metric with no time window is a metric that can't report a bad day.&lt;/p&gt;

&lt;p&gt;Best change in the lot: a &lt;strong&gt;"why they failed"&lt;/strong&gt; breakdown, grouping the recorded error messages and collapsing multi-line database stack traces to their error code. That data had been persisted from day one and never read once. The step tells you &lt;em&gt;where&lt;/em&gt; something stopped; only the message tells you &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A kitchen display screen&lt;/strong&gt; in a point-of-sale app was the same bug in food form. The display queried for orders whose status was &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;confirmed&lt;/code&gt;. But the till takes payment first and cooks after — checkout creates the order, then processes payment, which sets the order to &lt;code&gt;PAID&lt;/code&gt; while every line item is still &lt;code&gt;PENDING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So no order ever taken at the counter reached the kitchen. The screen had only ever shown "No pending orders", which reads exactly like a quiet afternoon.&lt;/p&gt;

&lt;p&gt;The insight is a modelling one worth stealing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Order status tracks money. Item status tracks cooking.&lt;/strong&gt; A cook is looking at food, so the query has to key on items.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The query now excludes only orders that were killed — cancelled, voided — and keys everything else on item state. A ticket drops off the board once every item is &lt;code&gt;READY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two statuses that both say "pending" are not the same status. Naming them for what they measure (&lt;code&gt;PaymentStatus&lt;/code&gt; vs &lt;code&gt;PreparationStatus&lt;/code&gt;) would have made the bug hard to write in the first place — which is most of the argument for enums over strings.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caches that were confidently out of date
&lt;/h2&gt;

&lt;p&gt;Two variations, both ending in "works after a hard refresh", which is the single most misleading symptom in web development because it makes the developer the only person who can't reproduce it.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;service worker&lt;/strong&gt; was matching static assets by pathname alone. In development that meant it also intercepted the dev server's own CSS and JS on a different origin and cached them cache-first — permanently. &lt;code&gt;Cmd+Shift+R&lt;/code&gt; bypasses the service worker entirely, which is precisely why only that showed the new styling.&lt;/p&gt;

&lt;p&gt;Two fixes, and the second is the general one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ignore every cross-origin request. Nothing from another origin belongs in an offline shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-first is only safe for content-hashed URLs.&lt;/strong&gt; Anything at a stable path — &lt;code&gt;/js/app.js&lt;/code&gt;, &lt;code&gt;/icons/*&lt;/code&gt; — freezes at whatever shipped first, forever. Those moved to stale-while-revalidate: instant from cache, refreshed in the background. Bump the cache name so the frozen entries get evicted on activation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The other variant: a &lt;strong&gt;generated report was styled by a bundle built before the report existed&lt;/strong&gt;. Same family — a build artifact asserting it was current when it predated the thing it was styling.&lt;/p&gt;

&lt;p&gt;While I was in there, a third one, and this is a nasty little Livewire trap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught TypeError: $wire.view is not a function
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A list/grid toggle introduced a &lt;code&gt;$view&lt;/code&gt; property on a component that already had a &lt;code&gt;view()&lt;/code&gt; method. On the &lt;code&gt;$wire&lt;/code&gt; proxy, &lt;strong&gt;a property shadows a method of the same name.&lt;/strong&gt; PHP is happy. Blade is happy. Static analysis is happy. The only symptom is a click that silently stops working.&lt;/p&gt;

&lt;p&gt;The guard is a test, because nothing else in the stack can see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'has no property shadowing a method on any Livewire component'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;livewire_components&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReflectionClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getProperties&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$methods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReflectionClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMethods&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$properties&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;intersect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$methods&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: property shadows a method of the same name on &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;wire"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same day, same repo, a related guard: a missing icon component throws at render time, so only a test that opens that exact page catches it — and a page nobody covers stays broken until a user clicks it. The new test walks every Blade file for icon references and asserts the file exists. That's the right level. Don't test that page 47 renders; test that &lt;strong&gt;no page can reference an icon that isn't there.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The one where the bug was blamed outward
&lt;/h2&gt;

&lt;p&gt;My favourite, because I've made this exact mistake and so has everyone reading this.&lt;/p&gt;

&lt;p&gt;Four directory attributes came back &lt;code&gt;null&lt;/code&gt; on live objects. This was written up as a missing read permission on the service account — a plausible story, an external cause, a ticket for someone else.&lt;/p&gt;

&lt;p&gt;It wasn't. The profile screens in the same application display one of those very attributes, from the same directory, on the same bind, with no special grant. The evidence that the story was wrong was already on screen.&lt;/p&gt;

&lt;p&gt;The actual defect: the helper normalises the &lt;em&gt;needle&lt;/em&gt; to lowercase, but the attribute bag is keyed exactly as the directory returned it. A mixed-case name like &lt;code&gt;pwdLastSet&lt;/code&gt; silently missed and returned &lt;code&gt;null&lt;/code&gt;. The profile views never hit it because they iterate the raw bag and lowercase &lt;strong&gt;both sides&lt;/strong&gt; of the comparison.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Lowercasing one side of a comparison is not case-insensitive matching.&lt;/span&gt;
&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$object&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttributes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An earlier "fix" had added an explicit attribute selection — which only narrowed what came back, since an unrestricted query was already returning those attributes. That's the tell for a wrong diagnosis: &lt;strong&gt;the fix makes the system do less and the symptom stays.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The part that took the longest wasn't the code. It was going back to correct the audit finding that said the evidence was unobtainable without a permission change. It was obtainable the whole time. If the write-up survives and the diagnosis doesn't, the write-up is the thing people act on next year.&lt;/p&gt;




&lt;h2&gt;
  
  
  And the same idea, one layer up
&lt;/h2&gt;

&lt;p&gt;The public repo today got a version of this on purpose. My &lt;a href="https://github.com/nasrulhazim/claude" rel="noopener noreferrer"&gt;Claude Toolkit&lt;/a&gt; shipped 2.5.0, and the substance of the release is that &lt;strong&gt;none of its 32 skills said when to stop&lt;/strong&gt; — they were all "how to start".&lt;/p&gt;

&lt;p&gt;Nine high-stakes skills now carry &lt;code&gt;## Common Rationalizations&lt;/code&gt;, &lt;code&gt;## Red Flags&lt;/code&gt; and &lt;code&gt;## Verification&lt;/code&gt;. The verification section is a checklist you run before claiming the work is done, and it's aimed squarely at today's failure mode:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The root cause is stated in one sentence, and it is a cause, not a symptom&lt;/li&gt;
&lt;li&gt;[ ] Every caller of the changed function was checked, not just the reported path&lt;/li&gt;
&lt;li&gt;[ ] A regression test exists, &lt;strong&gt;and it fails when the fix is reverted&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third box is the whole discipline in one line. Full write-up in the companion post.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The bugs that cost the most today weren't the ones that threw. They were the ones that answered confidently.&lt;/p&gt;

&lt;p&gt;A stack trace is a system admitting it doesn't know. A green control, a 97.8%, an empty kitchen queue and a cached stylesheet are systems claiming they do. Only one of those two categories can be found by waiting for it to break.&lt;/p&gt;

&lt;p&gt;So the pattern I'd take out of today:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whenever code emits a claim — a compliance status, a summary count, a "no results", a cached response — ask what would fail if the claim were false.&lt;/strong&gt; If the answer is "nothing", you don't have a feature. You have a decoration that people will make decisions from.&lt;/p&gt;

&lt;p&gt;Every fix today shipped with a guard: a test that fails if the trait goes unused, a test that fails on a property/method collision, a test that fails on a missing icon, a query that can't disagree with the list beneath it. Not because the tests are impressive — most of them are ten lines — but because a claim that nothing can falsify will drift back the moment nobody's watching.&lt;/p&gt;

&lt;p&gt;What's next: I want the compliance reporter to refuse to emit an evidence string it can't resolve to a live class. Right now it will happily cite a ghost.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dev Log: 1 September 2026 — Every Bug Today Was a Name Problem</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:29:29 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-1-september-2026-every-bug-today-was-a-name-problem-30i9</link>
      <guid>https://dev.to/nasrulhazim/dev-log-1-september-2026-every-bug-today-was-a-name-problem-30i9</guid>
      <description>&lt;p&gt;Fifty-three commits across eight repos today. Reading them back in one sitting, they sorted themselves into a single shape: &lt;strong&gt;something used a name as if it were an identity, and the name wasn't unique.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two people sharing a username. Two accounts sharing an email. Sixteen environments sharing the word "Production". A class whose name got rewritten under a caller that looks it up by name. Nineteen URLs sharing one document. Different stacks, same bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  Case-sensitive database, case-insensitive directory
&lt;/h2&gt;

&lt;p&gt;The one that took the longest to explain, and it's four words: &lt;strong&gt;&lt;code&gt;=&lt;/code&gt; is case-sensitive.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL and Oracle both compare strings case-sensitively. Active Directory, by specification, does not. So a uniqueness gate written as &lt;code&gt;where('username', $input)&lt;/code&gt; will happily tell you &lt;code&gt;AliceW&lt;/code&gt; is available while &lt;code&gt;alicew&lt;/code&gt; exists — and when the downstream sync writes to the directory, both names resolve to the &lt;em&gt;same object&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The gate says yes. The directory says "oh, that one". Two different answers to what everyone assumed was one question.&lt;/p&gt;

&lt;p&gt;The fix is a small support class rather than a scope, because it's needed on both Eloquent and query builders in several places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsernameQuery&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;whereMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;QueryBuilder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Two usernames collide when they differ only by case or surrounding
     * whitespace — the comparison the directory makes, and the one the
     * gates must make too.
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions in there are worth more than the code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;LOWER(col) = ?&lt;/code&gt;, not a case-insensitive &lt;code&gt;LIKE&lt;/code&gt;.&lt;/strong&gt; This is an exact-match gate. Usernames legitimately contain &lt;code&gt;_&lt;/code&gt;, and &lt;code&gt;LIKE&lt;/code&gt; treats &lt;code&gt;_&lt;/code&gt; as a single-character wildcard — you'd get false collisions on every underscore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on the generated SQL, not on the returned rows.&lt;/strong&gt; SQLite folds ASCII case for &lt;code&gt;=&lt;/code&gt;. A test that only checks "did I get the row back" passes &lt;em&gt;before and after&lt;/em&gt; the fix when it runs on SQLite. The test that actually pins the behaviour asserts the query contains &lt;code&gt;LOWER(&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lowercases both sides of the comparison'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UsernameQuery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'username'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'AliceW'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toSql&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LOWER('&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stopping new collisions surfaces none of the old ones.&lt;/strong&gt; So there's now a sweep that answers the inverse question. Every other lookup in that system is subject-scoped — "who owns X?", and you need X up front. This one is an aggregate over the entire name space, which no existing tool could express.&lt;/p&gt;

&lt;p&gt;Two details from writing it that generalise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The parts are combined with &lt;code&gt;UNION&lt;/code&gt;, not &lt;code&gt;UNION ALL&lt;/code&gt;, because one person can hold the same name in two different columns. Without the dedup, every ordinary user is reported as colliding with themselves.&lt;/li&gt;
&lt;li&gt;Any column with a stored prefix (&lt;code&gt;ext_&lt;/code&gt; and friends) has to have the prefix stripped &lt;em&gt;for grouping&lt;/em&gt;, or that whole population silently contributes nothing to the sweep. Strip it with &lt;code&gt;SUBSTR&lt;/code&gt;, not &lt;code&gt;LIKE 'ext_%'&lt;/code&gt; — &lt;code&gt;_&lt;/code&gt; is a wildcard, remember.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the finding I nearly shipped wrong: the first sweep flagged dozens of collisions as live takeovers. Almost none were. The risk label assumed a single directory namespace, and there isn't one — different identity types derive their directory object from different attributes entirely. Two owners can share a name without sharing an object.&lt;/p&gt;

&lt;p&gt;Grouping now aggregates in two steps — per &lt;code&gt;(name, type)&lt;/code&gt; first — so the outer query can distinguish "two of the same type share this" from "two different types do". The ordering keys on that signal too, because sorting on the raw count buried the one case worth acting on beneath thirty that weren't collisions at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A risk score that ranks noise above signal is worse than no score.&lt;/strong&gt; You'll read the top of the list and stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  A picker that collected a choice nobody consumed
&lt;/h2&gt;

&lt;p&gt;Different system, same species.&lt;/p&gt;

&lt;p&gt;A personal email address is not unique in most institutional databases — a person who progresses through two programmes keeps the same email on two ids. So the password reset flow shows a picker: here are your accounts, choose one.&lt;/p&gt;

&lt;p&gt;The picker was correct. It rendered the accounts, it validated the selection, it carried it through the form. And then the resolver ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IdmUser&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereEmailAlt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// by email. unordered.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selection was collected and then ignored. Resets reported success, wrote to a stale id, and the live account was never touched — so the directory's &lt;code&gt;pwdLastSet&lt;/code&gt; never moved and the client read it as "your reset isn't doing anything".&lt;/p&gt;

&lt;p&gt;What makes this one worth writing down isn't the bug, it's the history. This was logged and closed a year ago. The fix that closed it &lt;em&gt;shipped the picker&lt;/em&gt; — the UI half — and was marked Resolved. No test covered the multi-account path, so the incomplete fix shipped green and the gap survived twelve months behind a ticket marked done.&lt;/p&gt;

&lt;p&gt;Four changes went in, and only the first is the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Scope on the selected username AND the email, not the email alone.&lt;/span&gt;
&lt;span class="nc"&gt;UserCategory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereUsernameAndEmailAlt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Backstop: abort and log before any backend write if the resolved&lt;/span&gt;
&lt;span class="c1"&gt;//    identity doesn't match the one that was selected.&lt;/span&gt;
&lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UsernameQuery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$resolved&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$selected&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The picker now labels each account with its directory status, so two bare ids are actually distinguishable to the human choosing between them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The success screen names the account that was written.&lt;/strong&gt; A bare "Success" is what hid this for a year — from the user &lt;em&gt;and&lt;/em&gt; from support. If an operation picks one of several candidates, the confirmation has to say which one it picked. That's not UX polish; it's the only observability that costs nothing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's a diagnostics footnote too. The first report masked emails with one asterisk per character, so two unrelated people whose local parts shared their opening letters and length printed identically — the report read as one group where there were two. The counts were never wrong; the &lt;em&gt;rendering&lt;/em&gt; was ambiguous. It now carries a stable group ref (first 8 hex of a sha256 over the lowercased address) and a fixed-width mask, because per-character masking leaks the local part's length for no benefit at all.&lt;/p&gt;

&lt;p&gt;An identifier in a report needs to be unambiguous and stable across runs. A mask is neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Confirm by typing the name" only works if the name is unique
&lt;/h2&gt;

&lt;p&gt;Shipped a set of destructive MCP tools today — delete a project, an environment, a provider; retire a machine — and the guard pattern is the usual one: the caller must retype an identifier to confirm.&lt;/p&gt;

&lt;p&gt;Except sixteen of the seventeen environments on the live control plane are called &lt;code&gt;Production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Retyping a name that sixteen records share confirms nothing an agent couldn't have guessed. So the confirmation is on the &lt;strong&gt;slug&lt;/strong&gt;, and for a machine it's on the SSH host address rather than the hostname, which can be blank or shared.&lt;/p&gt;

&lt;p&gt;The pattern, as a trait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;trait&lt;/span&gt; &lt;span class="nc"&gt;RequiresTypedConfirmation&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;confirmOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$argument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$noun&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The argument key is passed in so a refusal is audited identically&lt;/span&gt;
        &lt;span class="c1"&gt;// whichever tool refused.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two other things went in the response text of every one of those tools, both because they're ways an agent reports a teardown wrongly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deleting a provider removes the &lt;strong&gt;credential&lt;/strong&gt;, not the host. An adopted machine keeps running and keeps costing money until its own hosting provider destroys it.&lt;/li&gt;
&lt;li&gt;The deletion is soft. "Deleted" in a tool response and "deleted" in a database are not the same claim, and an agent will relay the first as if it were the second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building MCP tools, that second one deserves a rule of its own: &lt;strong&gt;say what the tool did not do.&lt;/strong&gt; An LLM will fill any silence with the most obvious inference, and for a delete tool the obvious inference is "the thing is gone".&lt;/p&gt;

&lt;p&gt;There was also a dependency rule to move. The "can this be deleted?" check lived as a private method on a Livewire component, with a comment from its author warning it would be forgotten at the fifth call site. This was the fifth. It's now a &lt;code&gt;RecordDeletionGuard&lt;/code&gt; service asked by both the UI and the tool — and extracting it exposed that the rule was also &lt;em&gt;wrong&lt;/em&gt;: it counted every deployment row including destroyed ones, so the final step of a teardown was refused forever by the very deployments the teardown had just destroyed.&lt;/p&gt;

&lt;p&gt;Related, same day: a "has history" rule that was blocking deletes. History is not a dependency. Every provider anyone ever bootstrapped accumulates job rows, so the rule read, in practice, "you may delete a provider only if you never used it". The rows are kept on purpose, the parent is soft-deleted anyway, and they still resolve through &lt;code&gt;withTrashed()&lt;/code&gt;. Dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  A relation cached before the rows existed
&lt;/h2&gt;

&lt;p&gt;This one is pure Laravel, and it's the sharpest edge I hit today.&lt;/p&gt;

&lt;p&gt;A 24-step provisioning pipeline carries one &lt;code&gt;Deployment&lt;/code&gt; model instance through every step in a context object. Step 11 reads &lt;code&gt;$deployment-&amp;gt;routingRules&lt;/code&gt; — as a &lt;strong&gt;property&lt;/strong&gt;. That loads the relation and caches it on the instance. At step 11 it's empty, because the rules are written at step 16.&lt;/p&gt;

&lt;p&gt;Steps 20 and 21 then read the same cached, empty collection, and took the "an empty set is an honest success" branch. So DNS and SSL both reported &lt;strong&gt;success&lt;/strong&gt; on a deployment that finished 24/24 Active with no DNS record and no certificate. A second run worked, because it got a fresh instance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Cached at first access. Every later read on this instance sees step 11's answer.&lt;/span&gt;
&lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;routingRules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Re-queries. In a long-lived pipeline this is the one you want.&lt;/span&gt;
&lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;routingRules&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone knows relation access is cached. The part that bites is that it's cached &lt;strong&gt;on the instance&lt;/strong&gt;, and in a request/response app instances don't live long enough for it to matter. Move that same model into a pipeline, a queued job chain, or a long-running worker and the lifetime changes underneath you without the code changing at all.&lt;/p&gt;

&lt;p&gt;The general rule I'm taking from it: &lt;strong&gt;if a model outlives the thing that loaded its relations, treat property access as a snapshot, not a query.&lt;/strong&gt; And be suspicious of any branch that reads "empty set → success". Empty is ambiguous. It means both "nothing to do" and "I looked at the wrong moment", and only one of those is a success.&lt;/p&gt;

&lt;h2&gt;
  
  
  A coverage test that could only see what today's fixtures happened to draw
&lt;/h2&gt;

&lt;p&gt;Spent a chunk of the day on localisation — making English the key language, with the second language selectable rather than forced by an org-level setting.&lt;/p&gt;

&lt;p&gt;I'd added a translation-coverage test the day before. It reported clean. Dozens of public-facing keys were untranslated.&lt;/p&gt;

&lt;p&gt;The test was render-driven: render the pages, ask Laravel which keys were looked up and not found. Reasonable design. But the local seed had no priced membership tiers, so the tier cards never rendered, their keys were never looked up, and the test was satisfied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A render-driven test is only as good as your fixtures.&lt;/strong&gt; It can only fail on the paths your seed data happens to reach.&lt;/p&gt;

&lt;p&gt;The fix is not to replace it but to put a second pass beside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source-driven:&lt;/strong&gt; every &lt;code&gt;__()&lt;/code&gt; key found in the public view tree must have a translation, whether or not anything renders it today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render-driven:&lt;/strong&gt; kept, because it still catches strings emitted from outside that tree — a component library's own labels, for instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither alone is sufficient, and the two failure modes are exactly complementary.&lt;/p&gt;

&lt;p&gt;Then a third one that neither pass can catch. Page titles were being set as literal view attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-layouts.auth.card title="Apply for membership"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never a key at all. The source scan looks for &lt;code&gt;__(&lt;/code&gt;; the render pass needs a key to be looked up. A string that was never a key is invisible to both. Found it by opening the page.&lt;/p&gt;

&lt;p&gt;Two lessons in one afternoon: automated coverage has a shape, and things outside that shape are not merely uncovered — they're &lt;em&gt;reported as covered&lt;/em&gt;. And the most important page in a funnel is the one most worth loading manually before you trust a green test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build tool renamed a class the native layer looks up by name
&lt;/h2&gt;

&lt;p&gt;The mobile side of the day. An app was dying mid-match — no Dart exception, no error screen, the process just vanished.&lt;/p&gt;

&lt;p&gt;R8 (Android's shrinker/obfuscator) had renamed most of an ML runtime's Java classes down to two-letter names, the way it's supposed to. That runtime's native layer resolves those classes &lt;strong&gt;by their original names&lt;/strong&gt; through JNI, so the first inference hit a null class in &lt;code&gt;GetMethodID&lt;/code&gt; and the Android runtime aborted the process.&lt;/p&gt;

&lt;p&gt;The rule R8 can't know: a class referenced only from native code has no Java-side reference to keep it, so as far as the shrinker is concerned nothing is using that name. Keeping it takes an explicit &lt;code&gt;-keep&lt;/code&gt; — the same reason reflection needs one.&lt;/p&gt;

&lt;p&gt;A crash with no stack trace in your language is almost always a layer boundary. JNI, FFI, a native plugin — something on the other side of a boundary that your exception handler doesn't cross.&lt;/p&gt;

&lt;p&gt;And it's the name problem again, in the most literal form of the day: a tool rewrote a name, and something else was still holding the old one.&lt;/p&gt;

&lt;p&gt;Sibling fix in the same release: two macOS crash reports were SIGSEGVs inside the bundled SQLite framework, and one of them showed the framework mapped &lt;strong&gt;twice&lt;/strong&gt; — same UUID, two base addresses. Two independent copies of SQLite live in one process, each with its own global config, handles crossing between them. The database was being hosted on a spawned background isolate with handles sent across the boundary; opening it on one isolate removes the surface entirely.&lt;/p&gt;

&lt;p&gt;Same name, two live things. Again.&lt;/p&gt;

&lt;h2&gt;
  
  
  And nineteen URLs sharing one document
&lt;/h2&gt;

&lt;p&gt;The public one, and the reason it's the whole other post: &lt;a href="https://devhub.my" rel="noopener noreferrer"&gt;devhub.my&lt;/a&gt; was serving the identical &lt;code&gt;index.html&lt;/code&gt; for every path, at HTTP 200, because of the standard SPA catch-all rule. An SEO crawl reported five pages; the sitemap listed nineteen. Both were correct.&lt;/p&gt;

&lt;p&gt;That's now a prerender step that writes a real HTML document per route and fails the build on three things no validator catches — a page that never declared its head, a JSON-LD graph with a dangling &lt;code&gt;@id&lt;/code&gt;, and a canonical pointing somewhere the page isn't. Full write-up in the companion post; repo is &lt;a href="https://github.com/developers-hub-my/website" rel="noopener noreferrer"&gt;developers-hub-my/website&lt;/a&gt; if you want to read the scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A framework upgrade plus a design-system pass&lt;/strong&gt; on one product: Laravel 13 / PHP 8.5, a starter kit dragged forward across thirty-odd minor versions, unified design tokens, and a shared list pattern rolled out screen by screen with one reference screen built first. Building the reference screen properly and then porting is slower on screen one and much faster on screens two through twelve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale published assets after a package update.&lt;/strong&gt; If your framework has a "publish these vendor assets" step, it has a "these are now out of date and nobody noticed" state. Wire the republish into the update path rather than the install path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration planning as documentation&lt;/strong&gt;, not as a ticket: what the current platform actually does, observed rather than assumed, before deciding what to replace it with. The probe that produced the observations got committed alongside the notes, which is the part I'd have skipped a year ago.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If today had a single rule in it, it's this: &lt;strong&gt;a name is not an identity.&lt;/strong&gt; It's a label that happens to be unique in the sample you looked at.&lt;/p&gt;

&lt;p&gt;Every bug above is what happens when code treats a label as a key — and every fix is the same move in a different dialect: normalise before comparing, confirm on something actually unique, name the thing you acted on in the response, and check the assumption at the layer that will be surprised, not the one that's convenient.&lt;/p&gt;

&lt;p&gt;The cheap tell: any time you're about to compare, group, or confirm on a human-readable string, ask who else could hold that string. If the answer isn't "nobody, by construction", you have a bug waiting for a coincidence.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dev Log: 31 August 2026 — Loaded Is Not Serving</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:05:05 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-31-august-2026-loaded-is-not-serving-38d</link>
      <guid>https://dev.to/nasrulhazim/dev-log-31-august-2026-loaded-is-not-serving-38d</guid>
      <description>&lt;p&gt;Thirty-eight commits across five repos today. Reading them back, most were the same species of bug: &lt;strong&gt;two different things that a system had been treating as one word.&lt;/strong&gt; Loaded and serving. Both-S3 and copyable. Present and correct. Every one of them was cheap to fix and expensive to find.&lt;/p&gt;




&lt;h2&gt;
  
  
  Loaded is not serving
&lt;/h2&gt;

&lt;p&gt;Found in production, and my favourite bug of the day.&lt;/p&gt;

&lt;p&gt;A site was answering 200 to every request. The dashboard showed the workload as &lt;code&gt;failed&lt;/code&gt;, with its last release green and its deployment active. The journal was clean — php-fpm up, migrations run, no errors anywhere. A red workload sitting next to a perfectly healthy site, which is exactly the thing that sends somebody to go and fix a deployment that is fine.&lt;/p&gt;

&lt;p&gt;The blue-green runtime picks the "active" colour like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="no"&gt;SLOT_BLUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SLOT_GREEN&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unitLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the thing: &lt;strong&gt;systemd keeps a unit loaded after it dies.&lt;/strong&gt; &lt;code&gt;systemctl show&lt;/code&gt; on a stopped-but-known unit still reports &lt;code&gt;LoadState=loaded&lt;/code&gt;. So a retired blue outranked the green that was actually serving every request, the status probe went and asked blue how it was doing, blue said &lt;code&gt;ActiveState=failed&lt;/code&gt;, and the UI rendered red.&lt;/p&gt;

&lt;p&gt;The quieter half of the same bug: &lt;code&gt;standbySlot()&lt;/code&gt; is the inverse of that answer. With blue permanently "active", every release went to green. Two consecutive production releases landed on the same colour — blue-green alternating had silently stopped, which defeats the entire arrangement.&lt;/p&gt;

&lt;p&gt;The fix reads both properties in one round trip and lets an &lt;em&gt;active&lt;/em&gt; unit win outright, even though blue is examined first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unitState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// LoadState + ActiveState, one call&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isRunning&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;           &lt;span class="c1"&gt;// active or activating&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$loadedFallback&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// only used if NEITHER is running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback is deliberate — it's what keeps a genuinely stopped workload reporting &lt;em&gt;Stopped&lt;/em&gt; rather than &lt;em&gt;Pending&lt;/em&gt;. And the test that scripted the old single-property probe was updated rather than deleted, because it now expresses &lt;em&gt;serving&lt;/em&gt; instead of merely &lt;em&gt;loaded&lt;/em&gt;, which is the distinction the whole bug turned on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both endpoints being S3 is not "can copy server-side"
&lt;/h2&gt;

&lt;p&gt;While wiring bring-your-own object storage, I hit a variant of the same mistake in my own contract design.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CopyObject&lt;/code&gt; in the S3 API is served by the endpoint you send it to. That endpoint cannot reach into a &lt;em&gt;different&lt;/em&gt; service's buckets. So "source is S3 and destination is S3" does not imply a server-side copy is available — same endpoint does.&lt;/p&gt;

&lt;p&gt;The awkward part is that the implementation is resolved per &lt;strong&gt;driver&lt;/strong&gt;, not per row. An S3 destination asked "can you server-side copy from this?" genuinely cannot tell which of its own configured endpoints it's being asked about. So the method grew a parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supportsServerSideCopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;BackupDestination&lt;/span&gt; &lt;span class="nv"&gt;$destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;BackupArtefact&lt;/span&gt; &lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHPStan caught the arity break across one call site and two test doubles, which is the cheapest possible way to find out you changed a contract.&lt;/p&gt;

&lt;p&gt;The consequence is worth stating out loud: cross-endpoint S3-to-S3 has to &lt;strong&gt;stream&lt;/strong&gt;, which makes the streaming path the common case rather than the rare one. That promotes the transfer size ceiling from a defensive guard to a load-bearing part of the design — and it's why the byte counter counts bytes through the control plane, so a genuine server-side copy correctly records zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  A version that is a fact beats a version that is a guess
&lt;/h2&gt;

&lt;p&gt;A one-click Drupal install on an AlmaLinux 9 node bootstrapped, built, deployed, passed the health gate — and then died at step 18 with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The database server version 13.23 is less than the minimum required version 16.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EL9's AppStream defaults the &lt;code&gt;postgresql&lt;/code&gt; module to 13. Drupal 11 pins a minimum of 16. Those two can never agree, which means that recipe could not have installed on any RHEL-family node, ever — and the cost of learning it was a full composer build on a 1 vCPU box, reported on a step whose name points nowhere near a package stream.&lt;/p&gt;

&lt;p&gt;Two fixes, because either one alone leaves the failure sitting there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install a server modern apps accept.&lt;/strong&gt; A per-OS hook runs before a service's packages go on. Empty on Debian, whose &lt;code&gt;postgresql&lt;/code&gt; metapackage tracks the release's own server. On RHEL it switches the module stream, guarded three ways, each load-bearing: only when 16 actually exists in that node's AppStream, so an older minor keeps working; &lt;code&gt;module reset&lt;/code&gt; first, because enabling a second stream over an enabled one is an error rather than a switch; and only from the "no server installed yet" branch, because &lt;strong&gt;a stream switch does not upgrade a cluster&lt;/strong&gt; — it would leave the data on 13 and the packages on 16, which is worse than either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refuse early when it still can't be satisfied.&lt;/strong&gt; A recipe declares its minimum engine versions and a guard checks them at the end of the provisioning step — &lt;em&gt;not&lt;/em&gt; in preflight, and this is the part worth arguing about.&lt;/p&gt;

&lt;p&gt;Preflight has no database yet. The only thing available there is a guess from a table of distribution defaults: a table that goes stale silently and is wrong the moment somebody installs a version by hand. Six steps later, the version is a &lt;strong&gt;fact&lt;/strong&gt; — the provisioner has read &lt;code&gt;SHOW server_version&lt;/code&gt; off the running daemon. Still ten steps earlier than the failure it replaces, and it cannot be wrong.&lt;/p&gt;

&lt;p&gt;Two details from the same guard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An engine that isn't in the deployment at all is &lt;em&gt;unconstrained&lt;/em&gt;, not forbidden. The app might use SQLite, or a database this platform never provisioned.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;version_compare&lt;/code&gt;, not string comparison. &lt;code&gt;'9.6'&lt;/code&gt; sorts after &lt;code&gt;'16'&lt;/code&gt; as text, which is how a naive check waves through an engine seven majors too old.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A hostname nobody typed
&lt;/h2&gt;

&lt;p&gt;An application with no domain isn't merely unrouted — on a plain VM it's usually &lt;strong&gt;undeployable&lt;/strong&gt;. With no domain the runtime drops off fpm serving and onto the recipe's &lt;code&gt;php -S 0.0.0.0:${PORT}&lt;/code&gt; fallback, on the port the catalogue declared for the container image, and on any node already serving a domain, nginx is holding 80.&lt;/p&gt;

&lt;p&gt;Cloning is what makes this the common case rather than an edge case. Cloning strips every domain, correctly — a hostname is served by exactly one deployment — so the copy of a working site arrives in precisely the state that can't run, and the operator is asked to invent a hostname before they can see whether the copy even works.&lt;/p&gt;

&lt;p&gt;So a preview hostname gets issued rather than typed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{workload-slug}-{deployment-suffix}.{preview-base}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deployment's own random suffix carries the uniqueness, and that's the entire reason it's in the label. A scheme that &lt;em&gt;can&lt;/em&gt; collide needs a retry loop, a "name taken" error, and a human to resolve it — for a name nobody asked for in the first place. The workload slug is in there because one deployment may attach more than one HTTP application.&lt;/p&gt;

&lt;p&gt;The nice part: it's almost all wiring that already existed. One new step writes the name onto the routing rule; the reverse-proxy, DNS and SSL steps then do exactly what they already do for a hostname somebody typed, and none of them needs to know where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who owns the decision — a menu refactor that's really an access refactor
&lt;/h2&gt;

&lt;p&gt;On a multi-tenant product I work on, the Administration section had quietly become where anything unclassifiable landed: 22 items, six sub-groups, three levels deep, mixing a tenant's own settings with the vendor's diagnostics. A committee secretary was being shown Telescope, Horizon, an Artisan runner and an MCP console — tools they will never touch and shouldn't see.&lt;/p&gt;

&lt;p&gt;The dividing line I took wasn't &lt;em&gt;what kind of screen is this&lt;/em&gt;. It was &lt;strong&gt;who owns the decision.&lt;/strong&gt; Vendor-owned tooling moved into its own section, invisible to a tenant entirely. Tenant-owned administration stayed and flattened — single destinations moved to the top level instead of each sitting inside a sub-group that costs a click to reveal one item. Net effect on a tenant's sidebar: 22 items to 9, three levels to two.&lt;/p&gt;

&lt;p&gt;One implementation note that generalises. The new section gates on a landlord-level flag rather than a permission, for the same reason the platform routes do: &lt;strong&gt;these screens run with no tenant current, and a permission check then has no table to consult.&lt;/strong&gt; It throws rather than returning false.&lt;/p&gt;

&lt;p&gt;And the test that had checked the moved item is now &lt;em&gt;stronger&lt;/em&gt; than before — it reads the new section and short-circuits on that section's own authorisation, so the item can't leak through a per-item gate even if somebody adds one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Re-pricing moved out of a Livewire component and into an action.&lt;/strong&gt; Two callers now share one definition of what re-pricing means — a form and an MCP tool. If the tool had copied the "retire the active version, cut the next one" logic, there'd be two definitions free to drift, and the thing that drifts is what people get charged. The write tool also carries forward anything the caller didn't name rather than defaulting to zero, so an agent asked to raise one number can't silently wipe the parameters sitting beside it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A statistics dashboard that couldn't reach its own rows.&lt;/strong&gt; A mail-history package renders eight status counts and a banner reading "N messages stuck in Sending for over 1 hour" — with no way to reach those N. It can't have a list, because the table has no recipient or subject column; both live inside a JSON headers blob, which is why status is the only thing it can aggregate. Fix: add the two columns, fill them on &lt;code&gt;saving&lt;/code&gt; in a model that extends the package's, and backfill via a deploy operation that's idempotent and deliberately does &lt;strong&gt;not&lt;/strong&gt; bump &lt;code&gt;updated_at&lt;/code&gt; — the stuck-message window measures from it, so a backfill mustn't make a stuck message look fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateway credentials got a UI, and a rule.&lt;/strong&gt; A stored secret is never sent back to the browser; the form shows only whether one is set, and a blank field on save means "leave it alone" — so saving the page to change a URL can't silently blank a credential. Switching to a gateway with empty credentials is refused outright, because allowing it means every checkout throws and the operator hears about it from a customer instead of from that screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Livewire trap worth writing down:&lt;/strong&gt; &lt;code&gt;wire:model="settings.payment.stripe.secret"&lt;/code&gt; doesn't bind one value — Livewire reads each dot as nesting and builds a three-level array. It fails as "Array to string conversion", which names nothing useful. Flat field names mapped to dotted storage keys through an explicit &lt;code&gt;fieldMap()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sandbox route with no guard.&lt;/strong&gt; A pay-by-button-press endpoint that marks an invoice paid with no money involved — registered unconditionally by a glob-based route loader, checking nothing but that the invoice exists. That's fine in a sandbox and very much not fine anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And one genuinely small thing:&lt;/strong&gt; a mobile app version bump for optional player accounts and Google Sign-In, plus its changelog entry and two new BSD-3 licence notices. Third-party notices are one of those chores that's trivial the day you do it and awful the day you're asked for them at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Every bug in the first half of today was a word doing two jobs. &lt;code&gt;loaded&lt;/code&gt; meant both "systemd knows about this" and "this is serving traffic". &lt;code&gt;s3&lt;/code&gt; meant both "speaks the protocol" and "can copy from that". A distribution default meant both "probably installed" and "definitely installed".&lt;/p&gt;

&lt;p&gt;They're hard to spot because the collapsed version reads fine — it's usually the shorter, more natural sentence. The tell is when a screen and reality disagree and nobody can immediately say which one is wrong. That's not a display bug. That's two concepts sharing a name, and the name picked the wrong one.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>php</category>
    </item>
    <item>
      <title>The Most Dangerous Backup Is the One That Reports Success</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:05:00 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/the-most-dangerous-backup-is-the-one-that-reports-success-2bn4</link>
      <guid>https://dev.to/nasrulhazim/the-most-dangerous-backup-is-the-one-that-reports-success-2bn4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I spent today building out a backup subsystem, and almost every design decision that mattered came down to the same question: can this thing fail in a way that still renders as a green tick? A backup that fails loudly is an annoyance. A backup that fails quietly is the reason the restore doesn't exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure mode nobody designs for
&lt;/h2&gt;

&lt;p&gt;Backups have an unusual property among features: the moment you find out whether yours works is the worst possible moment to find out. Everything in between is inference. And inference is exactly where a system gets to lie to you politely.&lt;/p&gt;

&lt;p&gt;So the rule I kept coming back to today: &lt;strong&gt;a component may not report a success it did not earn.&lt;/strong&gt; Not "should not" — may not, structurally, because the code path that would produce the false green isn't there.&lt;/p&gt;

&lt;p&gt;Here's what that looked like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sync is not backup
&lt;/h2&gt;

&lt;p&gt;The first one is the one people get wrong most confidently, because the tool that does it wrong is excellent at its job.&lt;/p&gt;

&lt;p&gt;A sync tool mirrors a source into a destination. If a key disappears from the source, it disappears from the destination. That is correct behaviour for a mirror and catastrophic behaviour for a backup, because the thing you are backing up &lt;em&gt;against&lt;/em&gt; is somebody deleting the wrong folder at 2am — and a mirror propagates that deletion to the only copy that could have undone it, usually within the hour, and then reports success.&lt;/p&gt;

&lt;p&gt;So the bucket driver I wrote today records deletions and never replays them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * A key that vanished at the source is marked deleted in the manifest.
 * Its object is LEFT WHERE IT IS until retention expires it.
 * Nothing in this driver deletes an object at a destination.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tests pin the pair of consequences, and the second one matters as much as the first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an object deleted at the source is still restorable from the last run that had it;&lt;/li&gt;
&lt;li&gt;restoring a &lt;em&gt;later&lt;/em&gt; run does not resurrect it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second test is the one people forget. A restore reproduces the bucket as of that run. An operator recovering one folder did not also ask to undo every intentional deletion since — and if your restore does that, you've handed them a second incident while they were cleaning up the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Move" is copy, verify, then delete — in that order, always
&lt;/h2&gt;

&lt;p&gt;Moving a backup between destinations is the most obviously dangerous operation in the whole subsystem, because there's a window where the artefact exists in exactly one place and something is actively trying to remove it.&lt;/p&gt;

&lt;p&gt;The ordering rule is boring. What's interesting is making the rule &lt;em&gt;checkable&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// source_deleted_at stays null until the destination copy is Verified,&lt;/span&gt;
&lt;span class="c1"&gt;// and is written in its own statement, after verified_at.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two separate writes, deliberately. If they went out together you could argue forever about whether a bad row was a race or a bug. Kept apart, a query can find the violation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a transfer never deletes the source before the destination verifies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BackupTransfer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deletedSourceBeforeVerification&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other half: the destination copy gets its &lt;strong&gt;own&lt;/strong&gt; artefact row, with its own key, size, checksum and verification state — and the driver is asked to verify &lt;em&gt;that&lt;/em&gt; row. A move that verified the source's checksum would have proved nothing about the transfer it existed to prove. It would have been a green tick describing the wrong object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification that admits what it didn't check
&lt;/h2&gt;

&lt;p&gt;This is my favourite one, because the honest version is uglier than the dishonest version and that's the whole point.&lt;/p&gt;

&lt;p&gt;Verifying a bucket copy properly means re-hashing every object. To do that, the control plane has to read every object back through itself — during the verification of a transfer whose entire purpose was to keep that data out of the control plane. You can't have both.&lt;/p&gt;

&lt;p&gt;The dishonest option is to check what's cheap and call it verified. What shipped instead: check object count, total size, and a spread sample of per-object sizes — and then say so.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'verification'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'objects_checked'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1_284&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'bytes_expected'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4_118_233_712&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'etags_rehashed'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;etags_rehashed: false&lt;/code&gt; sits in the record permanently. A passing verification cannot be read, later, by somebody who wasn't there, as proving something it never checked. The same instinct produced a test that scans every operator-visible string in the feature for the words "point-in-time", "versioning" and "snapshot" — because the driver doesn't do those, and prose that merely &lt;em&gt;declines&lt;/em&gt; to claim something is not the same as prose that states the opposite. Somebody reading the screen in two years will assume whatever the words allow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrity is not restorability
&lt;/h2&gt;

&lt;p&gt;Every run checks that the artefact is present, the right size, the right checksum, and that it opens. All cheap, all worth having, and &lt;strong&gt;none of them prove it restores.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An archive that passes &lt;code&gt;pg_restore --list&lt;/code&gt; and then dies part-way through a real restore — a missing extension, an incompatible server version, an encoding mismatch — is indistinguishable from a good one right up until 3am.&lt;/p&gt;

&lt;p&gt;So: restore drills. A policy can opt into &lt;code&gt;verify_level = restorability&lt;/code&gt;, on its own cadence and its own cursor (&lt;code&gt;drill_cron&lt;/code&gt;, &lt;code&gt;next_drill_at&lt;/code&gt; — a nightly backup does not want a nightly drill; a drill costs a real target and a real restore).&lt;/p&gt;

&lt;p&gt;Two design calls in there I'd defend anywhere:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The drill uses the ordinary restore path.&lt;/strong&gt; Not a drill-specific one. A drill with its own code path proves the drill works, which is the one thing it was never in doubt about. It builds a restore record in new-target mode and hands it to the same &lt;code&gt;restore()&lt;/code&gt; an operator would trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed drill flags the policy; it never condemns the artefact.&lt;/strong&gt; Tempting to auto-demote a backup whose drill failed. That is precisely how your last good copy disappears on the strength of a drill-&lt;em&gt;environment&lt;/em&gt; problem. The artefact keeps its verified status. The policy goes red. There's a test pinning that, because it's the kind of rule a future refactor would "fix".&lt;/p&gt;

&lt;p&gt;And a bonus the drill gave me for free: &lt;strong&gt;restore time is a number nothing else in the system ever measures.&lt;/strong&gt; An operator planning around a backup needs to know if they're committing to ten minutes or six hours. There's no honest way to find out except doing it. So restore time is recorded from the drill's own clock, or from a real restore's — and where neither exists, the screen says "never measured". Never an estimate. An estimate here is a fabricated green tick wearing a number.&lt;/p&gt;

&lt;p&gt;Teardown, incidentally, goes in a &lt;code&gt;finally&lt;/code&gt; and is best-effort silent. A teardown error must not replace the real reason a drill failed. And a drill that leaves its target behind fills the server with drill databases — a backup feature causing the outage is a bad look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet ones: pagination and fakes
&lt;/h2&gt;

&lt;p&gt;Two smaller things, same family.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ListObjectsV2&lt;/code&gt; returns 1000 keys per call.&lt;/strong&gt; Back up the alphabetical first thousand objects of every bucket, report success, and you have built the worst possible version of this feature — the one that is indistinguishable from the correct one until somebody needs a file starting with "z". Paginate, and pin it with a test that seeds more than a thousand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fake is handed out only with a reason.&lt;/strong&gt; The resolver that picks an object-store client can fall back to a no-op in environments that have no daemon. It never does that silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$resolver&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isFake&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BackupOutcome&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The driver turns that reason into a refusal. A silent fake records a beautifully successful backup of nothing — which is, again, the same bug: a green tick with no work behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incremental baseline has to be verified, not just complete
&lt;/h2&gt;

&lt;p&gt;Objects are compared on &lt;code&gt;(key, etag, size)&lt;/code&gt; against the last run that &lt;strong&gt;both completed and has a verified copy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Seeding from a merely-completed run inherits its gaps silently and forever — every future incremental agrees with a baseline that was already wrong, and the gap only surfaces during the restore that needed it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lastModified&lt;/code&gt; is deliberately excluded from the identity triple. A re-uploaded object gets a new timestamp with identical bytes, and comparing on a clock turns an incremental quietly back into a full — the opposite failure, but still the system being wrong about its own work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Every one of these is the same shape. Somewhere there's a path where the system can report a success it didn't earn, and the fix is never "add a check" — it's usually to make the dishonest path impossible to express:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;delete-after-verify, in separate writes, so the violation is queryable rather than debatable;&lt;/li&gt;
&lt;li&gt;verification that records what it &lt;em&gt;didn't&lt;/em&gt; do;&lt;/li&gt;
&lt;li&gt;a drill that shares the production restore path;&lt;/li&gt;
&lt;li&gt;a fake that must carry a reason;&lt;/li&gt;
&lt;li&gt;a baseline that must be verified, not merely finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything in this space, the question I'd start with isn't "does it work?" It's &lt;strong&gt;"if this quietly stopped working, how long until anyone noticed, and what would the screen say in the meantime?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer to the second half is "success", that's the bug. Everything else is detail.&lt;/p&gt;

&lt;p&gt;Next up on my side: the restore-source selector — when several verified copies exist, picking the cheapest one to restore from is its own small pile of trade-offs.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>php</category>
    </item>
    <item>
      <title>A Symlink Swap Is Not a Zero-Downtime Deploy</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 31 Aug 2026 01:14:00 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/a-symlink-swap-is-not-a-zero-downtime-deploy-4bb2</link>
      <guid>https://dev.to/nasrulhazim/a-symlink-swap-is-not-a-zero-downtime-deploy-4bb2</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I had Capistrano-style releases on a plain VM: build into &lt;code&gt;releases/&amp;lt;ref&amp;gt;&lt;/code&gt;, swap a symlink, restart the service. Atomic, I thought. Then I put &lt;code&gt;curl&lt;/code&gt; in a loop and watched an actual deploy: &lt;strong&gt;502 on every single one&lt;/strong&gt;, for the length of the app's boot. The files were atomic. The process never was. And the nginx directive everyone reaches for to paper over that does absolutely nothing in the shape most people write it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The half that was already right
&lt;/h2&gt;

&lt;p&gt;The release layout is the well-trodden one, and there's nothing wrong with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/srv/app/
  releases/20260831-101500-a1b2c3d/
  releases/20260830-093000-9f8e7d6/
  current -&amp;gt; releases/20260831-101500-a1b2c3d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build into a fresh directory, then move the pointer. One detail worth getting right, because two of the three obvious ways to write it are broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# WRONG — `-sf` follows an existing symlink-to-a-directory and&lt;/span&gt;
&lt;span class="c"&gt;# quietly creates /srv/app/current/20260831-101500-a1b2c3d&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /srv/app/current

&lt;span class="c"&gt;# STILL WRONG — `-n` fixes that, but this is unlink() then symlink().&lt;/span&gt;
&lt;span class="c"&gt;# There is a window where the path resolves to nothing.&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /srv/app/current

&lt;span class="c"&gt;# RIGHT — create beside, then rename(2) over the top. Atomic.&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /srv/app/current.tmp
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-Tf&lt;/span&gt; /srv/app/current.tmp /srv/app/current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mv&lt;/code&gt; needs &lt;code&gt;-T&lt;/code&gt; for the same reason &lt;code&gt;ln&lt;/code&gt; needed &lt;code&gt;-n&lt;/code&gt;: without it, moving onto an existing symlink-to-a-directory moves &lt;em&gt;into&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;Get that right and your &lt;strong&gt;files&lt;/strong&gt; switch instantly. A request that started on the old release finishes against the old tree; the next one gets the new one. Genuinely atomic, genuinely nice.&lt;/p&gt;

&lt;p&gt;Which is exactly why the next line goes unexamined for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half nobody looks at
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;restart&lt;/code&gt; is &lt;code&gt;stop&lt;/code&gt;, then &lt;code&gt;start&lt;/code&gt;. Between those two, nothing is listening on your app's port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;t+0.00  mv -Tf current            files switch, atomic ✓
t+0.01  systemctl stop app        port closes
t+0.01  GET /  →  502
t+0.4   GET /  →  502
t+1.2   GET /  →  502
t+2.8   app finished booting, binds port
t+2.9   GET /  →  200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5mj8ce7e3qlpfvebcgs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5mj8ce7e3qlpfvebcgs.png" alt=" " width="800" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The deploy is atomic right up to step 3. Everything red after it is the part the symlink swap never covered.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three seconds of hard 502s, on every deploy, recovering by itself. That last part is why it survives so long: nobody files a bug for something that fixed itself before they could screenshot it. It gets called "flaky."&lt;/p&gt;

&lt;p&gt;It isn't flaky. It's exactly what you told the machine to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two shapes, two different 502s
&lt;/h2&gt;

&lt;p&gt;If you serve a process on a port, it's the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Process dies → port closes → &lt;code&gt;connect()&lt;/code&gt; refused → nginx has nothing to say but 502.&lt;/p&gt;

&lt;p&gt;The second shape catches people out, and it caught me. Conventional PHP on a VM: nginx owns the docroot and only &lt;code&gt;.php&lt;/code&gt; reaches the app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/srv/app/current/public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/app/php-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$realpath_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reasoning goes: &lt;em&gt;nginx serves the files itself, so a restart can't matter much.&lt;/em&gt; It matters completely — &lt;strong&gt;if the fpm master runs under the same systemd unit as the app&lt;/strong&gt;. Which is often exactly how you want it, because then logs, resource limits and lifecycle all work per-app instead of being shared through one system-wide pool.&lt;/p&gt;

&lt;p&gt;The cost of that choice: restarting the unit kills the master, the socket file is unlinked, and &lt;code&gt;fastcgi_pass&lt;/code&gt; gets &lt;code&gt;ENOENT&lt;/code&gt; until it comes back. Same 502, different mechanism, and the static assets keep serving perfectly the whole time — which makes it look like an application bug rather than a deploy bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The directive that does nothing
&lt;/h2&gt;

&lt;p&gt;Everyone's first fix, mine included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt; &lt;span class="s"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# ← inert&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This changes nothing. &lt;code&gt;proxy_next_upstream&lt;/code&gt; retries &lt;strong&gt;the next peer in the upstream group&lt;/strong&gt;. A &lt;code&gt;proxy_pass&lt;/code&gt; at a literal &lt;code&gt;host:port&lt;/code&gt; is a group of one. There is no next peer, so there is nothing to retry to, and you get the same 502 you got before — now with a config line that makes you think you handled it.&lt;/p&gt;

&lt;p&gt;The version that works looks like a typo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="s"&gt;max_fails=0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="s"&gt;max_fails=0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# yes, the same address twice&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt; &lt;span class="s"&gt;timeout&lt;/span&gt; &lt;span class="s"&gt;invalid_header&lt;/span&gt; &lt;span class="s"&gt;non_idempotent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_next_upstream_tries&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Listing the address twice is the entire mechanism. Now there &lt;strong&gt;is&lt;/strong&gt; a next peer, so a refused connect gets retried instead of reported.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_fails=0&lt;/code&gt; is not decoration either. The default is &lt;code&gt;max_fails=1 fail_timeout=10s&lt;/code&gt;, and these two "servers" are one process — so a single refused connect marks &lt;em&gt;both&lt;/em&gt; peers down and nginx serves 502 for the next ten seconds. You'd have converted a 200 ms gap into a ten-second outage while believing you'd added resilience.&lt;/p&gt;

&lt;p&gt;And while you're in there, stop showing people the nginx default page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;error_page&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;@unavailable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;@unavailable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default_type&lt;/span&gt; &lt;span class="nc"&gt;text/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Retry-After&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;!doctype&lt;/span&gt; &lt;span class="s"&gt;html&amp;gt;&amp;lt;title&amp;gt;Starting&lt;/span&gt; &lt;span class="s"&gt;up&amp;lt;/title&amp;gt;&amp;lt;p&amp;gt;This&lt;/span&gt; &lt;span class="s"&gt;service&lt;/span&gt; &lt;span class="s"&gt;is&lt;/span&gt; &lt;span class="s"&gt;starting.&lt;/span&gt; &lt;span class="s"&gt;Refresh&lt;/span&gt; &lt;span class="s"&gt;in&lt;/span&gt; &lt;span class="s"&gt;a&lt;/span&gt; &lt;span class="s"&gt;few&lt;/span&gt; &lt;span class="s"&gt;seconds.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;503 with &lt;code&gt;Retry-After&lt;/code&gt;, not 502. A process that is &lt;em&gt;starting&lt;/em&gt; is not a broken upstream, and the status code is the only part of that a client, a CDN or a health checker can act on.&lt;/p&gt;

&lt;p&gt;But be clear-eyed: all of this buys you a &lt;strong&gt;retry&lt;/strong&gt;, not zero downtime. It covers a sub-second gap. It does not cover a three-second boot. For that you need to stop having a gap at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixes it: two of everything
&lt;/h2&gt;

&lt;p&gt;Blue-green, but with systemd doing the work. A template unit gets you both colours from one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/app@.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;app (%i)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/srv/app/slots/%i&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;-/srv/app/slots/%i.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/node server.js&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemctl start app@blue&lt;/code&gt; and &lt;code&gt;app@green&lt;/code&gt; are now two independent services. The layout grows a slots directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/srv/app/
  releases/&amp;lt;ref&amp;gt;/
  current      -&amp;gt; releases/&amp;lt;ref&amp;gt;     the version that is SERVING
  slots/blue   -&amp;gt; releases/&amp;lt;ref&amp;gt;     one colour's release
  slots/green  -&amp;gt; releases/&amp;lt;ref&amp;gt;
  slots/blue.env                     PORT=41000
  slots/green.env                    PORT=41001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the deploy becomes a sequence rather than a restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl is-active &lt;span class="nt"&gt;--quiet&lt;/span&gt; app@blue &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;blue &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;green&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;standby&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$active&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; blue &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;green &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;blue&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 1. Build the release. Nothing serving is touched.&lt;/span&gt;
build_into &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'PORT=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STANDBY_PORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/srv/app/slots/&lt;/span&gt;&lt;span class="nv"&gt;$standby&lt;/span&gt;&lt;span class="s2"&gt;.env"&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"/srv/app/slots/&lt;/span&gt;&lt;span class="nv"&gt;$standby&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-Tf&lt;/span&gt; &lt;span class="s2"&gt;"/srv/app/slots/&lt;/span&gt;&lt;span class="nv"&gt;$standby&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"/srv/app/slots/&lt;/span&gt;&lt;span class="nv"&gt;$standby&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Start the standby beside the one that is serving.&lt;/span&gt;
systemctl restart &lt;span class="s2"&gt;"app@&lt;/span&gt;&lt;span class="nv"&gt;$standby&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 3. Prove it answers, on its OWN port — not through the proxy,&lt;/span&gt;
&lt;span class="c"&gt;#    which is still pointing at the version we are replacing.&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--retry&lt;/span&gt; 20 &lt;span class="nt"&gt;--retry-delay&lt;/span&gt; 1 &lt;span class="s2"&gt;"http://127.0.0.1:&lt;/span&gt;&lt;span class="nv"&gt;$STANDBY_PORT&lt;/span&gt;&lt;span class="s2"&gt;/up"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null

&lt;span class="c"&gt;# 4. The switch. A reload does not drop connections in flight.&lt;/span&gt;
write_upstream &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STANDBY_PORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload

&lt;span class="c"&gt;# 5. Only now is the old one expendable.&lt;/span&gt;
systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; &lt;span class="s2"&gt;"app@&lt;/span&gt;&lt;span class="nv"&gt;$active&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3avv6xo39lytoiv3cai.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3avv6xo39lytoiv3cai.png" alt=" " width="800" height="580"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same deploy, two colours. Blue keeps answering until step 4, and step 4 is a reload, not a restart.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Step 3 is the one people skip and shouldn't. Probe the &lt;strong&gt;standby's own port&lt;/strong&gt;, never the public URL — the public URL is still answered by the version you're trying to replace, so a probe through it always passes and proves nothing.&lt;/p&gt;

&lt;p&gt;Step 4 is the moment traffic moves, and it is the only moment anything visible changes. An &lt;code&gt;nginx -s reload&lt;/code&gt; starts new workers for the new config and lets the old workers finish what they're holding. Nobody gets cut off mid-response.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing container people never have to think about
&lt;/h2&gt;

&lt;p&gt;If you've only done blue-green with containers, this is the part that doesn't transfer.&lt;/p&gt;

&lt;p&gt;Each container gets its own network namespace, so &lt;strong&gt;both colours can bind port 3000&lt;/strong&gt; and only the network alias tells them apart. The name moves; the number stays.&lt;/p&gt;

&lt;p&gt;On a plain host there is one port space. Two processes cannot both bind 3000. So the &lt;strong&gt;number&lt;/strong&gt; is what moves, and the hostname stays &lt;code&gt;127.0.0.1&lt;/code&gt;. Everything downstream follows from that one fact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your proxy repoint has to rewrite the &lt;em&gt;port&lt;/em&gt;, not the upstream host;&lt;/li&gt;
&lt;li&gt;you need somewhere to keep "which port is this colour on" — I write it to &lt;code&gt;slots/&amp;lt;colour&amp;gt;.env&lt;/code&gt; and read it back at switch time;&lt;/li&gt;
&lt;li&gt;pick your slot ports above the registered range and &lt;strong&gt;below&lt;/strong&gt; &lt;code&gt;ip_local_port_range&lt;/code&gt; (32768–60999 on most boxes), or you'll eventually collide with an ephemeral port the kernel handed out for an outbound connection. 41000–41999 is a quiet neighbourhood.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one consequence worth refusing rather than shipping: if your app serves &lt;strong&gt;two&lt;/strong&gt; domains on &lt;strong&gt;two&lt;/strong&gt; ports, a colour only rebinds the primary one. The second domain keeps answering from the colour you are about to kill. That's a half-switched release, which is worse than an honest restart — so detect it and refuse, loudly, with a message that says why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rules I'd argue for in any implementation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A colour's &lt;code&gt;WorkingDirectory&lt;/code&gt; points at its own release, never at &lt;code&gt;current&lt;/code&gt;.&lt;/strong&gt; This sounds like a detail and it's the whole thing. Two versions can only run at once if each has its own working directory. Point both at &lt;code&gt;current&lt;/code&gt; and you've rebuilt the restart with extra ceremony.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;current&lt;/code&gt; moves at promote, not at deploy.&lt;/strong&gt; It should keep meaning "the version that is serving" right up until the switch. That matters because your companion units follow it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# worker unit — deliberately on `current`, not on a colour
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/srv/app/current&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/php artisan queue:work&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarting that at promote — and only at promote — is also exactly right for a queue worker. A worker holds the code it started with, and the moment the new version becomes canonical is the moment it should pick it up. Not while it's still a candidate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate on what you can actually reach
&lt;/h2&gt;

&lt;p&gt;Back to the php-fpm shape, because it's where I had to be honest with myself.&lt;/p&gt;

&lt;p&gt;A standby fpm master listens on a unix socket and nothing else. There's no port to curl. And the only nginx on the box is still pointing at the colour that's serving — so an HTTP probe "at the standby" through nginx is answered by &lt;em&gt;the version you're replacing&lt;/em&gt;. It passes every time. It's not a gate, it's a gate-shaped thing that can only ever say yes.&lt;/p&gt;

&lt;p&gt;Real options: stand up a second loopback listener just for the gate, or speak FastCGI to the socket directly. Both are actual work.&lt;/p&gt;

&lt;p&gt;What I did instead was record that switch as &lt;strong&gt;unverified&lt;/strong&gt; rather than dressing it up as passed, and keep the checks that genuinely are reachable — did the unit stay up through its first few seconds, and did the pre-start migration exit non-zero. That's less than an HTTP probe and it is not nothing.&lt;/p&gt;

&lt;p&gt;An honest &lt;em&gt;"we could not verify this one"&lt;/em&gt; is worth more than a green tick you invented. A check that cannot fail is indistinguishable from a check that never ran, and six months later nobody can tell you which one it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it with curl, not with your eyes
&lt;/h2&gt;

&lt;p&gt;Whatever you do, the test is two lines and you should run it before you believe anybody, including me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; :&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; http://localhost/&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;0.1
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave it running. Deploy. Watch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:  200 200 200 502 502 502 502 502 502 502 200 200
after:   200 200 200 200 200 200 200 200 200 200 200 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire acceptance criterion, and it's the one your users are actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Atomic file swaps are the easy half, and they're the half that gets all the attention because they're satisfying to get right. Nobody's &lt;code&gt;curl&lt;/code&gt; loop can see them.&lt;/p&gt;

&lt;p&gt;Ask the boring question instead: &lt;strong&gt;between the old process exiting and the new one accepting connections, what answers the phone?&lt;/strong&gt; If the answer is "nginx, with a 502", you don't have a zero-downtime deploy — you have an atomic file swap and an outage you've agreed not to measure.&lt;/p&gt;

&lt;p&gt;And if you're about to fix it with &lt;code&gt;proxy_next_upstream&lt;/code&gt; on a single-target &lt;code&gt;proxy_pass&lt;/code&gt;: that line does nothing. List the peer twice.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>nginx</category>
      <category>php</category>
    </item>
    <item>
      <title>Dev Log: 30 August 2026 — Absence Is Not the Same as Zero</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 31 Aug 2026 00:26:48 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-30-august-2026-absence-is-not-the-same-as-zero-1kh1</link>
      <guid>https://dev.to/nasrulhazim/dev-log-30-august-2026-absence-is-not-the-same-as-zero-1kh1</guid>
      <description>&lt;p&gt;Twenty-three commits across four repos today. Looking back at them, most had the same shape underneath: &lt;strong&gt;something that was empty, and the question of what empty was supposed to mean.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Empty, none, and unknown are three different answers
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;"this credential needs no special permissions"&lt;/em&gt; — which is a confident, specific, and possibly false claim.&lt;/p&gt;

&lt;p&gt;There are three states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;documented&lt;/strong&gt; — we know the exact scopes, because we know which API calls we make. Show them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;none&lt;/strong&gt; — the provider has no scope model at all. It's a plain key that works or doesn't. Saying &lt;em&gt;that&lt;/em&gt; is useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;undocumented&lt;/strong&gt; — nobody has written them down yet. Show nothing at all, not an empty list.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScopeGuidance&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$scopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* documented */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$note&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;                        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* no scope model */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;undocumented&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;                            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* not written down */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same rule a vulnerability scanner has to follow: an image that &lt;em&gt;couldn't&lt;/em&gt; be scanned records nothing, and &lt;strong&gt;unscanned must never render like scanned-and-clean.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;While I was in there I also learned that a cloud provider token needs &lt;code&gt;tag:create&lt;/code&gt; and not just &lt;code&gt;tag:read&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A count that was always zero
&lt;/h2&gt;

&lt;p&gt;Same theme, uglier version: a dashboard tile showing open incidents that read &lt;code&gt;0&lt;/code&gt; regardless of reality. A zero that's wrong is worse than a tile that's missing, because a wrong zero is &lt;em&gt;reassuring&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The neighbouring tile was rendering a raw enum case name straight into the page — &lt;code&gt;awaiting_promotion&lt;/code&gt; where a human wanted "Awaiting promotion". Which is the argument for enums carrying &lt;code&gt;label()&lt;/code&gt; and &lt;code&gt;color()&lt;/code&gt; rather than the Blade template doing &lt;code&gt;Str::headline()&lt;/code&gt; 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 &lt;em&gt;separately&lt;/em&gt;, until one doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;/storage&lt;/code&gt; is already taken
&lt;/h2&gt;

&lt;p&gt;My favourite bug of the day. I added a new section to a Laravel app under the URL prefix &lt;code&gt;/storage&lt;/code&gt;. Every test passed. In production, every page under it returned 403.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;public/storage&lt;/code&gt; is Laravel's own &lt;code&gt;storage:link&lt;/code&gt; symlink and it exists on essentially every Laravel deployment. A web server configured with &lt;code&gt;try_files $uri $uri/&lt;/code&gt; matches &lt;code&gt;/storage&lt;/code&gt; as a &lt;strong&gt;real directory&lt;/strong&gt; and answers before the request ever reaches PHP. The router never sees it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;public/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'does not register a route prefix that public/ shadows'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$shadowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;directories&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;public_path&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="nv"&gt;$prefixes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getRoutes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ltrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prefixes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;intersect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shadowed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Route &lt;em&gt;names&lt;/em&gt; can stay whatever reads well in the sidebar. It's the URL that has to dodge the framework's own conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusing loudly beats hiding the button
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;deployed successfully at least once&lt;/strong&gt; may be copied.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;looking&lt;/em&gt; like a copy of something that works.&lt;/p&gt;

&lt;p&gt;The interesting part is how the gate surfaces. Two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hide the Clone button when the app can't be cloned.&lt;/li&gt;
&lt;li&gt;Show it, and render the reason when it's refused.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;em&gt;"this application has not deployed successfully yet — deploy it once, then it can be copied"&lt;/em&gt; is a hidden button that answers its own support ticket.&lt;/p&gt;

&lt;p&gt;The shape that makes it work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * Why this cannot be cloned, or null when it can.
 * For rendering, never for deciding.
 */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Application&lt;/span&gt; &lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;refusal()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming things for whose thing it is
&lt;/h2&gt;

&lt;p&gt;Two smaller ones from a multi-tenant membership platform, both the same mistake in different clothes.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;member&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;Both are the same bug: content written from the builder's seat instead of the reader's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the ceiling goes
&lt;/h2&gt;

&lt;p&gt;Last one, from a chess app for kids. I added optional accounts, and with them a question: what does a signed-out player &lt;em&gt;not&lt;/em&gt; get?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then the part I actually want to note, which is &lt;em&gt;where the rule lives&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccountAccess&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;guestLevelCeiling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;allowsLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EngineLevel&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;signedIn&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;signedIn&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;index&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;guestLevelCeiling&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;allowsConnectedPlay&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;signedIn&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;signedIn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One place, unit-testable, consumed by both the level picker and the play controller. &lt;strong&gt;A ceiling nobody can unit-test is a ceiling that quietly moves&lt;/strong&gt; — someone adds a third surface, reimplements the check slightly differently, and now your product rule has two versions of itself.&lt;/p&gt;

&lt;p&gt;And locked levels stay &lt;em&gt;visible&lt;/em&gt;, 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;None of those were true. They were just what empty looked like when nobody decided what empty should mean.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>A Credential Rotation Without an Overlap Is Just an Outage You Scheduled</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 31 Aug 2026 00:26:35 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/a-credential-rotation-without-an-overlap-is-just-an-outage-you-scheduled-388f</link>
      <guid>https://dev.to/nasrulhazim/a-credential-rotation-without-an-overlap-is-just-an-outage-you-scheduled-388f</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I shipped managed S3-compatible storage servers today, and the most interesting thing in the whole feature wasn't the provisioning pipeline. It was discovering that the storage daemon has no "rotate key" command — and that this absence is a better design than the &lt;code&gt;setPassword()&lt;/code&gt; I'd already written for databases.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rotation I got wrong first
&lt;/h2&gt;

&lt;p&gt;My database provisioning has had credential rotation for a while. The contract looks about how you'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;DatabaseAdminContract&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One call. The old password stops working, the new one starts working, done. Clean API, satisfying to write.&lt;/p&gt;

&lt;p&gt;Here's the thing: between the moment that call returns and the moment every application holding the old password has been redeployed with the new one, &lt;strong&gt;every one of those applications is broken.&lt;/strong&gt; Not degraded — broken, authentication-failure broken. The rotation didn't secure anything during that window. It just caused an outage, and the outage's length is however long your slowest deploy takes.&lt;/p&gt;

&lt;p&gt;We call that a rotation. It's a cutover wearing a rotation's clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The absence that turned out to be the answer
&lt;/h2&gt;

&lt;p&gt;When I wired up the S3-compatible daemon, I went looking for its rotate command. There isn't one. You can create an access key, you can delete an access key, and that's the vocabulary.&lt;/p&gt;

&lt;p&gt;My first reaction was "fine, I'll emulate it — delete then create." My second reaction, about ten seconds later, was that emulating it would reproduce exactly the window I just described, on purpose, in a system that didn't have it.&lt;/p&gt;

&lt;p&gt;So rotation stopped being a call and became a workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Issue a &lt;strong&gt;replacement&lt;/strong&gt; key on the same server.&lt;/li&gt;
&lt;li&gt;Re-attach it to the same buckets with the same privileges.&lt;/li&gt;
&lt;li&gt;Let the application cut over on its own schedule.&lt;/li&gt;
&lt;li&gt;Delete the outgoing key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both keys work for the whole span between 1 and 4. There is no moment where nothing authenticates. The cost is that a one-liner became four acts, two of which belong to a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modelling "both keys are valid" as normal
&lt;/h2&gt;

&lt;p&gt;This is the part that bit me in review. If two live credentials for the same bucket look like a fault in your UI, the next operator who sees it will "fix" it — by deleting one, probably the wrong one, probably during a deploy.&lt;/p&gt;

&lt;p&gt;So the overlap has to be a first-class state, not an anomaly. In Laravel terms that's an enum carrying its own presentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;AccessKeyStatus&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Pending&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Active&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Retiring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'retiring'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// rotating out — still valid, on purpose&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Deleted&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'deleted'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Retiring&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Retiring — still valid until the overlap ends'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="c1"&gt;// …&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Retiring&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'amber'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// attention, not alarm&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Active&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'emerald'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// …&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Amber, not red. The distinction matters more than it sounds: red means &lt;em&gt;something went wrong&lt;/em&gt;, amber means &lt;em&gt;something is in progress and will need you later&lt;/em&gt;. A retiring key is the second thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two actions, because the second one is a decision
&lt;/h2&gt;

&lt;p&gt;Starting a rotation is safe and can be one click. Finishing one is not, so it isn't the same action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RotateAccessKeyAction&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_OVERLAP_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AccessKey&lt;/span&gt; &lt;span class="nv"&gt;$outgoing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$overlapDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DEFAULT_OVERLAP_DAYS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;AccessKey&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$overlapDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'overlapDays'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The overlap must be at least a day — a rotation with no window is a cutover, and this exists to avoid the outage a cutover causes.'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Resume, don't start a second one. Two live replacements is worse&lt;/span&gt;
        &lt;span class="c1"&gt;// than none: the recorded window would cover the wrong pair.&lt;/span&gt;
        &lt;span class="nv"&gt;$existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$outgoing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;replacements&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;AccessKeyStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AccessKeyStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Active&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;AccessKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// …issue the replacement, mirror the bucket permissions,&lt;/span&gt;
        &lt;span class="c1"&gt;//   mark the outgoing key Retiring with an expiry.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there I'd argue for in any rotation implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The idempotency check isn't defensive coding, it's correctness.&lt;/strong&gt; Double-clicking "Rotate" shouldn't produce two replacement keys. Not because two keys is untidy, but because the compliance record says "credential X is being replaced by credential Y, and X stays valid until Z" — and that sentence has no meaning with two Ys in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The validation message explains the rule, not the constraint.&lt;/strong&gt; &lt;code&gt;The overlap must be at least a day&lt;/code&gt; is a form error. The sentence after the dash is the reason someone can act on. Error copy is where your architectural decisions actually reach the people using the thing.&lt;/p&gt;

&lt;p&gt;Completing the rotation is separate, and it refuses by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CompleteAccessKeyRotationAction&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AccessKey&lt;/span&gt; &lt;span class="nv"&gt;$outgoing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$replacement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$outgoing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;replacements&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AccessKeyStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$replacement&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;AccessKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The replacement has not been created on the daemon yet. Withdrawing this one now would leave nothing able to authenticate.'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$outgoing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasExpired&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The overlap runs until :until. Finish early only if you are certain nothing is still using this key.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'until'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$outgoing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toDayDateTimeString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// …audit, close the rotation record, then reuse the ordinary delete.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why not just delete it on a timer?
&lt;/h2&gt;

&lt;p&gt;This was the tempting shortcut and I want to be honest about why I didn't take it.&lt;/p&gt;

&lt;p&gt;The platform &lt;strong&gt;cannot see&lt;/strong&gt; whether an application still holds the old secret. Nothing reports that. There's no callback, no heartbeat, no "I have cut over" signal. So a scheduled job that deletes the outgoing key when the timer expires is a platform arranging an outage for itself, at 3am, with nobody watching.&lt;/p&gt;

&lt;p&gt;The expiry on the key is the &lt;strong&gt;deadline&lt;/strong&gt;. The completion action is the &lt;strong&gt;decision&lt;/strong&gt;. Keeping those two things separate is the whole point — the deadline creates the pressure, a human confirms the fact.&lt;/p&gt;

&lt;p&gt;The trade-off is real and I'll name it: keys can now linger past their window if nobody clicks the button. That's a reporting problem (surface the overdue ones loudly), and I'd rather have a reporting problem than a scheduled outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;force&lt;/code&gt; flag is not a backdoor
&lt;/h2&gt;

&lt;p&gt;Note that &lt;code&gt;force&lt;/code&gt; doesn't skip the "does a replacement exist" check — only the "has the window elapsed" check. That's deliberate. One of those guards protects against impatience; the other protects against leaving a bucket with zero working credentials. Impatience is a judgement call. Zero working credentials never is.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post, take that shape: when you add an escape hatch, work out which of your guards it's allowed to open. A &lt;code&gt;force&lt;/code&gt; that opens all of them isn't an escape hatch, it's an unguarded second code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the window, not the calls
&lt;/h2&gt;

&lt;p&gt;Rotation is exactly the kind of feature where mocking the daemon and asserting "createKey was called" tests nothing worth testing. What you actually care about is that both credentials work at the same time, and that the record says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'keeps the outgoing key valid for the whole overlap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AccessKey&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$replacement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RotateAccessKeyAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overlapDays&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AccessKeyStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Retiring&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasExpired&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeFalse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$replacement&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AccessKeyStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$replacement&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refuses to start a second rotation for the same key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AccessKey&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$first&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RotateAccessKeyAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RotateAccessKeyAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$second&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refuses to complete while the window is still open'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// …rotate, then immediately try to complete&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CompleteAccessKeyRotationAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tests, three rules, none of them touching a real daemon. A fake admin driver behind the same contract makes that possible — which is the other half of the story and probably its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;I went in thinking the missing rotate command was a gap to paper over. It was a constraint that made me build the right thing.&lt;/p&gt;

&lt;p&gt;If your rotation is a single call that swaps a value in place, ask what's holding the old value and how it finds out. If the answer is "we redeploy everything quickly," you don't have rotation — you have a cutover you've agreed not to talk about.&lt;/p&gt;

&lt;p&gt;Overlap first. Deadline second. Human decides when the old one goes.&lt;/p&gt;

&lt;p&gt;Next up: I still need to go back and give the database side the same treatment. Its &lt;code&gt;setPassword()&lt;/code&gt; works fine, right up until the day it doesn't.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>php</category>
    </item>
    <item>
      <title>Dev Log: 29 August 2026 — Holes in the Ops Surface, and Docs That Lie</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sun, 30 Aug 2026 08:38:04 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-29-august-2026-holes-in-the-ops-surface-and-docs-that-lie-mgb</link>
      <guid>https://dev.to/nasrulhazim/dev-log-29-august-2026-holes-in-the-ops-surface-and-docs-that-lie-mgb</guid>
      <description>&lt;p&gt;Twenty-five commits across eight repos today. The thread running through most of them: &lt;strong&gt;finishing things that were 90% automated and still needed a human in the middle.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The one-step-in-the-middle problem
&lt;/h2&gt;

&lt;p&gt;I've been building a deployment platform with an MCP surface — an agent can provision a node, deploy an app, diagnose it, redeploy it. Today I deployed the first real tenant through it end to end and it went green, answered on its own domain with TLS, and returned 500 on every request.&lt;/p&gt;

&lt;p&gt;Migrations had run. The seeders hadn't. And nothing in the product could run them.&lt;/p&gt;

&lt;p&gt;Then the second hole, right next to it: the app was missing thirteen environment variables its seeders read, and no tool on the ops surface could set one. So a pipeline that could do everything still needed somebody in a web form for the one step in the middle. That's not a boundary. That's a hole.&lt;/p&gt;

&lt;p&gt;Two tools closed it, and the interesting part of both is what they &lt;em&gt;refuse&lt;/em&gt; to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setting env vars: values go inbound only.&lt;/strong&gt; Every read tool on that surface reports env keys and never values, and that had to stay true. So the write tool's response names each key and what happened to it — added, changed, unchanged, removed, generated — and never echoes a value back, not even one the caller just supplied. An agent that can read a password out of a deployment is a categorically worse tool than one that can set it. The audit trail records key names for the same reason: recording values would rebuild the plaintext credential store the encrypted column exists to avoid.&lt;/p&gt;

&lt;p&gt;There's also a &lt;code&gt;generate&lt;/code&gt; mode, so the values nobody ever needs to &lt;em&gt;read&lt;/em&gt; never appear in a transcript or a shell history at all. &lt;code&gt;APP_KEY&lt;/code&gt; is the case that matters — strong, secret, stable, never typed by a human. Deliberately not offered for anything that has to match something else, like a mail password, where a generated value is just silently wrong.&lt;/p&gt;

&lt;p&gt;And it &lt;strong&gt;merges, never replaces&lt;/strong&gt;. That rule is scar tissue: a wholesale restore once discarded the &lt;code&gt;APP_KEY&lt;/code&gt; the runtime had minted, so every release got a fresh one — signing out every user and making every encrypted column unreadable. A write that replaced the array would be the same defect from the other side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running first-install commands: declared, not dictated.&lt;/strong&gt; Some work can't live in the deploy path. A &lt;code&gt;preStartCommand&lt;/code&gt; runs on &lt;em&gt;every&lt;/em&gt; deploy, so it can only hold work that's safe to repeat. First-install work is the opposite shape — a seeder that creates a superadmin with &lt;code&gt;User::create()&lt;/code&gt; is a duplicate-key error on its second run.&lt;/p&gt;

&lt;p&gt;The tool decides &lt;strong&gt;when&lt;/strong&gt; first-install work runs and never &lt;strong&gt;what&lt;/strong&gt; it is. The commands live on the workload definition, and a small validator refuses anything that isn't &lt;code&gt;php artisan …&lt;/code&gt; free of shell syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SetupCommands&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;assertAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Allow-list of SHAPE, not a deny-list of metacharacters.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^php artisan [a-z0-9:_\-\. ]+$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'Setup commands must be a plain `php artisan` command.'&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An allow-list of shape, not a deny-list of dangerous characters. A deny-list is a list somebody has to keep complete forever, and the first omission is remote code execution as the application's own account. An MCP tool that accepted a command string outright would be RCE on a customer's node for anyone holding an ops token.&lt;/p&gt;

&lt;p&gt;The rest is unglamorous and load-bearing: run as the application's own account via &lt;code&gt;runuser&lt;/code&gt; and never as root (a root-created file in the release tree breaks the app permanently), load the unit's environment so a seeder reading &lt;code&gt;SUPERADMIN_EMAIL&lt;/code&gt; sees what the service sees, stop at the first failure because "seed, then provision" has an order, and refuse a second run unless forced — recorded in a new &lt;code&gt;setup_completed_at&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;Both tools got Pest coverage for the input systemd fails &lt;em&gt;silently&lt;/em&gt; on: an unparseable key is dropped outright, and a newline in a value makes the remainder read as further variables. Silent input mangling is exactly what you want a test for, because nothing else will tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trusting a rebuilt host's key
&lt;/h2&gt;

&lt;p&gt;Third one from the same session. You rebuild a machine, the OS regenerates its SSH host key, and every subsequent connection fails the way it should — because from the client's side, "the host key changed" and "someone is on the wire" are the same event.&lt;/p&gt;

&lt;p&gt;The thing you must not build is a tool that shrugs and accepts whatever key answers. So the shape is: a probe that fetches and fingerprints the key that's currently answering, a result object that carries it, and a distinct exception for &lt;em&gt;mismatch&lt;/em&gt; versus &lt;em&gt;probe failed&lt;/em&gt;. The operator sees the new fingerprint and confirms the re-trust. The system never decides on its own that a changed key is fine.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HostKeyMismatch&lt;/code&gt; and &lt;code&gt;HostKeyProbeFailed&lt;/code&gt; being separate types matters more than it looks. One means "the machine you rebuilt is answering with a new identity, as expected". The other means "I could not reach it at all". Collapse those into one exception and the UI has to guess, and it will guess wrong on the day it counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSP, twice, in opposite directions
&lt;/h2&gt;

&lt;p&gt;The day's other theme got its own post, because one story wasn't enough: a Laravel app that enabled CSP &lt;em&gt;by accident&lt;/em&gt; through a &lt;code&gt;null&lt;/code&gt; default that means "auto-enable in production", and a static site whose correctly-strict &lt;code&gt;script-src 'none'&lt;/code&gt; broke every &lt;code&gt;mailto:&lt;/code&gt; link on it because Cloudflare's email obfuscation needs a script to undo itself.&lt;/p&gt;

&lt;p&gt;Both failures were silent. Both happened at a seam where two systems each behaved correctly on their own. Full write-up in the companion post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping a desktop build
&lt;/h2&gt;

&lt;p&gt;A Flutter app I've been building got a Windows platform target, a release workflow, and a &lt;strong&gt;public releases repository&lt;/strong&gt; — the app repo stays private, the built artifacts and their download page go somewhere public.&lt;/p&gt;

&lt;p&gt;That split is worth stealing. Your CI needs somewhere to put binaries that end users can reach, and "make the whole repo public" is not the only answer. A separate public repo holding nothing but tagged releases gives you a URL you can link from a marketing site, without exposing source or issue history. It got an ADR, because six months from now the reason will be less obvious than the arrangement.&lt;/p&gt;

&lt;p&gt;The marketing site now renders its download buttons from that repo's latest release — fetched at &lt;strong&gt;build time only&lt;/strong&gt;. Two reasons, both worth stating plainly: the site's CSP is &lt;code&gt;connect-src 'none'&lt;/code&gt;, so a client-side fetch would be blocked anyway; and GitHub's unauthenticated rate limit of 60 requests per hour per IP does not survive real traffic. Every failure path returns null and the section simply doesn't render — a 404 is the &lt;em&gt;normal&lt;/em&gt; state before the first release exists, and a build shouldn't fail over the expected case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google Sign-In, behind a contract
&lt;/h2&gt;

&lt;p&gt;The API gained a native Google Sign-In endpoint plus SSO buttons on the dashboard auth screens. Structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;GoogleIdTokenVerifier&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$idToken&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;GoogleIdentity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One implementation verifies against Google's JWKS. The action depends on the contract, so tests bind a fake and never touch the network — and if the verification strategy ever changes, the action doesn't. This is the default shape for anything that talks to a third party: a contract, a DTO for the verified result, and a driver behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docs that were quietly lying
&lt;/h2&gt;

&lt;p&gt;Five commits on a documentation repo, and most of them were &lt;strong&gt;corrections&lt;/strong&gt;, not additions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A claim that dependency-allowlist enforcement was in place. It wasn't.&lt;/li&gt;
&lt;li&gt;An endpoint documented as "planned" that had shipped.&lt;/li&gt;
&lt;li&gt;A behavioural rule — guest play capped at a lower engine level, an account unlocks the rest — that existed in code and nowhere in the spec.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus two new ADRs, one of which forced a partial SRS rewrite. That's the correct outcome, by the way: if an architecture decision doesn't ripple into the requirements doc, one of the two documents isn't being read.&lt;/p&gt;

&lt;p&gt;Documentation drift is the same category as the silent CSP failure. Nothing errors. The build is green. The document is just wrong, and it stays wrong until somebody reads it closely enough to notice — usually while relying on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Legal pages&lt;/strong&gt;, with every placeholder replaced by an actual commitment, plus a Bahasa Malaysia privacy notice alongside the English one. Not an optional translation — PDPA s.7(3) requires the notice in both languages, so the bilingual pair is the deliverable, with &lt;code&gt;lang&lt;/code&gt; and &lt;code&gt;alternate&lt;/code&gt; props wiring up the hreflang link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A UI bug worth naming&lt;/strong&gt;: a failed in-app encounter was rendering the "you've collected everything" state instead of an error. A failure path falling through to the success path is a bug I keep meeting in different costumes. Test it as "the request failed" rather than "the request returned nothing", and it stops happening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mail history dashboard&lt;/strong&gt; and a sidebar regression from the baseline patch — collapsible menu groups had stopped collapsing, and a logo had gone missing from two sidebars. The unglamorous half of any framework upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Today's pattern, said once: &lt;strong&gt;the last 10% of an automation is where all the security decisions live.&lt;/strong&gt; Every tool I added had a version that would have been faster to write and wrong — echo the value back, accept any command string, trust whatever host key answers. The design work wasn't making the tools do things. It was deciding, precisely, what they must refuse to do.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>CSP Broke Two of My Apps Today, in Opposite Directions</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sun, 30 Aug 2026 08:37:59 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/csp-broke-two-of-my-apps-today-in-opposite-directions-45lg</link>
      <guid>https://dev.to/nasrulhazim/csp-broke-two-of-my-apps-today-in-opposite-directions-45lg</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Content-Security-Policy took down parts of two different apps in one day. One app enabled it by accident and blocked its own fonts and analytics. The other had it locked down correctly, and that correctness broke a legally-required contact link. Same header, opposite failure modes, and neither one threw an error.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure one: a &lt;code&gt;null&lt;/code&gt; that means "on"
&lt;/h2&gt;

&lt;p&gt;I patched an app onto a newer baseline of our internal Laravel starter kit. The baseline shipped a &lt;code&gt;SecurityHeaders&lt;/code&gt; middleware and a &lt;code&gt;config/security.php&lt;/code&gt; that looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'csp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SECURITY_CSP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// null = auto (production only)&lt;/span&gt;
    &lt;span class="s1"&gt;'csp_policy'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'SECURITY_CSP_POLICY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"default-src 'self'; "&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s2"&gt;"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s2"&gt;"style-src 'self' 'unsafe-inline'; "&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s2"&gt;"font-src 'self' data:; "&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s2"&gt;"connect-src 'self'; "&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s2"&gt;"frame-ancestors 'none'; "&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s2"&gt;"form-action 'self'"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that default carefully. &lt;code&gt;null&lt;/code&gt; doesn't mean "off". It means &lt;em&gt;auto&lt;/em&gt;, and auto means &lt;strong&gt;on in production&lt;/strong&gt;. The middleware branches on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cspEnabled&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'security.headers.csp'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$flag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isProduction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$flag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did notice the flag during the patch. I set &lt;code&gt;SECURITY_CSP=false&lt;/code&gt; in &lt;code&gt;.env.example&lt;/code&gt; and moved on, which felt like enough.&lt;/p&gt;

&lt;p&gt;It wasn't. &lt;strong&gt;&lt;code&gt;.env.example&lt;/code&gt; is documentation, not configuration.&lt;/strong&gt; The production box has its own &lt;code&gt;.env&lt;/code&gt; written months ago, and that file has no &lt;code&gt;SECURITY_CSP&lt;/code&gt; key at all. So &lt;code&gt;env()&lt;/code&gt; returned the default, the default was &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt; meant auto, and the app started enforcing &lt;code&gt;default-src 'self'&lt;/code&gt; on a page that loads a webfont CDN, Google Tag Manager, the Meta Pixel and a Cloudflare analytics beacon.&lt;/p&gt;

&lt;p&gt;Every one of those got blocked. The pages still rendered. Nothing 500'd, no exception hit the logs, no test failed — the fonts just fell back to system stacks and analytics quietly stopped recording. The only evidence lived in a browser console nobody had open.&lt;/p&gt;

&lt;p&gt;The fix is boring, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Opt-in, not auto. An explicit null still restores the old behaviour.&lt;/span&gt;
&lt;span class="s1"&gt;'csp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SECURITY_CSP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then widen the default policy so it's actually usable when someone does turn it on — the font host under &lt;code&gt;style-src&lt;/code&gt; and &lt;code&gt;font-src&lt;/code&gt;, the tag manager and pixel under &lt;code&gt;script-src&lt;/code&gt;, their beacon endpoints under &lt;code&gt;connect-src&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The lesson isn't about CSP. It's about &lt;strong&gt;defaults that change behaviour based on environment&lt;/strong&gt;. A &lt;code&gt;null&lt;/code&gt; that resolves differently in production than in local is a config value you cannot test locally by definition. If a security control needs to be on, make it explicitly on, per-app, after somebody looked at the policy. "Secure by default" is a good instinct, but a policy that was never verified against the app's actual asset list isn't security — it's an outage with good intentions.&lt;/p&gt;

&lt;p&gt;There's a second-order rule underneath it: &lt;strong&gt;a change to &lt;code&gt;.env.example&lt;/code&gt; changes nothing that is already running.&lt;/strong&gt; Any new key you introduce has exactly two safe forms — a safe default in &lt;code&gt;config/&lt;/code&gt;, or a deploy step that writes the key. &lt;code&gt;.env.example&lt;/code&gt; is neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure two: a CSP that was right, and broke something anyway
&lt;/h2&gt;

&lt;p&gt;Different project, same afternoon. A small static marketing site, ships an intentionally brutal policy: &lt;code&gt;script-src 'none'&lt;/code&gt;, &lt;code&gt;connect-src 'none'&lt;/code&gt;. It's a site for a children's app, and there's no JavaScript on it worth the attack surface.&lt;/p&gt;

&lt;p&gt;It's proxied through Cloudflare, with Scrape Shield's &lt;strong&gt;Email Address Obfuscation&lt;/strong&gt; on — a feature that rewrites every &lt;code&gt;mailto:&lt;/code&gt; in the HTML into &lt;code&gt;/cdn-cgi/l/email-protection#&amp;lt;hex&amp;gt;&lt;/code&gt; and injects a small script to decode it back in the browser.&lt;/p&gt;

&lt;p&gt;You can see where that lands. The rewrite happens at the edge, the decoder is a script, and &lt;code&gt;script-src 'none'&lt;/code&gt; blocks the decoder. So all ten contact links rendered as the literal string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;[email protected]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...pointed at a dead &lt;code&gt;/cdn-cgi/&lt;/code&gt; URL. That address happens to be the PDPA access-and-deletion contact and the child-safety contact a store review requires to be reachable, so "it's just a mailto" wasn't available to me.&lt;/p&gt;

&lt;p&gt;Three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn off Email Obfuscation in the Cloudflare dashboard. Works. It's also invisible state living in a web console that no repo remembers, and it dies the day the site moves to another DNS provider or someone spins up a fresh zone.&lt;/li&gt;
&lt;li&gt;Loosen the CSP to admit the decoder script. Weakening &lt;code&gt;script-src&lt;/code&gt; on a children's site to fix an email link is not a trade I'd defend in review.&lt;/li&gt;
&lt;li&gt;Use Cloudflare's own opt-out markers in the markup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Option three. Cloudflare skips anything wrapped in &lt;code&gt;&amp;lt;!--email_off--&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;!--email_on--&amp;gt;&lt;/code&gt;, so the whole fix is one tiny component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
/**
 * The contact address as a working mailto link.
 * Never write a bare mailto: anywhere else — route every one through this.
 */
import { CONTACT_EMAIL } from '../config';
---

&amp;lt;!--email_off--&amp;gt;&amp;lt;a href={`mailto:${CONTACT_EMAIL}`}&amp;gt;{CONTACT_EMAIL}&amp;lt;/a&amp;gt;&amp;lt;!--email_on--&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten &lt;code&gt;mailto:&lt;/code&gt; links became ten &lt;code&gt;&amp;lt;MailLink /&amp;gt;&lt;/code&gt;. The behaviour now lives in version control, survives a DNS move, and the docblock tells the next person why the component exists at all — which is most of its value. A one-line component with no explanation is the kind of thing a tidy-up PR deletes six months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two have in common
&lt;/h2&gt;

&lt;p&gt;Both failures were &lt;strong&gt;silent&lt;/strong&gt;, and silent in the way that costs you the most: the page rendered, the deploy went green, and the thing that broke was something you only notice by going and looking.&lt;/p&gt;

&lt;p&gt;Both also happened at a boundary where two systems each behaved correctly on their own. The middleware honoured its documented default. Cloudflare performed the feature it was asked to perform. Nobody's code was wrong; the &lt;em&gt;combination&lt;/em&gt; was.&lt;/p&gt;

&lt;p&gt;That's the shape I'd watch for. When you add a policy that says "deny everything not on this list", you have quietly signed up to maintain that list against every other system in the request path — your CDN, your edge proxy, your analytics vendor, your font host — none of which will tell you when they start needing something new.&lt;/p&gt;

&lt;p&gt;Two practical habits I'm taking from today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enable restrictive headers explicitly, per app, after checking the real asset list.&lt;/strong&gt; Not by inheriting a default from a shared baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode edge behaviour in the repo, not in a dashboard.&lt;/strong&gt; If a fix can be expressed in markup or config that ships with the code, prefer that over a toggle in someone's control panel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you run a strict CSP behind Cloudflare: go check your &lt;code&gt;mailto:&lt;/code&gt; links right now. I'll wait.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
