<?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: 2026-08-11 — a tenant for every account, four frontend traps that never error, and the fourth comparison site</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:36:00 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-08-11-a-tenant-for-every-account-four-frontend-traps-that-never-error-and-the-3dp3</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-08-11-a-tenant-for-every-account-four-frontend-traps-that-never-error-and-the-3dp3</guid>
      <description>&lt;p&gt;Thirty commits across three repos, and the day split cleanly into two halves: standing a control plane up on a bare host, and fixing the things that were quietly wrong once it was actually reachable.&lt;/p&gt;

&lt;p&gt;The deploy half has its own write-up — a deploy script that had been reporting success for weeks while skipping its own &lt;code&gt;git pull&lt;/code&gt;. This is everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. An account that can sign in and see nothing
&lt;/h2&gt;

&lt;p&gt;Multi-tenant app. A user belongs to organisations; every query is scoped to the current one. Registration created the user row and stopped there — no role, no organisation.&lt;/p&gt;

&lt;p&gt;Which produces the worst possible failure mode: the account works. Login succeeds, the session is valid, the dashboard renders. It's just &lt;em&gt;empty&lt;/em&gt;, because every scoped query returns zero rows and every permission check runs against a user holding no role at all.&lt;/p&gt;

&lt;p&gt;That doesn't read as "your account is awaiting access." It reads as a broken product.&lt;/p&gt;

&lt;p&gt;Self-serve registration should hand you something you own, so it now does:&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;create&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;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Validator&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;passwordRules&lt;/span&gt;&lt;span class="p"&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;validate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$user&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;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assignRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$organization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;":name's Organisation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;uniqueSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'owner_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&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="s1"&gt;'plan'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;OrganizationPlan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Starter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setDefaultOrganizationId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$organization&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&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 transaction isn't decoration. &lt;em&gt;User with a role but no organisation&lt;/em&gt; is exactly the state this change exists to eliminate — so a failure halfway through must not be able to create one.&lt;/p&gt;

&lt;p&gt;Two details worth pulling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The slug suffixes rather than throws.&lt;/strong&gt; &lt;code&gt;organizations.slug&lt;/code&gt; is unique, and two people called Zul is not an error condition, it's Tuesday. Anything derived from user-supplied names needs a collision strategy chosen deliberately, and "500 on the second signup" is rarely it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setting the default matters as much as creating the org.&lt;/strong&gt; Without it the organisation switcher opens on nothing, and the console is empty for a user who &lt;em&gt;does&lt;/em&gt; have somewhere to be. Same visible symptom, entirely different cause. Anywhere you have a "current X" concept, creating an X and selecting one are two steps, and forgetting the second looks exactly like forgetting the first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then the same rule, from the other direction
&lt;/h3&gt;

&lt;p&gt;Registration having a tenant while the admin-created path didn't means &lt;strong&gt;which door an account came through decides whether it works.&lt;/strong&gt; So both paths now go through one invokable action, and a deploy operation backfills the accounts made before either did.&lt;/p&gt;

&lt;p&gt;Pulling it into a shared action surfaced a good trap:&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;makeDefaultFor&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;Organization&lt;/span&gt; &lt;span class="nv"&gt;$organization&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 tenancy helper writes the *session's* current organisation as well&lt;/span&gt;
    &lt;span class="c1"&gt;// as the column. Called while an admin creates somebody else's account, it&lt;/span&gt;
    &lt;span class="c1"&gt;// would move the admin into the new user's tenant — silently, mid-request,&lt;/span&gt;
    &lt;span class="c1"&gt;// with nothing on screen to explain it.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;auth&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;check&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="nf"&gt;auth&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;id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$user&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="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forceFill&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$organization&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;save&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="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setDefaultOrganizationId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$organization&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The helper is named for the common case — you, setting your own default — and it does the extra session write because in that case you want it. Reuse it for &lt;em&gt;another&lt;/em&gt; user and the extra write becomes a bug that manifests as "the admin panel randomly teleported me into a customer's tenant."&lt;/p&gt;

&lt;p&gt;Pinned with a test, because this is the kind of thing that reads as correct forever:&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;'leaves the acting admin in their own organisation'&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;$admin&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;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;withOrganization&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;$before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$admin&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;organization_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$admin&lt;/span&gt;&lt;span class="p"&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;CreatePersonalOrganizationAction&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;$subject&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;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;create&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;$admin&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;organization_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;$before&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;$subject&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;organization_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;$before&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;Any helper that touches both persistent state and request state needs a hard look before it's called on behalf of somebody else.&lt;/strong&gt; The session write is invisible at the call site, which is precisely why it survives review.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Four frontend traps, none of which produce an error
&lt;/h2&gt;

&lt;p&gt;A UI panel — "this provider is restricted to N components" — that counted checkboxes and reported the wrong number forever. Unpicking it turned up four separate things that all fail silently. Every one went into the project's gotchas file the same afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A component library's checkbox is often not an &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;.&lt;/strong&gt; The one I'm using renders a custom element with &lt;code&gt;role="checkbox"&lt;/code&gt;. Any JavaScript querying &lt;code&gt;input[type=checkbox]&lt;/code&gt; matches &lt;em&gt;zero elements&lt;/em&gt; and reports a confident, wrong count. &lt;code&gt;:checked&lt;/code&gt; doesn't apply either, though the element does expose a &lt;code&gt;.checked&lt;/code&gt; property. Match both shapes, read &lt;code&gt;.checked&lt;/code&gt; — and pin the marker attribute in a test, so a library upgrade fails CI instead of silently returning the count to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A double quote anywhere inside an &lt;code&gt;x-data&lt;/code&gt; attribute ends the attribute.&lt;/strong&gt; Blade doesn't escape what you write in there. So a comment containing &lt;code&gt;role="checkbox"&lt;/code&gt; truncates the expression mid-way, leaves a stray &lt;code&gt;checkbox"&lt;/code&gt; attribute on the element, and un-Alpines the entire subtree — with &lt;em&gt;no console error&lt;/em&gt;. Just a panel that never reacts. Single quotes for JS strings inside Alpine attributes, Blade comments outside the tag, and &lt;code&gt;/* */&lt;/code&gt; over &lt;code&gt;//&lt;/code&gt; in multi-line attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;x-cloak&lt;/code&gt; does nothing without a CSS rule, and I never wrote one.&lt;/strong&gt; It's only an attribute Alpine removes on init; all the hiding lives in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;x-cloak&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt; &lt;span class="cp"&gt;!important&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;Without it, every &lt;code&gt;x-show="false"&lt;/code&gt; element renders visible on first paint and vanishes a moment later. The 2FA challenge, a couple of tab groups, a log disclosure — all flashing their hidden state for months. Nothing errors, so nothing catches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the one that ties them together:&lt;/strong&gt; a Livewire assertion proves the server rendered a string. It proves nothing about what the browser does with it. &lt;code&gt;assertSee&lt;/code&gt; passes just as happily for markup whose Alpine expression is broken, whose selector matches nothing, or whose &lt;code&gt;x-cloak&lt;/code&gt; is inert. All three shipped green suites.&lt;/p&gt;

&lt;p&gt;So: anything whose behaviour lives in Alpine or in a component library's custom element needs a real browser &lt;strong&gt;once&lt;/strong&gt;, and then the markup contract it depends on pinned in a test. The browser pass finds it; the pinned test keeps it found.&lt;/p&gt;

&lt;p&gt;Related, from the same panel: &lt;code&gt;wire:model&lt;/code&gt; is deferred, so any badge or counter derived from it is a round trip behind what the operator can see. Compute it client-side, and seed the initial value from the server so first paint is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Saying what the thing actually is
&lt;/h2&gt;

&lt;p&gt;Two small changes with the same spirit.&lt;/p&gt;

&lt;p&gt;An enum for infrastructure provider types used to render as its own case names. Now each type carries its real noun — a &lt;em&gt;server&lt;/em&gt;, a &lt;em&gt;cluster&lt;/em&gt;, a &lt;em&gt;hypervisor&lt;/em&gt; — through &lt;code&gt;label()&lt;/code&gt;, so the form says "add a server" instead of "add a provider (type: ssh)". This is the whole argument for enums with &lt;code&gt;label()&lt;/code&gt;/&lt;code&gt;color()&lt;/code&gt; rather than bare strings: the vocabulary lives in one place, and the UI stops leaking your internal taxonomy at the user.&lt;/p&gt;

&lt;p&gt;And a capabilities checklist that claimed more than it delivered — showing a full list of supported components regardless of what the selected target could actually do, and showing the same list for a target where the honest answer was "nothing yet." A checklist whose job is to tell you what's supported, quietly not doing that, is worse than no checklist: it converts an unknown into a wrong belief.&lt;/p&gt;

&lt;p&gt;Both of these are the same instinct as the deploy post. &lt;strong&gt;Anything that reports state should be reporting state it verified.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The fourth comparison site
&lt;/h2&gt;

&lt;p&gt;One fix on a different project, and it's a good cautionary tale about "we already fixed that."&lt;/p&gt;

&lt;p&gt;A legacy backend stores some records with inconsistent casing, and compares strings case-sensitively. The application lowercases input before querying. So a record stored in uppercase is simply invisible — the lookup finds nothing and the flow stops with a generic "unable to verify" message.&lt;/p&gt;

&lt;p&gt;That was found and fixed weeks ago. Three comparison sites were converted to compare &lt;code&gt;LOWER()&lt;/code&gt; on both sides. There were four.&lt;/p&gt;

&lt;p&gt;The fourth sat on a path reached only from the very last step of the flow. So the symptom didn't disappear — it &lt;em&gt;moved&lt;/em&gt;. The earlier steps now worked, the user got further, and it failed at submit instead of at the start. Which reads like a new, unrelated bug, and got triaged as one.&lt;/p&gt;

&lt;p&gt;Two things I'm taking from it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you fix a class of defect, enumerate the class.&lt;/strong&gt; Not "search for the failing call and fix it" — grep for every comparison against that column, list them, fix them together, then write down how many there were. Three of four is worse than zero of four, because it buys you a bug report that looks new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A symptom moving later in a flow is evidence of a partial fix, not of a different bug.&lt;/strong&gt; That's a genuinely useful triage heuristic and I don't apply it often enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Housekeeping
&lt;/h2&gt;

&lt;p&gt;Rounding out the day: a reverse-proxy generator that now answers TLS for hostnames no vhost claims — previously the catch-all served its 404 as a binary download, which is a memorable way to discover that nginx guesses content type from the file extension and &lt;code&gt;.html&lt;/code&gt; was missing. Native Redis provisioning, one instance per deployment, with per-OS profiles behind a contract so Debian and RHEL differences stay in one place each. A small swap file added during node bootstrap, because a two-gig box will OOM during &lt;code&gt;composer install&lt;/code&gt; and blame something unrelated. And on the public marketing site, a couple of chores: redirects retired, sign-in pointed at the deployed console.&lt;/p&gt;

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

&lt;p&gt;Reading it back, today was almost entirely about &lt;strong&gt;systems that report something they haven't checked&lt;/strong&gt;: a deploy that never pulled, a checklist claiming coverage it lacks, a badge counting elements it can't see, a fix that covered three of four sites, an enum showing its internal name.&lt;/p&gt;

&lt;p&gt;None of them errored. That's the property they share, and it's the reason they all lasted weeks. Loud failures get fixed on the day they appear. The expensive ones are the ones that look fine.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Dev Log: 2026-08-12 — a 96s suite that became 42s, a capability that wasn't a scope, and four steps to a passkey</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:35:24 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasnt-a-scope-and-four-steps-c0a</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasnt-a-scope-and-four-steps-c0a</guid>
      <description>&lt;p&gt;Fifteen commits, three repos, and the bulk of it in one: a control plane that got an MCP surface, a much faster test suite, and a handful of things that turned out to be quietly wrong once I looked properly.&lt;/p&gt;

&lt;p&gt;The MCP work has its own write-up — sixty tools, four servers, and a long argument with myself about what a refusal should say. This is everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Ninety-six seconds to forty-one
&lt;/h2&gt;

&lt;p&gt;The suite was six minutes on a full run and about 96s in parallel, which is exactly the range where you stop running it before you push. Three separate problems, and each one hid the next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A seeder in &lt;code&gt;beforeEach&lt;/code&gt;.&lt;/strong&gt; The access-control seeder ran before every feature test: 645 queries, ~110ms a pop, roughly &lt;em&gt;half&lt;/em&gt; the suite's wall clock. It's now &lt;code&gt;protected $seeder&lt;/code&gt; on the base &lt;code&gt;TestCase&lt;/code&gt;, so &lt;code&gt;RefreshDatabase&lt;/code&gt; seeds it once per process during &lt;code&gt;migrate:fresh&lt;/code&gt;. Every test still runs inside a transaction that rolls back, so the visible state is identical — the per-test call was buying nothing at all. Nine test files were also re-seeding it &lt;em&gt;on top of&lt;/em&gt; the global hook, which is the sort of thing that accretes when nobody's looking at the total.&lt;/p&gt;

&lt;p&gt;The general lesson: &lt;strong&gt;per-test setup that RefreshDatabase already preserves is pure tax.&lt;/strong&gt; Worth auditing your &lt;code&gt;Pest.php&lt;/code&gt; for anything that could move to &lt;code&gt;$seeder&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xdebug costing 3x on every run.&lt;/strong&gt; &lt;code&gt;conf.d&lt;/code&gt; sets &lt;code&gt;xdebug.mode = coverage&lt;/code&gt; and nothing overrode it. Pest's own Xdebug handling only drops it when the impact-analysis run is replaying a valid graph — a plain run was never covered. Every composer test script now pins the mode via &lt;code&gt;@putenv&lt;/code&gt;, using the array form so &lt;code&gt;@php&lt;/code&gt; and argument forwarding still work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And test impact analysis that had never once finished.&lt;/strong&gt; &lt;code&gt;--tia&lt;/code&gt; records a dependency graph, then replays it and re-runs only what your change touched. Great idea. It was dying at Composer's default 300s process timeout partway through recording, leaving worker-edge files and no graph — an unusable artifact, so the next run re-recorded from scratch and hit the same wall. Forever. Under Xdebug a cold record is about ninety minutes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Composer\Config::disableProcessTimeout&lt;/code&gt; plus pcov instead of Xdebug took the cold record to ~75s and a replay to ~5s.&lt;/p&gt;

&lt;p&gt;Two things fell out of that which cost more time than the fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pcov had to be built from source&lt;/strong&gt; (no published build for this PHP version), and a &lt;em&gt;static&lt;/em&gt; build silently produces a &lt;code&gt;.so&lt;/code&gt; with no &lt;code&gt;get_module&lt;/code&gt; symbol. PHP reports that as "Invalid library (maybe not a PHP library)" — indistinguishable from a version mismatch, so you go and debug the wrong thing. The build script now configures it shared, asserts the symbol exists before installing, and verifies the extension loads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PHP_INI_SCAN_DIR&lt;/code&gt;, not &lt;code&gt;php -d&lt;/code&gt;.&lt;/strong&gt; Paratest spawns workers without the parent's &lt;code&gt;-d&lt;/code&gt; flags, so only an environment variable reaches them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Final: parallel run 96.2s → 41.1s, impact-analysis replay ~5s, ~15s after an edit. 1451 passing, 1 skipped — unchanged, which is the number that makes the rest of it trustworthy.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;--tia&lt;/code&gt; stays out of CI deliberately. The point of CI is a full suite against a clean checkout; the point of TIA is your laptop between commits.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A capability permission is not a scope
&lt;/h2&gt;

&lt;p&gt;One page in the app had no tenant isolation at all, sitting behind the same &lt;code&gt;can:viewAny,SomeModel&lt;/code&gt; middleware as the correctly-scoped page next to it. The middleware was doing its job — it just isn't the job people assume it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;can:viewAny&lt;/code&gt; says the user may do this &lt;em&gt;kind&lt;/em&gt; of thing. It never says they may do it to &lt;em&gt;this row&lt;/em&gt;.&lt;/strong&gt; In an app with no global scopes (this one has none, by design), nothing downstream catches the difference. Every list, every bulk action, every delete, and one &lt;code&gt;is_default&lt;/code&gt; reset all ran unscoped, and the reset in particular reached beyond the caller's own tenant.&lt;/p&gt;

&lt;p&gt;Fixes, in order of how much I'd repeat them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every lookup goes through one scoped finder.&lt;/strong&gt; Ownership enforced at five call sites is ownership forgotten at the sixth. This keeps coming up and I keep re-learning it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't give a child table its own tenant column when the parent already knows.&lt;/strong&gt; The job rows here already carry a provider id, and a provider already knows its organisation. Two copies of one fact are two chances for them to disagree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A nullable-owner + &lt;code&gt;is_system&lt;/code&gt; pair for shared catalogue rows&lt;/strong&gt; — same shape as elsewhere in the app, so the visibility rule reads the same everywhere. Which immediately surfaced a factory bug: the old default produced rows matching &lt;em&gt;neither&lt;/em&gt; branch of &lt;code&gt;visibleTo()&lt;/code&gt;, so tests were "creating" records the UI could never list. When a model gains a two-branch visibility rule, the factory default has to land inside one of the branches.&lt;/p&gt;

&lt;p&gt;Two migration gotchas, both found the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Schema::hasIndex()&lt;/code&gt; returned false mid-migration for an index that plainly existed&lt;/strong&gt;, silently skipping the &lt;code&gt;dropUnique()&lt;/code&gt; it guarded. The migration reported DONE with the old constraint still in place. Index presence now gets read from &lt;code&gt;getIndexes()&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;down()&lt;/code&gt; hits errno 1553 where &lt;code&gt;up()&lt;/code&gt; doesn't&lt;/strong&gt;, because the new composite unique leading with &lt;code&gt;organization_id&lt;/code&gt; becomes the only index backing that foreign key. Drop the FK first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Round-tripped migrate → rollback → migrate on MySQL, because SQLite rebuilds the table on ALTER and proves none of it. If your migrations touch indexes, testing them on SQLite is testing a different program.&lt;/p&gt;

&lt;p&gt;Fourteen new tests, and each was checked to &lt;strong&gt;fail without the scope&lt;/strong&gt; rather than merely pass with it. A test that passes for the wrong reason is worse than no test — it's a green tick standing where a check should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Passkeys: four steps, not one
&lt;/h2&gt;

&lt;p&gt;The scaffold this app came from shipped &lt;code&gt;Features::passkeys()&lt;/code&gt; in the Fortify config, commented out with a note, because the packages weren't installed. Both are here now, and enabling it is genuinely four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Publish (config + migration)&lt;/li&gt;
&lt;li&gt;Migrate&lt;/li&gt;
&lt;li&gt;Put &lt;strong&gt;both&lt;/strong&gt; the &lt;code&gt;PasskeyAuthenticatable&lt;/code&gt; trait &lt;em&gt;and&lt;/em&gt; the &lt;code&gt;PasskeyUser&lt;/code&gt; contract on &lt;code&gt;User&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; uncomment the feature&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is the trap. The trait without the interface fails static analysis with &lt;code&gt;class.missingImplements&lt;/code&gt; and nothing at runtime, so if you don't run &lt;code&gt;analyse&lt;/code&gt; you'll find out later and further away.&lt;/p&gt;

&lt;p&gt;Two config values are load-bearing and &lt;em&gt;derived&lt;/em&gt; rather than set, which is the dangerous combination:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;relying_party_id&lt;/code&gt; comes from &lt;code&gt;APP_URL&lt;/code&gt;.&lt;/strong&gt; A passkey is bound to the RP ID it was registered against. A wrong &lt;code&gt;APP_URL&lt;/code&gt; in production is not a misconfiguration you can quietly correct later — every credential registered under the wrong one stops resolving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;user_handle_secret&lt;/code&gt; falls back to &lt;code&gt;APP_KEY&lt;/code&gt;.&lt;/strong&gt; The WebAuthn user handle is what binds a credential to an account, so &lt;strong&gt;rotating &lt;code&gt;APP_KEY&lt;/code&gt; invalidates every passkey on file&lt;/strong&gt;, with no error that says so. Set &lt;code&gt;PASSKEYS_USER_HANDLE_SECRET&lt;/code&gt; explicitly &lt;em&gt;before&lt;/em&gt; any rotation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the second thing this month where &lt;code&gt;APP_KEY&lt;/code&gt; turned out to be permanent in practice rather than in theory. Anything deriving a secret from it deserves an explicit value.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One seeder per layer
&lt;/h2&gt;

&lt;p&gt;A seeder doing four jobs at once — platform catalogue, tenancy, a provider, and sample workloads — meant nothing could be seeded without the rest, and &lt;code&gt;db:seed&lt;/code&gt; on a fresh install produced data a real customer would have to delete. Split by responsibility: catalogue only, owner-and-organisation, and development sample data hanging off the dev command rather than the prepare path.&lt;/p&gt;

&lt;p&gt;Two latent bugs fell out of the move, both the sort that only surface once code runs somewhere new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;code&gt;owner_id&lt;/code&gt; column that's a &lt;strong&gt;non-nullable FK&lt;/strong&gt; was being written as &lt;code&gt;$owner?-&amp;gt;id&lt;/code&gt;. So the catalogue seeder silently depended on the owner seeder having run first. The nullsafe operator is doing you no favours where the schema says the value is required — it converts "this must exist" into "let's find out later."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$user-&amp;gt;update(['email_verified_at' =&amp;gt; now()])&lt;/code&gt; &lt;strong&gt;never did anything&lt;/strong&gt;: the column is outside the model's fillable list. It &lt;em&gt;appeared&lt;/em&gt; to work only because &lt;code&gt;db:seed&lt;/code&gt; wraps seeding in &lt;code&gt;Model::unguarded()&lt;/code&gt;. Call the same seeder from a test and the owner comes out unverified. Now it's &lt;code&gt;markEmailAsVerified()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second one is worth sitting with. &lt;code&gt;unguarded()&lt;/code&gt; in the seeding path means &lt;strong&gt;mass-assignment bugs in seeders are invisible until someone runs the seeder outside &lt;code&gt;db:seed&lt;/code&gt;.&lt;/strong&gt; If you have seeders invoked from tests, that's a real gap.&lt;/p&gt;

&lt;p&gt;Also: the owner seeder now &lt;strong&gt;re-asserts&lt;/strong&gt; roles on an existing account holding the configured email, instead of returning early. Otherwise a fresh install where someone registered that address first gives you a superadmin nobody can use, and nothing on screen to explain it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Honest labels and icons that resolve
&lt;/h2&gt;

&lt;p&gt;A template library rendering as text-only cards, and two things behind it were wrong rather than merely plain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thirteen templates were labelled &lt;code&gt;Custom&lt;/code&gt;&lt;/strong&gt; — the enum case meaning "free-form, no enforced structure" — while being an exact edge → app → database. That put most of the library in one bucket and made the topology filter useless. They're now labelled by the shape their layers actually form. A few stay &lt;code&gt;Custom&lt;/code&gt; on purpose: a gateway over its own store isn't microservices until there are services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An &lt;code&gt;icon&lt;/code&gt; column populated on all 63 rows and rendered by nothing at all.&lt;/strong&gt; Now resolved through a small Blade component with a deliberate fallback, drawing from a brand-icon set committed to the repo — nothing fetched at runtime.&lt;/p&gt;

&lt;p&gt;The test I'm happiest with walks &lt;strong&gt;every seeded icon key&lt;/strong&gt; and fails on one that resolves to nothing, and asserts the &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; actually reaches the rendered markup. A missing icon is invisible in the UI, never loud. And a Blade component that swallowed the SVG entirely would pass every other assertion you'd think to write.&lt;/p&gt;

&lt;p&gt;Also eager-loaded the component relation on the index — fifteen cards each drawing a mark per component is fifteen pages of queries otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Docs that had drifted into lying
&lt;/h2&gt;

&lt;p&gt;A full documentation rebuild, and the interesting part is &lt;em&gt;how&lt;/em&gt; stale docs go bad. Not gradually vague — specifically wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A root README still listing six documentation links, &lt;strong&gt;all six dead&lt;/strong&gt;, pointing at paths that moved into numbered folders long ago.&lt;/li&gt;
&lt;li&gt;A CI badge pointing at a workflow that doesn't exist, rendering broken for who knows how long.&lt;/li&gt;
&lt;li&gt;A page titled "code quality" that was actually about a helper script &lt;strong&gt;not in the repository&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;No testing documentation at all.&lt;/li&gt;
&lt;li&gt;Stale facts stated confidently: a real-time stack that's commented out in the bootstrap file, one database engine named where five are supported, and the lint command described as the formatter when it's the syntax checker.&lt;/li&gt;
&lt;li&gt;A documented artisan command that doesn't exist, and nine real ones undocumented.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;125 markdownlint issues → 0. 8 broken internal links → 0. Every file reachable from the index.&lt;/p&gt;

&lt;p&gt;The through-line with everything else today: &lt;strong&gt;a document that names a command that doesn't exist isn't slightly out of date, it's actively sending people the wrong way.&lt;/strong&gt; Same failure mode as a checklist claiming coverage it lacks, or a deploy that reports success it didn't verify. Wrong is worse than absent, because absent makes you go and look.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Elsewhere
&lt;/h2&gt;

&lt;p&gt;On a second product: a dependency refresh, and a documentation pass across the planning set. Nothing structural — but worth saying that keeping planning docs current is the same discipline as item 6, just applied before the drift rather than after it.&lt;/p&gt;

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

&lt;p&gt;Today divided into &lt;em&gt;making the loop faster&lt;/em&gt; and &lt;em&gt;making things say what's true&lt;/em&gt;. Both matter, and the second one showed up in five different costumes: a permission that guards a capability but reads like a scope, a seeder call that never ran, an icon column nothing rendered, a topology label describing a shape it wasn't, and a README pointing at six pages that had moved.&lt;/p&gt;

&lt;p&gt;None of them errored. Which is the whole reason they survived.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Not Found is the wrong answer to a permission problem</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:35:04 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/not-found-is-the-wrong-answer-to-a-permission-problem-25cm</link>
      <guid>https://dev.to/nasrulhazim/not-found-is-the-wrong-answer-to-a-permission-problem-25cm</guid>
      <description>&lt;p&gt;I spent today putting an MCP surface on a multi-tenant Laravel control plane — the kind of app where a wrong answer doesn't just render badly, it tears down infrastructure. Sixty-odd tools across four servers, and honestly the tools were the easy part. The hard part was the base class every tool sits on, and most of what went into it came from getting it wrong first.&lt;/p&gt;

&lt;p&gt;Here's the thing about exposing an app to an agent: every assumption your web UI quietly relies on stops holding. There's no session. There's no human reading the screen and going "hmm, that's odd." An agent takes your response literally, and then acts on it. So the design question stops being "what can this tool do" and becomes &lt;strong&gt;"what does this tool say when it refuses, and can the thing reading it tell the difference between refusals?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four guards, in order, each one a bug I shipped once
&lt;/h2&gt;

&lt;p&gt;The base tool runs four checks before a tool's own code executes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is there an authenticated user at all?&lt;/li&gt;
&lt;li&gt;Does their role grant this &lt;em&gt;server&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;Does this &lt;em&gt;token&lt;/em&gt; carry the required ability?&lt;/li&gt;
&lt;li&gt;Does their role grant this &lt;em&gt;tool's&lt;/em&gt; domain permission?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then it resolves a tenant. That's the shape:&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;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;McpTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&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;ScopesToTenant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** Which server this tool belongs to — `mcp.access.{server}`. */&lt;/span&gt;
    &lt;span class="k"&gt;abstract&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;serverPermission&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="cd"&gt;/** The tool's own work. Guards have run; actor() and organization() are non-null. */&lt;/span&gt;
    &lt;span class="k"&gt;abstract&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;Response&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;permission&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/** Read-only tools inherit this. Writers must say so. Destroyers must say `mcp:destroy`. */&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;ability&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="s1"&gt;'mcp:read'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;final&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&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;boot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;McpException&lt;/span&gt; &lt;span class="nv"&gt;$e&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;errorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ValidationException&lt;/span&gt; &lt;span class="nv"&gt;$e&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;McpErrorCode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;InvalidArgument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nb"&gt;implode&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="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;errors&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;all&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;handle()&lt;/code&gt; is &lt;code&gt;final&lt;/code&gt; and &lt;code&gt;run()&lt;/code&gt; is &lt;code&gt;abstract&lt;/code&gt; on purpose. If a tool author can override the entry point, one of sixty tools eventually will, and it'll be the one that destroys things.&lt;/p&gt;

&lt;p&gt;Note that the guards &lt;strong&gt;throw&lt;/strong&gt; rather than return a Response. That was a deliberate switch partway through. Guards compose — server, ability, permission, tenant, then the record itself — and returning a refusal from each puts five &lt;code&gt;if ($denied) return ...&lt;/code&gt; blocks at the head of every tool. That's precisely the shape where the sixth call site forgets one. Throw a typed exception, catch it once in &lt;code&gt;handle()&lt;/code&gt;, and forgetting becomes impossible rather than merely unlikely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I reverted: hiding tools you're not allowed to call
&lt;/h2&gt;

&lt;p&gt;MCP has &lt;code&gt;shouldRegister()&lt;/code&gt; — return false and the tool doesn't appear in the caller's tool list at all. Tempting. My first pass filtered on &lt;em&gt;every&lt;/em&gt; permission a tool needed, so you only saw what you could run.&lt;/p&gt;

&lt;p&gt;It's wrong, and it took a confused colleague to see why.&lt;/p&gt;

&lt;p&gt;A tool that isn't registered answers &lt;strong&gt;"Tool not found."&lt;/strong&gt; For someone pointed at a server their role doesn't grant, that's honest — the whole surface genuinely isn't theirs, and an empty tool list says so. But for someone who's on the right server and merely lacks one domain permission, "not found" is a lie with a cost attached. It sends them hunting for a typo in the tool name. What they needed to hear was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This action requires the &lt;code&gt;deployments.view.operations&lt;/code&gt; permission.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a sentence they can take to an administrator. "Not found" isn't.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;shouldRegister()&lt;/code&gt; filters on the &lt;strong&gt;server&lt;/strong&gt; permission only, and per-tool permissions are enforced in &lt;code&gt;boot()&lt;/code&gt;, where the refusal can name what's missing:&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;shouldRegister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;$user&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&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;serverPermission&lt;/span&gt;&lt;span class="p"&gt;());&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;boot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&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;serverPermission&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;McpException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;serverAccessDenied&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;serverPermission&lt;/span&gt;&lt;span class="p"&gt;());&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;Notice the server permission is checked &lt;strong&gt;twice&lt;/strong&gt; — once to hide, once to refuse. &lt;code&gt;shouldRegister()&lt;/code&gt; is never the lock. A tool list can be stale, cached, or replayed by a client that connected under a different role; if the only thing keeping a tool out of reach is that you didn't list it, the list becomes the security boundary. It isn't one. It's a UI affordance.&lt;/p&gt;

&lt;p&gt;Generalise it: &lt;strong&gt;visibility is a courtesy, authorisation is a check.&lt;/strong&gt; Any time you're tempted to hide something instead of denying it, ask what happens when the caller guesses the name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard mismatch that denies everything, superadmin included
&lt;/h2&gt;

&lt;p&gt;This one cost me a good half hour of staring at a role that plainly had the permission.&lt;/p&gt;

&lt;p&gt;MCP requests authenticate on &lt;code&gt;sanctum&lt;/code&gt;. Roles and permissions are registered on &lt;code&gt;web&lt;/code&gt;. A bare &lt;code&gt;$user-&amp;gt;can('...')&lt;/code&gt; resolves against the &lt;em&gt;active&lt;/em&gt; guard — where Spatie has no such permission registered — so every check returns false. Every single one. Including for a superadmin, which is the detail that makes it so misleading: it looks exactly like a broken role assignment, so you go and debug the role.&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;allows&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;string&lt;/span&gt; &lt;span class="nv"&gt;$permission&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'superadmin'&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;try&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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPermissionTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$permission&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PermissionDoesNotExist&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="kc"&gt;false&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;Two things worth keeping:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin the guard explicitly.&lt;/strong&gt; Not "it works locally because the session guard happened to be active" — name it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed on an unknown permission.&lt;/strong&gt; A typo'd permission string throws &lt;code&gt;PermissionDoesNotExist&lt;/code&gt;, and letting that bubble up turns a policy question into a 500 from the authorisation boundary. Catching it and returning false means a typo denies rather than explodes. Which side you'd rather be wrong on isn't a close call.&lt;/p&gt;

&lt;p&gt;The general rule: &lt;strong&gt;any surface authenticating on a non-default guard has to pin the guard it checks permissions against.&lt;/strong&gt; Same trap bites API-token surfaces on Passport.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions and abilities are not the same thing
&lt;/h2&gt;

&lt;p&gt;A permission says what the &lt;em&gt;human&lt;/em&gt; may do. A token ability says what &lt;em&gt;this credential&lt;/em&gt; may do. They're independent, and collapsing them is how a read-only CI token ends up able to tear down production because the person who minted it happens to be an admin.&lt;/p&gt;

&lt;p&gt;Every tool declares its tier — &lt;code&gt;mcp:read&lt;/code&gt; by default, &lt;code&gt;mcp:write&lt;/code&gt; if it mutates, &lt;code&gt;mcp:destroy&lt;/code&gt; if it's irreversible — and the check is:&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;assertAbility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$token&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;accessToken&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;$token&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="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="c1"&gt;// no token, no ability list to narrow against&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;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&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;ability&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;McpException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;abilityDenied&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;ability&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;Tokens minted before abilities existed carry Sanctum's &lt;code&gt;*&lt;/code&gt; wildcard and pass everything. That's Sanctum's own semantics and I left it alone deliberately — retroactively narrowing a wildcard would break every already-connected client silently, at some unpredictable later moment. Migrate credentials forward on purpose; don't change what an existing one means underneath its holder.&lt;/p&gt;

&lt;h2&gt;
  
  
  No session means your tenant resolution is guessing
&lt;/h2&gt;

&lt;p&gt;This is the one I'd flag hardest to anyone doing the same thing.&lt;/p&gt;

&lt;p&gt;The app has a &lt;code&gt;current_organization()&lt;/code&gt; helper. Reads the session, and when there's no session, falls back to "the first organisation you own." Perfectly reasonable for the web app, where the fallback fires roughly never.&lt;/p&gt;

&lt;p&gt;Under a token there is &lt;strong&gt;never&lt;/strong&gt; a session. So the fallback isn't a fallback anymore, it's the primary path — and a user who belongs to three organisations silently addresses whichever one sorts first. The agent has no idea. It asks for "the deployments," gets a list, and reports it as &lt;em&gt;the&lt;/em&gt; answer.&lt;/p&gt;

&lt;p&gt;Two rules came out of this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every tool takes an optional explicit &lt;code&gt;organization&lt;/code&gt;.&lt;/strong&gt; Wins over any default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every response echoes the organisation it resolved.&lt;/strong&gt; Not as a debug field — as part of the 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="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;respond&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;$payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&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="n"&gt;organization&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'organization'&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="s1"&gt;'uuid'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;organization&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;organization&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'cross_tenant'&lt;/span&gt;&lt;span class="p"&gt;]&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;crossTenant&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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&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 ambiguous answer that states its own scope stops being ambiguous. That's the cheapest fix available for a whole class of "it returned the wrong data" bugs, and it applies well beyond MCP — any API where scope is inferred rather than supplied should be telling you what it inferred.&lt;/p&gt;

&lt;p&gt;The related decision: reaching &lt;em&gt;another&lt;/em&gt; tenant is a named capability, &lt;code&gt;mcp.access.cross-tenant&lt;/code&gt;, deliberately &lt;strong&gt;not&lt;/strong&gt; inherited from the general &lt;code&gt;organizations.*&lt;/code&gt; permissions. It's a capability flag, not a scope — per-resource access still runs through policies. And a cross-tenant read is written to the &lt;strong&gt;target&lt;/strong&gt; organisation's audit trail:&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;// An ops user reading a customer's tenant is legitimate — and must still&lt;/span&gt;
&lt;span class="c1"&gt;// leave a trace. Written against the organisation, so it surfaces on that&lt;/span&gt;
&lt;span class="c1"&gt;// tenant's own trail rather than only in an internal log.&lt;/span&gt;
&lt;span class="nc"&gt;Audit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;actor&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'event'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mcp_cross_tenant_access'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'auditable_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$organization&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMorphClass&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'auditable_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$organization&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="s1"&gt;'new_values'&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;'tool'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'server'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;serverPermission&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="s1"&gt;'tags'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mcp'&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;Support reading a customer's data is a normal, legitimate thing. The customer being able to see that it happened is what makes it normal.&lt;/p&gt;

&lt;p&gt;One more, and it's structural: this app has &lt;strong&gt;no global scopes&lt;/strong&gt;. Every query scopes itself or it leaks. So tools never reach a record with a bare &lt;code&gt;where('uuid', ...)&lt;/code&gt; — they go through finders on a &lt;code&gt;ScopesToTenant&lt;/code&gt; trait. Ownership enforced at five call sites is ownership forgotten at the sixth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the agent a vocabulary, not just prose
&lt;/h2&gt;

&lt;p&gt;An agent that only ever receives sentences can't distinguish "you may not do this" from "this doesn't exist" from "your token is too narrow" — and those call for completely different next moves. Retrying the first two is pointless noise. Retrying the third after minting a wider token is exactly right.&lt;/p&gt;

&lt;p&gt;So there's an enum:&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;McpErrorCode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Contract&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;InteractsWithEnum&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;NotAuthenticated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'not_authenticated'&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;ServerAccessDenied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'server_access_denied'&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;PermissionDenied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'permission_denied'&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;AbilityDenied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ability_denied'&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;CrossTenantDenied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cross_tenant_denied'&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;NotFound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'not_found'&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;InvalidArgument&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invalid_argument'&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;ConfirmationMismatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'confirmation_mismatch'&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;CapabilitySimulated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'capability_simulated'&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;Conflict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'conflict'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Whether the caller could succeed by supplying different arguments.
     * A retryable error is worth trying again; an authorisation failure is
     * not, and an agent that retries one just generates noise against a
     * boundary that will refuse it every time.
     */&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;isRetryable&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="nv"&gt;$this&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="nc"&gt;InvalidArgument&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;===&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;ConfirmationMismatch&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;===&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;NotFound&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 code travels &lt;em&gt;alongside&lt;/em&gt; the human message, never instead of it — a client with no code support still gets something readable:&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;McpErrorCode&lt;/span&gt; &lt;span class="nv"&gt;$code&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;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&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;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'error'&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;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'retryable'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isRetryable&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;&lt;code&gt;isRetryable()&lt;/code&gt; living on the enum rather than in each tool is the usual argument for enums-with-behaviour: the classification is a property of the error kind, not of the caller. Same reason &lt;code&gt;label()&lt;/code&gt; and &lt;code&gt;color()&lt;/code&gt; belong there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Destructive tools: make the argument come from something the caller read
&lt;/h2&gt;

&lt;p&gt;Three tools here can't be undone. An agent asked to "clean up the old deployments" will cheerfully call destroy on whatever it matched, and &lt;em&gt;whatever it matched&lt;/em&gt; is doing an enormous amount of load-bearing work in that sentence.&lt;/p&gt;

&lt;p&gt;The lock is a typed confirmation — the resource's own name, retyped exactly:&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;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;Request&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;Model&lt;/span&gt; &lt;span class="nv"&gt;$subject&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;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$supplied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&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;span class="s1"&gt;'confirm_name'&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="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$supplied&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$supplied&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$expected&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;auditDestructive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mcp_destructive_refused'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'expected'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'supplied'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$supplied&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$supplied&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="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;McpException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;confirmationMismatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&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 point isn't friction for its own sake. It's that the destructive argument now &lt;strong&gt;has to come from something the caller actually read&lt;/strong&gt; — a prior &lt;code&gt;get-deployment&lt;/code&gt; call — rather than from a pattern it inferred from the request. Same reasoning as making a human type a count instead of clicking a browser confirm.&lt;/p&gt;

&lt;p&gt;And note the refusal is audited too. A refused destruction is evidence: it says something tried. Logging only successes gives you a record that's blind to exactly the events you'd most want to know about.&lt;/p&gt;

&lt;p&gt;Combined with a &lt;code&gt;mcp:destroy&lt;/code&gt; token ability, a state check on the model's lifecycle enum, and an &lt;code&gt;#[IsDestructive]&lt;/code&gt; annotation so a well-behaved client can prompt its human, that's four independent things that have to line up. For an irreversible action against real infrastructure, four feels about right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Say when the work is only queued
&lt;/h2&gt;

&lt;p&gt;Last one, and it's my favourite because it's a bug this repo already shipped in the UI.&lt;/p&gt;

&lt;p&gt;A lifecycle tool dispatches a job and returns. If the response says "deployment stopping," the agent reports it as done. Meanwhile the job is sitting unclaimed, because the work goes onto the &lt;code&gt;database&lt;/code&gt; connection and Horizon's supervisors watch redis.&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;queuedNote&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;$what&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;'queued'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;QUEUE_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'worker_required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;'message'&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;':what queued on the `database` connection — this needs a worker '&lt;/span&gt;
            &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'(`php artisan queue:work database`). Horizon does not claim these jobs.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'what'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$what&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="s1"&gt;'worker_appears_alive'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;workerAppearsAlive&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="cd"&gt;/**
 * A cheap liveness hint, not a guarantee: jobs older than two minutes mean
 * nothing is claiming them. Null means the question could not be asked,
 * which is deliberately distinct from "yes".
 */&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;workerAppearsAlive&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'jobs'&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;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&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;subMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;getTimestamp&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="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&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="kc"&gt;null&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 &lt;code&gt;?bool&lt;/code&gt; matters. &lt;code&gt;null&lt;/code&gt; means "couldn't check," and that is not the same as &lt;code&gt;true&lt;/code&gt;. Any time a health signal can fail to be read, the un-readable case needs its own value — collapsing it into the optimistic one is how monitoring lies to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server instructions are where the reading rules live
&lt;/h2&gt;

&lt;p&gt;Each server ships &lt;code&gt;#[Instructions(...)]&lt;/code&gt;, and I ended up using it for something I didn't expect: not "here are the tools" but &lt;strong&gt;"here is how to read an answer without drawing the wrong conclusion."&lt;/strong&gt; Things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Unscanned is not clean.&lt;/em&gt; An empty vulnerability list may mean nothing was found, or nothing was looked at.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;An unknown severity stays unknown.&lt;/em&gt; Mapping it down to "low" launders an unknown into a reassurance.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;No recorded activity is not "inactive."&lt;/em&gt; The audit trail records actions; someone who signs in and only reads leaves no trace.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A control with no evidence is unevidenced, not failing.&lt;/em&gt; Different states, different responses.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;An overdue clock reports negative hours,&lt;/em&gt; not clamped to zero — overdue is the most important thing it can say.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's institutional knowledge that normally lives in a senior engineer's head and gets passed on in code review. Writing it into the server instructions is the first time I've had somewhere obvious to put it. Worth doing even if you never connect an agent, frankly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take to the next one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Refusals are an API.&lt;/strong&gt; Design them as carefully as the success payloads. A wrong refusal costs more than a missing feature, because the caller acts on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hide for convenience, deny for security.&lt;/strong&gt; Check the same thing in both places.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-default guard? Pin it.&lt;/strong&gt; Explicitly, everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A default that only fires without a session becomes the primary path under tokens.&lt;/strong&gt; Go and read every fallback in your app with that in mind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Echo the scope you inferred.&lt;/strong&gt; Cheapest ambiguity fix there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything that can't answer a health question needs a third value.&lt;/strong&gt; Not the optimistic one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up is exercising the whole thing from a real client and seeing which refusals actually read well in practice. My guess is at least two of the messages I'm proud of today turn out to be useless in context.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>The deploy script that always said it worked</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:34:47 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/the-deploy-script-that-always-said-it-worked-3l4o</link>
      <guid>https://dev.to/nasrulhazim/the-deploy-script-that-always-said-it-worked-3l4o</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I stood a Laravel control plane up on a bare Ubuntu host today. The provisioning script was the easy part. The deploy script was the one that had been lying to me.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git pull&lt;/code&gt; had been failing on every deploy since the first one — &lt;em&gt;dubious ownership&lt;/em&gt;, because the script chowns the tree to &lt;code&gt;www-data&lt;/code&gt; on the way out and runs as root on the way in. Nothing checked git's exit status, so the deploy went on to install, migrate and health-check code it had never updated. And it passed, because the old release was fine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;php artisan operations --force&lt;/code&gt; had never once run. &lt;code&gt;--force&lt;/code&gt; is a &lt;code&gt;migrate&lt;/code&gt; flag; the deploy operations command takes &lt;code&gt;--isolated&lt;/code&gt;. It exited non-zero every single deploy and nobody looked.&lt;/li&gt;
&lt;li&gt;The route cache was never cleared — only config and views. When &lt;code&gt;/&lt;/code&gt; changed from rendering a view to redirecting, production started 500ing on every page, and the health check stayed green, because &lt;code&gt;/up&lt;/code&gt; was in that same stale cache and still answered 200.&lt;/li&gt;
&lt;li&gt;Under &lt;code&gt;set -o pipefail&lt;/code&gt;, &lt;code&gt;cmd | grep -q&lt;/code&gt; exits &lt;strong&gt;141&lt;/strong&gt;, not 0. &lt;code&gt;grep -q&lt;/code&gt; leaves on the first match and the producer takes SIGPIPE. Read into a variable, then match with a here-string.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Today was the day the control plane had to run somewhere real: a bare Ubuntu host, provisioned from nothing, deployed to over SSH. I expected to spend it on the provisioning script — nginx, PHP-FPM, Redis, supervisor, the usual apt-flavoured tedium.&lt;/p&gt;

&lt;p&gt;Instead I spent most of it on a deploy script that had been reporting success for weeks without doing its job. Three separate faults, all with the same shape: &lt;strong&gt;something failed, nobody read the exit status, and the next step happily proceeded on stale state that looked healthy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the whole post. Everything below is the four places it showed up.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A pull that failed silently, for weeks
&lt;/h2&gt;

&lt;p&gt;The deploy script ends by chowning the release tree to the web user. It starts by running as root. Git, quite reasonably, refuses to touch a repository owned by somebody else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fatal: detected dubious ownership in repository at '/var/www/app'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which would be fine — obvious, even — except the script looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan down &lt;span class="nt"&gt;--retry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;60

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Pulling codes"&lt;/span&gt;
git checkout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git pull &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REMOTE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Install dependencies"&lt;/span&gt;
composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;set -e&lt;/code&gt; on that stretch, no status check. Both git commands printed their refusal to stdout, exited non-zero, and the script moved on. Composer installed. Migrations ran. The health check hit the site and got a 200 — of course it did, the &lt;em&gt;previous&lt;/em&gt; release was serving and it was perfectly healthy.&lt;/p&gt;

&lt;p&gt;Every deploy after the first one deployed nothing, and every one of them said it worked.&lt;/p&gt;

&lt;p&gt;The fix is two things, and the second matters more than the first:&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;# Deploys run as root against a tree owned by www-data. Declare it safe once,&lt;/span&gt;
&lt;span class="c"&gt;# so a deploy cannot silently skip its own checkout.&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--get-all&lt;/span&gt; safe.directory 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; git config &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--add&lt;/span&gt; safe.directory &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; git checkout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: could not check out &lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt; -- nothing was deployed."&lt;/span&gt;
    php artisan up
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; git pull &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REMOTE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: could not pull &lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt; from &lt;/span&gt;&lt;span class="nv"&gt;$REMOTE&lt;/span&gt;&lt;span class="s2"&gt; -- nothing was deployed."&lt;/span&gt;
    php artisan up
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Now at: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--short&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;php artisan up&lt;/code&gt; before each &lt;code&gt;exit 1&lt;/code&gt;. If you're going to bail mid-deploy, decide &lt;em&gt;deliberately&lt;/em&gt; whether the site comes back. Here nothing has changed yet, so it should. Later in the script, after migrations have run, the answer flips — more on that in a second.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;Now at: &amp;lt;sha&amp;gt;&lt;/code&gt;. One line, and the failure mode becomes visible from the deploy log alone: if the SHA doesn't move, the deploy didn't.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The general rule: &lt;strong&gt;any command whose failure would be survivable is a command whose failure you will not notice.&lt;/strong&gt; Those are precisely the ones that need an explicit check, because the ones that blow up loudly take care of themselves.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. A command that had never run, because of one wrong flag
&lt;/h2&gt;

&lt;p&gt;Same script, further down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan operations &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--force&lt;/code&gt; is a &lt;code&gt;migrate&lt;/code&gt; flag. It means &lt;em&gt;yes, in production, I'm sure.&lt;/em&gt; The deploy-operations command (I use &lt;a href="https://github.com/TheDragonCode/laravel-deploy-operations" rel="noopener noreferrer"&gt;&lt;code&gt;dragon-code/laravel-deploy-operations&lt;/code&gt;&lt;/a&gt;) doesn't have it — its equivalent guard is &lt;code&gt;--isolated&lt;/code&gt;, plus &lt;code&gt;--no-interaction&lt;/code&gt; for the prompt. So every deploy, that line printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The "--force" option does not exist.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…exited 1, and the script carried on to restart Horizon. Post-deploy operations had never run. Not once.&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;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; php artisan operations &lt;span class="nt"&gt;--isolated&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: deploy operations failed."&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"The application is still in maintenance mode at &lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_PATH&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Fix the operation, then re-run this script or 'php artisan up'."&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the flipped case from earlier: here I &lt;em&gt;don't&lt;/em&gt; call &lt;code&gt;php artisan up&lt;/code&gt;. Migrations have already run, so the release is half-applied, and a half-applied release must not go live. Maintenance mode stays on and the message says so explicitly, including the way out. A 503 that tells you why is a much better outcome than a live site running new schema against code that never finished deploying.&lt;/p&gt;

&lt;p&gt;Which sets up the nastiest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. An operation that succeeded and reported failure
&lt;/h2&gt;

&lt;p&gt;The migration to give every legacy account its own tenant ran as a deploy operation. I put a progress line in it, the way you would in any command:&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;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$action&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;CreatePersonalOrganizationAction&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="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;whereDoesntHave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ownedOrganizations'&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;cursor&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;each&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="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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$action&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;$action&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;$user&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;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"  organisation created for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;Operation&lt;/code&gt; is not a &lt;code&gt;Command&lt;/code&gt;. It extends nothing console-shaped. No &lt;code&gt;line()&lt;/code&gt;, no &lt;code&gt;info()&lt;/code&gt;, no &lt;code&gt;$this-&amp;gt;command&lt;/code&gt;. So that call threw — &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;$action-&amp;gt;execute($user)&lt;/code&gt; had already committed.&lt;/p&gt;

&lt;p&gt;Look at the ordering, because it's the worst one available:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The work lands. The organisation is created, in the database, for real.&lt;/li&gt;
&lt;li&gt;The operation throws on the reporting line.&lt;/li&gt;
&lt;li&gt;The command exits non-zero.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bin/deploy&lt;/code&gt; does exactly what I just taught it to do and stops, leaving maintenance mode on.&lt;/li&gt;
&lt;li&gt;Production serves 503 over a change that had, in fact, succeeded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And it hides on the re-run. By then every affected account already has an organisation, so the query returns nothing, the closure never executes, and the throwing line is never reached. The operation "passes". You'd have to reproduce it against a fresh copy of production data to see it again.&lt;/p&gt;

&lt;p&gt;Two things I'd take from that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Side effects before reporting is a footgun.&lt;/strong&gt; If the observable outcome differs depending on whether the &lt;em&gt;logging&lt;/em&gt; worked, the failure is uninterpretable from outside. Do the work, or don't — but don't let a cosmetic line decide whether the caller thinks it happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotent-by-query means self-concealing.&lt;/strong&gt; Anything that loops over "rows not yet fixed" erases its own reproduction case as it goes. That's a feature for reliability and a trap for debugging, and it's worth knowing which one you're relying on at any moment.&lt;/p&gt;

&lt;p&gt;The fix was one deleted line and a comment explaining why it must stay deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A health check that shared the stale state it was meant to catch
&lt;/h2&gt;

&lt;p&gt;This one is my favourite, because every individual piece is correct.&lt;/p&gt;

&lt;p&gt;The deploy cleared caches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan config:clear
php artisan view:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routes: not cleared. And the host had had &lt;code&gt;route:cache&lt;/code&gt; run on it once, at some point, by some hand. So it was serving the route table exactly as it was at that moment, forever.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;/&lt;/code&gt; changed from rendering a view to redirecting. Deploy, and production 500s on every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;View [welcome] not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— against a release whose &lt;code&gt;routes/&lt;/code&gt; file says nothing of the sort. That alone will cost you twenty minutes, because you're staring at code that is plainly right.&lt;/p&gt;

&lt;p&gt;The part that made it &lt;em&gt;silent&lt;/em&gt;, though, is the health check:&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;HTTP_STATUS&lt;/span&gt;&lt;span class="o"&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="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APP_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/up"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HTTP_STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check passed"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/up&lt;/code&gt; is a framework route. It lives in the same stale route cache. It answered 200 throughout, so the deploy reported healthy while every real page in the application was throwing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A health check that reads from the same cache as the thing it's checking isn't a health check.&lt;/strong&gt; It's a second copy of the bug agreeing with the first.&lt;/p&gt;

&lt;p&gt;The repaired shape:&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Clear config, route and view caches"&lt;/span&gt;
php artisan config:clear
php artisan route:clear
php artisan view:clear

&lt;span class="c"&gt;# ... migrate, operations ...&lt;/span&gt;

&lt;span class="c"&gt;# Rebuild after migrations, before restarting processes, so the caches always&lt;/span&gt;
&lt;span class="c"&gt;# describe the release that is about to serve.&lt;/span&gt;
php artisan optimize

&lt;span class="c"&gt;# ...&lt;/span&gt;

&lt;span class="c"&gt;# /up is not evidence the release works — probe a real page too, following&lt;/span&gt;
&lt;span class="c"&gt;# redirects, since / is one.&lt;/span&gt;
&lt;span class="nv"&gt;HTTP_STATUS&lt;/span&gt;&lt;span class="o"&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="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APP_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/up"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;PAGE_STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sL&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="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APP_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check: /up=&lt;/span&gt;&lt;span class="nv"&gt;$HTTP_STATUS&lt;/span&gt;&lt;span class="s2"&gt; /=&lt;/span&gt;&lt;span class="nv"&gt;$PAGE_STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HTTP_STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&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="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PAGE_STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check passed"&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING: Health check failed (/up=&lt;/span&gt;&lt;span class="nv"&gt;$HTTP_STATUS&lt;/span&gt;&lt;span class="s2"&gt; /=&lt;/span&gt;&lt;span class="nv"&gt;$PAGE_STATUS&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
    &lt;span class="c"&gt;# ... roll back to $PREVIOUS_COMMIT&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl -L&lt;/code&gt; on the second probe, because &lt;code&gt;/&lt;/code&gt; redirects — without it you assert on a 302 and learn nothing about whether the destination renders.&lt;/p&gt;

&lt;p&gt;The generalisation: &lt;strong&gt;probe at the layer you care about.&lt;/strong&gt; &lt;code&gt;/up&lt;/code&gt; proves PHP is alive and the framework booted. It says nothing about whether your application's pages render. Those are different claims and they fail independently, so check both — and print both numbers, so the log tells you which one broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: &lt;code&gt;grep -q&lt;/code&gt; and &lt;code&gt;pipefail&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;While hardening the provisioning script I hit a genuinely confusing one. This aborts under &lt;code&gt;set -euo pipefail&lt;/code&gt;:&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;if &lt;/span&gt;apt-cache policy &lt;span class="s2"&gt;"php&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-opcache"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'Candidate:'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;grep -q&lt;/code&gt; exits the moment it finds a match. &lt;code&gt;apt-cache&lt;/code&gt; is still writing, gets SIGPIPE, dies with 141 — and &lt;code&gt;pipefail&lt;/code&gt; makes the &lt;em&gt;pipeline's&lt;/em&gt; status the last non-zero one. So a successful match reports failure. Assign first, match against a here-string:&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;OPCACHE_POLICY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;apt-cache policy &lt;span class="s2"&gt;"php&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-opcache"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'Candidate:'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OPCACHE_POLICY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;PHP_PKGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PHP_PKGS&lt;/span&gt;&lt;span class="s2"&gt; php&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-opcache"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the conditional at all: &lt;strong&gt;opcache stopped being a separate package in PHP 8.4.&lt;/strong&gt; It's compiled into the interpreter now. Asking apt for &lt;code&gt;php8.4-opcache&lt;/code&gt; doesn't warn, it aborts the whole install — so on a modern PHP you have to &lt;em&gt;not&lt;/em&gt; request it, while on 8.3 and below you must. Hence: ask apt whether the package exists, then assert on the outcome instead of the package:&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;PHP_MODULES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;php&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s1"&gt;'zend opcache'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PHP_MODULES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: opcache is not loaded for PHP &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A missing accelerator is a silent slowdown, never an error. So make it one.&lt;/p&gt;

&lt;p&gt;Same category, different tool: install the &lt;strong&gt;PostgreSQL client from PGDG, not from the distro&lt;/strong&gt;. Ubuntu noble ships client 16, and &lt;code&gt;pg_dump&lt;/code&gt; is only backward compatible — point it at a newer server and it refuses with &lt;em&gt;aborting because of server version mismatch&lt;/em&gt;. Managed clusters upgrade on their own schedule; your backup script finds out at 3am. Pin a client at least as new as the server and print &lt;code&gt;pg_dump --version&lt;/code&gt; during provisioning so the log carries the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the untestable bit
&lt;/h2&gt;

&lt;p&gt;None of the above is unit-testable in any satisfying way — it's a shell script talking to a real host. What &lt;em&gt;is&lt;/em&gt; testable is the thing the shell script deploys, and after today I test one specific claim: that the health-check target actually renders.&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;'serves the root page the health check probes'&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;$this&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;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;assertRedirect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;// / redirects; the check follows it&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSessionHasNoErrors&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;'answers the framework health route'&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;$this&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;span class="s1"&gt;'/up'&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;assertOk&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;Trivial tests. They would have caught the stale-route incident in CI instead of in production — not because they're clever, but because they assert the same two things the deploy asserts, from a place where nothing is cached.&lt;/p&gt;

&lt;p&gt;That's the pattern worth keeping: &lt;strong&gt;whatever your deploy uses as its definition of "healthy", assert the same thing in your test suite.&lt;/strong&gt; If the two ever disagree, one of them is reading a stale copy — and it's the one running in production.&lt;/p&gt;

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

&lt;p&gt;Every fault today was an unchecked exit status wearing a different costume. The lesson isn't "add &lt;code&gt;set -e&lt;/code&gt;" — I had &lt;code&gt;set -e&lt;/code&gt; where it mattered and it still slipped through the pipelines and the &lt;code&gt;if&lt;/code&gt;s.&lt;/p&gt;

&lt;p&gt;It's narrower than that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a step fails and the next step can still succeed on yesterday's state, you have built a machine that reports success. Check the status, print the SHA, probe a real page — and make the deploy prove it changed something.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next up: getting the provisioning script to the point where a fresh host is one command away, and having the deploy write its resulting SHA somewhere the application itself can display. If the release is going to claim it deployed, it should be able to show me what.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
    </item>
    <item>
      <title>Dev Log: 2026-08-10 — a toast that lied, an enum with a missing case, and a design system that finally has an address</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Tue, 11 Aug 2026 01:57:47 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-08-10-a-toast-that-lied-an-enum-with-a-missing-case-and-a-design-system-that-405j</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-08-10-a-toast-that-lied-an-enum-with-a-missing-case-and-a-design-system-that-405j</guid>
      <description>&lt;p&gt;Four commits across two repos, and a short day by commit count that still managed to contain the most instructive bug I've hit in a while. The through-line, if there is one: &lt;strong&gt;things that report a state they haven't verified.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The big piece — bulk actions and how they report partial outcomes — has its own write-up here. This is the rest, plus the one detail from that work that generalises furthest.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The toast that was confidently wrong
&lt;/h2&gt;

&lt;p&gt;Bulk lifecycle jobs went out with a named queue on the wrong connection. The queue name was registered, present in the supervisor list, entirely plausible in review. The jobs sat unclaimed in the table while the UI said &lt;em&gt;"3 deployments stopping."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two things worth carrying out of that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A queue name registered for Horizon is a Redis queue.&lt;/strong&gt; Force a job onto the &lt;code&gt;database&lt;/code&gt; connection and it never reaches that supervisor — it's watching a different connection. The name being in config is not evidence that anything is listening on it. Local dev made it worse: the listener ran with no &lt;code&gt;--queue&lt;/code&gt; flag, so it consumed &lt;code&gt;default&lt;/code&gt; only, and a correctly-named queue with nobody on it looks exactly like a healthy one from the browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The message is what made it silent.&lt;/strong&gt; "Stopping" is a claim about the world. The request can't honestly make that claim — all it did was enqueue. It now says &lt;em&gt;"queued to stop — needs a queue worker on the database connection"&lt;/em&gt;, which costs one clause and turns a mystery into a checklist item.&lt;/p&gt;

&lt;p&gt;That went into the project's gotchas file the same afternoon. Traps like this aren't the kind you rediscover; they're the kind you re-introduce, six weeks later, in a different feature. A gotchas doc is cheap insurance against your own future confidence, and it's worth writing the entry while you're still annoyed enough to be specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An enum defined by the case it doesn't have
&lt;/h2&gt;

&lt;p&gt;Providers grew a stored health status from their last connection check, and the enum has three cases:&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;ProviderHealthStatus&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;Healthy&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'healthy'&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;Failed&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'failed'&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;Simulated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'simulated'&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 design notes, both of which are about refusing to let distinct situations collapse into one another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is deliberately no &lt;code&gt;Unknown&lt;/code&gt; case.&lt;/strong&gt; A provider nobody has ever checked has a &lt;strong&gt;null&lt;/strong&gt; status, and null is what the UI renders as "never checked". Add an &lt;code&gt;Unknown&lt;/code&gt; case and a never-checked provider can be &lt;em&gt;stored&lt;/em&gt; as though a check ran and came back inconclusive. Those are different facts. Null already means "no value here" perfectly well; inventing an enum case for absence gives absence a way to be written down as presence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Simulated&lt;/code&gt; exists because a fake driver passing is not a pass.&lt;/strong&gt; If the driver behind a provider is a declared fake, the check exercised nothing real — no socket opened, no credential used. Folding that into &lt;code&gt;Healthy&lt;/code&gt; would put a green badge next to a provider that has never been contacted, which is the exact failure mode this whole feature was built to catch. So it's amber, and its description says so plainly: &lt;em&gt;no real driver for this provider type — nothing was contacted&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you've read the last few of these you'll spot the pattern. Most of my week has been variations on &lt;strong&gt;don't let "we didn't check" render as "we checked and it's fine"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Enums with &lt;code&gt;label()&lt;/code&gt;, &lt;code&gt;description()&lt;/code&gt; and &lt;code&gt;color()&lt;/code&gt; keep paying for themselves here, by the way. The moment the UI has to distinguish three shades of "we know something about this", the presentation belongs next to the case, not in a Blade &lt;code&gt;@if&lt;/code&gt; ladder that four views each get slightly differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Bulk actions, briefly
&lt;/h2&gt;

&lt;p&gt;The feature itself: select rows in any listing, act on all of them, and get back a sentence that says what happened &lt;em&gt;and why the rest didn't&lt;/em&gt;. Skip reasons are counted individually, so the toast reads "2 not permitted, 1 already stopped" rather than "3 skipped". A run that changed nothing is a warning even when every skip was legitimate — the operator asked for something and got no change, and green means "your thing happened".&lt;/p&gt;

&lt;p&gt;Selection is keyed on the public UUID rather than the internal id (these are client-writable properties), resolved through a tenant-scoped query so a UUID from elsewhere doesn't resolve at all, and cleared on any filter, search or page change — a selection that outlives its filter means confirming "delete 12" against a list you can no longer see.&lt;/p&gt;

&lt;p&gt;Full write-up in the other post. Six listings now share the trait; the one still to do is the destroy confirmation, which asks you to retype the selected count when the number it should show is the count that will actually be acted on.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The design system got an address
&lt;/h2&gt;

&lt;p&gt;Different repo, different kind of work. &lt;a href="https://github.com/developers-hub-my/website" rel="noopener noreferrer"&gt;developers-hub-my/website&lt;/a&gt; — the DevHub site — got a persisted design system file and lost a stale planning doc.&lt;/p&gt;

&lt;p&gt;The deletion is the more interesting half. &lt;code&gt;DESIGN_PROPOSAL.md&lt;/code&gt; had been sitting in the repo describing work that was, by now, mostly &lt;em&gt;already done&lt;/em&gt;. A planning document that outlives its plan is worse than no document: it reads as current, and the three items in it that genuinely remain are buried in twenty that don't. Those three moved to a tracked issue along with the tokens it documented, and a longer benchmark study broke out into sixteen individual issues, each with a verified current state and a target state.&lt;/p&gt;

&lt;p&gt;The general rule I keep relearning: &lt;strong&gt;a document describes how things are; an issue describes work to be done.&lt;/strong&gt; The moment a doc starts carrying intentions, it acquires an expiry date nobody is watching. Files in a repo are read as fact.&lt;/p&gt;

&lt;p&gt;What replaced it is a &lt;code&gt;MASTER.md&lt;/code&gt; that's the opposite — purely descriptive. Colour tokens with their CSS variable names, heading and body fonts, the spacing and shadow scales, and a pre-delivery checklist, all under one rule at the top: page-specific files override the master, otherwise the master applies. It codifies a direction the site had already committed to ("Trust &amp;amp; Authority" — navy and blue, WCAG AAA, full light/dark) so page work can be checked against a single source of truth instead of whichever page someone last looked at.&lt;/p&gt;

&lt;p&gt;Nothing exotic. The value isn't in the tokens — it's that there's now exactly one place where a disagreement about them gets settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The destroy confirmation showing a count it hasn't verified is, on today's evidence, an offence I'm apparently determined to commit in every corner of the app. That one next.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>"3 skipped" tells the operator nothing</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Tue, 11 Aug 2026 01:57:35 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/3-skipped-tells-the-operator-nothing-c7i</link>
      <guid>https://dev.to/nasrulhazim/3-skipped-tells-the-operator-nothing-c7i</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Bulk actions over a mixed selection always skip rows. A count of skips is not a result — the operator needs to know &lt;em&gt;which&lt;/em&gt; reason applied to how many, because "not permitted" and "already gone" call for completely different next moves.&lt;/li&gt;
&lt;li&gt;Count skip reasons individually, not in total. One counter per reason is about ten lines of code and it's the entire difference between an actionable toast and a shrug.&lt;/li&gt;
&lt;li&gt;A run that changed nothing is a &lt;strong&gt;warning&lt;/strong&gt;, even when every single skip was legitimate. The operator asked for something and got no change; a green toast there reads as "done".&lt;/li&gt;
&lt;li&gt;Selection is keyed on the public UUID, never the auto-increment id — it's a client-writable property.&lt;/li&gt;
&lt;li&gt;The nastiest bug of the day wasn't in any of that. It was a toast that said "3 deployments stopping" while all three jobs sat unclaimed in the queue table, because the job was dispatched to a queue no worker was listening on.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Most of yesterday went into one feature that sounds boring on the changelog line: &lt;em&gt;act on many rows at once&lt;/em&gt;. Select some rows in a listing, hit Stop, done.&lt;/p&gt;

&lt;p&gt;It is boring, right up until you notice that a bulk action is the one place in a CRUD app where &lt;strong&gt;the operation is guaranteed to be partially refused&lt;/strong&gt;. Single-row actions are binary — the button is either there or it isn't, and if you click it and it fails you get an error about that one thing. Bulk is different. Ten rows go in, the system has opinions about each of them individually, and something has to come back out that a human can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lie is "3 skipped"
&lt;/h2&gt;

&lt;p&gt;Here's the shape I started with, which is the shape almost every bulk implementation ships with:&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;$succeeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$skipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolveSelected&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;$row&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;canAct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$skipped&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;continue&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;act&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$succeeded&lt;/span&gt;&lt;span class="o"&gt;++&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;toast&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;$succeeded&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; stopped, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$skipped&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; skipped."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"7 stopped, 3 skipped." Looks like a result. It isn't.&lt;/p&gt;

&lt;p&gt;Put yourself behind that message. Three rows didn't move. Is that because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your role can't perform this action on them,&lt;/li&gt;
&lt;li&gt;they were already stopped,&lt;/li&gt;
&lt;li&gt;they belong to another team and never resolved at all,&lt;/li&gt;
&lt;li&gt;or they're in a state where this action doesn't apply?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those has a &lt;em&gt;different&lt;/em&gt; next action. The first is "ask someone with the right role". The second is "nothing to do, carry on". The third is "you're looking at the wrong page". The fourth is "wait, then retry". A single integer collapses all four into "hmm", and "hmm" reliably becomes "click it again and see".&lt;/p&gt;

&lt;p&gt;So the counter grew a key:&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;BulkActionResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @var array&amp;lt;string, int&amp;gt; reason =&amp;gt; count */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$skipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$succeeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;skip&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;$reason&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$reason&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$reason&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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="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;succeeded&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;succeeded&lt;/span&gt;&lt;span class="o"&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="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;didNothing&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;succeeded&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&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's it. That's the whole idea. A map instead of an integer, so the toast can say &lt;strong&gt;"2 not permitted, 1 already stopped"&lt;/strong&gt; instead of "3 skipped", and the difference between &lt;em&gt;refused&lt;/em&gt; and &lt;em&gt;nothing to do&lt;/em&gt; stops being invisible.&lt;/p&gt;

&lt;p&gt;Small object, and it earns its keep by being the only place that knows how to phrase the outcome:&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;toastMessage&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;$summary&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="nv"&gt;$parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$summary&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;skipped&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$reason&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&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;':count skipped — :reason.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reason'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$reason&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="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&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;':count failed.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;failed&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="nb"&gt;implode&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="nv"&gt;$parts&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;
  
  
  Green is a claim
&lt;/h2&gt;

&lt;p&gt;The severity of the toast turned out to need a rule of its own, and it's the bit I'd have got wrong on autopilot:&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;toastType&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;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="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="n"&gt;succeeded&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'warning'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'error'&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;didNothing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'warning'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'success'&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 the last line carefully. &lt;strong&gt;A run where every row was legitimately skipped is still a warning.&lt;/strong&gt; No errors occurred. Nothing is broken. The system behaved exactly as designed. And it is still not a success, because success is green, green means "your thing happened", and the operator's thing did not happen.&lt;/p&gt;

&lt;p&gt;This is the same class of bug as a stub that returns an empty array: technically correct, and it teaches the human the wrong thing. Colour is a claim about what changed. Don't make a claim you can't back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rules the selection itself has to encode
&lt;/h2&gt;

&lt;p&gt;Bulk selection lives in a trait shared by every listing, and two rules went in there because both are quietly easy to lose:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selection is keyed on the public UUID, never the internal id.&lt;/strong&gt; These are Livewire properties — client-writable, by definition. Resolving a bulk mutation from an auto-increment key that a browser handed you is guessable-id enumeration with a helpful loop around it. And even with UUIDs, the resolution query has to be tenant-scoped, so a UUID from another organisation simply doesn't resolve. The policy check is the &lt;em&gt;second&lt;/em&gt; gate. It is not the first one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selection is current-page-only, and any filter, search or page change clears it.&lt;/strong&gt; A selection that survives a filter change means the operator confirms "delete 12" while looking at a list of 4. Whatever they're about to destroy, they can no longer see it. Same reasoning for always clearing after the run — including when nothing succeeded — because the rows behind that selection were just re-evaluated, and leaving the boxes ticked invites a second click on a set nobody has re-read.&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;reportBulkResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;BulkActionResult&lt;/span&gt; &lt;span class="nv"&gt;$result&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;$summary&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clearSelection&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;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'toast'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toastType&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toastMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$summary&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;h2&gt;
  
  
  Check order is a UX decision, not a style one
&lt;/h2&gt;

&lt;p&gt;In the loop, I check the row's &lt;strong&gt;status before the policy&lt;/strong&gt;, and that ordering is deliberate.&lt;/p&gt;

&lt;p&gt;The destroy path also refuses an already-destroyed row. So if the policy check came first, an operator with a weaker role selecting a row that's already gone would be told &lt;strong&gt;"not permitted"&lt;/strong&gt; — which sends them off to ask for elevated access to do something that didn't need doing at all.&lt;/p&gt;

&lt;p&gt;The usual argument for checking authorization first is to avoid leaking state to someone who shouldn't see it. That doesn't apply here: the row's status is rendered in the list they're looking at. Nothing leaks, and the more specific reason is the useful one.&lt;/p&gt;

&lt;p&gt;Worth generalising: &lt;strong&gt;when two guards both refuse, the one whose reason is more actionable should run first&lt;/strong&gt; — unless the other one's reason is the thing you're protecting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that actually bit me
&lt;/h2&gt;

&lt;p&gt;None of the above was the real bug of the day.&lt;/p&gt;

&lt;p&gt;The bulk lifecycle jobs were dispatched with &lt;code&gt;-&amp;gt;onConnection('database')-&amp;gt;onQueue('deployments')&lt;/code&gt; — a queue name that is registered, that appears in the supervisor list, that looks entirely correct in review. The toast came back cheerfully: &lt;em&gt;"3 deployments stopping."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nothing was stopping. All three jobs were sitting unclaimed in the &lt;code&gt;jobs&lt;/code&gt; table.&lt;/p&gt;

&lt;p&gt;Two layers to it, and both are worth having in your head:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A queue name registered for Horizon is a Redis queue.&lt;/strong&gt; A job forced onto the &lt;code&gt;database&lt;/code&gt; connection never reaches it — the supervisor is watching a different connection entirely. The name being present in config is not evidence that anything is listening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The local dev listener had no &lt;code&gt;--queue&lt;/code&gt; flag&lt;/strong&gt;, so it consumed &lt;code&gt;default&lt;/code&gt; and nothing else. A named queue on the right connection with no worker on that name is indistinguishable, from the browser, from a queue that's working fine.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix in the code was one line — match the dispatch of the job that already got this right, which uses that connection with &lt;em&gt;no&lt;/em&gt; named queue at all. The fix that matters more was in the message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"3 deployments queued to stop — needs a queue worker on the database connection."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A message that claims the work is happening is what makes the failure silent.&lt;/strong&gt; "Stopping" is a claim about the world. "Queued to stop" is a claim about what this request did, which is the only thing the request can honestly report. The extra clause costs nothing and turns a mystery into a checklist item.&lt;/p&gt;

&lt;p&gt;And because a message is now load-bearing, the test asserts the message:&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;'skips a row whose status forbids the action and says which requirement failed'&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="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$active&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deploymentAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeploymentStatus&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="nv"&gt;$stopped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deploymentAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeploymentStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Stopped&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Index&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'selected'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$active&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stopped&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uuid&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;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bulkStop'&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;assertDispatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'toast'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'1 deployment queued to stop — needs a queue worker on the database connection. '&lt;/span&gt;
                   &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'1 skipped — only an active or degraded deployment can be stopped.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertPushed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeploymentLifecycleJob&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="mi"&gt;1&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;Asserting on a full user-facing string feels brittle, and it is a little. I'd normally push back on it. Here it's the point: the string &lt;em&gt;is&lt;/em&gt; the feature. If someone later "tidies" it back to "3 deployments stopping", I want a red test, not a shrug.&lt;/p&gt;

&lt;p&gt;The connection and the null queue are pinned by that test too — because the trap isn't the kind of thing you rediscover, it's the kind of thing you re-introduce.&lt;/p&gt;

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

&lt;p&gt;A bulk action's real output isn't the mutation. It's the sentence you hand back to the person who clicked it. Budget for that sentence like it's a feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one counter per skip reason, not one counter,&lt;/li&gt;
&lt;li&gt;warning when nothing changed, even if nothing was wrong,&lt;/li&gt;
&lt;li&gt;and never phrase a queued job as though the work already happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up is making the destroy confirmation carry the same honesty — it currently asks you to retype the count, which is good, but the count it shows is the &lt;em&gt;selected&lt;/em&gt; count, not the count that will actually be acted on. Those two numbers are allowed to differ, and the moment they do, the confirmation is confirming the wrong thing.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Dev Log: 2026-08-09 — 72 classes that should have been 8 rows, and a compliance clock</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 10 Aug 2026 02:02:26 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-08-09-72-classes-that-should-have-been-8-rows-and-a-compliance-clock-25h0</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-08-09-72-classes-that-should-have-been-8-rows-and-a-compliance-clock-25h0</guid>
      <description>&lt;p&gt;Sixteen commits, all on one private deployment platform, spread across a very long day — a batch just after midnight and then a second run in the late afternoon.&lt;/p&gt;

&lt;p&gt;The big architectural thread of the day — drivers that admit what they can't do — is written up properly in a separate post. This is everything else, which turned out to have its own through-line: &lt;strong&gt;most of it was about making a claim checkable.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Managed services as data, not as 72 classes
&lt;/h2&gt;

&lt;p&gt;The catalogue seeds 24 managed service types. Four of them had provisioners. So a blueprint asking for MongoDB, RabbitMQ or MinIO got refused at validation — a clear failure, but a missing capability.&lt;/p&gt;

&lt;p&gt;Filling that gap the obvious way meant 20 types × 3 backends = &lt;strong&gt;60 more near-identical classes&lt;/strong&gt;, each a copy of its two siblings, with one service's image tag and port living in three files that immediately start drifting apart.&lt;/p&gt;

&lt;p&gt;Each of those classes was already about fifty lines of pure declaration — image, port, env, command, readiness probe, connection string — sitting inside a base class that held all the actual behaviour. That's not a class. That's a row.&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ComponentDefinition&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt; &lt;span class="nv"&gt;$k8sCommand&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$readiness&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="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;Twelve hand-written provisioners became eight definitions and three thin adapters that answer their base class's seams from the data. Net: &lt;strong&gt;679 lines added, 742 removed&lt;/strong&gt;, and adding a service is now an entry, not a directory.&lt;/p&gt;

&lt;p&gt;Three things the format had to &lt;strong&gt;admit rather than paper over&lt;/strong&gt;, and they're the interesting part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;k8sCommand&lt;/code&gt; exists because the shells genuinely differ.&lt;/strong&gt; Docker needs &lt;code&gt;sh -c&lt;/code&gt; to expand &lt;code&gt;$VAR&lt;/code&gt;; kubelet expands &lt;code&gt;$(VAR)&lt;/code&gt; itself and a shell there is an extra process for nothing. Redis is the case. Pretending one command fits both would have shipped a broken Redis on one backend. When two things are 95% the same, the escape hatch for the 5% is not a design failure — pretending the 5% doesn't exist is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No readiness probe resolves to &lt;code&gt;false&lt;/code&gt;, never to ready.&lt;/strong&gt; Nothing to check must not mean nothing to wait for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A slug that isn't in the registry stays refused.&lt;/strong&gt; An entry that merely guessed an image and a port would provision something that starts, accepts connections, and stores nothing where anyone expects. Kafka and friends are &lt;em&gt;honestly absent&lt;/em&gt;, and a test asserts they are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The refactor caught two things I'd have shipped otherwise, both because I kept the old tests running against the new implementation: a readiness probe I had quietly tightened, and one backend's &lt;code&gt;--entrypoint&lt;/code&gt; argument that the definition initially applied only on the other. Both restored byte-identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A refactor that changes behaviour is not a refactor.&lt;/strong&gt; The old tests are the only thing that can tell you which one you did.&lt;/p&gt;

&lt;p&gt;There's also a test that walks every definition against the seeded catalogue, so a definition for a slug the picker never offers can't masquerade as coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Scanning the images that are actually running
&lt;/h2&gt;

&lt;p&gt;Vulnerability scanning went from a fake scanner to a real one wired to a CLI scanner, with severity as a proper enum and a scheduled command.&lt;/p&gt;

&lt;p&gt;The framing in the commit title is the bit worth keeping: &lt;strong&gt;the images that are actually running&lt;/strong&gt;. Not the images in the manifest, not the images in the registry, not the base images someone listed in a spec once. A CVE report about an image you aren't running is noise that trains people to ignore the report.&lt;/p&gt;

&lt;p&gt;It also grew a scanner-availability check, because a security scan that silently doesn't run is — you'll recognise the pattern by now — worse than one that isn't installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Finishing the alert lifecycle
&lt;/h2&gt;

&lt;p&gt;Alert rules and incidents already existed. What didn't exist was the part where anything &lt;em&gt;happens&lt;/em&gt;: evaluation, notification, escalation.&lt;/p&gt;

&lt;p&gt;So: an evaluator, a notifier, a mailable, an &lt;code&gt;escalated_at&lt;/code&gt; timestamp on the response record, and a scheduled command to drive it. An alert rule with no evaluator is a monitoring dashboard's version of the same lie — it &lt;em&gt;looks&lt;/em&gt; like coverage.&lt;/p&gt;

&lt;p&gt;Paired with a real Prometheus / Grafana / Loki adapter to replace the simulated one, and a blueprint change that provisions the observability and identity layers as part of a deployment rather than as a thing you remember to do afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A compliance clock, and what a clock has to be
&lt;/h2&gt;

&lt;p&gt;PDPA work: data residency validation, data subject export, and breach notification.&lt;/p&gt;

&lt;p&gt;Two design notes generalise well beyond one jurisdiction's privacy law:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Residency is validated at blueprint time, not at audit time.&lt;/strong&gt; The check went into the blueprint validation step, so a deployment that would place data in the wrong region fails before it exists. Compliance you can only discover after the fact isn't a control, it's a report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A breach clock needs a start it didn't choose.&lt;/strong&gt; The notification window runs from when the breach was &lt;em&gt;detected&lt;/em&gt;, and detection has to be recorded by whatever noticed it — not set by the person who later has to explain the delay. A field that the interested party fills in is a field you can't rely on.&lt;/p&gt;

&lt;p&gt;Subject access got a console command as well as a UI path, which is the pragmatic call: these requests arrive rarely, urgently, and often to someone who isn't going to click through an admin panel under time pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Three checks before the deploy button
&lt;/h2&gt;

&lt;p&gt;The onboarding checklist happily sends a new user to the deploy button, where three well-documented failure modes are waiting — all of which look like the application being broken:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No queue worker&lt;/strong&gt; on the right connection: the UI says "provisioning started, this page updates live", and then nothing happens. Forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No scheduler&lt;/strong&gt;: certificates lapse and rotation windows never close, silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A provider that resolves to fakes&lt;/strong&gt;: the deployment reports Active with no infrastructure behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A preflight check answers all three before the last step, and each answer carries &lt;strong&gt;what to do about it&lt;/strong&gt; — a failing check that doesn't say how to fix it is just a red badge.&lt;/p&gt;

&lt;p&gt;The thing that took the actual work was making each check &lt;em&gt;answerable rather than plausible&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Laravel records nothing when a scheduled task fires, so the scheduler stamps a heartbeat every minute. Without it the check could only ever return "unknown", which is a guard that never fires wearing a checkmark.&lt;/li&gt;
&lt;li&gt;The queue check asks whether an &lt;strong&gt;unclaimed job has been sitting for two minutes&lt;/strong&gt; — not whether a worker is configured. A configured worker that isn't running looks identical from the config.&lt;/li&gt;
&lt;li&gt;The provider check had to move from per-type to per-provider, because an adopted VM is real with credentials and fake without them. The type-level answer would have told an operator their half-configured provider could deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is a good general rule: &lt;strong&gt;check the instance, not the class.&lt;/strong&gt; Configuration is a claim; state is the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Helm chart, and bootstrap templates as data
&lt;/h2&gt;

&lt;p&gt;Two smaller ones. A Helm chart so the platform itself installs onto Kubernetes the way anything else does — dogfooding, and also the fastest way to find out which of your config values were secretly required.&lt;/p&gt;

&lt;p&gt;And bootstrap templates moved into seeds with the form able to set the rules they carry, which is the same move as §1: a template is data, and data belongs in a table where you can see all of it at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The definition registry covers eight services out of twenty-four. The remaining sixteen are now a data-entry problem with a test that refuses to let me fake it, which is roughly the best shape an unfinished feature can be in.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>A driver that quietly does nothing is worse than one that isn't there</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 10 Aug 2026 02:02:02 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/a-driver-that-quietly-does-nothing-is-worse-than-one-that-isnt-there-61m</link>
      <guid>https://dev.to/nasrulhazim/a-driver-that-quietly-does-nothing-is-worse-than-one-that-isnt-there-61m</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A method that can't do its job has three options: do it, &lt;strong&gt;throw&lt;/strong&gt;, or &lt;strong&gt;return "I can't"&lt;/strong&gt; as a value. What it must never do is return normally having done nothing.&lt;/li&gt;
&lt;li&gt;I gave a firewall abstraction two extra methods — &lt;code&gt;enforces()&lt;/code&gt; and &lt;code&gt;unavailableReason()&lt;/code&gt; — so "not implemented here" became something a caller can act on instead of something it has to infer.&lt;/li&gt;
&lt;li&gt;The security fix that actually landed wasn't a firewall at all. It was binding published ports to loopback, because a port bound to &lt;code&gt;127.0.0.1&lt;/code&gt; is closed no matter what any firewall says or fails to say.&lt;/li&gt;
&lt;li&gt;Fail &lt;em&gt;closed&lt;/em&gt; on unrecognised input. An unknown exposure value binds loopback — a typo must never open a port.&lt;/li&gt;
&lt;li&gt;If two lists in your codebase have to agree, derive both from one predicate and write the test that asserts they partition the set.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Yesterday I wrote about stubs that answer politely — the fakes and empty columns that make a system look complete while it does nothing. Today's work was the next layer of the same problem, and it's the more dangerous one.&lt;/p&gt;

&lt;p&gt;A stub that returns an empty array is at least &lt;em&gt;inert&lt;/em&gt;. What I spent the day on was code that &lt;strong&gt;reported success&lt;/strong&gt; for work it had never done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The record of intent that no packet ever meets
&lt;/h2&gt;

&lt;p&gt;There was a &lt;code&gt;network_policies&lt;/code&gt; table. Rows were written on every provision, from the first day the provisioning pipeline existed. Ingress rules, egress rules, allowed CIDRs, ports. All of it stored, validated, displayed in the UI.&lt;/p&gt;

&lt;p&gt;Nothing ever read it.&lt;/p&gt;

&lt;p&gt;Not "read it and got it wrong" — nothing read it at all. There was no code path anywhere that turned a row in that table into a rule on any machine. The deployment pipeline had a step called &lt;em&gt;apply network policies&lt;/em&gt;, that step wrote the rows, the step returned success, and the pipeline reported the deployment as fully provisioned with policy applied.&lt;/p&gt;

&lt;p&gt;That's a record of intent that no packet ever encounters. And it's a nastier bug than an unimplemented feature, because it produces &lt;strong&gt;evidence&lt;/strong&gt;. An operator looking at that screen has every reason to believe their ingress is restricted.&lt;/p&gt;

&lt;p&gt;So the fix was a contract — but the interesting part of the contract isn't &lt;code&gt;apply()&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="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;FirewallContract&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @param list&amp;lt;FirewallRuleSpec&amp;gt; $rules */&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;apply&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;$rules&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="cd"&gt;/** Read back from the enforcement point, not from the database. */&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;current&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;teardown&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="cd"&gt;/**
     * Whether this driver can actually filter packets.
     *
     * False means callers must not report policy as applied. It exists so
     * "not implemented here" is a value a step can act on, rather than
     * something inferred from a no-op.
     */&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;enforces&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="cd"&gt;/** Why enforcement is unavailable, when enforces() is false. */&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;unavailableReason&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those last two methods are the whole point of the change. &lt;code&gt;enforces()&lt;/code&gt; makes capability an &lt;strong&gt;explicit return value&lt;/strong&gt; rather than something you deduce by reading the implementation and noticing it has an empty body.&lt;/p&gt;

&lt;p&gt;And the only implementation that ships right now is the unenforced one, whose &lt;code&gt;apply()&lt;/code&gt; &lt;strong&gt;throws&lt;/strong&gt;. Every provider family resolves to it, each with its own reason naming the actual mechanism it would need — the &lt;code&gt;DOCKER-USER&lt;/code&gt; chain for one runtime, the routing mesh for another, the CNI for the cluster case.&lt;/p&gt;

&lt;p&gt;The step that used to report success now records &lt;strong&gt;Skipped&lt;/strong&gt;, with the reason attached. The pipeline's test expectation changed from "20 steps succeeded" to "18 succeeded, 2 skipped." That diff — a green number getting smaller — is the actual deliverable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two steps stopped claiming work they had not done. That is a system getting more correct while its dashboard gets worse-looking, which is the trade you want and the one that's hardest to sell.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What I deliberately didn't ship
&lt;/h3&gt;

&lt;p&gt;No real iptables driver. Writing &lt;code&gt;DOCKER-USER&lt;/code&gt; rules against a host you can't verify against is how somebody locks themselves out of their own machine over SSH, and I'd rather ship a driver that says "I cannot" than one that half-applies.&lt;/p&gt;

&lt;p&gt;A firewall that half-applies is worse than one that says it can't. Same principle as the no-op, one step further along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the exposure where the exposure is
&lt;/h2&gt;

&lt;p&gt;Here's the part that reframed the whole issue for me.&lt;/p&gt;

&lt;p&gt;The actual security problem wasn't the missing firewall driver. It's a well-documented Docker behaviour: published ports install DNAT and FORWARD rules that iptables evaluates &lt;strong&gt;before&lt;/strong&gt; ufw's INPUT chain. So a node with a perfectly correct deny-inbound baseline still answers on every published port. &lt;code&gt;ufw status&lt;/code&gt; says closed. &lt;code&gt;nmap&lt;/code&gt; says otherwise. Both are telling the truth about different chains.&lt;/p&gt;

&lt;p&gt;The instinct is to reach for a wider firewall baseline. Wrong instinct — the baseline is correct for what it covers. The fix is that &lt;strong&gt;a published port should bind an address&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="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;PortMapping&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;LOCAL_BIND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&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;PUBLIC_BIND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'0.0.0.0'&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;bindAddress&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;public&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;PUBLIC_BIND&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;LOCAL_BIND&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/** The value for `docker run -p`. Never a bare `host:container`. */&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;publishArg&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bindAddress&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;&lt;span class="mf"&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;publish&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;&lt;span class="mf"&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;container&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;A port bound to loopback is invisible from outside the host &lt;strong&gt;whatever any firewall does or fails to do&lt;/strong&gt;. Containers still reach each other by network alias. Anything that must be public goes through the reverse proxy, which is the one deliberate exception and writes &lt;code&gt;0.0.0.0&lt;/code&gt; literally rather than inheriting it — it's the front door, and port 80 has to keep answering or ACME renewals fail.&lt;/p&gt;

&lt;p&gt;Three design decisions in that small class worth stealing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed on garbage.&lt;/strong&gt; An unrecognised exposure value binds loopback. Not an exception, not a default-to-public — loopback. A typo in a config file must not open a port to the internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the limitation into the code, not the wiki.&lt;/strong&gt; One orchestrator genuinely can't do this: its routing mesh publishes on every node and offers no bind address. &lt;code&gt;mode=host&lt;/code&gt; narrows it to nodes running a task and no further. So a published port there is world-facing and the cluster firewall is the only control — and that sentence lives in a comment next to the code that can't fix it, rather than in a doc someone will find after the incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify the guard fires.&lt;/strong&gt; The regression test fails on any &lt;code&gt;-p&lt;/code&gt; argument that goes back to a bare &lt;code&gt;host:container&lt;/code&gt;. I verified it by putting the old code back and watching it fail, because a regression guard nobody has seen fire is indistinguishable from one that cannot:&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;'never publishes a port without a bind address'&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;$args&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;ContainerRuntime&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;runArgumentsFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&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;portArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&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;$arg&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="nv"&gt;$arg&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;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^(127\.0\.0\.1|0\.0\.0\.0):\d+:\d+$/'&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;h2&gt;
  
  
  "I can't" as an exception type
&lt;/h2&gt;

&lt;p&gt;Separate thread, same idea. I added a native runtime — deploying a workload straight onto a VM with systemd, no containers. Air-gapped and hardened estates need it, and containers aren't always on the table.&lt;/p&gt;

&lt;p&gt;That runtime has to satisfy the same contract as the container runtimes, and some of that contract it genuinely cannot honour. &lt;code&gt;scale()&lt;/code&gt; on a single VM has no honest implementation.&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;UnsupportedByRuntime&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&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;capability&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;$runtime&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;$capability&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;$reason&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&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;$runtime&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; cannot &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$capability&lt;/span&gt;&lt;span class="si"&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;$reason&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&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;The alternative — returning quietly — would let an operator believe a workload had been scaled. The exception isn't a failure mode, it's the &lt;strong&gt;honest answer to a question that has no other answer&lt;/strong&gt;. A named exception type also makes it greppable: "what can't this runtime do?" becomes a search rather than an archaeology exercise.&lt;/p&gt;

&lt;p&gt;Note it's a different tool from &lt;code&gt;enforces()&lt;/code&gt;. Use a &lt;strong&gt;query method&lt;/strong&gt; when the caller can reasonably route around the gap (the pipeline can skip a step and record why). Use an &lt;strong&gt;exception&lt;/strong&gt; when there is no sensible alternative path and continuing would be a lie. Getting that split right is most of the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capability is per-provider, not per-type
&lt;/h2&gt;

&lt;p&gt;The bug that made me split the axes properly:&lt;/p&gt;

&lt;p&gt;Only one provider type was marked "ready", even though every adopted-VM type — cloud droplet, on-prem box, whatever — resolves to the same real SSH driver, the same agent, the same runtime once it has credentials. So an operator with a cloud VM had to register it as "Bare Metal". A lie the form tells once and a support ticket repeats forever.&lt;/p&gt;

&lt;p&gt;Two things came out of fixing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One predicate, two lists.&lt;/strong&gt; The provider form had a hand-written literal list of ready types, and a "coming soon" list computed from &lt;code&gt;isReady()&lt;/code&gt;. So the moment a type became ready it vanished from &lt;em&gt;both&lt;/em&gt; — excluded from one, filtered out of the other. Another type had been sitting in that hole for a while: a real driver that had never once appeared on the form.&lt;/p&gt;

&lt;p&gt;Both lists now derive from the same predicate, and there's a test asserting they &lt;strong&gt;partition the enum&lt;/strong&gt;. If two collections in your code have to agree, the fix is never "remember to update both."&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;'partitions every provider type into ready or coming soon'&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;$ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InfraProviderType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;readyTypes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$soon&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InfraProviderType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;comingSoonTypes&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;$ready&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$soon&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;sort&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;values&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;toEqual&lt;/span&gt;&lt;span class="p"&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;InfraProviderType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;cases&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;sort&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;values&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;$ready&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;$soon&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;&lt;strong&gt;Two axes, not one.&lt;/strong&gt; &lt;em&gt;Where the machine came from&lt;/em&gt; and &lt;em&gt;how workloads run on it&lt;/em&gt; are independent. A bare-metal box can host containers; a hardened one cannot. So the container/native choice moved onto the per-provider capability set, not the type enum:&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;/**
 * Whether this provider deploys workloads as containers.
 *
 * Per provider rather than per type on purpose: a bare-metal box can host
 * containers and a hardened one cannot, so the type is the wrong axis.
 */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$containers&lt;/span&gt; &lt;span class="o"&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;Defaulting to &lt;code&gt;true&lt;/code&gt; keeps every provider that predates the key behaving exactly as it did. New capability flags should default to the old behaviour — otherwise adding a flag is a silent migration.&lt;/p&gt;

&lt;p&gt;And "ready" got a definition worth writing down: &lt;strong&gt;deployable, not complete&lt;/strong&gt;. A type is ready if a real driver, agent and runtime back it — while its load balancer and some provisioners are still simulated and still &lt;em&gt;declared&lt;/em&gt; as such. The UI renders that as a per-capability list rather than a single badge, because collapsing it to one boolean is exactly what overclaims.&lt;/p&gt;

&lt;p&gt;One more: the checklist where you tick which components a provider supports grew a warning when the provider has no provisioners at all. Ticking a box there &lt;strong&gt;restricts&lt;/strong&gt; what the engine may schedule; it doesn't install anything. A restriction reading as a promise is the same class of bug as everything else on this page.&lt;/p&gt;

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

&lt;p&gt;Every one of these is the same rule wearing a different hat:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doing nothing must be distinguishable from doing the thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At the method level, that's an exception type. At the contract level, it's a capability query. At the pipeline level, it's a &lt;em&gt;Skipped&lt;/em&gt; status carrying a reason. At the UI level, it's a per-capability list instead of a green badge.&lt;/p&gt;

&lt;p&gt;None of it is clever. All of it is the difference between a system you can reason about and one that is confidently wrong in a direction you'll discover from a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The rules table is now the desired state a future enforcer reads, which means the enforcer is a well-defined piece of work rather than an open question — with a contract that already tells it what it must never do. When I do write the real driver, the first test is that it can't be mistaken for the unenforced one.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Dev Log: 2026-08-08 — stubs that answer politely, and a CSS property that doesn't exist</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sun, 09 Aug 2026 02:18:52 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-08-08-stubs-that-answer-politely-and-a-css-property-that-doesnt-exist-4ig2</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-08-08-stubs-that-answer-politely-and-a-css-property-that-doesnt-exist-4ig2</guid>
      <description>&lt;p&gt;Heavy day, mostly on one private deployment platform, plus a small but instructive fix in a PDF book engine and a pass over a product marketing site.&lt;/p&gt;

&lt;p&gt;The through-line, and I didn't see it until I read the day back: &lt;strong&gt;almost everything was a thing that answered without being able to answer.&lt;/strong&gt; A stub that says yes. A column with no producer. A CSS rule the renderer doesn't implement.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The platform: making the fakes declare themselves
&lt;/h2&gt;

&lt;p&gt;Fourteen commits, and they're variations on one theme.&lt;/p&gt;

&lt;p&gt;An approval gate that approved everything. A rollback engine with a contract and a fake whose step classes didn't exist — so a previous piece of work read the whole seam as scaffolding and routed around it. A complete access-review model with zero callers, meaning the compliance control it was written for couldn't be evidenced at all. A public-IP column with a migration, a UI row, and no driver anywhere that ever set it.&lt;/p&gt;

&lt;p&gt;The fix that generalises: make &lt;strong&gt;"which parts of this are real?"&lt;/strong&gt; an enumerable question. One enum case per resolvable capability — driver, agent, reverse proxy, load balancer, runtime — so a deliberate fake and an accidental fake stop looking identical in the code.&lt;/p&gt;

&lt;p&gt;Two rules I'll be reusing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write compliance evidence outside the transaction it describes.&lt;/strong&gt; Inside, it rolls back together with the thing it was recording — you lose the record exactly when you need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Null is unknown, not zero.&lt;/strong&gt; An unreachable provider is not a cluster that shrank to nothing. Keep the last known value and say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also landed: a bootstrap pipeline that handles both Debian and RHEL families from one path. The trick there was giving up on package &lt;em&gt;lists&lt;/em&gt; — &lt;code&gt;dnsutils&lt;/code&gt; is &lt;code&gt;bind-utils&lt;/code&gt;, &lt;code&gt;gnupg&lt;/code&gt; is &lt;code&gt;gnupg2&lt;/code&gt;, &lt;code&gt;iproute2&lt;/code&gt; is &lt;code&gt;iproute&lt;/code&gt;. A template naming packages is a template that works on exactly one family. So the baseline is stated as &lt;strong&gt;capabilities&lt;/strong&gt; ("DNS tools", "time sync", "firewall") and an OS profile turns each one into the right names for the machine in front of it. Steps stop branching on the OS entirely, which is what makes the difference between the two families assertable in a test instead of only on a real box.&lt;/p&gt;

&lt;p&gt;Written up properly in a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The book engine: I explained a bug with a property that doesn't exist
&lt;/h2&gt;

&lt;p&gt;My favourite kind of commit — the one that deletes yesterday's fix.&lt;/p&gt;

&lt;p&gt;I had a pagination problem in a print stylesheet: headings getting stranded at the bottom of a page with their content pushed to the next. The fix I'd reasoned out relied on &lt;code&gt;break-after: avoid&lt;/code&gt; being transitive — heading welded to intro, intro welded to whatever follows.&lt;/p&gt;

&lt;p&gt;It isn't transitive. It doesn't exist. The rendering engine honours &lt;code&gt;break-inside&lt;/code&gt; only and ignores &lt;code&gt;break-before&lt;/code&gt;/&lt;code&gt;break-after: avoid&lt;/code&gt; along with the legacy &lt;code&gt;page-break-*&lt;/code&gt; aliases entirely.&lt;/p&gt;

&lt;p&gt;So every weld I'd added was inert, and the chain-cutter I'd introduced to stop the welds running away cut nothing. The bug was real; the mechanism I'd explained it with was fiction.&lt;/p&gt;

&lt;p&gt;Two things came out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The dead rules stay in the sheet — they're correct CSS and cost nothing if the print path ever moves to an engine that implements them — but they're now documented as dead, in a comment at the head of their section. Specifically so the &lt;em&gt;next&lt;/em&gt; pagination bug isn't diagnosed in their terms again.&lt;/li&gt;
&lt;li&gt;The actual fix uses the mechanism that does work: wrap the pair that must move together in one box with &lt;code&gt;break-inside: avoid&lt;/code&gt;, and tag oversized code panels so they're allowed to break rather than being forced onto a fresh page and stranding the heading above them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson, and it's not really about CSS: &lt;strong&gt;before you explain a bug in terms of a mechanism, check that the mechanism is implemented.&lt;/strong&gt; A plausible explanation that produces a plausible fix and no observable change is far more expensive than no explanation, because it stops you looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Marketing site: every claim has to be checkable in the codebase
&lt;/h2&gt;

&lt;p&gt;Smallest diff, most discipline. A pass over a product site to align what it says with what actually ships — a provider list corrected to what's implemented, deployment modes folded down to the ones that exist, spec tables replacing prose.&lt;/p&gt;

&lt;p&gt;Not a code lesson. Still a real one: the marketing page is documentation with a shorter feedback loop and a worse failure mode. If a claim on it can't be traced to something in the repo, it's a bug — just one that gets reported by a customer instead of CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The capability enum only covers the resolver's own methods right now. The obvious next step is rendering it as a per-provider readiness table in the UI, so "this provider is 60% real" is something an operator sees before they deploy onto it, not after.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Which parts of this are real? Should be a question with an answer</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sun, 09 Aug 2026 01:37:12 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/which-parts-of-this-are-real-should-be-a-question-with-an-answer-2a2j</link>
      <guid>https://dev.to/nasrulhazim/which-parts-of-this-are-real-should-be-a-question-with-an-answer-2a2j</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The most expensive bug I hit yesterday wasn't a crash. It was a stub that answered politely.&lt;/li&gt;
&lt;li&gt;Four shapes of the same problem: a column nobody writes to, a model nobody calls, an engine backed by a fake, and a resolver that falls back to a null implementation &lt;em&gt;silently&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;The fix isn't "delete the fakes". Fakes are how you ship in slices. The fix is making &lt;strong&gt;which parts are real&lt;/strong&gt; an enumerable question instead of a code-reading exercise.&lt;/li&gt;
&lt;li&gt;Corollary that keeps paying off: &lt;strong&gt;null means unknown, not zero.&lt;/strong&gt; An unreachable provider is not a cluster that shrank to nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yesterday was a long day on a private deployment platform, and by the end of it I noticed every commit was the same commit wearing a different hat.&lt;/p&gt;

&lt;p&gt;Nothing had thrown an exception. Nothing had failed a test. The system had simply been answering questions it had no business answering, confidently, for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 1: the column with no producer
&lt;/h2&gt;

&lt;p&gt;There was a &lt;code&gt;ip_public&lt;/code&gt; column on a node record. It had a migration, it had a row in the UI panel, and something downstream preferred it over the internal IP when present.&lt;/p&gt;

&lt;p&gt;It was always null. Not sometimes — always. No driver ever set it, and the status object the drivers return didn't even have a field for it, so the three places that re-read a node couldn't have refreshed it if a driver had.&lt;/p&gt;

&lt;p&gt;The downstream branch that preferred it had never once executed. It looked like a feature. It was a comment.&lt;/p&gt;

&lt;p&gt;Here's the thing about a column with no producer: it doesn't fail loudly, it fails as &lt;em&gt;absence&lt;/em&gt;. Every reader takes the null branch, every reader is correct to, and the whole path stays green.&lt;/p&gt;

&lt;p&gt;The interesting part came when I went to fix it, because it turned out different providers can't honestly answer the same question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One scheduler backend can report the cluster node's external address — or its internal one, since a single-node dev cluster has no external address and still serves traffic there.&lt;/li&gt;
&lt;li&gt;A hypervisor driver has to sort the guest agent's addresses by scope. Taking "the first IPv4" is how you end up publishing a Docker bridge address.&lt;/li&gt;
&lt;li&gt;The container drivers report &lt;strong&gt;null on purpose&lt;/strong&gt;. A container's published port answers on the daemon host, and that's provider credentials, not node state. Copying it onto every node row goes stale the moment someone edits that credential.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the fix wasn't "make every driver fill in the field". It was deciding, per driver, what the field &lt;em&gt;means&lt;/em&gt; — and then pinning the deliberate nulls in tests so a future contributor doesn't read them as a gap and helpfully fill them in.&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;'reports no public ip for container-backed nodes'&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;$status&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;ContainerDriver&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;getNodeStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'node-ref'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Deliberate: the published port answers on the daemon host, which is&lt;/span&gt;
    &lt;span class="c1"&gt;// provider-level config, not node state. Do not "fix" this to non-null.&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;$status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;publicIp&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;toBeNull&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;A test whose whole job is to say &lt;em&gt;this null is on purpose&lt;/em&gt; is one of the highest-value tests you can write. It converts an absence into a decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 2: the model nobody calls
&lt;/h2&gt;

&lt;p&gt;There was a complete access-review model. Enums, migration, &lt;code&gt;markInProgress()&lt;/code&gt;, &lt;code&gt;complete()&lt;/code&gt;. Zero references anywhere in the application.&lt;/p&gt;

&lt;p&gt;The compliance control it was written for — periodic access review — couldn't be evidenced at all, because there was no way to actually run one.&lt;/p&gt;

&lt;p&gt;The load-bearing part turned out to be completion, and it's worth stating plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A review that records "revoke" and leaves the access in place is a &lt;strong&gt;permanently green control&lt;/strong&gt;. That's worse than an absent one, because it produces evidence certifying nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So completion does the revocation and the record in one transaction — deactivate the membership, clear the roles — and writes the evidence &lt;em&gt;outside&lt;/em&gt; it. That second half is the part people get wrong, and I've now been bitten by it twice:&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="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;applyDecisions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// actually revokes&lt;/span&gt;
    &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;markCompleted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Outside. Evidence written inside the transaction it describes gets rolled&lt;/span&gt;
&lt;span class="c1"&gt;// back together with it — you lose the record of the failure precisely when&lt;/span&gt;
&lt;span class="c1"&gt;// you need it.&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;evidence&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'access-review.completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more rules fell out of it, both the "obvious in hindsight" kind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Completion refuses while any member is undecided.&lt;/strong&gt; A review signed off with blanks certifies people nobody looked at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opening is idempotent per scope.&lt;/strong&gt; Two open sessions mean two reviewers working from different membership lists, and whoever finishes last silently overwrites the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the membership list is snapshotted when the review opens, not recomputed on render. A review of "who has access right now" is not a review, it's a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 3: the engine backed by a fake
&lt;/h2&gt;

&lt;p&gt;The rollback mechanism had a contract, a plan/step schema, and a fake implementation whose step-class strings named handlers that didn't exist.&lt;/p&gt;

&lt;p&gt;This one has a nasty second-order effect. A previous piece of work read the whole mechanism as scaffolding and &lt;strong&gt;routed around it&lt;/strong&gt; — wrote its own path. So now you have two rollback stories, one of which is decorative, and the decorative one is the one with the nice contract.&lt;/p&gt;

&lt;p&gt;That's the real cost of a convincing fake: it doesn't just fail to work, it teaches the next engineer that this seam isn't load-bearing.&lt;/p&gt;

&lt;p&gt;Making it real meant four idempotent step classes, resumable from the first step that isn't already successful, and one seam — a single releaser class — where a previously-built image goes back onto a workload. Both callers now go through it, which means there's exactly one file to change when this needs to become zero-downtime.&lt;/p&gt;

&lt;p&gt;Two things the tests caught that I'd have shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repeated rollbacks walked &lt;em&gt;forward&lt;/em&gt; through the release history instead of back. Rolling back twice is not "go to the previous release" twice unless you're tracking where you already are.&lt;/li&gt;
&lt;li&gt;A workload-scoped rollback whose health check fails should escalate to a full rollback — &lt;strong&gt;unless&lt;/strong&gt; the runtime refused the image outright. That's a broken artifact, not evidence the infrastructure is at fault. Escalating there just widens the blast radius of a bad build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same category as the promotion engine, which was also a fake: it approved everything. An approval gate that cannot say no is not a gate, it's a log line. Replacing it meant a real exception type carrying &lt;em&gt;which&lt;/em&gt; checks failed and why, so the UI can say something an operator can act on instead of "promotion failed":&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;PromotionBlockedException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @param  array&amp;lt;int, PromotionCheck&amp;gt;  $failed */&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;__construct&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;readonly&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;__construct&lt;/span&gt;&lt;span class="p"&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;'Promotion blocked by :gates.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'gates'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;implode&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="nb"&gt;array_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;PromotionCheck&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="nv"&gt;$failed&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A distinct exception type rather than a bare &lt;code&gt;RuntimeException&lt;/code&gt;, because the caller has something useful to say about it. If your error only carries a message, every handler upstream is reduced to string-matching or shrugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 4: the silent fallback
&lt;/h2&gt;

&lt;p&gt;This is the worst one, and it's the one that generalises furthest beyond infrastructure.&lt;/p&gt;

&lt;p&gt;A resolver picked a driver per provider. When it couldn't find what it needed, it fell back to a no-op implementation. Quietly. So a provider configured with credentials that could never work reported "connection fine" — because the thing answering was the fake, and the fake always says yes.&lt;/p&gt;

&lt;p&gt;The same fallback meant one deployment reported a load balancer it had never created.&lt;/p&gt;

&lt;p&gt;The fix that actually stuck wasn't better error messages. It was making the question enumerable:&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;DriverCapability&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;Driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'driver'&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;Agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'agent'&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;ReverseProxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'reverse_proxy'&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;LoadBalancer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'load_balancer'&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;Runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'runtime'&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;description&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;Driver&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;'Creates, destroys, starts and stops nodes.'&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;Agent&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;'Runs commands and reports metrics on a node.'&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;One case per resolver method. Now "which parts of this provider are real?" has an answer you can render in a table, assert in a test, and show an operator — instead of an answer you get by reading every &lt;code&gt;match&lt;/code&gt; arm by hand.&lt;/p&gt;

&lt;p&gt;A deliberate fake and an accidental fake look identical in the code. They only become distinguishable when something &lt;em&gt;declares&lt;/em&gt; which is which.&lt;/p&gt;

&lt;p&gt;And for the probes themselves: stop asking the abstraction whether it works, and ask the underlying thing. A "test connection" that goes through the resolver tests the resolver. A probe that opens a real SSH session and reads &lt;code&gt;/etc/os-release&lt;/code&gt; proves the login &lt;em&gt;and&lt;/em&gt; answers a question you needed anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit problem, briefly
&lt;/h2&gt;

&lt;p&gt;One more from the same day, different flavour. Every driver implements &lt;code&gt;provisionNode()&lt;/code&gt;. On one it creates a VM. On another, a container. On another, a single-replica scheduled workload.&lt;/p&gt;

&lt;p&gt;The interface is honest — all of them provision &lt;em&gt;a node&lt;/em&gt;. The word isn't. Counting node rows uniformly bills fifty containers on one host exactly like fifty machines across fifty hosts.&lt;/p&gt;

&lt;p&gt;So the difference got named:&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;NodeBillingModel&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="cd"&gt;/** We create the machine — each node row is a host. */&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;PerProvisionedNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'per_provisioned_node'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** We schedule onto machines the customer already runs. */&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;PerClusterHost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'per_cluster_host'&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 discriminator I landed on: &lt;strong&gt;does the thing have its own OS kernel?&lt;/strong&gt; It sorts every provider type cleanly, and — the part that matters for anything commercial — it's a line a procurement officer can verify without reading the code.&lt;/p&gt;

&lt;p&gt;Then one class owns the count. Licence checks, quotas, invoice lines, the admin UI all come through it. A second implementation anywhere is how the dashboard and the invoice come to disagree in front of a customer.&lt;/p&gt;

&lt;p&gt;And the rule I keep coming back to:&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;// An outage is not a cluster that shrank to zero. Keep the last known value.&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;$count&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unknown and zero are different answers. Conflating them is how a monitoring system reports perfect health five minutes after the region went down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;p&gt;If you're carrying a system with staged, partially-built seams — and you are, everyone is — three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every stub declares itself.&lt;/strong&gt; Not in a comment. In something you can enumerate, list, and assert on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliberate nulls get a test that says so.&lt;/strong&gt; Otherwise your next contributor "fixes" them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probe the real thing, not your own abstraction over it.&lt;/strong&gt; Your abstraction was written by someone who assumed it worked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It's just that "does this work?" and "does this answer?" are different questions, and only one of them has been tested.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Dev Log: 2026-08-07 — scrubbing secrets, reconciling drift, and extracting a book engine</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:46:10 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-08-07-scrubbing-secrets-reconciling-drift-and-extracting-a-book-engine-11g1</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-08-07-scrubbing-secrets-reconciling-drift-and-extracting-a-book-engine-11g1</guid>
      <description>&lt;p&gt;A heavy day across six repos. Rather than list commits, here's the through-line: almost everything was about &lt;strong&gt;making an implicit thing explicit&lt;/strong&gt; — an implicit redaction rule, an implicit assumption that the database matches reality, an implicit colour palette baked into a rendering engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Secret scrubbing, as a package
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cleaniquecoders/pii-protection&lt;/code&gt; picked up three pieces: &lt;code&gt;SecretScrubber&lt;/code&gt; (pattern-based — PEM blocks, JWTs, URL credentials, AWS key IDs, &lt;code&gt;KEY=value&lt;/code&gt; assignments), &lt;code&gt;LiteralScrubber&lt;/code&gt; (mask exact values the caller already holds), and &lt;code&gt;RedactedStrategy&lt;/code&gt; (fixed placeholder, no length preserved).&lt;/p&gt;

&lt;p&gt;The design decision I keep coming back to: &lt;strong&gt;secrets and PII are separate passes on purpose&lt;/strong&gt;. Point a PII scrubber at an infra log and it deletes the IP addresses you need to debug, while leaving the connection string untouched.&lt;/p&gt;

&lt;p&gt;Also: don't mask credentials with a length-preserving strategy. &lt;code&gt;*******&lt;/code&gt; tells an attacker the value was seven characters.&lt;/p&gt;

&lt;p&gt;Written up properly in a separate post.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/cleaniquecoders/pii-protection" rel="noopener noreferrer"&gt;cleaniquecoders/pii-protection&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Drift reconciliation and a scaling engine
&lt;/h2&gt;

&lt;p&gt;On a private deployment platform: a scheduled drift reconciler and a real scaling engine. Both landed as "observe → compare to intent → decide → act", behind contracts, driven by scheduled commands.&lt;/p&gt;

&lt;p&gt;The rule that made it worth building: &lt;strong&gt;actual state is read from the provider, not from our own tables&lt;/strong&gt;. A row saying "running" beside a VM that no longer exists is precisely the drift you're looking for — counting rows reports a healthy deployment right until someone tries to use it.&lt;/p&gt;

&lt;p&gt;Second lesson, learned the annoying way: no baseline means &lt;em&gt;unknown&lt;/em&gt;, not &lt;em&gt;drifted&lt;/em&gt;. The first version flagged every layer of every pre-existing deployment.&lt;/p&gt;

&lt;p&gt;Also separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A hand-rolled redactor, deleted
&lt;/h2&gt;

&lt;p&gt;Small commit, disproportionate satisfaction. The same platform had a bespoke &lt;code&gt;LogRedactor&lt;/code&gt; — three regexes, no tests, written inside a feature ticket because nobody budgets a week for a redactor. It now delegates to the package above.&lt;/p&gt;

&lt;p&gt;That's the whole case for extracting things: the standalone version gets the tests and the edge cases, because it's the only thing in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Email automations get an identity
&lt;/h2&gt;

&lt;p&gt;On a private CRM: sequences gained a sender identity, a preview, a test send, goals, and analytics.&lt;/p&gt;

&lt;p&gt;The interesting bit is &lt;strong&gt;goals&lt;/strong&gt;. A sequence without a goal can only report "did it send". With a goal — replied, booked, purchased — it can report whether it &lt;em&gt;worked&lt;/em&gt;, and stop emailing someone who already converted. That's one enum and one table, and it changes what the whole feature is for.&lt;/p&gt;

&lt;p&gt;Worth noting for anyone building sequences: "preview" and "test send" are different features. Preview renders the merge fields against a sample contact; test send proves your sender identity, SPF and DKIM actually work. Shipping only the first is how you discover deliverability problems in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Extracting a book engine from the workspace it grew in
&lt;/h2&gt;

&lt;p&gt;A private tooling project got the treatment every internal tool eventually needs: pull the engine out of the workspace it grew inside, and make everything it silently assumed into configuration.&lt;/p&gt;

&lt;p&gt;The specific change I liked: the colour palette moved out of the rendering code and into the book's own config, alongside a token table applied across four output surfaces. Before, a "theme" was a patch. After, it's a file — and the proof it's a real seam is that two themes exist and look genuinely different.&lt;/p&gt;

&lt;p&gt;Also a sanitisation pass to strip workspace-specific paths out of the engine. If your tool only works from one folder on one machine, it isn't a tool yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Public sites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The company site: dependency upgrade clearing all outstanding advisories, plus a correction to the business address and the map coordinates. Boring, necessary — a wrong map pin is a support ticket generator.&lt;/li&gt;
&lt;li&gt;A new public site for a community summit went up. First commit, plain and static.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Four of six items were the same move: something that was working fine as an assumption became a named, tested, configurable thing. A redaction rule became a package. "The DB is right" became a provider probe. A palette became a config file. A sequence's success became an enum.&lt;/p&gt;

&lt;p&gt;None of them added a feature users would name. All of them made the next feature cheaper.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Drift detection and autoscaling are the same shape: ask the provider, then decide</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:45:20 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/drift-detection-and-autoscaling-are-the-same-shape-ask-the-provider-then-decide-473o</link>
      <guid>https://dev.to/nasrulhazim/drift-detection-and-autoscaling-are-the-same-shape-ask-the-provider-then-decide-473o</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Drift detection and autoscaling look like different features. They're the same loop: &lt;strong&gt;observe → compare to intent → decide → act&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The single most important rule: &lt;strong&gt;read actual state from the provider, not from your own tables.&lt;/strong&gt; Your table saying "running" next to a VM that no longer exists &lt;em&gt;is&lt;/em&gt; the drift.&lt;/li&gt;
&lt;li&gt;Both belong behind a &lt;strong&gt;contract&lt;/strong&gt;, driven by a &lt;strong&gt;scheduled command&lt;/strong&gt;, so the decision logic is testable without touching real infrastructure.&lt;/li&gt;
&lt;li&gt;A fake context object beats mocking the world.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yesterday I built two things on a deployment platform: a drift reconciler and a scaling engine. I expected two unrelated features. Halfway through the second one I realised I was writing the first one again with different nouns.&lt;/p&gt;

&lt;p&gt;Sharing the shape, because it generalises well beyond infrastructure — anything with "what I intended" and "what is actually true" has this problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observe actual state  →  compare to desired state  →  produce a decision  →  act (or just record)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For drift: desired = the recorded blueprint snapshot, actual = what the provider reports, decision = "this layer has 2 nodes and should have 3".&lt;/p&gt;

&lt;p&gt;For scaling: desired = the policy thresholds, actual = live utilisation from the nodes, decision = "scale up by 1".&lt;/p&gt;

&lt;p&gt;Same skeleton. Which means the same contracts, the same scheduling, and — the part people skip — the same testing strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: actual state comes from the provider
&lt;/h2&gt;

&lt;p&gt;This is the one that decides whether the feature is worth building at all.&lt;/p&gt;

&lt;p&gt;The lazy implementation counts your own 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;// Wrong. This can never detect the failure you care about.&lt;/span&gt;
&lt;span class="nv"&gt;$actual&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;nodes&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="s1"&gt;'running'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A row saying &lt;code&gt;running&lt;/code&gt; beside a VM that somebody deleted by hand at 2am is &lt;em&gt;exactly&lt;/em&gt; the drift you're hunting. Counting rows reports a perfectly healthy deployment right up until someone tries to use it.&lt;/p&gt;

&lt;p&gt;So the reconciler asks the provider:&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;DriftReconcilerContract&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;recordDesiredState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Deployment&lt;/span&gt; &lt;span class="nv"&gt;$deployment&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;detectDrift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Deployment&lt;/span&gt; &lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;DriftReport&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;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Deployment&lt;/span&gt; &lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;DriftReport&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 &lt;code&gt;detectDrift()&lt;/code&gt; goes out to the provider driver for each node's real status. Slower, chattier, occasionally times out. Also the only version that's true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generalise the lesson:&lt;/strong&gt; if your "verification" reads the same table your "write" wrote, you haven't verified anything. You've asserted your own database is consistent with itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: no baseline means no drift
&lt;/h2&gt;

&lt;p&gt;The first bug I wrote: every layer of every pre-existing deployment reported as drifted, because nothing had ever recorded a desired state for 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="nv"&gt;$desired&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;latestDesiredState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$layer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Nothing was ever recorded for this layer, so there is no baseline to&lt;/span&gt;
&lt;span class="c1"&gt;// diff against. Reporting drift here would flag every layer of every&lt;/span&gt;
&lt;span class="c1"&gt;// deployment provisioned before this ran.&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;$desired&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;continue&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;A comparison with a missing side isn't a difference — it's an unknown. Alerting on unknowns is how you train a team to ignore alerts in week two.&lt;/p&gt;

&lt;p&gt;Related: write the baseline to a &lt;strong&gt;table you actually read&lt;/strong&gt;. Mine was being snapshotted into a &lt;code&gt;metadata&lt;/code&gt; JSON column that nothing queried, so the reconciler had to record it properly itself. Worth checking whether your "we already store that" is stored somewhere anyone reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: pick a unit and make everything agree
&lt;/h2&gt;

&lt;p&gt;The scaling engine works in &lt;strong&gt;utilisation percentages&lt;/strong&gt;, not absolute cores and gigabytes. Not because percentages are nicer — because the node agent already reports fractions, and converting to absolutes needs per-node capacity that nothing reliably records yet.&lt;/p&gt;

&lt;p&gt;Same for replicas. A layer's replica count &lt;em&gt;is&lt;/em&gt; its node count — the same definition the provisioning pipeline uses when it honours &lt;code&gt;min_replicas&lt;/code&gt;. If scaling and bootstrap disagreed about what a replica is, they'd fight each other forever, quietly, on a schedule.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pick the definition your &lt;em&gt;cheapest reliable source&lt;/em&gt; can produce, and make every component use that one. A more "correct" unit that only two of five components can compute is worse than a rough unit everybody shares.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Absolute consumption is still needed — for cost and quotas — so it lives in a separate resource tracker. Two concerns, two units, no fudging one into the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: the schedule is the trigger, not the logic
&lt;/h2&gt;

&lt;p&gt;Both features ship as an invokable service behind a contract, plus a thin console command:&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;// routes/console.php&lt;/span&gt;
&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'drift:detect'&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;everyFifteenMinutes&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;withoutOverlapping&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'scale:evaluate'&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;everyFiveMinutes&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;withoutOverlapping&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command does argument parsing, iteration and output. The engine does the deciding. That split is what lets you run the whole decision path in a test in milliseconds, and it's why &lt;code&gt;--dry-run&lt;/code&gt; is three lines rather than a parallel code path.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;withoutOverlapping()&lt;/code&gt; is not optional here. A scaling evaluation that starts before the previous one's provisioning finishes will see the old node count and scale up again. Twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: fake the context, don't mock the world
&lt;/h2&gt;

&lt;p&gt;The genuinely useful trick. Both engines take a &lt;strong&gt;context&lt;/strong&gt; object — the thing that knows how to reach this deployment's provider, node agent and DNS. In tests, hand them a fake:&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;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FakeScalingContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'web'&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;'cpu'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.91&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'web'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$decisions&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;ScalingEngineContract&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;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deployment&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;$decisions&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;toHaveCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;$decisions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;action&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;ScalingAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Up&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;$decisions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;delta&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;Http::fake()&lt;/code&gt;. Not five chained mock expectations. One object that answers the questions the engine asks, constructed with the scenario you're testing.&lt;/p&gt;

&lt;p&gt;The difference matters when you're covering the cases that actually bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;utilisation sitting exactly on the threshold&lt;/li&gt;
&lt;li&gt;already at &lt;code&gt;max_replicas&lt;/code&gt; and still hot&lt;/li&gt;
&lt;li&gt;provider throws mid-evaluation&lt;/li&gt;
&lt;li&gt;a node the provider has never heard of&lt;/li&gt;
&lt;li&gt;two layers, one scaling up, one scaling down, same pass&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a constructor argument away with a fake context. With mocks, each is a small research project, so in practice they don't get written.&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 scale beyond max_replicas'&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;$policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScalingPolicy&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;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'max_replicas'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'scale_up_threshold'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluateWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&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;$decision&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;action&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;ScalingAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;None&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;$decision&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reason&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="s1"&gt;'at_max_replicas'&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;Note the decision carries a &lt;strong&gt;reason&lt;/strong&gt;. "Did nothing" and "did nothing because it's capped" are very different answers at 3am, and a nullable reason string costs you nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd watch out for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reconciling automatically is a big step.&lt;/strong&gt; Detect-only first, log the reports, look at a week of them. Auto-correcting drift you don't understand yet means your platform fights your ops team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cooldowns before cleverness.&lt;/strong&gt; A crude "don't act again within N minutes" prevents more incidents than any sophisticated prediction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider calls fail.&lt;/strong&gt; A failed status probe is &lt;em&gt;unknown&lt;/em&gt;, not &lt;em&gt;missing&lt;/em&gt;. Treating a timeout as "node is gone" is how a scheduled job deletes production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every decision should be recorded&lt;/strong&gt;, including the no-ops. The scaling event log is what makes the thresholds tunable later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general pattern, stripped of infrastructure: &lt;strong&gt;record what you intended, observe reality from the authoritative source, diff on a schedule, and record every decision — including the decision to do nothing.&lt;/strong&gt; That's an accounting reconciliation, a config sync, a cache invalidator, an inventory audit. Same shape every time.&lt;/p&gt;

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