<?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>One folder shape for every course: a lifecycle monorepo convention</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:46:35 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/one-folder-shape-for-every-course-a-lifecycle-monorepo-convention-383f</link>
      <guid>https://dev.to/nasrulhazim/one-folder-shape-for-every-course-a-lifecycle-monorepo-convention-383f</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I merged several training courses into one monorepo and gave every course the &lt;strong&gt;same numbered folder shape&lt;/strong&gt;: &lt;code&gt;00-planning → 04-post-training&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;_TEMPLATE/&lt;/code&gt; you copy to start the next one, an &lt;code&gt;_ARCHIVED/&lt;/code&gt; you park old stuff in, and a &lt;code&gt;CLAUDE.md&lt;/code&gt; that makes the convention machine-readable.&lt;/li&gt;
&lt;li&gt;The trick that makes it stick: templates use &lt;code&gt;&amp;lt;angle-bracket-slots&amp;gt;&lt;/code&gt;, and "done" is a &lt;code&gt;grep&lt;/code&gt; that returns nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had course material scattered across repos — planning notes here, marketing copy there, facilitator run-sheets in a third place. Every new course started from a blank page, and I re-invented the folder layout each time. So I collapsed everything into one monorepo with a single rule: &lt;strong&gt;every course has the identical shape.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifecycle, as folders
&lt;/h2&gt;

&lt;p&gt;The insight isn't "use folders" — it's that a course has a &lt;em&gt;lifecycle&lt;/em&gt;, and numbered folders make that lifecycle the primary axis instead of file type. Plan it, sell it, teach it, run it, follow up. Five stages, always in order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What lives here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;00-planning/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prepare&lt;/td&gt;
&lt;td&gt;Syllabus (source of truth), specs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;01-marketing/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sell&lt;/td&gt;
&lt;td&gt;Positioning master + channel assets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;02-content/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Teach&lt;/td&gt;
&lt;td&gt;Modules, one file each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;03-delivery/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run&lt;/td&gt;
&lt;td&gt;Run-sheet, pre-flight checklist, fallback plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;04-post-training/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Follow up&lt;/td&gt;
&lt;td&gt;Feedback form, follow-up email, post-mortem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The numeric prefix isn't decoration. It forces sort order to match the actual workflow, so the folder listing reads like the process itself. Same reason migration files are timestamped — order is information.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;course&amp;gt;/
  00-planning/       -&amp;gt; derives everything below
  01-marketing/
  02-content/
  03-delivery/
  04-post-training/  --. post-mortem findings
                        |
     ^------------------'  feed back into 00..03
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That loop at the end matters: the post-mortem doesn't sit in a graveyard folder. Its findings flow back into the syllabus, the run-sheet, the marketing. The structure runs the same feedback loop it's supposed to teach.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;_TEMPLATE/&lt;/code&gt;: a course starts as a copy
&lt;/h2&gt;

&lt;p&gt;Starting a new course is one line:&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; _TEMPLATE my-new-course
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template is the full lifecycle skeleton with the writing already stubbed. What you must fill is marked as &lt;code&gt;&amp;lt;angle-bracket-slots&amp;gt;&lt;/code&gt; — &lt;code&gt;&amp;lt;course-name&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;promise-one-liner&amp;gt;&lt;/code&gt;, and so on. Everything &lt;em&gt;not&lt;/em&gt; in a slot is standing structure you keep.&lt;/p&gt;

&lt;p&gt;The part I like most is the definition of done. A course is ready when this returns nothing:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.md'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No slot hits = no unfilled blanks. It turns "is this finished?" from a judgment call into a command with an exit code — the same instinct as a failing test. If I were wiring this into CI, that grep becomes the assertion.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;_ARCHIVED/&lt;/code&gt;: park it, don't delete it
&lt;/h2&gt;

&lt;p&gt;Retired formats and old campaigns go into &lt;code&gt;_ARCHIVED/&lt;/code&gt;, in dated subfolders with a note on &lt;em&gt;why&lt;/em&gt; it was retired and what replaced it. One rule keeps it honest: &lt;strong&gt;nothing live may reference anything in there.&lt;/strong&gt; If a live file still links to it, it isn't archived — it's just moved.&lt;/p&gt;

&lt;p&gt;Why keep it at all? Because a retired price, a killed campaign style, or an abandoned format is a &lt;em&gt;recorded decision&lt;/em&gt;. Deleting it loses the "we already tried that, here's why it didn't work." Git history technically has it, but nobody spelunks reflog for a pricing decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;CLAUDE.md&lt;/code&gt;: the convention, machine-readable
&lt;/h2&gt;

&lt;p&gt;The last piece is a &lt;code&gt;CLAUDE.md&lt;/code&gt; at the root that spells out the layout, the naming rules, and the standing decisions. It's written for an AI coding agent, but the real value is broader: it's the one file where the convention lives as text instead of as tribal knowledge in my head. Any collaborator — human or agent — reads one file and knows where things go.&lt;/p&gt;

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

&lt;p&gt;The pattern generalizes past courses. Any repeatable body of work with a lifecycle — client engagements, product launches, incident write-ups — benefits from the same three moves: &lt;strong&gt;one fixed shape, a copy-to-start template with a grep-checkable done state, and a parking lot for retired decisions.&lt;/strong&gt; Consistency you can &lt;code&gt;grep&lt;/code&gt; beats consistency you have to remember.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Stop Screenshotting Architecture Diagrams: Build Them as Single-File HTML</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:24:38 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/stop-screenshotting-architecture-diagrams-build-them-as-single-file-html-50df</link>
      <guid>https://dev.to/nasrulhazim/stop-screenshotting-architecture-diagrams-build-them-as-single-file-html-50df</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Spent today rebuilding two training diagrams — a layered reference architecture and a workflow swimlane — as &lt;strong&gt;single-file HTML&lt;/strong&gt; instead of exported images.&lt;/li&gt;
&lt;li&gt;The trick: &lt;strong&gt;data in a plain JS array, layout in CSS Grid, connectors in an SVG overlay measured at runtime&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Result: diagrams you can step through, click into, and hand over as one file that opens offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why not just export a PNG?
&lt;/h2&gt;

&lt;p&gt;Because a screenshot is stale by the next sprint, and nobody re-opens the drawing tool to fix it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Editable by a dev&lt;/th&gt;
&lt;th&gt;Interactive&lt;/th&gt;
&lt;th&gt;Portable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PNG from a drawing tool&lt;/td&gt;
&lt;td&gt;No (need the source file)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mermaid in a doc&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Needs a renderer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand-built HTML&lt;/td&gt;
&lt;td&gt;Yes (it's a data array)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;One file, opens anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third option costs a few hours once. After that, updating a component is editing one object in an array.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the data from the drawing
&lt;/h2&gt;

&lt;p&gt;This is the whole design. Nothing about position, colour, or DOM lives in the content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIERS&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="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clients&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Clients&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;consumers · admins&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Edge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TLS · load balancing&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COMPONENTS&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Control Plane&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tech&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PHP-FPM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Governance UI and sync orchestration.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pushes approved config downstream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LINKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;consumers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gateway&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="cm"&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;tier&lt;/code&gt; picks the row. &lt;code&gt;x&lt;/code&gt; is a &lt;strong&gt;fraction (0..1) across that row&lt;/strong&gt;, not a pixel. So the layout survives any viewport without me hand-tuning coordinates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layout: Grid for the boxes, SVG on top for the wires
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  +--------------------------------------------------+
  | lane label |  [card 1]   [card 2]   [card 3]      |  &amp;lt;- CSS Grid row
  |------------+-------------------------------------|
  | lane label |       [card 4]      [card 5]         |
  +--------------------------------------------------+
        ^                    ^
   sticky column      SVG overlay (position:absolute; inset:0)
                      draws paths between measured centres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CSS Grid does the honest work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gridTemplateColumns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`186px repeat(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, 178px)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gridTemplateRows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`repeat(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;LANES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, 1fr)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then measure the rendered cards and draw connectors into the overlay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;centre&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&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="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measure &lt;strong&gt;after&lt;/strong&gt; paint (&lt;code&gt;requestAnimationFrame&lt;/code&gt;), and re-measure on resize or when a side drawer opens — otherwise your wires point at where the boxes used to be. That was the one bug that cost me real time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit learners actually respond to
&lt;/h2&gt;

&lt;p&gt;A dot that travels along the flow as you step through it. No library, ~10 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;moveToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;centres&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;centres&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="nx"&gt;dur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;650&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;dur&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&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="c1"&gt;// easeInOutQuad&lt;/span&gt;
    &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="nx"&gt;performance&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's cosmetic, and it's the thing that makes a request's journey &lt;em&gt;land&lt;/em&gt; in a room. Movement encodes causality better than an arrow does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs, honestly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Win&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One file, no build step, opens offline&lt;/td&gt;
&lt;td&gt;You hand-write the layout logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content is a data array anyone can edit&lt;/td&gt;
&lt;td&gt;No auto-layout — you place things&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full control of interaction&lt;/td&gt;
&lt;td&gt;Accessibility is on you (add keyboard nav)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your diagram is throwaway, use Mermaid. If it's going to be taught from repeatedly and evolve with the system, the data-driven HTML pays for itself fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Extracting the shell into a reusable template so a new diagram is just a new data file. That's the real deliverable — not the diagram, the mould it came out of.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>css</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Ebook Reviewer Wanted: Help Me Find What's Gone Stale</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 22 Jul 2026 03:41:53 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/ebook-reviewer-wanted-help-me-find-whats-gone-stale-1dh7</link>
      <guid>https://dev.to/nasrulhazim/ebook-reviewer-wanted-help-me-find-whats-gone-stale-1dh7</guid>
      <description>&lt;p&gt;Technical books have a shelf life that nobody prints on the cover.&lt;/p&gt;

&lt;p&gt;A cookbook from 2018 still works. A history book from 2018 still works. A Laravel book from 2018 will teach you a middleware pattern that was replaced twice, recommend a package whose maintainer archived it, and show you a test suite in a syntax that no longer runs.&lt;/p&gt;

&lt;p&gt;I've written a series of ebooks on PHP, OOP, SOLID, design patterns, Laravel conventions, testing with Pest, application architecture, and AI-assisted development. There are also two aimed at kids learning to code. Every one of them was correct when I wrote it. That is a much weaker claim than "correct now," and I'd rather find the gap myself than have a reader find it for me.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;I'm opening the series to technical reviewers before the final edition ships.&lt;/strong&gt; Applications close Sunday, 26 July.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I can't do this alone
&lt;/h2&gt;

&lt;p&gt;Not because I lack the time. Because I lack the distance.&lt;/p&gt;

&lt;p&gt;When you write a technical book, you build a mental model of the reader and then you write to that model for months. The model is always partly wrong, and you are the last person who can see how. You skip a step because it's obvious to you. You keep an example that made sense when you drafted the chapter and no longer matches the surrounding code. You cite a package because it was the right answer for a project you shipped two years ago, and you never checked whether it's still maintained.&lt;/p&gt;

&lt;p&gt;None of this shows up on a reread. Rereading your own work is mostly pattern-matching against your own memory. You see what you meant, not what you wrote.&lt;/p&gt;

&lt;p&gt;A reviewer who has never seen the manuscript reads what's actually on the page. That's the whole value, and it's not something more effort on my side can substitute for.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the job actually is
&lt;/h2&gt;

&lt;p&gt;You pick &lt;strong&gt;one&lt;/strong&gt; book from the series. You read it properly — not skim it — and you flag what's broken.&lt;/p&gt;

&lt;p&gt;Four things in particular:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that are outdated.&lt;/strong&gt; The framework moved, the syntax changed, there's now a cleaner way to do the same thing. The advice isn't wrong exactly, it's just from an older world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that are wrong.&lt;/strong&gt; The code sample doesn't run. The explanation misstates how something works. This is the highest-value category and the one I most want to hear about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that are abandoned.&lt;/strong&gt; A package I recommended hasn't seen a commit in three years. A tool I told you to install no longer exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that are unclear.&lt;/strong&gt; You read a section twice and still didn't follow it. That's not your failure, it's mine, and I need to know which sections do it.&lt;/p&gt;

&lt;p&gt;You don't need to fix anything. Pointing is enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  The feedback format
&lt;/h2&gt;

&lt;p&gt;This is the part that's worth stealing even if you never apply.&lt;/p&gt;

&lt;p&gt;Most book feedback arrives as prose: &lt;em&gt;"Really enjoyed chapter 4, though I wondered about the routing bit."&lt;/em&gt; That's pleasant and almost impossible to act on. I don't know which page, what specifically, or what you'd rather see.&lt;/p&gt;

&lt;p&gt;So reviewers use four columns. One row per issue.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chapter / Page&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;What's wrong&lt;/th&gt;
&lt;th&gt;What you'd do instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ch. 4, p. 61&lt;/td&gt;
&lt;td&gt;Outdated&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Route::resource&lt;/code&gt; example uses Laravel 8 style&lt;/td&gt;
&lt;td&gt;Show scoped route binding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ch. 7, p. 118&lt;/td&gt;
&lt;td&gt;Broken&lt;/td&gt;
&lt;td&gt;Code sample fails — missing import for &lt;code&gt;expect()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;use function Pest\Laravel\get;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ch. 2, p. 24&lt;/td&gt;
&lt;td&gt;Unclear&lt;/td&gt;
&lt;td&gt;Interface explanation stays abstract throughout&lt;/td&gt;
&lt;td&gt;Replace with a payment gateway example&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things this does that prose doesn't.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;forces specificity&lt;/strong&gt;. You can't write a row without naming a location, so vague impressions get filtered out before they reach me.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;separates severity from opinion&lt;/strong&gt;. "Outdated" and "Broken" are different problems with different urgency. A prose paragraph blends them together and I have to guess which is which.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;converts directly into work&lt;/strong&gt;. A filled-in sheet is already a to-fix list. I sort by status, work top to bottom, and reply to each row. Nothing gets lost in a thread.&lt;/p&gt;

&lt;p&gt;If you ever run a review process — for docs, a spec, a design, a codebase — some version of this table will serve you better than asking for thoughts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who I'm looking for
&lt;/h2&gt;

&lt;p&gt;Anyone who writes code. That's the entire filter.&lt;/p&gt;

&lt;p&gt;Seniority helps for some books and actively hurts for others. If you're three months into PHP and a chapter loses you, that's the single most useful signal I can get — I wrote it for you, and it didn't land. A senior engineer will read straight past the same gap because they can fill it from experience without noticing they're doing it.&lt;/p&gt;

&lt;p&gt;The one thing I do need is bluntness. If a chapter is weak, I need you to write "this chapter is weak" rather than "this chapter has potential." I'm not sentimental about any of it. If a section should be cut entirely, say so and I'll cut it.&lt;/p&gt;

&lt;p&gt;Reviewers who tell me it's all great are, with respect, not useful to me.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The full series.&lt;/strong&gt; Not just the book you review — all of it, free, permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your name in the acknowledgements.&lt;/strong&gt; Printed in every copy of the new edition, under whatever handle you choose.&lt;/p&gt;

&lt;p&gt;Let me be honest about the exchange: reading a technical book carefully takes somewhere between four and eight hours depending on which one you pick. What I think does cover it is early access to the whole series, a permanent credit, and — if you're the kind of person who enjoys this — a few hours of deliberate close reading in your own field, which is not the worst way to spend a weekend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;26 July&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Applications close&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 August&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reviewers selected and books assigned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3 – 24 August&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Review window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Late August&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Final edition ships&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three weeks for the review itself. You can break it up however you like — nobody expects a single sitting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Applying
&lt;/h2&gt;

&lt;p&gt;The form takes about a minute. You'll pick which book you want, tell me roughly where your experience sits, and give me the handle you want in the acknowledgements.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://shore-cloud-15d.notion.site/b7f715b6bb414e4bbec6edc592748b0f?pvs=105" rel="noopener noreferrer"&gt;Apply Now&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Applications close Sunday, 26 July.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzawhwdpwa98fn10y1it0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzawhwdpwa98fn10y1it0.png" alt="100 Tips &amp;amp; Secrets for Building Quality Laravel Applications" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>writing</category>
      <category>community</category>
    </item>
    <item>
      <title>Dev Log: 2026-07-19</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 01:54:59 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-07-19-2n7m</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-07-19-2n7m</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shipped an email broadcast module: audience filtering, suppression, queued per-recipient sends, delivery tracking.&lt;/li&gt;
&lt;li&gt;Fixed a silent implicit-route-binding bug — the route param name didn't match the method parameter, so Laravel injected an empty model.&lt;/li&gt;
&lt;li&gt;Lesson of the day: Laravel's implicit binding fails &lt;em&gt;quietly&lt;/em&gt;, not loudly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Email broadcasts
&lt;/h2&gt;

&lt;p&gt;The bulk of today. A campaign resolves an audience filter into a contact query, passes it through a suppression gate (unsubscribed, do-not-contact, no email), materialises one recipient row per contact, then dispatches one queued job per row.&lt;/p&gt;

&lt;p&gt;The design principle: the recipient row is the source of truth. Every job re-checks the row's status before sending, so a retry or a double dispatch is a no-op. No locks, no dedupe table.&lt;/p&gt;

&lt;p&gt;Delivery webhooks sync back onto the same rows with a rank guard (&lt;code&gt;sent &amp;lt; delivered &amp;lt; opened &amp;lt; clicked&lt;/code&gt;) so an out-of-order event never walks a status backwards. Terminal states — bounced, unsubscribed, failed — are handled separately.&lt;/p&gt;

&lt;p&gt;Full write-up in the focused post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The route binding bug that says nothing
&lt;/h2&gt;

&lt;p&gt;A show page blew up with &lt;code&gt;Call to a member function label() on null&lt;/code&gt;. The cause was two names that had drifted apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Route param&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{infraProvider}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{provider}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mount signature&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mount(InfraProvider $provider)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mount(InfraProvider $provider)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result&lt;/td&gt;
&lt;td&gt;empty model injected&lt;/td&gt;
&lt;td&gt;correct model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// route&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/{provider}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProviderShow&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;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'show'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// component&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;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;InfraProvider&lt;/span&gt; &lt;span class="nv"&gt;$provider&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the part worth remembering: Laravel matches implicit route-model bindings &lt;strong&gt;by name&lt;/strong&gt;. The URI segment &lt;code&gt;{infraProvider}&lt;/code&gt; has to match the variable &lt;code&gt;$provider&lt;/code&gt;. When it doesn't, you don't get an exception — you get a freshly instantiated, empty model. &lt;code&gt;id&lt;/code&gt; is 0, every attribute is null, and the failure surfaces three layers later when something calls a method on a null enum.&lt;/p&gt;

&lt;p&gt;It's a latent bug. The page had never been opened with real data before, so nothing had noticed. Seeding the first record surfaced it immediately.&lt;/p&gt;

&lt;p&gt;The fix was renaming the param to match the sibling resource routes (&lt;code&gt;{project}&lt;/code&gt;, &lt;code&gt;{team}&lt;/code&gt;). Consistency across a route file is the cheap defence here — if every route uses the singular model name, the mismatch never happens.&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;'resolves the provider on the show 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;$provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InfraProvider&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'providers.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$provider&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$provider&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One test hitting the real route would have caught it. Testing the component in isolation would not — the binding only happens when the router does the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeding as a smoke test
&lt;/h2&gt;

&lt;p&gt;The other commit was an idempotent, config-driven seeder that provisions a realistic baseline environment rather than dummy rows. It earned its keep the same afternoon — it produced the first real record on a page nobody had exercised, which is how the binding bug surfaced. A good seeder is a smoke test that leaves useful data behind.&lt;/p&gt;

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

&lt;p&gt;Failures that return a valid-looking empty object are worse than exceptions. Convention — matching names, consistent route params — is what keeps them from happening in the first place.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building an Email Broadcast Engine That Survives Retries</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 01:53:51 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/building-an-email-broadcast-engine-that-survives-retries-34ei</link>
      <guid>https://dev.to/nasrulhazim/building-an-email-broadcast-engine-that-survives-retries-34ei</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shipped an email broadcast feature: audience filter → suppression gate → one queued job per recipient → per-recipient status ledger.&lt;/li&gt;
&lt;li&gt;The whole design rests on one idea: &lt;strong&gt;the recipient row is the source of truth&lt;/strong&gt;, so retries and double dispatches are harmless.&lt;/li&gt;
&lt;li&gt;Delivery webhooks sync back onto that ledger with a rank guard so a late event never downgrades a status.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Sending one email is easy. Sending 5,000 and being able to answer "what happened to contact #4127?" is the actual problem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Campaign (draft)
      |
      v
  Audience filter  -&amp;gt;  Suppression gate  -&amp;gt;  CampaignRecipient rows (pending)
                                                    |
                                              dispatch 1 job each
                                                    v
                                              SendCampaignEmail
                                                    |
                                        mail sent -&amp;gt; status: sent
                                                    |
                       webhook: delivered / opened / clicked / bounced
                                                    v
                                          recipient ledger updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One job per recipient, not one job per campaign. A 5,000-recipient blast becomes 5,000 small units of work — the queue throttles naturally, and a failure blast-radius is one email, not the whole campaign.&lt;/p&gt;

&lt;h2&gt;
  
  
  The suppression gate
&lt;/h2&gt;

&lt;p&gt;Audience filtering and &lt;em&gt;eligibility&lt;/em&gt; are two different questions, and mixing them is how you email someone who unsubscribed last week.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Includes&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseQuery()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;matches the filter&lt;/td&gt;
&lt;td&gt;preview counts in the UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eligibleQuery()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;filter &lt;strong&gt;+&lt;/strong&gt; reachable&lt;/td&gt;
&lt;td&gt;the actual send&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;eligibleQuery&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;$audience&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&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;baseQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$audience&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;emailReachable&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;emailReachable()&lt;/code&gt; is a scope on the contact model — not do-not-contact, still subscribed, has an email. One scope, one place to change the rules, impossible to forget at the call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency lives in the row
&lt;/h2&gt;

&lt;p&gt;The job takes IDs, not models, and every send re-checks the recipient's status before doing anything:&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;SendCampaignEmail&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$recipientId&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;int&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;If the queue redelivers, the status guard makes the second attempt a no-op. That's the whole trick — no distributed lock, no dedupe table, just a state machine on a row you already have.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;failed()&lt;/code&gt; hook matters just as much. Without it, a recipient stuck in &lt;code&gt;pending&lt;/code&gt; after retries are exhausted leaves the campaign hanging in &lt;code&gt;sending&lt;/code&gt; forever. Mark it failed, recompute stats, let the campaign finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never downgrade a status
&lt;/h2&gt;

&lt;p&gt;Delivery webhooks arrive out of order. A &lt;code&gt;delivered&lt;/code&gt; event landing after &lt;code&gt;opened&lt;/code&gt; should not walk the status backwards.&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;const&lt;/span&gt; &lt;span class="no"&gt;RANK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'sent'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&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;'opened'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'clicked'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&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;Monotonic progression, terminal off-ramps (&lt;code&gt;bounced&lt;/code&gt;, &lt;code&gt;unsubscribed&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;) handled separately. Two enums carry the vocabulary — one for the campaign lifecycle, one for the per-recipient state, both with &lt;code&gt;label()&lt;/code&gt;, &lt;code&gt;color()&lt;/code&gt; and &lt;code&gt;description()&lt;/code&gt; so the UI never hardcodes a string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unsubscribe is a public route
&lt;/h2&gt;

&lt;p&gt;The tokenised opt-out link has no auth, so it always renders the same generic confirmation — valid token or not. Otherwise you've built an oracle that tells an attacker which tokens exist.&lt;/p&gt;

&lt;p&gt;The opt-out also back-fills any still-pending recipient rows to &lt;code&gt;unsubscribed&lt;/code&gt;, so reports stay honest instead of counting a send that will never happen.&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;'suppresses pending recipients when a contact unsubscribes'&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;$recipient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pendingRecipient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$recipient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsubscribeFromEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'via link'&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;$recipient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CampaignRecipientStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UNSUBSCRIBED&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;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Broadcast systems get complicated because people try to make the &lt;em&gt;sending&lt;/em&gt; reliable. Make the &lt;strong&gt;ledger&lt;/strong&gt; reliable instead, keep every job small and re-runnable, and reliability falls out for free.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Seeder Should Describe a Shape, Not Your Laptop</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 01:53:39 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/your-seeder-should-describe-a-shape-not-your-laptop-mad</link>
      <guid>https://dev.to/nasrulhazim/your-seeder-should-describe-a-shape-not-your-laptop-mad</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rewrote a bootstrap seeder so the deployment stack — DB engine, queue runner, PHP version — is config, not hardcoded.&lt;/li&gt;
&lt;li&gt;Renamed it away from a phase-based name (&lt;code&gt;MvpSeeder&lt;/code&gt;). If a class name describes &lt;em&gt;when&lt;/em&gt; you wrote it, it expires.&lt;/li&gt;
&lt;li&gt;Trimmed a CI matrix from three PHP versions to one. Matrix breadth should match what you actually deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The seeder that only worked for me
&lt;/h2&gt;

&lt;p&gt;Bootstrap seeders rot predictably. Version one seeds exactly the environment the author had open at the time: one database engine, one queue setup, one PHP version. It works, so nobody touches it. Six months later someone needs Postgres and discovers the choice was never a choice.&lt;/p&gt;

&lt;p&gt;I hit that on a control-plane app — the thing that describes and provisions deployments. Its seeder encoded a single stack shape, so I pulled the variable parts into config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'control_plane'&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;'default_blueprint'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'stack-laravel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// or -ha, -single-node&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CONTROL_PLANE_DB'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;      &lt;span class="c1"&gt;// mysql | postgresql&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CONTROL_PLANE_QUEUE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'horizon'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// horizon | default&lt;/span&gt;
    &lt;span class="s1"&gt;'php_version'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CONTROL_PLANE_PHP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'8.4'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

    &lt;span class="s1"&gt;'apps'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// each entry may override blueprint / queue per app&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Knob&lt;/th&gt;
&lt;th&gt;Options&lt;/th&gt;
&lt;th&gt;Default from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blueprint&lt;/td&gt;
&lt;td&gt;single-node, standard, HA&lt;/td&gt;
&lt;td&gt;config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MySQL, PostgreSQL&lt;/td&gt;
&lt;td&gt;env&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue runner&lt;/td&gt;
&lt;td&gt;Horizon, plain &lt;code&gt;queue:work&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;env&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP runtime&lt;/td&gt;
&lt;td&gt;8.3 – 8.5&lt;/td&gt;
&lt;td&gt;env&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rules kept it tidy. Every knob has a sane default, so a fresh clone still seeds without a populated &lt;code&gt;.env&lt;/code&gt;. And every knob is overridable per app — the moment you have more than one workload, one will be the exception.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   config/seeder.php
        |
        v
   PrepareSeeder  ----&amp;gt;  ControlPlaneSeeder
                              |
                +-------------+-------------+
                v             v             v
           blueprint       database      queue runner
        (single/std/ha)  (mysql/pgsql)  (horizon/default)
                \             |             /
                 +-----&amp;gt; Workload + Deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Horizon or not is a real fork
&lt;/h2&gt;

&lt;p&gt;Both options run under supervisord, but they're different operational bargains:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Horizon&lt;/th&gt;
&lt;th&gt;Plain &lt;code&gt;queue:work&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-balancing across queues&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Manual worker counts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra dependency&lt;/td&gt;
&lt;td&gt;Redis required&lt;/td&gt;
&lt;td&gt;Any queue driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config surface&lt;/td&gt;
&lt;td&gt;&lt;code&gt;config/horizon.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Supervisor program blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Horizon is the better default for real queue load. But a single-node app running three jobs a day doesn't need Redis in the stack just for a dashboard nobody opens. Per-app key means neither choice forks the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rename anything named after a phase
&lt;/h2&gt;

&lt;p&gt;The seeder used to be &lt;code&gt;MvpSeeder&lt;/code&gt;. It's now &lt;code&gt;ControlPlaneSeeder&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MvpSeeder&lt;/code&gt; answers "when was this written". &lt;code&gt;ControlPlaneSeeder&lt;/code&gt; answers "what does this do". The first stops being interesting the moment the MVP ships; the second stays true for the life of the class. Same for &lt;code&gt;NewUserService&lt;/code&gt;, &lt;code&gt;V2Controller&lt;/code&gt;, &lt;code&gt;TempJob&lt;/code&gt; — timestamps pretending to be names.&lt;/p&gt;

&lt;h2&gt;
  
  
  While I was in there: the CI matrix
&lt;/h2&gt;

&lt;p&gt;Related cleanup elsewhere. A test workflow was running PHP 8.3, 8.4 and 8.5. Trimmed to 8.5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Matrix breadth should match your deployment surface.&lt;/strong&gt; A published package genuinely needs to prove it works across versions — its users pick the runtime, not you. An application you deploy yourself runs exactly one PHP version, and testing two others buys nothing but Actions minutes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project type&lt;/th&gt;
&lt;th&gt;Matrix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Published package&lt;/td&gt;
&lt;td&gt;Every supported PHP/framework combo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployed application&lt;/td&gt;
&lt;td&gt;The version you actually deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also removed a duplicate workflow running the same suite twice per push. Free 50% cut, which is the best kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the knobs
&lt;/h2&gt;

&lt;p&gt;Seeders are worth a test the moment they encode decisions rather than fixtures:&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;'seeds the queue runner from config'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'seeder.control_plane.queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'default'&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;seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ControlPlaneSeeder&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Deployment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;first&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;queue_runner&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;'default'&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, "the config knob works" is an assumption that holds until the one time you actually flip it — usually on a fresh server at an inconvenient hour.&lt;/p&gt;

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

&lt;p&gt;A seeder is documentation that executes. If it says "this system runs MySQL with Horizon on PHP 8.4", it should be describing a &lt;em&gt;default&lt;/em&gt;, not a &lt;em&gt;constraint&lt;/em&gt;. Push the specifics to config, keep the shape in the class, and give every knob a default that works out of the box.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The differentiator test: if a rival can copy the claim, it isn't one</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:04:14 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/the-differentiator-test-if-a-rival-can-copy-the-claim-it-isnt-one-2768</link>
      <guid>https://dev.to/nasrulhazim/the-differentiator-test-if-a-rival-can-copy-the-claim-it-isnt-one-2768</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I rewrote a product's differentiators after noticing four of five were claims &lt;em&gt;any&lt;/em&gt; competitor could paste onto their own page word-for-word.&lt;/li&gt;
&lt;li&gt;The test: could a rival put this exact sentence on their site and have it be just as true? If yes, it's a category description, not a difference.&lt;/li&gt;
&lt;li&gt;Keep only claims rooted in &lt;em&gt;how the thing was built&lt;/em&gt; — structural facts a rival can't retrofit.&lt;/li&gt;
&lt;li&gt;Same discipline for diagrams: if the reader has to decode it, redraw it so it states the argument literally.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem with a "differentiators" section
&lt;/h2&gt;

&lt;p&gt;I was reworking a product landing page and stared at the differentiators block. Five confident claims. Then I ran each through one question: &lt;strong&gt;could a competitor put this exact sentence on their own page?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Rival can copy it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;On-prem, sovereign, your infrastructure&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One line of accountability, one throat to choke&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Governance-first architecture&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adopt at your pace, grows with you&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priced per deployment, not per seat&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four of five failed. Not because they're untrue — because they describe the &lt;em&gt;category&lt;/em&gt;, not the product. Every serious vendor in the space says "governance-first" and "sovereign." A claim your rival can echo verbatim isn't a differentiator; it's table stakes wearing a badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep only what's structural
&lt;/h2&gt;

&lt;p&gt;The fix isn't better adjectives. It's finding claims that come from &lt;em&gt;how the thing was actually built&lt;/em&gt; — facts a competitor would have to rebuild their company to match. What survived:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Designed as one, not assembled from acquisitions.&lt;/strong&gt; Most enterprise suites are bolted together from bought products, each with its own data model and directory, and seams nobody removes. "Built by one team as one system" is checkable and structural — you can't retrofit the absence of acquisitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole suite runs inside your perimeter.&lt;/strong&gt; Sharpened from generic "on-prem" to "air-gappable in full" — not one self-hostable module while the rest phones home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Products that stand on their own.&lt;/strong&gt; Several products built inside the suite ship live under their own brands. That's proof, not a promise — and it answers the lock-in fear without having to name it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priced per deployment.&lt;/strong&gt; The one that already passed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern: a good differentiator is a &lt;em&gt;fact about your construction&lt;/em&gt;, phrased so a reader can check it. If it's an adjective anyone can claim, cut it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the diagram say it out loud
&lt;/h2&gt;

&lt;p&gt;Same day, same lesson on the visuals. Two diagrams were technically correct and practically useless.&lt;/p&gt;

&lt;p&gt;One was drawn as a funnel — but the content was a &lt;em&gt;sequence&lt;/em&gt; (order flowing through stages), not a filter narrowing down. Readers auto-read "funnel" as "we lose people at each step." Relabeled and redrawn to show order, not attrition.&lt;/p&gt;

&lt;p&gt;The other was a balance scale (&lt;em&gt;dacing&lt;/em&gt;) meant to convey per-deployment pricing. Too abstract — you had to stare to work out what it argued. I replaced it with a cost-vs-headcount line chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cost │              ╱ per seat (rises with headcount)
     │           ╱
     │        ╱
     │─────────────────── per deployment (flat)
     └───────────────────────────► users
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flat line and the crossover state the argument literally: the same fee whether ten people use it or ten thousand. No decoding required.&lt;/p&gt;

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

&lt;p&gt;Two versions of one rule. For copy: &lt;strong&gt;if a rival can say it too, it isn't a differentiator&lt;/strong&gt; — keep only the structural, checkable claims. For diagrams: &lt;strong&gt;if the reader has to decode the metaphor, it's failing&lt;/strong&gt; — redraw until the shape &lt;em&gt;is&lt;/em&gt; the argument. Both come down to the same thing: say the true, specific thing, and let it stand without decoration.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Dev Log: 2026-07-16</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:02:14 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-07-16-300j</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-07-16-300j</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rewrote a product's landing-page differentiators to only keep claims a rival can't copy — its own post today.&lt;/li&gt;
&lt;li&gt;Made two server installers genuinely end-to-end and &lt;em&gt;resumable&lt;/em&gt;, and taught them to probe SSH before doing any work.&lt;/li&gt;
&lt;li&gt;Handled a Cloudflare-proxied origin so the installer talks to the real box, not the edge.&lt;/li&gt;
&lt;li&gt;Fixed a Laravel cache config to use &lt;code&gt;CACHE_STORE&lt;/code&gt;, and documented Redis ACL (username + password) auth.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Differentiators a rival can't copy
&lt;/h2&gt;

&lt;p&gt;Most of a landing page's "differentiators" turned out to be claims any competitor could paste verbatim. I rewrote them to keep only structural, checkable facts, and redrew two diagrams to state their argument literally instead of hiding it in a metaphor. Full write-up in its own post today — the short version: &lt;em&gt;if a rival can say it too, it isn't a differentiator.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Installers that survive being run twice
&lt;/h2&gt;

&lt;p&gt;Spent the evening on provisioning scripts for a fresh server (a dedicated analytics database plus an API deploy). Two lessons worth keeping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resumable beats "run once, pray."&lt;/strong&gt; An installer that dies halfway and leaves you with a half-built box is worse than no installer. The fix is to make every step idempotent — check whether the thing already exists before creating it — so re-running picks up where it left off instead of tripping over its own earlier work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe before you provision.&lt;/strong&gt; The script now checks SSH connectivity &lt;em&gt;up front&lt;/em&gt;, before it starts changing anything. Failing fast on "I can't even reach this host" is far kinder than failing on step 14 with a half-configured server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare-proxied origin trap
&lt;/h2&gt;

&lt;p&gt;The API host sat behind a Cloudflare proxy, so the name resolved to Cloudflare's edge, not the origin box. An installer that SSHes to "the domain" ends up talking to the wrong thing entirely. Lesson: when a host is proxied, the deploy path needs the real origin address, not the public DNS name. Obvious in hindsight, a confusing 20 minutes in the moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel: CACHE_STORE and Redis ACL
&lt;/h2&gt;

&lt;p&gt;Small but worth noting. Laravel renamed the cache env var to &lt;code&gt;CACHE_STORE&lt;/code&gt; (from the older &lt;code&gt;CACHE_DRIVER&lt;/code&gt;); a config was still keyed off the old name, so the intended store wasn't being picked. Fixed the reference and documented the Redis setup alongside it — modern Redis supports ACLs, so auth is a &lt;em&gt;username and password&lt;/em&gt; pair, not just a password. If you've only ever set &lt;code&gt;REDIS_PASSWORD&lt;/code&gt;, that's the bit that bites you when the server enforces ACLs.&lt;/p&gt;

&lt;p&gt;That's the day: sharper positioning copy, tougher install scripts, and two config gotchas documented so future-me doesn't rediscover them.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>laravel</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dev Log: 2026-07-15</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:01:44 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-07-15-5dni</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-07-15-5dni</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shipped account suspension across two Laravel apps — an enum, a middleware, a policy check (own post).&lt;/li&gt;
&lt;li&gt;Fixed a reverse-proxy gotcha: the proxy hid the gateway's real status behind NGINX's built-in 405.&lt;/li&gt;
&lt;li&gt;Moved a DB transaction to wrap only the business write, so a rollback stops erasing the audit trail.&lt;/li&gt;
&lt;li&gt;Added two agent (MCP) tools: read the source-of-truth record directly, and resolve a username to its owner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Account suspension without deleting the user
&lt;/h2&gt;

&lt;p&gt;Two apps needed the same thing: block a user from signing in without deleting the account. Same shape in both — an enum for the status, a middleware that rejects a suspended session mid-flight, and an admin toggle behind a policy. Its own post today, because the interesting part is the edge cases, not the toggle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NGINX 405 that lied
&lt;/h2&gt;

&lt;p&gt;A reverse proxy sits in front of an API gateway. GET worked; anything non-GET came back as a flat &lt;code&gt;405 Method Not Allowed&lt;/code&gt; — but that 405 was &lt;strong&gt;NGINX's own&lt;/strong&gt;, not the gateway's. The proxy was answering for a backend it should have forwarded to. The trap: a &lt;code&gt;limit_except GET&lt;/code&gt; (or a stray &lt;code&gt;error_page 405&lt;/code&gt;) makes NGINX intercept the method itself instead of passing it through.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;POST/PUT reaches gateway&lt;/td&gt;
&lt;td&gt;No — NGINX returns its own 405&lt;/td&gt;
&lt;td&gt;Yes — forwarded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status the client sees&lt;/td&gt;
&lt;td&gt;NGINX built-in 405&lt;/td&gt;
&lt;td&gt;The gateway's real status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debuggability&lt;/td&gt;
&lt;td&gt;"why is everything 405?"&lt;/td&gt;
&lt;td&gt;Real upstream errors surface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Lesson: a proxy forwards; it shouldn't have opinions about methods. See a generic 405 on writes? Check the proxy before you blame the backend. I documented it in the config header so the next deploy doesn't reintroduce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope the transaction around the business write only
&lt;/h2&gt;

&lt;p&gt;Subtle one. An operation did a business write &lt;strong&gt;and&lt;/strong&gt; an audit write inside the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;DB::transaction&lt;/code&gt;. Looks tidy. But when the business write failed and rolled back, the audit row rolled back with it — the one record meant to survive a failure vanished exactly when you needed it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before: audit dies with the thing it was supposed to witness&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="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;applyChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// may throw&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;audit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// rolled back too&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// after: transaction guards the business write; audit is logged around 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;audit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change.attempt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;);&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;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;applyChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&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;audit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change.done'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: an audit trail records &lt;em&gt;attempts and outcomes&lt;/em&gt; — so it can't live inside the transaction whose failure it's meant to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two read-only agent tools
&lt;/h2&gt;

&lt;p&gt;Also added two MCP tools over an internal system: one reads the source-of-truth record directly, one resolves a username to its owner across every source it might live in. Both read-only. Treating MCP tools as &lt;strong&gt;narrow, read-only lenses&lt;/strong&gt; — rather than write endpoints — keeps them safe to hand an agent and easy to test.&lt;/p&gt;

&lt;p&gt;That's the day: one security feature, one infra gotcha, one durability fix, two agent lenses.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
      <category>devops</category>
    </item>
    <item>
      <title>Suspend a user in Laravel without deleting them</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:01:42 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/suspend-a-user-in-laravel-without-deleting-them-1bcc</link>
      <guid>https://dev.to/nasrulhazim/suspend-a-user-in-laravel-without-deleting-them-1bcc</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Suspension is not deletion: keep the row, block the sign-in.&lt;/li&gt;
&lt;li&gt;Model it as a status enum, enforce it with middleware on every authenticated request, gate the toggle with a policy.&lt;/li&gt;
&lt;li&gt;The bugs live in the edges: an already-authenticated session, and an admin suspending themselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I needed the same feature in two apps: an admin should be able to &lt;strong&gt;suspend&lt;/strong&gt; a user — block them from signing in — without deleting the account. Soft-deleting is wrong here (it hides the record and cascades relationships); a boolean &lt;code&gt;is_active&lt;/code&gt; is too thin once you want a reason or a timestamp. Here's the shape I landed on.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Model the state, not a flag
&lt;/h2&gt;

&lt;p&gt;A nullable &lt;code&gt;suspended_at&lt;/code&gt; timestamp carries more than a boolean — it tells you &lt;em&gt;when&lt;/em&gt;, which you'll want for the audit trail and for the "suspended 3 days ago" UI.&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;// migration&lt;/span&gt;
&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'suspended_at'&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;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// User.php&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;isSuspended&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;suspended_at&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;suspend&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;forceFill&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'suspended_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;save&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;reinstate&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;forceFill&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'suspended_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already lean on an enum for user status (&lt;code&gt;UserStatus::Active&lt;/code&gt;, &lt;code&gt;UserStatus::Suspended&lt;/code&gt;) with &lt;code&gt;label()&lt;/code&gt; and &lt;code&gt;color()&lt;/code&gt;, derive the badge from that — but keep the timestamp as the source of truth so you don't lose &lt;em&gt;when&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Enforce on every request, not just at login
&lt;/h2&gt;

&lt;p&gt;The obvious spot is the login flow. But that only stops &lt;em&gt;new&lt;/em&gt; sign-ins — a user suspended while already logged in keeps their session. So the check belongs in &lt;strong&gt;middleware&lt;/strong&gt; on authenticated routes, not just the auth attempt.&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;EnsureUserIsNotSuspended&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;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;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&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;$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="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSuspended&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;guard&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&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;session&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;invalidate&lt;/span&gt;&lt;span class="p"&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;session&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;regenerateToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'login'&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;withErrors&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="s1"&gt;'Your account has been suspended.'&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;$next&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in the authenticated group so it runs on the very next request after suspension — the session dies mid-flight, not at some future login.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Gate the toggle with a policy — including "not yourself"
&lt;/h2&gt;

&lt;p&gt;Two rules: only an admin may suspend, and &lt;strong&gt;nobody suspends themselves&lt;/strong&gt; (an admin locking themselves out is a support ticket waiting to happen).&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;suspend&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;$actor&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;$target&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;$actor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&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="nv"&gt;$actor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isNot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$target&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;$actor-&amp;gt;isNot($target)&lt;/code&gt; is the whole edge case in one line — it's the bug you'd otherwise find in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Test the edges, not the happy path
&lt;/h2&gt;

&lt;p&gt;The happy path barely needs a test. The edges do: a live session getting kicked, and self-suspension being refused.&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;'kicks a user whose account is suspended mid-session'&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;$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;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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&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="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;suspend&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;'/dashboard'&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="s1"&gt;'/login'&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;assertGuest&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;'forbids an admin from suspending themselves'&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;admin&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;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'suspend'&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeFalse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Suspension is three small pieces — a timestamp, a middleware, a policy — and one insight: enforce it on &lt;strong&gt;every&lt;/strong&gt; request, because the user you most want to stop might already be inside. Model the state richly enough to answer "when?", gate the action so an admin can't lock themselves out, and let the tests live where the bugs do.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Dev Log: 2026-07-14</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 15 Jul 2026 02:36:18 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-07-14-232e</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-07-14-232e</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fast-forwarded two Laravel apps off an old in-house skeleton — in layers, not one heroic merge.&lt;/li&gt;
&lt;li&gt;Added a security-header middleware and a retention purge command whose guard rails are the real feature.&lt;/li&gt;
&lt;li&gt;Built a reconciler for queued jobs that never ran (own post).&lt;/li&gt;
&lt;li&gt;Shipped a 404 page and a QR-code redirect on the company website.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fast-forwarding an app off an old skeleton
&lt;/h2&gt;

&lt;p&gt;Both product apps were generated from an in-house skeleton (auth, roles, menus, audit). One had drifted ~30 minor versions behind. A skeleton is a starting point, not a dependency — once you generate from it, upstream fixes don't flow to you.&lt;/p&gt;

&lt;p&gt;What made it tractable: &lt;strong&gt;layers, as separate commits&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What goes in&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Packages&lt;/td&gt;
&lt;td&gt;New composer packages, nothing wired&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Assets + docs&lt;/td&gt;
&lt;td&gt;Icons, vendor assets, feature docs&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Config + env&lt;/td&gt;
&lt;td&gt;Config files, &lt;code&gt;.env.example&lt;/code&gt;, access control&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Infrastructure&lt;/td&gt;
&lt;td&gt;Queue/Horizon config, static pages&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Wiring&lt;/td&gt;
&lt;td&gt;Providers, middleware, menus&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. Cheap wins&lt;/td&gt;
&lt;td&gt;Trusted proxies, UUID column on audits&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each layer reverts independently. Compare that to one 68-file commit where the only rollback is "undo everything."&lt;/p&gt;

&lt;p&gt;Underrated cheap win in there: a &lt;strong&gt;UUID column on the audit table&lt;/strong&gt;. Auto-increment internally, UUID for anything leaving the system. Retrofitting that after other systems reference audit rows by integer id is far more painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security headers, default-on
&lt;/h2&gt;

&lt;p&gt;Nothing exotic here — the value is that it's a default, not a per-route decision:&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&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;'X-Content-Type-Options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'nosniff'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&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;'X-Frame-Options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DENY'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&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;'Referrer-Policy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'strict-origin-when-cross-origin'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&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;'Permissions-Policy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'camera=(), microphone=(), geolocation=()'&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="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'production'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&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;'Strict-Transport-Security'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max-age=31536000; includeSubDomains'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ...CSP, when enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One nuance worth stealing: &lt;strong&gt;CSP on in production, off locally.&lt;/strong&gt; Vite's dev server and HMR trip over a strict policy, and an always-on CSP just teaches the team to disable it. Sensible default, env flag to override.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention purge: the guard rails are the feature
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;data:purge&lt;/code&gt; command is boring — &lt;code&gt;delete where created_at &amp;lt; cutoff&lt;/code&gt;. What matters is what it refuses to do.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Retention from config, not hard-coded&lt;/td&gt;
&lt;td&gt;A hard-coded &lt;code&gt;365&lt;/code&gt; shreds years of history every night&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refuses the append-only audit trail&lt;/td&gt;
&lt;td&gt;CRUD diffs are prunable; the governance log is not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--dry-run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never point a delete loop at production blind&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two audit concepts, two lifetimes. A framework CRUD-diff table ("field X went A → B") is operational data — prune it. An append-only domain audit log &lt;em&gt;is&lt;/em&gt; the record. Encoding that as an early &lt;code&gt;return&lt;/code&gt; is cheaper than remembering it at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconciler for jobs that never ran
&lt;/h2&gt;

&lt;p&gt;Rows stuck &lt;code&gt;pending&lt;/code&gt; forever, nothing in &lt;code&gt;failed_jobs&lt;/code&gt; — the job was dispatched once at creation and that dispatch got lost. Retries can't save a job that never entered the queue; a scheduled sweeper over stale, unclaimed rows can. That got its own post ("The Reconciler Pattern").&lt;/p&gt;

&lt;p&gt;Bonus lesson: I registered the sweeper's cron from a deploy step, then reverted it an hour later. Deploy-time schedule seeding is convenient until a rollback re-registers a schedule you deliberately removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Website: a 404 and a redirect
&lt;/h2&gt;

&lt;p&gt;On the public &lt;a href="https://github.com/developers-hub-my/website" rel="noopener noreferrer"&gt;company website&lt;/a&gt;: a real 404 page, plus &lt;code&gt;/company-profile&lt;/code&gt; — the URL printed on a QR code — redirecting to the profile document.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_COMPANY_PROFILE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// External URL → full page navigation; &amp;lt;Navigate&amp;gt; only does in-app routes.&lt;/span&gt;
&lt;span class="c1"&gt;// Env unset → fall back home rather than dead-end a printed URL.&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="nx"&gt;url&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Navigate&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;replace&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback is the point. Any URL living outside your codebase — QR code, email footer, business card — is a permanent public API. Treat it like one.&lt;/p&gt;

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

&lt;p&gt;Accidental theme: &lt;strong&gt;defaults and fallbacks.&lt;/strong&gt; Layered upgrades so rollback is cheap. Headers on by default. A purge that refuses the dangerous table. A redirect that never dead-ends. Not clever — just the stuff that prevents a 2am page.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Reconciler Pattern: When a Queued Job Simply Never Runs</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 15 Jul 2026 02:36:00 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/the-reconciler-pattern-when-a-queued-job-simply-never-runs-1k29</link>
      <guid>https://dev.to/nasrulhazim/the-reconciler-pattern-when-a-queued-job-simply-never-runs-1k29</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A job dispatched &lt;strong&gt;once&lt;/strong&gt;, at row-creation time, has a silent failure mode: if that dispatch is lost, the row sits &lt;code&gt;pending&lt;/code&gt; forever — and never appears in &lt;code&gt;failed_jobs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Retries only exist for jobs that &lt;em&gt;ran&lt;/em&gt;. A job that never entered the queue has nothing to retry.&lt;/li&gt;
&lt;li&gt;Fix: a &lt;strong&gt;reconciler&lt;/strong&gt; — a scheduled sweep for stale, unclaimed rows that re-dispatches them. The database is the source of truth; the queue is just delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bug that leaves no trace
&lt;/h2&gt;

&lt;p&gt;Records stuck in &lt;code&gt;pending&lt;/code&gt;. No error, no failed job, no log line. The shape of the code:&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;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SyncStatus&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="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// sync_status = 'pending'&lt;/span&gt;
&lt;span class="nc"&gt;ProcessSyncStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&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;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One dispatch, at creation. Fine 99% of the time. The 1%:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Where the job went&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;queue:clear&lt;/code&gt; during an incident&lt;/td&gt;
&lt;td&gt;Payload deleted&lt;/td&gt;
&lt;td&gt;Stuck &lt;code&gt;pending&lt;/code&gt;, no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No worker running at dispatch&lt;/td&gt;
&lt;td&gt;Flushed&lt;/td&gt;
&lt;td&gt;Stuck &lt;code&gt;pending&lt;/code&gt;, no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redeploy dropped the connection mid-enqueue&lt;/td&gt;
&lt;td&gt;Never enqueued&lt;/td&gt;
&lt;td&gt;Stuck &lt;code&gt;pending&lt;/code&gt;, no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job ran and threw&lt;/td&gt;
&lt;td&gt;&lt;code&gt;failed_jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An error you can act on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the last row is visible. The first three are invisible failures — dashboards green, data quietly rotting.&lt;/p&gt;

&lt;p&gt;The bad mental model: &lt;em&gt;"the queue is durable, so a dispatched job will eventually run."&lt;/em&gt; Your &lt;strong&gt;database row&lt;/strong&gt; is the durable thing. The queue is a delivery hint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry ≠ reconcile
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Retry&lt;/th&gt;
&lt;th&gt;Reconcile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Triggered by&lt;/td&gt;
&lt;td&gt;A job that ran and failed&lt;/td&gt;
&lt;td&gt;A &lt;em&gt;state&lt;/em&gt; that looks wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source of truth&lt;/td&gt;
&lt;td&gt;The queue / &lt;code&gt;failed_jobs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches lost dispatches?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Retry is a queue-level mechanism. Reconciliation is a domain-level one. You need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconciler
&lt;/h2&gt;

&lt;p&gt;Periodically ask the database "does anything look stuck?", and fix it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  every 15 min
       |
       v
+----------------------------+
|  sweep pending rows        |
|  updated_at &amp;lt; now()-15m    |  &amp;lt;- stale
|  AND claimed_at IS NULL    |  &amp;lt;- never picked up by a worker
+----------------------------+
       | touch(row)   &amp;lt;- so the next tick won't sweep it again
       v
   re-dispatch ProcessSyncStatus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two predicates carry the weight. &lt;strong&gt;Stale&lt;/strong&gt;: &lt;code&gt;updated_at&lt;/code&gt; older than a threshold — fresh pending rows are just working, give the queue time. &lt;strong&gt;Unclaimed&lt;/strong&gt;: the job stamps the row the moment a worker picks it up, so a pending-and-claimed row is in flight right now; leave it alone. Pending and unclaimed after 15 minutes was never delivered.&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;ReconcilePendingSync&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sync:reconcile-pending
                            {--minutes=15 : Only sweep rows untouched this long}
                            {--limit=200  : Cap re-dispatches per run}
                            {--dry-run}'&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;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SyncStatus&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sync_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'claimed_at'&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;'updated_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="n"&gt;int&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;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minutes'&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;limit&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;int&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;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'limit'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dry-run'&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;span class="c1"&gt;// ...report only&lt;/span&gt;

            &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                &lt;span class="c1"&gt;// don't sweep again next tick&lt;/span&gt;
            &lt;span class="nc"&gt;ProcessSyncStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&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="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reconciler: re-dispatched'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$row&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things that are easy to get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;touch()&lt;/code&gt; before dispatch.&lt;/strong&gt; Otherwise the next tick sees the same stale row and dispatches it again — a job multiplier, not a reconciler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--limit&lt;/code&gt;.&lt;/strong&gt; If something breaks for a day, the first sweep shouldn't dump 40,000 jobs into Redis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent job.&lt;/strong&gt; Reconciliation &lt;em&gt;will&lt;/em&gt; occasionally re-run something that was fine. That must be a no-op: claim the row at the top of &lt;code&gt;handle()&lt;/code&gt;, make the work an upsert.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Queue::fake()&lt;/code&gt; plus a backdated &lt;code&gt;updated_at&lt;/code&gt; covers the cases that matter:&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;function&lt;/span&gt; &lt;span class="n"&gt;agePendingRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SyncStatus&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$minutes&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;// Bypass Eloquent timestamps to backdate updated_at.&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;'sync_status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$row&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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'updated_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nv"&gt;$minutes&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;'re-dispatches a stale unclaimed pending row'&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;$stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SyncStatus&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;'sync_status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'claimed_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nf"&gt;agePendingRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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;artisan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sync:reconcile-pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--minutes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&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;assertSuccessful&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;ProcessSyncStatus&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;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'skips a pending row already claimed by a worker'&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;$claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SyncStatus&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;'sync_status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'claimed_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
    &lt;span class="nf"&gt;agePendingRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$claimed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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;artisan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sync:reconcile-pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--minutes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&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;assertSuccessful&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;assertNotPushed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProcessSyncStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a third: a &lt;em&gt;fresh&lt;/em&gt; pending row must be left alone. Anyone can write a sweeper that re-dispatches everything — the value is in what it refuses to touch.&lt;/p&gt;

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

&lt;p&gt;If a database row implies "a job should be running for this", something needs to check that claim on a schedule. Dispatch-on-create is a happy-path optimisation, not a guarantee.&lt;/p&gt;

&lt;p&gt;The rule I'm keeping: &lt;strong&gt;the queue delivers, the database decides.&lt;/strong&gt; Any state that can only be advanced by a queued job needs a reconciler behind it — otherwise "eventually consistent" really means "usually consistent, and silent when it isn't."&lt;/p&gt;

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