<?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 Bin Mohamad</title>
    <description>The latest articles on DEV Community by Nasrul Hazim Bin Mohamad (@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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F47230%2Fc062b1f5-2c98-4750-8877-6991f248b4bf.jpg</url>
      <title>DEV Community: Nasrul Hazim Bin Mohamad</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 Nullable Timestamp, Four Account States: Deriving User Status in Laravel</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 12 Jun 2026 09:18:01 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/one-nullable-timestamp-four-account-states-deriving-user-status-in-laravel-1d00</link>
      <guid>https://dev.to/nasrulhazim/one-nullable-timestamp-four-account-states-deriving-user-status-in-laravel-1d00</guid>
      <description>&lt;p&gt;Most of today went into a user-management overhaul in &lt;a href="https://github.com/nasrulhazim/kickoff" rel="noopener noreferrer"&gt;kickoff&lt;/a&gt; — my Laravel starter kit. Flyout CRUD panels, bulk actions, permission assignment, and the piece I want to talk about: &lt;strong&gt;account status&lt;/strong&gt;. Active, suspended, unverified, deleted.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the feature. It's the modelling decision underneath it. Where do those four states actually &lt;em&gt;live&lt;/em&gt;?&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: a &lt;code&gt;status&lt;/code&gt; column
&lt;/h2&gt;

&lt;p&gt;The obvious move is a &lt;code&gt;status&lt;/code&gt; enum column on &lt;code&gt;users&lt;/code&gt;. Set it to &lt;code&gt;suspended&lt;/code&gt; when you suspend someone, &lt;code&gt;active&lt;/code&gt; when you reinstate, &lt;code&gt;unverified&lt;/code&gt; until they verify their email, &lt;code&gt;deleted&lt;/code&gt; when they're soft-deleted.&lt;/p&gt;

&lt;p&gt;It works. Until it doesn't. Now you've got a &lt;code&gt;status&lt;/code&gt; column &lt;strong&gt;and&lt;/strong&gt; an &lt;code&gt;email_verified_at&lt;/code&gt; column &lt;strong&gt;and&lt;/strong&gt; a &lt;code&gt;deleted_at&lt;/code&gt; from soft deletes — and all three encode overlapping truths. Soft-delete a user and forget to flip &lt;code&gt;status&lt;/code&gt;? Now your database says the account is both &lt;code&gt;active&lt;/code&gt; and trashed. Verify an email but the status update fails mid-request? Drift. Every place that mutates a user becomes a place that has to remember to keep &lt;code&gt;status&lt;/code&gt; in sync. That's not a feature, that's a maintenance tax.&lt;/p&gt;

&lt;p&gt;The signals already exist. &lt;code&gt;email_verified_at&lt;/code&gt; tells you verified-or-not. &lt;code&gt;deleted_at&lt;/code&gt; (soft deletes) tells you removed-or-not. The only genuinely new state is &lt;em&gt;suspended&lt;/em&gt; — an admin deliberately blocking sign-in. So that's the only thing worth storing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually store: one nullable timestamp
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&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;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&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;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email_verified_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole migration. Not a boolean &lt;code&gt;is_suspended&lt;/code&gt; — a nullable timestamp. Null means not suspended; a value means suspended &lt;em&gt;and&lt;/em&gt; tells you when. A boolean throws that second fact away for free; the timestamp keeps it at no extra cost. Same instinct as &lt;code&gt;email_verified_at&lt;/code&gt; and &lt;code&gt;deleted_at&lt;/code&gt; — Laravel models "did this happen, and when" as a nullable timestamp everywhere, so we follow the grain of the framework.&lt;/p&gt;

&lt;p&gt;The behaviour on the model stays tiny:&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;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;unsuspend&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;h2&gt;
  
  
  Status is derived, never stored
&lt;/h2&gt;

&lt;p&gt;Here's the move. &lt;code&gt;status()&lt;/code&gt; isn't a column read — it's a &lt;code&gt;match&lt;/code&gt; over the signals, in priority order:&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;status&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;UserStatus&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;trashed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UserStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DELETED&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;isSuspended&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UserStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUSPENDED&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;email_verified_at&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UserStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UNVERIFIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;                            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UserStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;match (true)&lt;/code&gt; reads like a &lt;code&gt;cond&lt;/code&gt; — the first arm whose condition is truthy wins, so &lt;strong&gt;order encodes precedence&lt;/strong&gt;. Deleted beats suspended beats unverified beats active. A trashed-and-suspended user reads as &lt;code&gt;DELETED&lt;/code&gt;, which is what you want: the strongest fact wins, and there's exactly one place that decides. No drift, because there's nothing to keep in sync — the status is computed fresh from columns that other parts of Laravel are already maintaining for you.&lt;/p&gt;

&lt;p&gt;Querying gets the same treatment via scopes, so "active" means the same thing in a list query as it does on a single model:&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;scopeActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;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;$query&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;'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;whereNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email_verified_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;scopeSuspended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;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;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNotNull&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The enum carries its own presentation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;UserStatus&lt;/code&gt; is a string-backed enum, but it implements a &lt;code&gt;Contract&lt;/code&gt; and pulls in an &lt;code&gt;InteractsWithEnum&lt;/code&gt; trait (from my &lt;a href="https://github.com/cleaniquecoders/traitify" rel="noopener noreferrer"&gt;traitify&lt;/a&gt; package) so every enum in the app exposes the same &lt;code&gt;label()&lt;/code&gt; / &lt;code&gt;color()&lt;/code&gt; / &lt;code&gt;description()&lt;/code&gt; surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;UserStatus&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Contract&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithEnum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;ACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;SUSPENDED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'suspended'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;UNVERIFIED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'unverified'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;DELETED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'deleted'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ACTIVE&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'green'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUSPENDED&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'amber'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UNVERIFIED&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'zinc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DELETED&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'red'&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;// label() and description() follow the same match shape&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff is the view never branches on status. It asks the enum:&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;flux:badge :color="$user-&amp;gt;status()-&amp;gt;color()"&amp;gt;
    {{ $user-&amp;gt;status()-&amp;gt;label() }}
&amp;lt;/flux:badge&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a &lt;code&gt;BANNED&lt;/code&gt; case later and you touch exactly one file — the enum — not every Blade template that paints a badge. The presentation lives with the data it describes, which is the whole point of giving an enum methods instead of scattering &lt;code&gt;match&lt;/code&gt; blocks across the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deriving the state isn't enforcing it
&lt;/h2&gt;

&lt;p&gt;A computed &lt;code&gt;status()&lt;/code&gt; is a label. It does &lt;strong&gt;not&lt;/strong&gt; stop a suspended user from using an existing session — they were logged in before you suspended them, and the cookie doesn't care about your enum. Enforcement is a separate, deliberate boundary: middleware.&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="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;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;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Your account has been suspended. Please contact the administrator.'&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;Note it doesn't just redirect — it &lt;code&gt;logout()&lt;/code&gt;s, invalidates the session, and regenerates the CSRF token. Suspension should &lt;em&gt;evict&lt;/em&gt;, not merely inconvenience. A redirect alone leaves a valid session sitting in the cookie jar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pest: pin the precedence and the eviction
&lt;/h2&gt;

&lt;p&gt;Two things are worth locking down — that the &lt;code&gt;match&lt;/code&gt; precedence holds, and that the middleware actually kicks a suspended session out.&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;'derives status with deleted taking precedence over suspended'&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="s1"&gt;'email_verified_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;expect&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;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;UserStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ACTIVE&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="nf"&gt;expect&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;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;UserStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUSPENDED&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="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// soft delete&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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;UserStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DELETED&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;'evicts a suspended user 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="s1"&gt;'email_verified_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="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;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;assertRedirect&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;'login'&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="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;p&gt;The first test is the one that earns its keep over time: it freezes the precedence order so a future refactor can't quietly let &lt;code&gt;SUSPENDED&lt;/code&gt; outrank &lt;code&gt;DELETED&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Don't store what you can derive. A &lt;code&gt;status&lt;/code&gt; column looks convenient and turns into four columns that disagree with each other. Keep one nullable timestamp for the only state nobody else tracks (&lt;code&gt;suspended_at&lt;/code&gt;), lean on &lt;code&gt;email_verified_at&lt;/code&gt; and &lt;code&gt;deleted_at&lt;/code&gt; for the rest, and compute &lt;code&gt;status()&lt;/code&gt; with an ordered &lt;code&gt;match (true)&lt;/code&gt; so precedence is explicit and there's a single source of truth.&lt;/p&gt;

&lt;p&gt;Then remember the part that's easy to skip: deriving a state and &lt;em&gt;enforcing&lt;/em&gt; it are different jobs. The enum labels the account; the middleware is what actually shows a suspended user the door.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Deep-Linkable Livewire: Scoping a Browser to the Thing You Clicked</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 12 Jun 2026 04:21:17 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/deep-linkable-livewire-scoping-a-browser-to-the-thing-you-clicked-52co</link>
      <guid>https://dev.to/nasrulhazim/deep-linkable-livewire-scoping-a-browser-to-the-thing-you-clicked-52co</guid>
      <description>&lt;p&gt;Today's work was a small piece of UX that's easy to underestimate: making an admin "browser" page scope itself to whatever you clicked to get there. The setup is one I keep running into — you have a list of &lt;em&gt;connectors&lt;/em&gt; (think AD/LDAP directory connections), and each connector has its own users and its own groups. The old flow dumped you into a generic Users browser and made you re-pick the connector from a dropdown. Annoying. You already told the app which connector you cared about by clicking its row.&lt;/p&gt;

&lt;p&gt;The fix is the kind of thing Livewire makes almost too easy, so I want to slow down and talk about &lt;em&gt;why&lt;/em&gt; it works and where the edge cases hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: a URL-bound property
&lt;/h2&gt;

&lt;p&gt;The whole feature hinges on one attribute. In Livewire, you can bind a public property straight to a query-string parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserBrowser&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Url(as: 'connector')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$selectedConnector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="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;&lt;code&gt;#[Url(as: 'connector')]&lt;/code&gt; means two things at once. When &lt;code&gt;$selectedConnector&lt;/code&gt; changes, the URL becomes &lt;code&gt;?connector=acme-ad&lt;/code&gt; without a full page load. And when someone &lt;em&gt;arrives&lt;/em&gt; at &lt;code&gt;?connector=acme-ad&lt;/code&gt;, Livewire hydrates the property from the query string before &lt;code&gt;mount()&lt;/code&gt; finishes. That second half is what makes deep links work — the page reads its own starting state from the URL.&lt;/p&gt;

&lt;p&gt;So the "View Users" link on a connector row is nothing fancier than:&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;a href="{{ route('admin.directory.users', ['connector' =&amp;gt; $connector['name']]) }}"&amp;gt;
    View Users
&amp;lt;/a&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click it, land on the browser, and the property is already set to that connector. No dropdown dance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge case everyone forgets: untrusted input
&lt;/h2&gt;

&lt;p&gt;Here's the thing — &lt;code&gt;?connector=&lt;/code&gt; is user input. Anyone can type &lt;code&gt;?connector=lol-not-real&lt;/code&gt; into the address bar. If you trust it blindly, you get an empty browser, a stray exception, or worse, a UI that &lt;em&gt;looks&lt;/em&gt; like it's scoped to something that doesn't exist.&lt;/p&gt;

&lt;p&gt;A URL-bound property is a contract with the outside world, and the outside world lies. So &lt;code&gt;mount()&lt;/code&gt; has to validate against the real, allowed set and fall back gracefully:&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;mount&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;$names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConnectorSettings&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="n"&gt;connectors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&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;$c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'is_active'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ldap'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Honour a ?connector= deep link, but only if it's real and active.&lt;/span&gt;
    &lt;span class="c1"&gt;// Otherwise fall back to the first active connector.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;selectedConnector&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$names&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;contains&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;selectedConnector&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="n"&gt;selectedConnector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$names&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two failure modes, one guard: empty (no deep link, default to the first) and unknown (someone made up a name, also default to the first). Notice the allow-list is derived from config — only &lt;em&gt;active&lt;/em&gt; connectors of the right &lt;em&gt;type&lt;/em&gt; count. The query string can request anything; the component only honours what's genuinely on the menu.&lt;/p&gt;

&lt;p&gt;This is the same instinct as validating a Form Request or authorizing a policy: never let the edge of the system set internal state without a check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripping secrets out of a details modal
&lt;/h2&gt;

&lt;p&gt;The second half of the day was a "View Details" modal on each connector row. Connectors carry connection config — host, port, base DN, sync settings — and they also carry credentials. The modal should show the former and never the latter.&lt;/p&gt;

&lt;p&gt;I leaned on a &lt;code&gt;#[Computed]&lt;/code&gt; property so the view never touches the raw 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Computed&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;$detailsIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;showDetails&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;$index&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="n"&gt;detailsIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$index&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;modal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'connector-details-modal'&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;show&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;#[Computed]&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;detailsConnector&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;detailsIndex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$connector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConnectorSettings&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="n"&gt;connectors&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;detailsIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connector&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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="c1"&gt;// Never let credentials reach the Blade view (or the wire payload).&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connector&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$connector&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'client_secret'&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;$connector&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;Why a computed property and not just a public array? Because anything you put in a public property gets serialized into the component's wire payload and shipped to the browser on every request. A &lt;code&gt;#[Computed]&lt;/code&gt; value is resolved server-side at render time and isn't part of the persisted state — so the stripped array is the only shape the secret-free data ever takes. The &lt;code&gt;unset()&lt;/code&gt; is the redaction; the computed property is what keeps the un-redacted version from ever leaving the server in the first place.&lt;/p&gt;

&lt;p&gt;Small but worth saying out loud: stripping a secret in the &lt;em&gt;view&lt;/em&gt; is too late. Strip it at the boundary where data becomes "stuff the client can see."&lt;/p&gt;

&lt;h2&gt;
  
  
  A test worth writing
&lt;/h2&gt;

&lt;p&gt;The deep-link fallback is exactly the kind of logic that quietly rots. Pest makes the contract explicit:&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;'falls back to the first active connector for an unknown deep link'&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="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;'connectors'&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="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'acme-ad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'is_active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'beta-ldap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ldap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'is_active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withQueryParams&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'connector'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'does-not-exist'&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserBrowser&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;assertSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'selectedConnector'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-ad'&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;'honours a valid connector deep link'&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="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;'connectors'&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="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'acme-ad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'is_active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'beta-ldap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ldap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'is_active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withQueryParams&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'connector'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'beta-ldap'&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserBrowser&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;assertSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'selectedConnector'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'beta-ldap'&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;Livewire::withQueryParams()&lt;/code&gt; is the bit that makes this honest — it simulates the deep-link arrival, so you're testing the same hydration path a real browser hits, not just calling &lt;code&gt;mount()&lt;/code&gt; by hand.&lt;/p&gt;

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

&lt;p&gt;Three ideas did the heavy lifting today, none of them new, all of them easy to get subtly wrong:&lt;/p&gt;

&lt;p&gt;A URL-bound property turns "where you came from" into shareable, bookmarkable state for free — but it's untrusted input, so validate it against an allow-list and fall back, never trust it raw. And when you surface config in a modal, redact at the boundary with a computed property, not in the template, so secrets never ride along in the wire payload.&lt;/p&gt;

&lt;p&gt;The UX win — click a connector, land already scoped to it — is the visible part. The invisible part is the two guards that keep a convenient query string from becoming a foot-gun.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why an encrypted config backup breaks when you move servers — and how I fixed it in laravel-config-backup</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 12 Jun 2026 03:52:06 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/why-an-encrypted-config-backup-breaks-when-you-move-servers-and-how-i-fixed-it-in-22ia</link>
      <guid>https://dev.to/nasrulhazim/why-an-encrypted-config-backup-breaks-when-you-move-servers-and-how-i-fixed-it-in-22ia</guid>
      <description>&lt;p&gt;Imagine you write a letter in a secret code that only your old house key can read. Then you move. You photocopy the coded letter, carry it to the new house… and realise the new key can't decode any of it. The letter is valid, just useless.&lt;/p&gt;

&lt;p&gt;That's effectively what happens when you back up encrypted values from a Laravel database and restore them onto a different server. I hit exactly this while working on &lt;a href="https://github.com/cleaniquecoders/laravel-config-backup" rel="noopener noreferrer"&gt;laravel-config-backup&lt;/a&gt; today, so here's the problem and the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cause: &lt;code&gt;Crypt&lt;/code&gt; is bound to &lt;code&gt;APP_KEY&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When you store sensitive settings (think API tokens or OAuth secrets) in the database, you typically encrypt them with &lt;code&gt;Crypt::encryptString()&lt;/code&gt;. Lovely — until you remember &lt;code&gt;Crypt&lt;/code&gt; uses your app's &lt;code&gt;APP_KEY&lt;/code&gt; as the key.&lt;/p&gt;

&lt;p&gt;A naive backup copies that ciphertext straight across:&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;// Naive approach — move the ciphertext as-is&lt;/span&gt;
&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settings'&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;'key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'some.secret'&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;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// this value is encrypted with the OLD server's APP_KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new server has a different &lt;code&gt;APP_KEY&lt;/code&gt;. Try to decrypt → &lt;code&gt;DecryptException: The payload is invalid&lt;/code&gt;. Your backup is technically complete but practically dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: decrypt on the way out, re-encrypt on the way in
&lt;/h2&gt;

&lt;p&gt;The decision is easy to state, hard to stay disciplined about: &lt;strong&gt;never carry ciphertext across a server boundary.&lt;/strong&gt; Instead —&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On &lt;strong&gt;create&lt;/strong&gt;: decrypt the values with the source server's &lt;code&gt;APP_KEY&lt;/code&gt;, store &lt;strong&gt;plaintext&lt;/strong&gt; inside the archive.&lt;/li&gt;
&lt;li&gt;Protect that archive with &lt;strong&gt;AES-256 and a password&lt;/strong&gt; (a human-held secret, not the APP_KEY).&lt;/li&gt;
&lt;li&gt;On &lt;strong&gt;restore&lt;/strong&gt;: re-encrypt the values with the destination server's &lt;code&gt;APP_KEY&lt;/code&gt; before writing to the DB.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Back to the analogy: you decode the letter, carry the plain letter in a locked briefcase (the password-protected archive), and re-encode it with the new house's lock on arrival. The briefcase handles security in transit — not the old code that's no longer relevant.&lt;/p&gt;

&lt;p&gt;I made that intent explicit right where the behaviour lives, in &lt;code&gt;ConfigBackupService&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * Config Backup &amp;amp; Restore.
 *
 * Bundles .env + DB-stored settings into a single AES-256, password-encrypted
 * ZIP. Content inside the archive is stored DECRYPTED so the encrypted DB
 * columns are re-encrypted on import with the destination server's APP_KEY —
 * making a backup portable across servers.
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfigBackupService&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;"Naked" plaintext inside the archive sounds scary, but the security boundary has moved on purpose: from the APP_KEY (which you &lt;em&gt;want&lt;/em&gt; to differ per server) to the archive password (which you control and can rotate). That's the right trade-off for an artifact whose whole job is to move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authz: one source of truth, not scattered checks
&lt;/h2&gt;

&lt;p&gt;The same pass hardened authorization. It's too easy to scatter gate checks across the UI, routes, and commands. One method everything refers to keeps it honest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * Whether the current context passes the configured authorization gate.
 * Returns true when no gate is configured. CLI commands run by a server
 * operator deliberately bypass this. Single source of truth for authz.
 */&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;authorizes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;gate&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;$gate&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$gate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two subtle but important points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The gate is nullable.&lt;/strong&gt; If the host app doesn't set a gate, the package doesn't impose its own policy — you can rely on route middleware. Good tooling suggests, it doesn't dictate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI deliberately bypasses it.&lt;/strong&gt; Someone running &lt;code&gt;php artisan config-backup:create&lt;/code&gt; on the server already has shell access. Forcing them through a web gate is theatre, not security.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keep it honest with a test
&lt;/h2&gt;

&lt;p&gt;The portability part is hard to verify "by eye". I keep it honest with a round-trip test: encrypt with one key, simulate a different key, and assert the restore can still read the original value.&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;'restores secrets under a different APP_KEY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'app.key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'base64:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settings'&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;insert&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'some.secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Crypt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;encryptString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'super-secret'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$backup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConfigBackupService&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;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'pa55'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Simulate the destination server: a different APP_KEY&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;'app.key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'base64:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settings'&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;truncate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConfigBackupService&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;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$backup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'pa55'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settings'&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;'key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'some.secret'&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;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'value'&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;Crypt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;decryptString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&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;'super-secret'&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 this stays green across two different APP_KEYs, you know your backup is genuinely portable — not just "works on my machine".&lt;/p&gt;

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

&lt;p&gt;Whenever you design something that crosses a boundary — server, environment, tenant — ask: &lt;em&gt;which key is glued to this artifact, and does that key exist on the other side?&lt;/em&gt; The answer is often no, and the safest thing to carry is plaintext in a container whose key you control — not ciphertext bound to a key you left behind.&lt;/p&gt;

&lt;p&gt;The package is open source: &lt;a href="https://github.com/cleaniquecoders/laravel-config-backup" rel="noopener noreferrer"&gt;github.com/cleaniquecoders/laravel-config-backup&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shipping a Livewire 4 + Flux admin UI inside a package: four gotchas that 500'd on me</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 12 Jun 2026 03:51:07 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/shipping-a-livewire-4-flux-admin-ui-inside-a-package-four-gotchas-that-500d-on-me-1obp</link>
      <guid>https://dev.to/nasrulhazim/shipping-a-livewire-4-flux-admin-ui-inside-a-package-four-gotchas-that-500d-on-me-1obp</guid>
      <description>&lt;p&gt;Bundling an admin UI &lt;em&gt;inside&lt;/em&gt; a Laravel package is a different game from building one in an app. The app's conveniences — a compiled Vite manifest, a registered layout, your own Livewire components — aren't there. Today, getting the bundled admin UI in &lt;a href="https://github.com/cleaniquecoders/laravel-config-webhook" rel="noopener noreferrer"&gt;laravel-config-webhook&lt;/a&gt; to actually render meant walking through four separate 500s. Each one is a small, sharp lesson about the boundary between a package and its host app.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A Livewire 4 component name can't contain &lt;code&gt;::&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I registered the component with a namespaced-looking name and got a &lt;code&gt;ComponentNotFoundException&lt;/code&gt; at runtime. The cause is subtle: under Livewire 4, a name containing &lt;code&gt;::&lt;/code&gt; triggers namespace resolution that &lt;em&gt;ignores&lt;/em&gt; singly-registered components. So a "nice looking" name silently routes to a lookup that will never find it.&lt;/p&gt;

&lt;p&gt;The fix is to register a plain, dotted name:&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;// ❌ looks tidy, but the "::" sends Livewire down a namespace path&lt;/span&gt;
&lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;component&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'config-webhook::webhooks'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Webhooks&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="c1"&gt;// ✅ a flat dotted name resolves to the singly-registered component&lt;/span&gt;
&lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;component&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'config-webhook.webhooks'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Webhooks&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: in a package, treat the component name as an identifier with framework-reserved characters — &lt;code&gt;::&lt;/code&gt; is not yours to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Flux ships Heroicons, not Pro/Lucide names
&lt;/h2&gt;

&lt;p&gt;The free tier of Flux ships &lt;strong&gt;Heroicons&lt;/strong&gt;. Reach for a Pro-only or Lucide-style name and it throws at runtime. I'd used &lt;code&gt;webhook&lt;/code&gt;, &lt;code&gt;ellipsis&lt;/code&gt;, and &lt;code&gt;list&lt;/code&gt;; the free equivalents are &lt;code&gt;bolt&lt;/code&gt;, &lt;code&gt;ellipsis-horizontal&lt;/code&gt;, and &lt;code&gt;queue-list&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the same trap that bit my SSO package — which is exactly why I now guard it with a static test that reads the Blade and checks every icon against Flux's actual stub files. (Separate post on that.) If you ship a package UI with Flux, assume free-tier icons only unless you require Pro.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Don't &lt;code&gt;@vite&lt;/code&gt; host assets that don't exist
&lt;/h2&gt;

&lt;p&gt;The bundled fallback layout &lt;code&gt;@vite&lt;/code&gt;-d the host app's assets. In a fresh consumer (or the package's own workbench) there's no compiled manifest, so you get a &lt;code&gt;ViteManifestNotFoundException&lt;/code&gt;. A package's &lt;em&gt;fallback&lt;/em&gt; layout has to stand on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- bundled fallback layout: self-contained, no host build step --}}
&amp;lt;head&amp;gt;
    &amp;lt;script src="https://cdn.tailwindcss.com"&amp;gt;&amp;lt;/script&amp;gt;
    @fluxAppearance
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    {{ $slot }}
    @fluxScripts
&amp;lt;/body&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tailwind Play CDN + Flux directives mean the UI renders out of the box, with zero assumptions about the host's build pipeline. The host can always override the layout when it wants the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A non-null layout default defeats your own fallback
&lt;/h2&gt;

&lt;p&gt;This one is sneaky. The config had:&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;'ui'&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;'layout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'components.layouts.app'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// a sensible-looking default…&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and the render used &lt;code&gt;config('config-webhook.ui.layout') ?: $bundledFallback&lt;/code&gt;. Because the default was non-null, the &lt;code&gt;?:&lt;/code&gt; &lt;strong&gt;never&lt;/strong&gt; fell back — it always pointed at &lt;code&gt;components.layouts.app&lt;/code&gt;, which doesn't exist in a bare consumer, so: 500. Defaulting to &lt;code&gt;null&lt;/code&gt; lets the fallback actually do its job:&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;'ui'&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;'layout'&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="c1"&gt;// null → the bundled fallback is used unless the host sets one&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: when you offer a fallback via &lt;code&gt;?:&lt;/code&gt; or &lt;code&gt;??&lt;/code&gt;, the default that triggers it must be the empty value, not a placeholder that looks like a value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: prove it end-to-end in the workbench
&lt;/h2&gt;

&lt;p&gt;Bugs like these hide because nothing exercises the full path. So I wired the package's Testbench workbench to deliver a webhook for real: seed an active subscriber, add a &lt;code&gt;/receiver&lt;/code&gt; route that verifies the HMAC signature, and — importantly — exclude the CSRF middleware (&lt;code&gt;PreventRequestForgery&lt;/code&gt;) from that receiver route, since an inbound webhook isn't a browser form post:&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="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/receiver'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;VerifyAndStore&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;withoutMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PreventRequestForgery&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/fire&lt;/code&gt; delivers end-to-end straight after &lt;code&gt;migrate:fresh --seed&lt;/code&gt;. The whole point: a demo that actually runs the real path is the cheapest way to keep these four gotchas from coming back.&lt;/p&gt;

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

&lt;p&gt;Every one of these bugs lives at the &lt;strong&gt;package ↔ host boundary&lt;/strong&gt; — names the framework reserves, assets the host may not have built, layouts the host may not define, defaults that quietly disable your own safety net. When you ship UI inside a package, assume the host gives you nothing, make the fallback self-sufficient, and wire a workbench that exercises the real path end-to-end.&lt;/p&gt;

&lt;p&gt;Open source: &lt;a href="https://github.com/cleaniquecoders/laravel-config-webhook" rel="noopener noreferrer"&gt;github.com/cleaniquecoders/laravel-config-webhook&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>livewire</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A test that catches the bug your feature tests can't see</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 12 Jun 2026 03:50:50 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/a-test-that-catches-the-bug-your-feature-tests-cant-see-44da</link>
      <guid>https://dev.to/nasrulhazim/a-test-that-catches-the-bug-your-feature-tests-cant-see-44da</guid>
      <description>&lt;p&gt;There's a class of bug that's maddening: it passes every test you have, then crashes in the user's face. I hit one in the admin UI of &lt;a href="https://github.com/cleaniquecoders/laravel-config-sso" rel="noopener noreferrer"&gt;laravel-config-sso&lt;/a&gt; today, and the real fix wasn't changing an icon name — it was writing a test that &lt;em&gt;could&lt;/em&gt; see the bug in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: wrong icon name, crashes only at runtime
&lt;/h2&gt;

&lt;p&gt;The admin UI uses &lt;a href="https://fluxui.dev" rel="noopener noreferrer"&gt;Flux&lt;/a&gt;. Flux resolves icons through &lt;code&gt;&amp;lt;flux:delegate-component&amp;gt;&lt;/code&gt;, and it &lt;strong&gt;throws&lt;/strong&gt; for a name that doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Flux component [icon.ellipsis] does not exist.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's an easy mistake. Flux ships &lt;strong&gt;Heroicons&lt;/strong&gt;, not Lucide. So your Lucide reflexes lie to you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You type (Lucide)&lt;/th&gt;
&lt;th&gt;Flux wants (Heroicon)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ellipsis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ellipsis-horizontal&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trash-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;trash&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eye-off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eye-slash&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why feature tests don't catch it
&lt;/h2&gt;

&lt;p&gt;Here's the interesting part. I had a feature test that hits the admin route and asserts 200. Green. But the real UI crashes. How?&lt;/p&gt;

&lt;p&gt;Because in the headless test harness, &lt;strong&gt;Flux renders icons as no-ops.&lt;/strong&gt; No real &lt;code&gt;&amp;lt;flux:delegate-component&amp;gt;&lt;/code&gt; boots, so the icon name never gets resolved. The crash only surfaces under a full boot (&lt;code&gt;testbench serve&lt;/code&gt;) — exactly where your automated tests don't go.&lt;/p&gt;

&lt;p&gt;Analogy: it's like a spell-checker that only runs when you &lt;em&gt;print&lt;/em&gt; the document, not while you type. Your tests type away happily. The crash waits at the printer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a static test that reads the Blade and validates every icon
&lt;/h2&gt;

&lt;p&gt;Instead of relying on runtime, I wrote a Pest test that reads the Blade view, extracts every icon name (static &lt;em&gt;and&lt;/em&gt; inside dynamic expressions), and asserts Flux actually ships a stub for each one:&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;$fluxIconStubs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'vendor/livewire/flux/stubs/resources/views/flux/icon'&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;'only references Flux icons that exist'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fluxIconStubs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fluxIconStubs&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;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Flux icon stubs not found"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/../../resources/views/livewire/sso-providers.blade.php'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Static `icon="name"` plus quoted tokens inside dynamic&lt;/span&gt;
    &lt;span class="c1"&gt;// `icon="{{ $cond ? 'eye-slash' : 'eye' }}"` expressions&lt;/span&gt;
    &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/icon="([a-z][a-z0-9-]*)"/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$static&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/icon="\{\{(.+?)\}\}"/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$dynamic&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$static&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dynamic&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="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$expression&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/'([a-z][a-z0-9-]*)'/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$expression&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tokens&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="nv"&gt;$names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$names&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;$names&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&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;$names&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$fluxIconStubs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.blade.php"&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;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Flux has no icon [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] — use a valid Heroicon name (Flux ships Heroicons, not Lucide)."&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;What I like about this test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It runs against the source of truth.&lt;/strong&gt; Flux's icon registry is a folder of stubs in &lt;code&gt;vendor/&lt;/code&gt;. The test checks directly against that — not a hardcoded list that goes stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It handles dynamic icons.&lt;/strong&gt; Toggles like &lt;code&gt;eye&lt;/code&gt; / &lt;code&gt;eye-slash&lt;/code&gt; are the ones that usually slip through. The second regex catches quoted tokens inside Blade expressions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure message teaches.&lt;/strong&gt; When it breaks, it tells you the real reason: &lt;em&gt;"Flux ships Heroicons, not Lucide."&lt;/em&gt; Future-me will be grateful.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That payoff was immediate: the very same Flux free-vs-Pro icon trap bit a sibling package the same day (a &lt;code&gt;webhook&lt;/code&gt;/&lt;code&gt;ellipsis&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt; set that only exists in Flux Pro). A guard test like this turns a "crashes in production" into a "fails in CI" — which is exactly where you want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for this pattern
&lt;/h2&gt;

&lt;p&gt;Not every typo deserves a test. This pattern shines when &lt;strong&gt;your runtime lies to you during tests&lt;/strong&gt; — where a component becomes a no-op, where an adapter is mocked out, where the environment differs from production. There, a static test that reads the artifact (a Blade view, a config file, a migration) can catch what a dynamic test can't.&lt;/p&gt;

&lt;p&gt;The rule I keep: if a failure only appears under a full boot but your tests run headless, don't chase the full boot in CI. Catch the thing earlier with a static check over the source files. It's cheaper, faster, and it never renders a no-op.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>testing</category>
      <category>php</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Making encrypted Laravel config backups portable across APP_KEYs</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 12 Jun 2026 03:44:17 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/making-encrypted-laravel-config-backups-portable-across-appkeys-14g1</link>
      <guid>https://dev.to/nasrulhazim/making-encrypted-laravel-config-backups-portable-across-appkeys-14g1</guid>
      <description>&lt;p&gt;Here's a fun one. You build a package that backs up an app's config — the &lt;code&gt;.env&lt;/code&gt; plus the settings stored encrypted in the database — into a single password-protected ZIP. The whole selling point is &lt;em&gt;portability&lt;/em&gt;: take a backup on server A, restore it on server B, even when the two servers have different &lt;code&gt;APP_KEY&lt;/code&gt;s. Then you write a test that actually changes the key during a restore, and it fails. The DB settings come back garbled.&lt;/p&gt;

&lt;p&gt;Turns out the bug wasn't in the encryption at all. It was in a cache I forgot was there. Today I shipped 1.1.0 of &lt;a href="https://github.com/cleaniquecoders/laravel-config-backup" rel="noopener noreferrer"&gt;laravel-config-backup&lt;/a&gt; and this portability fix was the headline. Let me walk through it, because the lesson generalizes way beyond this package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why APP_KEY portability is even a thing
&lt;/h2&gt;

&lt;p&gt;Laravel encrypts things with &lt;code&gt;APP_KEY&lt;/code&gt;. Encrypted Eloquent casts, signed cookies, sessions — all of it keys off that value. So if you naively &lt;code&gt;mysqldump&lt;/code&gt; a table with encrypted columns and load it onto another server, every encrypted column is now ciphertext that the new key can't decrypt. Dead data.&lt;/p&gt;

&lt;p&gt;The trick this package uses is to store the archive contents &lt;strong&gt;decrypted&lt;/strong&gt;. When I export the database, rows go out &lt;em&gt;through their casts&lt;/em&gt;, so an encrypted column becomes a plain value inside the ZIP (the ZIP itself is AES-256 password-encrypted, so it's not sitting around in plaintext). On import, each row is written back &lt;strong&gt;through the model&lt;/strong&gt;, which means the cast re-encrypts it with whatever &lt;code&gt;APP_KEY&lt;/code&gt; is active on the destination.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server A (key A)              Archive (decrypted)         Server B (key B)
────────────────             ───────────────────         ────────────────
settings.payload ──decrypt──▶   "Portable"   ──import──▶ settings.payload
(ciphertext A)     (cast)                       (cast)    (ciphertext B)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Think of it like shipping furniture: you don't ship the assembled wardrobe through a doorway it doesn't fit, you flat-pack it and reassemble at the destination with the screws you have there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restore sequence
&lt;/h2&gt;

&lt;p&gt;A restore that also brings a new &lt;code&gt;.env&lt;/code&gt; has to be careful about &lt;em&gt;ordering&lt;/em&gt;. Here's the real flow:&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;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$absZipPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$sections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Safety snapshot of the CURRENT config before we touch anything.&lt;/span&gt;
    &lt;span class="nv"&gt;$safety&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;ConfigBackupSection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Automatic pre-restore safety backup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;isSafety&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$zip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;openArchive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$absZipPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// validates password&lt;/span&gt;
    &lt;span class="nv"&gt;$appKeyChanged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Restore .env FIRST. If APP_KEY changes, swap the active encrypter&lt;/span&gt;
    &lt;span class="c1"&gt;//    so any subsequent DB re-encryption uses the FINAL key.&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;wants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ConfigBackupSection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ENV&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="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$oldKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.key'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;base_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.env'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$newEnv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$newKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$newEnv&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'APP_KEY'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$oldKey&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;$newKey&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$newKey&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$oldKey&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;useEncryptionKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$newKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$appKeyChanged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Restore DB settings — now re-encrypted with the now-active key.&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;

    &lt;span class="nc"&gt;ConfigRestored&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;$restored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$databaseSummary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$appKeyChanged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$safety&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'safety_backup'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$safety&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'restored'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$restored&lt;/span&gt;&lt;span class="p"&gt;,&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="nv"&gt;$databaseSummary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'app_key_changed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$appKeyChanged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; before database, always. Otherwise you'd re-encrypt the DB with the old key and then swap — exactly backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: a warm encrypter cache
&lt;/h2&gt;

&lt;p&gt;So why did the test fail even with the ordering correct? Look at step 1: the pre-restore safety snapshot &lt;strong&gt;reads encrypted rows&lt;/strong&gt; to back them up. Reading encrypted rows resolves the encrypter — and &lt;code&gt;Crypt::getFacadeRoot()&lt;/code&gt; &lt;em&gt;caches&lt;/em&gt; its instance. By the time I swapped &lt;code&gt;app.key&lt;/code&gt; in step 2, the encrypter the casts actually use was already warmed with the &lt;strong&gt;old&lt;/strong&gt; key. So step 3 dutifully re-encrypted everything with a stale encrypter. The key in &lt;code&gt;.env&lt;/code&gt; said B; the bytes on disk were still keyed to A.&lt;/p&gt;

&lt;p&gt;This is the classic shape of a caching bug: the value you changed and the value being read came from two different places. Setting &lt;code&gt;config(['app.key' =&amp;gt; ...])&lt;/code&gt; updates the config repository, but the already-resolved &lt;code&gt;encrypter&lt;/code&gt; singleton doesn't care about that — it's holding its own copy of the key.&lt;/p&gt;

&lt;p&gt;The fix is to swap the live binding &lt;em&gt;and&lt;/em&gt; clear the resolved facade instance so the casts re-resolve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;useEncryptionKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$appKey&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;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parseKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$appKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// strip base64: and decode&lt;/span&gt;
    &lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.cipher'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'AES-256-CBC'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="o"&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;'app.key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$appKey&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;instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'encrypter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Encrypter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nc"&gt;Crypt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;clearResolvedInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'encrypter'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// force re-resolve on next use&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the whole fix. Without it, the encrypter the casts grab is the stale one and the "portable" backup quietly isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock the test around the actual failure
&lt;/h2&gt;

&lt;p&gt;The dangerous thing about this class of bug is that a casual test passes. If your test restores and reads the value back &lt;em&gt;in the same request&lt;/em&gt; with a still-warm encrypter, everything looks fine. The bug only shows when something has read encrypted data &lt;em&gt;before&lt;/em&gt; the key swap — which is exactly what the safety snapshot does in production.&lt;/p&gt;

&lt;p&gt;So the regression test has to reproduce that ordering: write a row under key A, take a backup, then restore an archive that carries key B, and assert the bytes on disk are decryptable under B and not A.&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;'re-encrypts database settings under the restored APP_KEY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Stored under the original key.&lt;/span&gt;
    &lt;span class="nc"&gt;Setting&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'smtp.password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Portable'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigBackup&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;backup&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'env'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'database'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'secret-pass'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Archive carries a different APP_KEY in its .env.&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigBackup&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'secret-pass'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'env'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'database'&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;$result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'app_key_changed'&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;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Raw column must decrypt under the NEW key, and fail under the OLD one.&lt;/span&gt;
    &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settings'&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;'key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'smtp.password'&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;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'value'&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;Crypt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;decryptString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&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;'Portable'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// new key is active&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 ever delete the &lt;code&gt;clearResolvedInstance&lt;/code&gt; line, this test goes red. That's the goal — the test pins the exact behaviour, not a happy-path approximation of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other 1.1.0 changes, briefly
&lt;/h2&gt;

&lt;p&gt;Two more things landed in the same release that are worth a mention because they're about &lt;em&gt;not leaking&lt;/em&gt; and &lt;em&gt;not trusting the UI&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secure password prompt.&lt;/strong&gt; &lt;code&gt;config-backup:create&lt;/code&gt; used to want &lt;code&gt;--password&lt;/code&gt; on the command line. That leaks straight into shell history and the process list — anyone with &lt;code&gt;ps&lt;/code&gt; access sees your backup password. Now, if you omit &lt;code&gt;--password&lt;/code&gt;, it prompts with a hidden, confirmed input. The flag still works for unattended/scheduled runs where there's no TTY.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authorization at the route boundary.&lt;/strong&gt; The package has a Livewire admin screen behind a configurable gate. The gate check lived inside the Livewire component, which is fine until someone hits the route some other way. So authorization is now centralized in one method and also enforced as route middleware:&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;authorizes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;gate&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;$gate&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$gate&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="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/web.php — belt and braces&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;$gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'config-backup.gate'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'can:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$gate&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 source of truth (&lt;code&gt;authorizes()&lt;/code&gt;), enforced at two layers. The Pest suite covers all four cases: no gate configured (allow), guest (deny), wrong user (deny), right user (allow). Cheap tests, and they document the contract better than a paragraph of prose ever would.&lt;/p&gt;

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

&lt;p&gt;The portability fix is really a reminder that &lt;strong&gt;&lt;code&gt;config()&lt;/code&gt; and a resolved singleton are not the same source of truth&lt;/strong&gt;. Any time you mutate framework config at runtime and expect already-resolved services to notice, you probably need to re-bind and clear the resolved instance — encrypter, DB connections, cache stores, they all cache. And when you find a bug like this, write the test that reproduces the &lt;em&gt;ordering&lt;/em&gt; that triggered it, not the easy version that passes by accident.&lt;/p&gt;

&lt;p&gt;Package is here if you want to poke at it: &lt;a href="https://github.com/cleaniquecoders/laravel-config-backup" rel="noopener noreferrer"&gt;cleaniquecoders/laravel-config-backup&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building a Permission-Gated MCP Server in Laravel (Without Opening a Backdoor)</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 12 Jun 2026 02:41:37 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/building-a-permission-gated-mcp-server-in-laravel-without-opening-a-backdoor-3jk5</link>
      <guid>https://dev.to/nasrulhazim/building-a-permission-gated-mcp-server-in-laravel-without-opening-a-backdoor-3jk5</guid>
      <description>&lt;p&gt;I spent today wiring an MCP server into a Laravel app that manages a Kong API gateway. The interesting part wasn't "make the AI talk to the app" — that's the easy bit now that there's a first-party package for it. The interesting part was making sure the MCP layer is &lt;em&gt;just another UI&lt;/em&gt; over the same rules, and never a quiet little backdoor that skips authorization.&lt;/p&gt;

&lt;p&gt;Here's how I think about it, and the patterns that kept it honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP is a third front-end, not a new set of powers
&lt;/h2&gt;

&lt;p&gt;The app already has two ways in: a web UI and an HTTP API. Both go through the same authorization, the same action classes, the same approval workflow. When you bolt on an MCP server, the temptation is to let the tools "just query the database" because it's faster. That's exactly how you end up with an AI agent that can do things a logged-in user never could.&lt;/p&gt;

&lt;p&gt;So the rule I set for myself: &lt;strong&gt;every MCP tool maps to a permission the human already has, and every write goes through the same action class the web UI calls.&lt;/strong&gt; MCP gets zero special privileges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool base class: gate first, work second
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;laravel/mcp&lt;/code&gt;, a tool is a class extending &lt;code&gt;Laravel\Mcp\Server\Tool&lt;/code&gt;. Instead of letting each tool reinvent the auth check, I push it into an abstract base. Each concrete tool just declares the permission it needs; the base decides whether the caller is allowed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GatewayTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** The permission this tool requires, e.g. "gateway.manage.services". */&lt;/span&gt;
    &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;authorizedUser&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;?User&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;unauthorized&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"Unauthorized — this tool requires the '&lt;/span&gt;&lt;span class="si"&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;permission&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' permission."&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$request-&amp;gt;user()&lt;/code&gt; here is the token holder — I issue scoped bearer tokens with Sanctum, so the MCP session is authenticated as a real user with real permissions. Superadmin still bypasses through the usual &lt;code&gt;Gate::before&lt;/code&gt;, so I don't have to special-case it.&lt;/p&gt;

&lt;p&gt;One small thing that matters more than it looks: &lt;strong&gt;an unauthorized tool returns an error, not partial data.&lt;/strong&gt; No "here's what you're allowed to see" half-answers. If you can't call it, you get a clean refusal and nothing leaks.&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;ListServicesTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;GatewayTool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'gateway.view.services'&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authorizedUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;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;unauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// ...paginated, uuid/code only — never the internal id&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the identifiers: tools take and return &lt;code&gt;uuid&lt;/code&gt; or a human &lt;code&gt;code&lt;/code&gt;, never the auto-increment primary key. The internal id stays internal — same convention I use everywhere, and it matters doubly when an LLM is the one passing arguments around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiered capabilities via a driver-based contract
&lt;/h2&gt;

&lt;p&gt;Not every deployment has the same data behind it. Aggregated metrics might come from gateway snapshots on a small install, from PostgreSQL request logs on a bigger one, or from Elasticsearch on the heavy tier. I didn't want analytics tools to care which.&lt;/p&gt;

&lt;p&gt;So the analytics tools depend on a contract, and the &lt;em&gt;container&lt;/em&gt; binds the right implementation based on a configured tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UsageMetricsProvider&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;usageSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getProviderName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Only the richer tiers implement this marker contract.&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;DetailedUsageMetricsProvider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;UsageMetricsProvider&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;errorBreakdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;slowestEndpoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base analytics tool resolves the detailed provider only when it's actually available, and degrades gracefully when it isn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AnalyticsTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;GatewayTool&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;protected&lt;/span&gt; &lt;span class="kt"&gt;UsageMetricsProvider&lt;/span&gt; &lt;span class="nv"&gt;$metrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'gateway.view.usage-report'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;detailedMetrics&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?DetailedUsageMetricsProvider&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;metrics&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;DetailedUsageMetricsProvider&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;metrics&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A detailed tool checks &lt;code&gt;detailedMetrics()&lt;/code&gt; and, if it's &lt;code&gt;null&lt;/code&gt;, tells the caller &lt;em&gt;why&lt;/em&gt; — "not available on this reporting tier, switch to the log-backed tier" — instead of throwing a confusing error or returning empty arrays. The marker-interface trick (a sub-interface that only the capable drivers implement) is a clean way to do capability detection without a pile of &lt;code&gt;if ($tier &amp;gt;= 2)&lt;/code&gt; checks scattered around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write tools reuse the action classes — no shortcuts
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most. The write tools (approve a service, expire a subscription, configure a plugin) don't touch models directly. They call the exact same invokable action classes the web controllers call. Which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new service still starts as &lt;code&gt;draft&lt;/code&gt; and must be submitted, then approved by &lt;em&gt;someone other than the owner&lt;/em&gt;, before it syncs to the gateway.&lt;/li&gt;
&lt;li&gt;Changing a plugin on an approved service still reverts it to &lt;code&gt;pending_approval&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Expiring a subscription still revokes gateway access; renewing still restores it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MCP tool is a thin adapter: parse input, resolve the resource by uuid, call the action, format the response. All the business rules live in one place and the AI can't route around them. If I ever change the approval flow, the MCP server inherits it for free.&lt;/p&gt;

&lt;p&gt;I also deliberately &lt;em&gt;left some things out&lt;/em&gt;. Destructive deletion isn't exposed over MCP at all — that stays a web-only, human-driven request/approve flow. Not everything needs an AI-accessible tool, and "can I" shouldn't decide "should I".&lt;/p&gt;

&lt;h2&gt;
  
  
  Two servers, two audiences
&lt;/h2&gt;

&lt;p&gt;I ended up splitting into two MCP servers sharing the same tool conventions: an &lt;strong&gt;ops&lt;/strong&gt; server (manage services, routes, plugins, run audits, read full analytics) and a &lt;strong&gt;consumer-facing catalogue&lt;/strong&gt; server (browse available APIs, request a subscription, check my own usage). Same Sanctum tokens, different permission sets decide which endpoint a token can even reach. The catalogue server simply can't see the management tools — it's not a UI toggle, it's a different tool list bound to different access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the gate
&lt;/h2&gt;

&lt;p&gt;The thing worth a Pest test isn't the happy path — it's the refusal. The most important assertion is "a user without the permission gets an error and zero data":&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;'refuses a tool when the token user lacks the permission'&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="c1"&gt;// no gateway permissions&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ListServicesTool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'page'&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="n"&gt;actingAs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$user&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isError&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;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'requires'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'uuid'&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;'returns data once the permission is granted'&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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;givePermissionTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gateway.view.services'&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;=&lt;/span&gt; &lt;span class="nc"&gt;ListServicesTool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'page'&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="n"&gt;actingAs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$user&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isError&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;p&gt;Test the deny path first. It's the one a refactor is most likely to quietly break, and it's the one with the worst blast radius if it does.&lt;/p&gt;

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

&lt;p&gt;An MCP server is a powerful new surface, and that's exactly why it should be the &lt;em&gt;least&lt;/em&gt; privileged one — never more capable than the human whose token it carries. Three habits made that easy to hold: gate every tool through a base class against an existing permission, lean on a driver-based contract so capabilities degrade honestly instead of lying, and make write tools call the same action classes as the rest of the app so the workflow can't be bypassed. The AI gets to be useful without getting to be sneaky.&lt;/p&gt;

&lt;p&gt;Next up: tightening the tool instructions so the model picks the right tool the first time — turns out a good &lt;code&gt;#[Instructions]&lt;/code&gt; block is half the battle, but that's a post for another day.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Laravel Billing: one package, every gateway, working on day one</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Mon, 01 Jun 2026 07:21:02 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/laravel-billing-one-package-every-gateway-working-on-day-one-109f</link>
      <guid>https://dev.to/nasrulhazim/laravel-billing-one-package-every-gateway-working-on-day-one-109f</guid>
      <description>&lt;p&gt;Every SaaS billing integration starts the same way: you pick a provider, pull in their package, wire it up — and three months later when the business wants to add a second gateway (or swap to a Malaysian one like BayarCash or ToyyibPay because Stripe doesn't do local rails), you discover your entire subscription layer is welded to the first provider's package. Different webhook shapes, different status vocabularies, different model assumptions. You're not adding a gateway; you're re-architecting.&lt;/p&gt;

&lt;p&gt;I kept watching this happen — especially in the Malaysian market, where the "obvious" global packages assume a gateway that half my clients can't actually use. So I built &lt;a href="https://github.com/cleaniquecoders/laravel-billing" rel="noopener noreferrer"&gt;&lt;code&gt;cleaniquecoders/laravel-billing&lt;/code&gt;&lt;/a&gt; around one inversion: &lt;strong&gt;the gateway is the plugin, not the package.&lt;/strong&gt; The engine owns subscription and invoice state. A gateway is a single contract your app implements. This post is less about the API surface and more about &lt;em&gt;why it's shaped this way&lt;/em&gt; — because the shape is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core decision: one package, gateways as a contract
&lt;/h2&gt;

&lt;p&gt;The temptation when building a billing library is to ship &lt;code&gt;laravel-billing-stripe&lt;/code&gt;, &lt;code&gt;laravel-billing-bayarcash&lt;/code&gt;, &lt;code&gt;laravel-billing-toyyibpay&lt;/code&gt;, and so on. It feels modular. It's actually a maintenance trap — every gateway sub-package re-implements the same subscription lifecycle slightly differently, and the core can never assume a stable shape because each adapter bends it.&lt;/p&gt;

&lt;p&gt;This package goes the other way. There is &lt;strong&gt;one package, one repo&lt;/strong&gt;, and it never references a real provider by name. Instead there's a single extension point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;CleaniqueCoders\LaravelBilling\Contracts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&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;createCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;Billable&lt;/span&gt; &lt;span class="nv"&gt;$billable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;Plan&lt;/span&gt; &lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;PlanInterval&lt;/span&gt; &lt;span class="nv"&gt;$interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$returnUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;CheckoutIntent&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;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;parseWebhook&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;?WebhookEvent&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 methods. That's the entire surface your app implements to onboard BayarCash, ToyyibPay, Chip, senangPay, Stripe, or anything else. The trick that makes it hold together is the two DTOs at the boundary — &lt;code&gt;CheckoutIntent&lt;/code&gt; going out, &lt;code&gt;WebhookEvent&lt;/code&gt; coming back:&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;CheckoutIntent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$redirectUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// where to send the customer&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$externalId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// echoed back by the webhook for correlation&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gateway's job is to translate the provider's idiosyncratic world into these two neutral shapes. Once it does, the engine — subscription transitions, invoice issuance, events — never needs to know which provider it's talking to. &lt;strong&gt;The provider-specific mess is quarantined inside one class&lt;/strong&gt; instead of leaking through your whole billing layer. That's the package-worthy lesson here, independent of billing: when you integrate N external services that do conceptually-the-same thing, define your own DTO at the boundary and make each adapter responsible for the translation. Don't let provider shapes propagate inward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batteries included: a gateway that needs no merchant account
&lt;/h2&gt;

&lt;p&gt;Here's the part I'm most pleased with. A fresh install defaults to &lt;code&gt;BILLING_GATEWAY=local&lt;/code&gt;, and the bundled &lt;code&gt;LocalGateway&lt;/code&gt; runs the &lt;strong&gt;entire&lt;/strong&gt; subscribe → activate → invoice → receipt flow with no real money and no merchant account. You &lt;code&gt;composer require&lt;/code&gt;, run migrations, and the billing flow works immediately — in demo, in development, in UAT, in CI.&lt;/p&gt;

&lt;p&gt;But it's not a stub. This is the detail that matters:&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;// LocalGateway::createCheckout — approval flows through the SAME&lt;/span&gt;
&lt;span class="c1"&gt;// WebhookEvent path a real gateway uses&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebhookEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;WebhookEventType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionActivated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;externalId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'external_id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;amountCents&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'amount_cents'&lt;/span&gt;&lt;span class="p"&gt;]&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="n"&gt;providerEventId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'local-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'external_id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;rawPayload&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you click "Approve" on the local dev checkout page, it produces a &lt;code&gt;WebhookEvent&lt;/code&gt; and runs it through &lt;code&gt;Billing::handle()&lt;/code&gt; — the &lt;em&gt;exact same code path&lt;/em&gt; a real BayarCash webhook would take. It even HMAC-signs its checkout token with your &lt;code&gt;app.key&lt;/code&gt; and verifies the signature on the way back, so signature-verification logic is exercised too:&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&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="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;key&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// tampered or invalid&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;Why go to this trouble for a "dev" gateway? Because a fake that takes a &lt;em&gt;different&lt;/em&gt; path than production is worse than no fake — it gives you false confidence. By making the local gateway flow through the real activation pipeline, your tests against &lt;code&gt;local&lt;/code&gt; actually validate the pipeline a paying customer will hit. Set &lt;code&gt;BILLING_LOCAL_AUTO=true&lt;/code&gt; and the whole thing runs synchronously in a single request, which is perfect for CI and feature tests. The local routes also refuse to register in production, so there's no footgun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headless core, optional UI
&lt;/h2&gt;

&lt;p&gt;The engine — models, services, contract, events, the manager — works with no UI at all. If you want billing pages fast, there's an opt-in Livewire + Flux UI (plan picker, billing portal, receipt card) that closes the full loop. The guard is clean:&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;if&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;'billing.routes.enabled'&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="nb"&gt;class_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// register /billing routes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Livewire isn't installed, or you set &lt;code&gt;BILLING_UI_ENABLED=false&lt;/code&gt;, the package stays fully headless and you build your own pages against the same models and facade. No hard dependency on the UI stack bleeds into the core. This is the right default for a library: the opinionated convenience layer is there if you want it, but it's behind a &lt;code&gt;class_exists&lt;/code&gt; check and a config flag, never mandatory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook flow, and a replay guard worth stealing
&lt;/h2&gt;

&lt;p&gt;Your app owns the route; the package does the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/webhooks/{gateway}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;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;string&lt;/span&gt; &lt;span class="nv"&gt;$gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Billing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$gateway&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;parseWebhook&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="nf"&gt;abort_if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&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="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Billing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// dedups, transitions state, issues invoices, fires events&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;noContent&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;parseWebhook()&lt;/code&gt; (your gateway's code) verifies the signature and normalises the payload, or returns &lt;code&gt;null&lt;/code&gt; to reject it. Then &lt;code&gt;Billing::handle()&lt;/code&gt; delegates to a &lt;code&gt;WebhookProcessor&lt;/code&gt; that replay-guards, locates the subscription, transitions status, issues an invoice on activate/renew, and fires the matching domain event.&lt;/p&gt;

&lt;p&gt;The replay guard is a small thing I like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isReplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;WebhookEvent&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;providerEventId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'billing:webhook:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;providerEventId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$ttl&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing.webhook.replay_ttl'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&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="c1"&gt;// Cache::add returns false when the key already exists → replay.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gateways retry. They send the same event twice, three times, because they didn't get your &lt;code&gt;200&lt;/code&gt; fast enough. If you don't dedup, you double-issue invoices. The neat part is leaning on &lt;code&gt;Cache::add&lt;/code&gt;'s atomicity — it only writes if the key is absent and tells you whether it won the race, in one operation. No read-then-write window for a concurrent duplicate to slip through. That's a reusable pattern for any idempotent-event handling, not just billing.&lt;/p&gt;

&lt;h2&gt;
  
  
  State transitions live in one place
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WebhookProcessor&lt;/code&gt; is where provider events become subscription state, and it reads like a state machine:&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;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;WebhookEventType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionActivated&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;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;WebhookEventType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionRenewed&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;renew&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;WebhookEventType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;PaymentSucceeded&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;paymentSucceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;WebhookEventType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;PaymentFailed&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;paymentFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;WebhookEventType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionCanceled&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;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gateway's only responsibility is mapping its provider's vocabulary onto these five &lt;code&gt;WebhookEventType&lt;/code&gt; cases. Everything downstream — what "activate" means for period dates, when an invoice gets issued, which event fires — is decided once, in the engine, regardless of provider. A &lt;code&gt;SubscriptionStatus&lt;/code&gt; enum carries its own access logic so the rule isn't scattered:&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;grantsAccess&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Trialing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;PastDue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Canceled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Incomplete&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;PastDue&lt;/code&gt; still grants access — a failed renewal shouldn't instantly lock someone out mid-period. That's a deliberate dunning-friendly choice, and because it lives on the enum, it's consistent everywhere access is checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polymorphic billing: tenancy is optional
&lt;/h2&gt;

&lt;p&gt;The bill target is polymorphic, so the same engine serves single-tenant (&lt;code&gt;User&lt;/code&gt;) and multi-tenant (&lt;code&gt;Team&lt;/code&gt;/&lt;code&gt;Workspace&lt;/code&gt;/&lt;code&gt;Organization&lt;/code&gt;) without caring which:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Billable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasSubscriptions&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;HasSubscriptions&lt;/code&gt; satisfies the whole &lt;code&gt;Billable&lt;/code&gt; contract and gives you the accessors the engine and UI depend on — &lt;code&gt;subscription()&lt;/code&gt;, &lt;code&gt;subscribedTo('pro')&lt;/code&gt;, &lt;code&gt;onTrial()&lt;/code&gt;, &lt;code&gt;onGracePeriod()&lt;/code&gt;, &lt;code&gt;plan()&lt;/code&gt;, &lt;code&gt;invoices()&lt;/code&gt;, plus metered-usage gating via &lt;code&gt;canConsume('seats', 1)&lt;/code&gt; / &lt;code&gt;recordUsage('seats', 1)&lt;/code&gt;. To scope billing to a team instead of the logged-in user, you point one config closure at 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="s1"&gt;'billable_resolver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&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;$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="n"&gt;currentTeam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every UI query and every invoice download is constrained to the resolved billable, and the download routes &lt;code&gt;403&lt;/code&gt; on a foreign invoice — so one tenant can never see another's invoices. Tenancy didn't require a tenancy &lt;em&gt;feature&lt;/em&gt;; it fell out of making the target polymorphic and routing all access through one resolver.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few more details worth noting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Snapshot vs live.&lt;/strong&gt; A subscription stores &lt;code&gt;plan_tier&lt;/code&gt; as a &lt;em&gt;snapshot&lt;/em&gt; string, but the live &lt;code&gt;Plan&lt;/code&gt; is resolved from the repository at read time. So plan definitions can live in config or a database table (same &lt;code&gt;PlanRepository&lt;/code&gt; interface either way), and a subscriber's tier reference survives even if you restructure your plan models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic invoice numbers.&lt;/strong&gt; Sequential numbering (&lt;code&gt;INV-2026-000001&lt;/code&gt;) is allocated in a row-locked transaction, so concurrent issuance never collides on a number:&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;$sequence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$sequenceModel&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;'year'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$year&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;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$current&lt;/span&gt; &lt;span class="o"&gt;=&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;$sequence&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;next_number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$sequence&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;next_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$sequence&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Malaysia-friendly, neutrally.&lt;/strong&gt; MYR default, an SST/SSM-aware tax-invoice template, configurable seller details — but all neutral by default, so it's not &lt;em&gt;only&lt;/em&gt; a Malaysian package. The tax math is just &lt;code&gt;round(subtotal * rate)&lt;/code&gt;, stored as a breakdown on the invoice so the PDF renders correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Events as your extension seam.&lt;/strong&gt; The engine only updates state and issues invoices. Provisioning access, dunning emails, Slack pings — those are your listeners on &lt;code&gt;SubscriptionActivated&lt;/code&gt;, &lt;code&gt;SubscriptionRenewed&lt;/code&gt;, &lt;code&gt;SubscriptionCanceled&lt;/code&gt;, &lt;code&gt;PaymentSucceeded&lt;/code&gt;, &lt;code&gt;PaymentFailed&lt;/code&gt;, &lt;code&gt;InvoiceIssued&lt;/code&gt;. The package doesn't presume to know your side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you'd reach for this
&lt;/h2&gt;

&lt;p&gt;It fits when you want subscription + invoicing in Laravel and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you need &lt;strong&gt;more than one gateway&lt;/strong&gt;, or a &lt;strong&gt;Malaysian gateway&lt;/strong&gt;, or the freedom to swap later without re-architecting;&lt;/li&gt;
&lt;li&gt;you want the &lt;strong&gt;full flow working on day one&lt;/strong&gt; — demo, UAT, CI — before any merchant account exists;&lt;/li&gt;
&lt;li&gt;you want a &lt;strong&gt;headless engine&lt;/strong&gt; you can drive from your own UI, with an optional bundled UI when you're moving fast;&lt;/li&gt;
&lt;li&gt;you bill &lt;strong&gt;teams or workspaces&lt;/strong&gt;, not just users;&lt;/li&gt;
&lt;li&gt;you're in a &lt;strong&gt;SST/SSM&lt;/strong&gt; context and want sane local invoicing without a provider lock-in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're all-in on a single global gateway forever and its first-party Laravel package covers you, use that. The value here shows up the moment "which gateway" becomes a question with more than one answer — which, for anyone building for the Malaysian market, it always is.&lt;/p&gt;

&lt;p&gt;It's MIT-licensed and on Packagist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require cleaniquecoders/laravel-billing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo and full docs (architecture, gateways, the full billing cycle, writing your own driver): &lt;a href="https://github.com/cleaniquecoders/laravel-billing" rel="noopener noreferrer"&gt;github.com/cleaniquecoders/laravel-billing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Implementing a gateway is one class and three methods — if you write a BayarCash or ToyyibPay driver, I'd love to see it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>saas</category>
      <category>opensource</category>
    </item>
    <item>
      <title>PII Protection in PHP without a framework holding the leash</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:18:44 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/pii-protection-in-php-without-a-framework-holding-the-leash-22j4</link>
      <guid>https://dev.to/nasrulhazim/pii-protection-in-php-without-a-framework-holding-the-leash-22j4</guid>
      <description>&lt;p&gt;Every app that touches personal data eventually hits the same wall. You've got a &lt;code&gt;national_id&lt;/code&gt; column, an &lt;code&gt;email&lt;/code&gt;, a phone number, maybe a credit card. Compliance (PDPA here in Malaysia, GDPR, SOC 2 — pick your acronym) says you can't just store them in plaintext, and you definitely can't dump them into your audit log when someone edits a record.&lt;/p&gt;

&lt;p&gt;The usual answer is "use the framework's encryption." And that works — until you're in a queue worker, a standalone CLI importer, a Symfony service, or a plain PHP webhook handler that doesn't have the framework's container booted. Suddenly your PII handling is coupled to &lt;code&gt;config()&lt;/code&gt;, &lt;code&gt;env()&lt;/code&gt;, and a service provider that isn't there.&lt;/p&gt;

&lt;p&gt;I kept hitting this across different codebases, so I extracted the primitives into a small library: &lt;a href="https://github.com/cleaniquecoders/pii-protection" rel="noopener noreferrer"&gt;&lt;code&gt;cleaniquecoders/pii-protection&lt;/code&gt;&lt;/a&gt;. This post walks through what's in it, but more usefully, &lt;em&gt;why it's shaped the way it is&lt;/em&gt; — because the design decisions are the actual lesson here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one constraint that drove everything
&lt;/h2&gt;

&lt;p&gt;The package has a single hard rule that everything else falls out of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No framework. No global state. No static facades. No reading from &lt;code&gt;env&lt;/code&gt;/&lt;code&gt;config&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every class is constructor-injected with explicit inputs and returns explicit outputs. That's it. The &lt;code&gt;OpenSslEncrypter&lt;/code&gt; doesn't go looking for &lt;code&gt;APP_KEY&lt;/code&gt; — you hand it a key. The masking strategies don't read a config file — you instantiate them with the options you want.&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\PiiManager&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Encryption\OpenSslEncrypter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Masking\TailStrategy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$manager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PiiManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenSslEncrypter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$appKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TailStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="o"&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;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'0123456789'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// store at rest&lt;/span&gt;
&lt;span class="nv"&gt;$plain&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// "0123456789"&lt;/span&gt;
&lt;span class="nv"&gt;$masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'0123456789'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// "******6789" for display&lt;/span&gt;
&lt;span class="nv"&gt;$audit&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'national_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why bother being this strict? Because PII protection is exactly the kind of cross-cutting concern that shows up in places your framework &lt;em&gt;doesn't&lt;/em&gt; reach. The moment your encryption helper assumes a booted Laravel container, it's useless in the standalone importer that's actually processing the sensitive batch file at 2am. Keeping the primitives portable means the same code protects data everywhere — web request, artisan command, raw worker, test harness — with zero conditional "are we in a framework right now" branching.&lt;/p&gt;

&lt;p&gt;There's a real trade-off here, and I want to name it honestly: you give up convenience. No auto-resolved facade, no &lt;code&gt;Crypt::encrypt()&lt;/code&gt; one-liner. You have to wire the key in yourself. The package's position is that &lt;strong&gt;key handling is the caller's job&lt;/strong&gt; — it'll rotate keys for you (more on that below), but loading and storing them is your responsibility. For a library whose entire reason to exist is correctness around sensitive data, I'd rather make the dependency explicit than hide it.&lt;/p&gt;

&lt;p&gt;Requirements are PHP &lt;code&gt;^8.4&lt;/code&gt;, &lt;code&gt;ext-openssl&lt;/code&gt;, &lt;code&gt;ext-mbstring&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require cleaniquecoders/pii-protection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three jobs, kept separate
&lt;/h2&gt;

&lt;p&gt;The library cleanly splits into three responsibilities that are &lt;em&gt;not&lt;/em&gt; the same thing, even though people often conflate them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encryption&lt;/strong&gt; — reversible. You need the value back later. (AES-256-GCM)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Masking&lt;/strong&gt; — one-way display transformation. &lt;code&gt;******6789&lt;/code&gt; for the UI or a log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redaction&lt;/strong&gt; — walk a payload and mask the listed fields before persisting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conflating these is where bugs come from. Masking is &lt;em&gt;not&lt;/em&gt; security — it's a display concern. If you "mask" a value and store the masked version thinking it's protected, you've lost the data. If you encrypt a value you only ever need to &lt;em&gt;show partially&lt;/em&gt;, you've added decryption surface for no reason. Knowing which job you're doing is half the battle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Masking strategies
&lt;/h3&gt;

&lt;p&gt;Each strategy implements one tiny contract — &lt;code&gt;MaskStrategy::mask(string $value): string&lt;/code&gt; — and there's one per common PII shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TailStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep last N chars&lt;/td&gt;
&lt;td&gt;&lt;code&gt;******6789&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FullStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mask everything&lt;/td&gt;
&lt;td&gt;&lt;code&gt;**********&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EmailStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mask local-part, keep domain&lt;/td&gt;
&lt;td&gt;&lt;code&gt;****@acme.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HashStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One-way &lt;code&gt;sha256&lt;/code&gt; digest&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f4b0...e21&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CreditCardStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep last 4, preserve grouping&lt;/td&gt;
&lt;td&gt;&lt;code&gt;**** **** **** 1111&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IpStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mask the last octet/group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.1.**&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NameStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep each word's initial&lt;/td&gt;
&lt;td&gt;&lt;code&gt;J*** D**&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NricStrategy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mask MyKad digits, keep dashes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;******-**-****&lt;/code&gt;&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmailStrategy&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;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'john.doe@acme.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// "****@acme.com"&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CreditCardStrategy&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;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'4111 1111 1111 1111'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "**** **** **** 1111"&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NricStrategy&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;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'900101-01-1234'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// "******-**-****"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every strategy takes an optional &lt;code&gt;maskChar&lt;/code&gt; if &lt;code&gt;*&lt;/code&gt; doesn't suit your 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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TailStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maskChar&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'•'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'0123456789'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "••••••6789"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NricStrategy&lt;/code&gt; is the local touch — masking Malaysian MyKad numbers while keeping the dash grouping intact, which is what you actually want on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encryption at rest, done properly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;OpenSslEncrypter&lt;/code&gt; uses &lt;strong&gt;AES-256-GCM&lt;/strong&gt;, and a few details 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Encryption\OpenSslEncrypter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$encrypter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenSslEncrypter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$appKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$encrypter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'012345678'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// store this&lt;/span&gt;
&lt;span class="nv"&gt;$plain&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$encrypter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// "012345678"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per message, it generates a random 16-byte salt and a 12-byte IV, then derives a &lt;em&gt;fresh&lt;/em&gt; 256-bit data key with &lt;strong&gt;HKDF-SHA256&lt;/strong&gt; from the ring key plus that salt. The plaintext is encrypted under that derived key, with the caller context bound as GCM AAD. So encrypting the same value twice produces &lt;em&gt;different&lt;/em&gt; ciphertext — correct behaviour for at-rest encryption, with a consequence people forget that I'll come back to.&lt;/p&gt;

&lt;p&gt;The per-message derived key isn't decoration. Deriving a unique key per message means a single key/nonce mishap can't cascade — the blast radius is one record, not the whole column. You're not encrypting a million rows under literally the same AES key.&lt;/p&gt;

&lt;p&gt;Ciphertext is written in a &lt;strong&gt;self-describing, versioned format&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v2.&amp;lt;keyId&amp;gt;.&amp;lt;base64( salt(16) || iv(12) || tag(16) || ciphertext )&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything decrypt needs travels &lt;em&gt;with&lt;/em&gt; the ciphertext — which key id wrote it, the salt, the IV, the GCM tag. That's what makes seamless upgrades possible. The clever bit is backward compatibility: older 1.x releases (pre-1.2) wrote an unversioned &lt;code&gt;base64(iv || tag || ciphertext)&lt;/code&gt; blob with a &lt;code&gt;sha256&lt;/code&gt;-derived key. On decrypt, the code checks for the &lt;code&gt;v2.&lt;/code&gt; prefix — present means versioned path, absent means legacy path. And the prefix is &lt;em&gt;unambiguous&lt;/em&gt; because &lt;code&gt;.&lt;/code&gt; isn't a base64 character, so legacy ciphertext can never accidentally look versioned. No flag column, no migration, no guessing. Old data keeps decrypting unchanged. Versioning your serialized formats from day one — and choosing a delimiter that can't appear in the payload — is the kind of small decision that saves a brutal migration later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Context binding (AAD)
&lt;/h3&gt;

&lt;p&gt;AES-GCM supports Additional Authenticated Data, and the package exposes it as context binding. You bind ciphertext to a context — a user id, a column name — and that same context is required to decrypt:&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;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$encrypter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encryptWithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'012345678'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user:123'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$plain&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$encrypter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decryptWithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user:123'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// wrong context throws&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why care? This defends against a sneaky class of attack: moving valid ciphertext from one row or column to another. Without AAD, an attacker (or a buggy migration) could copy user A's encrypted SSN into user B's row and it would decrypt fine. Bind the context to &lt;code&gt;user:123&lt;/code&gt; and that ciphertext is worthless anywhere else. It's cheap insurance against data being shuffled around.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key rotation
&lt;/h3&gt;

&lt;p&gt;Hand the encrypter a &lt;code&gt;KeyRing&lt;/code&gt; instead of a single key, and rotation becomes a non-event:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Encryption\KeyRing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$encrypter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenSslEncrypter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;KeyRing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'2024'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$oldKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2025'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$newKey&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;currentId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'2025'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// encrypt with this; '2024' ciphertext still decrypts&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New writes use the current key. Old ciphertext keeps decrypting with whichever key its embedded id points to (remember that versioned format — the key id rides along). &lt;strong&gt;No big-bang re-encryption migration.&lt;/strong&gt; You rotate forward, and old data re-encrypts lazily as it's touched, or never, and it still works. Anyone who's tried to re-encrypt a 50-million-row table in one shot knows why this design exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: never query an encrypted column
&lt;/h2&gt;

&lt;p&gt;Here's the consequence I deferred earlier. Because encryption is non-deterministic — random IV and salt every call — &lt;strong&gt;the same value encrypts to different ciphertext each time.&lt;/strong&gt; Which means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- This will NEVER match. Don't do it.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email_cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most common mistake with at-rest encryption, and the package treats it as a guardrail worth shouting about. The fix is a &lt;strong&gt;blind index&lt;/strong&gt; — a deterministic, one-way HMAC you store &lt;em&gt;alongside&lt;/em&gt; the ciphertext and query instead:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Encryption\HmacBlindIndex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$blind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HmacBlindIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$indexKey&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;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'email_cipher'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$encrypter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// for retrieval/display&lt;/span&gt;
    &lt;span class="s1"&gt;'email_index'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$blind&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;// for WHERE email_index = ?&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$blind&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email_index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The index confirms a match — it never reveals the value, and it's not reversible. &lt;code&gt;matches()&lt;/code&gt; compares in constant time (&lt;code&gt;hash_equals&lt;/code&gt;), so you're not leaking timing information either. You get equality lookups on encrypted data without compromising the encryption. (If you don't need the value back at all, just &lt;code&gt;hash&lt;/code&gt; or &lt;code&gt;mask&lt;/code&gt; it and skip the cipher column entirely.)&lt;/p&gt;

&lt;p&gt;There's one trap worth calling out, because it's the thing people actually get wrong: the index is computed on the &lt;em&gt;exact&lt;/em&gt; bytes you pass. &lt;code&gt;John@Acme.com&lt;/code&gt; and &lt;code&gt;john@acme.com&lt;/code&gt; produce different indexes, so a naive email lookup silently misses. The fix is a normaliser — applied at &lt;em&gt;both&lt;/em&gt; write and query time:&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;$blind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HmacBlindIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$indexKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// hex chars; trade storage for collision-resistance&lt;/span&gt;
    &lt;span class="n"&gt;normaliser&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lowercase-and-trim the value before hashing and your lookups behave the way users expect. The &lt;code&gt;length&lt;/code&gt; knob lets you shorten the stored index (down from the full 64 hex chars) when collision-resistance matters less than storage — a deliberate trade-off you get to make per column.&lt;/p&gt;

&lt;p&gt;This whole area is the kind of thing that's obvious in hindsight and a production incident in foresight. Worth internalizing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redaction — where audit logs get cleaned up
&lt;/h2&gt;

&lt;p&gt;This is the part I reach for most. You've got a change-log payload (&lt;code&gt;old_values&lt;/code&gt; / &lt;code&gt;new_values&lt;/code&gt;, or any nested map) about to be written to an audit table, and it's full of PII. &lt;code&gt;ArrayRedactor&lt;/code&gt; walks it and masks only the fields you name — recursing into nested arrays &lt;em&gt;and&lt;/em&gt; JSON-decoded structures — leaving everything else untouched.&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\ArrayRedactor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Masking\TailStrategy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$redactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ArrayRedactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TailStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$redactor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'national_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two features make this genuinely useful in real schemas rather than toy examples.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-field strategies&lt;/strong&gt; — each field gets the right masking in a single pass:&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;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$redactor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmailStrategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'phone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TailStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'nric'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashStrategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// bare name → uses the redactor's default strategy&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dot-path and wildcard targeting&lt;/strong&gt; — so you mask a precise location, not &lt;em&gt;any&lt;/em&gt; key that happens to share a name:&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;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$redactor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'user.phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// only user.phone, not a top-level "phone"&lt;/span&gt;
    &lt;span class="s1"&gt;'users.*.phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// every users[].phone&lt;/span&gt;
    &lt;span class="s1"&gt;'contact.email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmailStrategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;users.*.phone&lt;/code&gt; wildcard is the difference between this being a demo and being something you can point at a real nested API payload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redacting objects and DTOs
&lt;/h3&gt;

&lt;p&gt;If you're working with typed objects instead of arrays, tag the properties with an attribute and let &lt;code&gt;ObjectRedactor&lt;/code&gt; handle 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Attributes\Pii&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\ObjectRedactor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;CleaniqueCoders\PiiProtection\Masking\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;EmailStrategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FullStrategy&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&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="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Pii&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Pii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;EmailStrategy&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$email&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;$age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// untagged — copied through untouched&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="nv"&gt;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ObjectRedactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FullStrategy&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;redact&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="c1"&gt;// ['name' =&amp;gt; '********', 'email' =&amp;gt; '****@acme.com', 'age' =&amp;gt; 30]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Declaring sensitivity &lt;em&gt;at the property&lt;/em&gt; is a nice pattern — the DTO becomes self-documenting about what's PII, and the redaction logic doesn't live in some far-away config list that drifts out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scrubbing free text
&lt;/h2&gt;

&lt;p&gt;Named fields are easy. The harder problem is PII buried in &lt;em&gt;unstructured&lt;/em&gt; text — log lines, exception messages, user comments — where there's no "field" to target. &lt;code&gt;PiiScrubber&lt;/code&gt; runs pattern detectors over free text:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Detection\PiiScrubber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PiiScrubber&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;scrub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'contact john@acme.com from 192.168.1.42'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "contact ************* from ************"&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PiiScrubber&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;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$logLine&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// [['type' =&amp;gt; 'email', 'value' =&amp;gt; ..., 'offset' =&amp;gt; ...], ...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Built-in detectors cover email, credit card, Malaysian NRIC, IPv4, and phone numbers (the phone and NRIC patterns are tuned for Malaysian formats). There's a subtle design decision in here worth stealing: the detectors run in a &lt;em&gt;fixed order&lt;/em&gt;, broadest pattern first. Credit cards get masked before the phone-number detector runs, because a 16-digit card number can otherwise partially match a phone pattern and you'd end up with half-masked garbage. When you're chaining regex replacements over the same string, order isn't cosmetic — it's correctness.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;detect()&lt;/code&gt; is the non-destructive sibling: it returns each hit's type, value, and byte offset without touching the text, which is handy when you want to &lt;em&gt;log that PII was present&lt;/em&gt; without logging the PII itself. You can also restrict which detectors run by passing &lt;code&gt;types: ['email', 'nric']&lt;/code&gt; to the constructor.&lt;/p&gt;

&lt;p&gt;For anything bespoke, wrap a pattern in &lt;code&gt;RegexStrategy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\PiiProtection\Masking\RegexStrategy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegexStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\d{10}/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TailStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&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;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ref 0123456789'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "ref ******6789"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pipe this into a log processor and your log files stop being a compliance liability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokenization
&lt;/h2&gt;

&lt;p&gt;Sometimes you don't want the value &lt;em&gt;or&lt;/em&gt; a reversible cipher floating around your main system — you want an opaque placeholder, with the real value parked somewhere isolated. That's tokenization:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;CleaniqueCoders\PiiProtection\Tokenization\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;Tokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ArrayVault&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$tokenizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Tokenizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ArrayVault&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$tokenizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'012345678'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "tok_9f3a..." — reveals nothing&lt;/span&gt;
&lt;span class="nv"&gt;$tokenizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;detokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// "012345678"&lt;/span&gt;
&lt;span class="nv"&gt;$tokenizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                 &lt;span class="c1"&gt;// drop the mapping&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An in-memory &lt;code&gt;ArrayVault&lt;/code&gt; ships for tests and simple cases. For production you implement the &lt;code&gt;Vault&lt;/code&gt; contract against wherever you actually want the mapping to live — a separate secured datastore, an external tokenization service, whatever. The token itself reveals nothing, so your primary database only ever sees &lt;code&gt;tok_...&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;p&gt;The whole architecture is contracts plus small, swappable implementations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Contracts/        Encrypter, ContextualEncrypter, MaskStrategy, Redactor, Vault
Masking/          Tail, Full, Email, Hash, CreditCard, Ip, Name, Nric, Regex
Encryption/       OpenSslEncrypter, KeyRing, HmacBlindIndex
Detection/        PiiScrubber
Tokenization/     Tokenizer, ArrayVault
Attributes/       #[Pii]
Exceptions/       PiiException → EncryptionException, DecryptionException
ArrayRedactor · ObjectRedactor · PiiManager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consumers depend on the &lt;strong&gt;contracts&lt;/strong&gt;, not the concretions — so every piece is swappable. Don't like the OpenSSL implementation? Implement &lt;code&gt;Encrypter&lt;/code&gt; yourself and the rest of the library doesn't notice. Want a masking shape that isn't in the box? One method, &lt;code&gt;mask(string): string&lt;/code&gt;, and you're done.&lt;/p&gt;

&lt;p&gt;Failures throw typed exceptions: &lt;code&gt;EncryptionException&lt;/code&gt; and &lt;code&gt;DecryptionException&lt;/code&gt;, both extending &lt;code&gt;PiiException&lt;/code&gt;, which extends &lt;code&gt;RuntimeException&lt;/code&gt; — so existing catch blocks keep working while you can catch precisely when you want to.&lt;/p&gt;

&lt;p&gt;A few things that matter when you're deciding whether to trust a security-adjacent dependency: it has &lt;strong&gt;zero runtime dependencies&lt;/strong&gt; (just PHP 8.4, &lt;code&gt;ext-openssl&lt;/code&gt;, &lt;code&gt;ext-mbstring&lt;/code&gt; — Pest, PHPStan and Pint are dev-only), every masking strategy is &lt;strong&gt;multibyte-safe&lt;/strong&gt; (&lt;code&gt;mb_*&lt;/code&gt; throughout, so non-ASCII names slice by character not byte), PHPStan runs at &lt;strong&gt;level max&lt;/strong&gt;, and the suite includes &lt;strong&gt;mutation testing&lt;/strong&gt; plus a test that fails the build if a stray &lt;code&gt;dd()&lt;/code&gt;, &lt;code&gt;dump()&lt;/code&gt;, or &lt;code&gt;ray()&lt;/code&gt; is left in &lt;code&gt;src/&lt;/code&gt;. For a library whose whole job is handling sensitive data, that level of paranoia about its own correctness is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you'd reach for this
&lt;/h2&gt;

&lt;p&gt;It earns its place when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you handle PII in &lt;strong&gt;places the framework doesn't boot&lt;/strong&gt; — workers, CLI tools, microservices, webhook handlers;&lt;/li&gt;
&lt;li&gt;you need &lt;strong&gt;audit/change logs that don't leak&lt;/strong&gt; personal data;&lt;/li&gt;
&lt;li&gt;you want &lt;strong&gt;encryption at rest with a sane rotation story&lt;/strong&gt; and searchable lookups via blind index;&lt;/li&gt;
&lt;li&gt;you're under &lt;strong&gt;PDPA / GDPR / SOC 2&lt;/strong&gt; and need to &lt;em&gt;show&lt;/em&gt; your PII handling is deliberate, not incidental.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If all you ever do is &lt;code&gt;Crypt::encrypt()&lt;/code&gt; inside a single Laravel monolith and nothing else, the framework's built-in is fine. The value of going pure-PHP shows up the moment your data crosses a boundary the framework doesn't own — and in any non-trivial system, it always does.&lt;/p&gt;

&lt;p&gt;It's MIT-licensed and on Packagist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require cleaniquecoders/pii-protection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo and full docs (architecture, usage, API reference): &lt;a href="https://github.com/cleaniquecoders/pii-protection" rel="noopener noreferrer"&gt;github.com/cleaniquecoders/pii-protection&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've got a PII shape that isn't covered yet, the strategy contract is one method — PRs welcome.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>laravel</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Screenshot-Driven Vibe Coding: Why Your AI Workflow Needs a Glossary Step</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 22 May 2026 00:11:45 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/screenshot-driven-vibe-coding-why-your-ai-workflow-needs-a-glossary-step-529e</link>
      <guid>https://dev.to/nasrulhazim/screenshot-driven-vibe-coding-why-your-ai-workflow-needs-a-glossary-step-529e</guid>
      <description>&lt;p&gt;The other day I came across a Claude Code todo list that looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Read and identify all screenshots
✓ Categorize screenshots into 4 groups
◼ Create folders and move/rename screenshots
☐ Write business requirements doc per screenshot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four steps. Clean. The kind of plan a vibe coder would screenshot and post on X with the caption &lt;em&gt;"AI is unreal right now."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And honestly — it is a good plan. Better than 90% of what I see in the wild. But it's also one tiny step away from producing 30 beautifully written documents that quietly contradict each other.&lt;/p&gt;

&lt;p&gt;Let me walk you through why this strategy works, where it breaks, and the one step I'd add before you let your AI write a single requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The strategy, decoded
&lt;/h2&gt;

&lt;p&gt;Before we critique anything, let's give credit where it's due. This workflow has three things going for it that most vibe coding sessions don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It uses screenshots as the source of truth.&lt;/strong&gt; Most people start vibe coding from a vague idea in their head — &lt;em&gt;"build me a project management app like ClickUp but cheaper"&lt;/em&gt;. The AI has nothing concrete to anchor on, so it hallucinates a generic CRUD app, and you spend the next three days correcting it. Screenshots flip this. They're concrete. They show real states, real data, real edge cases. An AI looking at a screenshot can't drift as far as one listening to a wish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It categorizes before it documents.&lt;/strong&gt; The "4 groups" step is the unsung hero here. Without grouping, you end up with thirty disconnected requirements docs that don't share vocabulary. Grouping forces pattern recognition — &lt;em&gt;these eight screens are all CRUD on the same entity, these five are reporting, these three are settings&lt;/em&gt;. That clustering naturally maps to &lt;strong&gt;modules&lt;/strong&gt; or &lt;strong&gt;bounded contexts&lt;/strong&gt; later, which is exactly what you want when you start translating documents into code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It produces atomic, reviewable artifacts.&lt;/strong&gt; One BRD per screenshot. Small, self-contained, easy to diff, easy to hand to Claude Code with a prompt like &lt;em&gt;"generate the Livewire component for this spec."&lt;/em&gt; You can iterate on one BRD without rewriting the whole project.&lt;/p&gt;

&lt;p&gt;This is genuinely a good foundation. If you stopped reading here and ran with it, you'd be ahead of most teams.&lt;/p&gt;

&lt;p&gt;But we're not stopping here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode #1: BRDs without a shared vocabulary
&lt;/h2&gt;

&lt;p&gt;Here's what happens when you skip from categorization straight to writing BRDs.&lt;/p&gt;

&lt;p&gt;Screenshot 03 shows a customer list. The AI writes: &lt;em&gt;"The system shall display a paginated list of **Customers&lt;/em&gt;&lt;em&gt;."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Screenshot 11 shows a customer detail page. The AI writes: &lt;em&gt;"The **Member&lt;/em&gt;* profile shall include..."*&lt;/p&gt;

&lt;p&gt;Screenshot 19 shows the same person logging in. The AI writes: &lt;em&gt;"Upon authentication, the **User&lt;/em&gt;* is redirected..."*&lt;/p&gt;

&lt;p&gt;Screenshot 24 is a B2B contact view. The AI writes: &lt;em&gt;"Each **Account&lt;/em&gt;* has a primary contact..."*&lt;/p&gt;

&lt;p&gt;Customer. Member. User. Account. Four words. Same entity. Different document. Different author session. Different vibes.&lt;/p&gt;

&lt;p&gt;Now imagine you feed all 30 BRDs to Claude Code and ask it to generate migrations. Watch what happens.&lt;/p&gt;

&lt;p&gt;You get a &lt;code&gt;customers&lt;/code&gt; table, a &lt;code&gt;members&lt;/code&gt; table, a &lt;code&gt;users&lt;/code&gt; table (probably from Laravel's default), and an &lt;code&gt;accounts&lt;/code&gt; table. None of them reference each other. The Eloquent relationships are guesswork. Half your Actions take &lt;code&gt;Customer $customer&lt;/code&gt;, the other half take &lt;code&gt;User $user&lt;/code&gt;, and your form requests use both interchangeably.&lt;/p&gt;

&lt;p&gt;This is the most common failure I see in screenshot-driven workflows, and it never shows up in the demo. It shows up in week three, when you realize half your codebase is talking past itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is a vocabulary extraction step.&lt;/strong&gt; Before any BRD gets written, you produce a &lt;code&gt;glossary.md&lt;/code&gt; that looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Term     | Definition                              | Aliases                  |
|----------|-----------------------------------------|--------------------------|
| Customer | An organisation that purchases services | Account, Client          |
| Member   | An individual user within a Customer    | User, Contact, Staff     |
| Order    | A purchase transaction                  | Sale, Invoice (informal) |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now when the AI writes BRD-019, it consults the glossary, sees that "Member" and "User" are aliases for the same concept, and picks one canonical term. Suddenly your thirty documents speak the same language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode #2: Screen-driven design is not system design
&lt;/h2&gt;

&lt;p&gt;Screenshots tell you what the user sees. They don't tell you what the system does.&lt;/p&gt;

&lt;p&gt;Look at any login screen. The screenshot shows a form with email, password, and a button. The BRD writes itself: &lt;em&gt;"User enters credentials, system authenticates, user is redirected to dashboard."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What the screenshot doesn't show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The rate limiter that locks the account after five failed attempts&lt;/li&gt;
&lt;li&gt;The audit log entry that records every login attempt, successful or not&lt;/li&gt;
&lt;li&gt;The webhook that fires to your CRM when a new user logs in for the first time this month&lt;/li&gt;
&lt;li&gt;The background job that recomputes the user's permission cache&lt;/li&gt;
&lt;li&gt;The SSO redirect path that bypasses the password field entirely&lt;/li&gt;
&lt;li&gt;The API endpoint that mobile clients use, which doesn't render this form at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is in the picture. All of it is in the system.&lt;/p&gt;

&lt;p&gt;If you're building a SaaS — and especially if you're in the kind of architecture I tend to work in, where you've got a hub-and-spoke pattern with things like identity providers, API gateways, and webhook routers — half your architecture lives behind the UI. A pure screenshot-driven workflow will miss it entirely.&lt;/p&gt;

&lt;p&gt;The fix is to add a step &lt;em&gt;per group&lt;/em&gt; (not per screenshot) where you explicitly list the &lt;strong&gt;non-UI concerns&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background jobs and scheduled tasks&lt;/li&gt;
&lt;li&gt;Webhooks emitted and consumed&lt;/li&gt;
&lt;li&gt;Permission matrix (who can do what)&lt;/li&gt;
&lt;li&gt;Audit and observability requirements&lt;/li&gt;
&lt;li&gt;API contracts for non-UI consumers&lt;/li&gt;
&lt;li&gt;Integration points with other modules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This doesn't need to be exhaustive on the first pass. It just needs to exist so you remember to look behind the curtain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode #3: Losing the trail
&lt;/h2&gt;

&lt;p&gt;The original workflow says &lt;em&gt;"create folders and move/rename screenshots."&lt;/em&gt; Good — but rename them to what?&lt;/p&gt;

&lt;p&gt;If your screenshots become &lt;code&gt;screenshot_001.png&lt;/code&gt;, &lt;code&gt;screenshot_002.png&lt;/code&gt;, you've lost the only useful thing you could have encoded: traceability.&lt;/p&gt;

&lt;p&gt;Three months later, when you're debugging a Livewire component and you want to know &lt;em&gt;"what was the original requirement for this?"&lt;/em&gt;, you want to be able to trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Code:        app/Livewire/CustomerList.php
   ↑ generated from
BRD:         docs/brd/g01-s03-customer-list.md
   ↑ extracted from
Screenshot:  screenshots/g01-customers/s03-list-view.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naming convention does this work for you, silently. Something like &lt;code&gt;g01-s03-customer-list.png&lt;/code&gt; tells you immediately: group 1, screen 3, the customer list. The BRD inherits the same ID. The generated code references it in a docblock. Now you have a chain of custody from pixel to production.&lt;/p&gt;

&lt;p&gt;This costs you nothing at rename time. It costs you everything if you skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode #4: Inconsistent BRD structure
&lt;/h2&gt;

&lt;p&gt;If you let the AI freestyle each BRD, you'll get thirty documents with thirty slightly different structures. One has a "User Stories" section, another has "Use Cases", a third has "Functional Requirements", a fourth jumps straight into a numbered list of behaviours.&lt;/p&gt;

&lt;p&gt;This isn't a vibe problem, it's a &lt;em&gt;parsing&lt;/em&gt; problem. When you later want to feed these BRDs into Claude Code to generate code, the AI does much better with predictable structure. Same headings, same order, same vocabulary for sections.&lt;/p&gt;

&lt;p&gt;Define the template once, before the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# {Screen ID} — {Screen Name}&lt;/span&gt;

&lt;span class="gu"&gt;## Purpose&lt;/span&gt;
What this screen exists to do, in one sentence.

&lt;span class="gu"&gt;## Actors &amp;amp; Permissions&lt;/span&gt;
Who can access this screen, and what role gates apply.

&lt;span class="gu"&gt;## Entities Referenced&lt;/span&gt;
List of glossary terms this screen interacts with.

&lt;span class="gu"&gt;## User Actions&lt;/span&gt;
For each action: trigger, validation rules, side effects, success state, failure state.

&lt;span class="gu"&gt;## States&lt;/span&gt;
Empty state, loading state, error state, success state.

&lt;span class="gu"&gt;## Non-Functional Notes&lt;/span&gt;
Performance expectations, accessibility, audit requirements.

&lt;span class="gu"&gt;## Open Questions&lt;/span&gt;
Things the screenshot doesn't tell us and we need to confirm.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every BRD looks the same. Claude Code knows exactly where to look for "what validation rules apply to the Save button." Your reviewers know exactly where to add comments. Your future self thanks you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refined workflow
&lt;/h2&gt;

&lt;p&gt;Putting it all together, here's what I'd actually run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. ✓ Read and identify all screenshots
2. ✓ Categorize into groups (by feature area, not screen type)
3. ◼ Create folders and rename with traceable IDs (g{NN}-s{NN}-{slug})
4. NEW: Extract shared entities, enums, and permissions → glossary.md
5. NEW: Define the BRD template once
6. ☐ Write BRD per screenshot, referencing the glossary
7. NEW: Per group, document non-UI concerns (jobs, webhooks, audits, APIs)
8. NEW: Synthesise — produce the domain model, module boundaries, Action inventory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 4, 5, 7, and 8 are the ones that turn a demo into a system.&lt;/p&gt;

&lt;p&gt;Step 8 is where this becomes powerful, by the way. Once you have a glossary, BRDs in consistent format, and non-UI concerns mapped, you can feed all of it to Claude Code with a prompt like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Given this glossary and these BRDs, generate the Eloquent models with relationships, Form Requests, invokable Actions, and Pest tests for the Customers module.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the output will actually fit. The naming will be consistent. The relationships will make sense. The tests will reference the right entities. Because you did the upstream work of giving the AI a coherent context to operate in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson nobody puts on the screenshot
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this, take this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI is excellent at extraction. Humans must still define the schema of what's extracted.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The screenshot-to-BRD workflow looks like the AI is doing all the work. It isn't. It's doing the &lt;em&gt;typing&lt;/em&gt;. The thinking — the categorization, the vocabulary, the template, the boundaries between modules — that's still yours. Skipping those steps doesn't speed you up. It just defers the cost to a later point in the project where it's ten times more expensive to fix.&lt;/p&gt;

&lt;p&gt;Vibe coding isn't about letting the AI decide. It's about giving the AI a structured enough world that its decisions stop being random.&lt;/p&gt;

&lt;p&gt;Add the glossary step. Define the template. Document what the screenshot doesn't show. Encode the trail in your filenames.&lt;/p&gt;

&lt;p&gt;Then let it rip.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've tried screenshot-driven workflows on your own projects, I'd love to hear which failure modes you've hit and how you worked around them. Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>architecture</category>
      <category>laravel</category>
    </item>
    <item>
      <title>Here's How I Build Products Without Losing My Mind.</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Tue, 07 Apr 2026 00:58:17 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/heres-how-i-build-products-without-losing-my-mind-1mi1</link>
      <guid>https://dev.to/nasrulhazim/heres-how-i-build-products-without-losing-my-mind-1mi1</guid>
      <description>&lt;p&gt;I've been building software for over a decade.&lt;/p&gt;

&lt;p&gt;Laravel trainer. Package author. Solution architect. Juggling multiple products at once — G8Stack, G8ID, Nadi, GatherHub, Warung POS, and more.&lt;/p&gt;

&lt;p&gt;And for a long time, I had the same problem every developer has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd jump into code before the thinking was done.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Half-built features. Scope creep. Vague requirements that sounded clear in my head but turned into a mess the moment I opened my editor. I wasn't building products — I was chasing ideas.&lt;/p&gt;

&lt;p&gt;Something had to change.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Shift: Blueprint Before Code
&lt;/h2&gt;

&lt;p&gt;The turning point wasn't a new framework or a new tool. It was a mindset shift.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Build the blueprint first. Code later.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Sounds obvious. But most of us don't actually do it. We treat planning as a formality — something we rush through so we can get to the "real" work.&lt;/p&gt;

&lt;p&gt;I stopped doing that. And everything changed.&lt;/p&gt;

&lt;p&gt;Here's the workflow I landed on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1 — Idea to Clear Direction
&lt;/h2&gt;

&lt;p&gt;Every product starts with a brainstorm. No filters. No structure. Just dumping everything out of my head.&lt;/p&gt;

&lt;p&gt;But I don't do this alone.&lt;/p&gt;

&lt;p&gt;I bring Claude into the conversation early — not to generate ideas, but to stress-test them. I'll describe what I'm thinking and let Claude push back: &lt;em&gt;Who exactly is this for? What happens if the user does X? Have you considered this edge case?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's like having a thinking partner available at 2am who never gets tired of your half-formed ideas and isn't afraid to point out the holes.&lt;/p&gt;

&lt;p&gt;By the end of this phase I can answer clearly: &lt;em&gt;what exactly am I building, for who, and why does it matter?&lt;/em&gt; And more importantly — what are the constraints, the risks, and the things I hadn't thought of yet.&lt;/p&gt;

&lt;p&gt;Only then do I move forward.&lt;/p&gt;

&lt;p&gt;This phase sounds simple. But skipping it — or rushing through it alone — is the number one reason products stall. You can't build what you haven't clearly thought through.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2 — Name It. Own the Domain.
&lt;/h2&gt;

&lt;p&gt;Before any file is created, before any command is run — I name the project.&lt;/p&gt;

&lt;p&gt;This sounds like a small thing. It isn't.&lt;/p&gt;

&lt;p&gt;The name shapes everything: the repo, the folder structure, the domain, the brand, the way you talk about it to clients. Getting it wrong early means renaming things later, and renaming things is painful.&lt;/p&gt;

&lt;p&gt;So I take the time. I discuss naming options with Claude — checking for clarity, memorability, and whether it communicates what the product actually does. Then I check domain availability immediately. If the domain isn't available, I keep going until I find a name where both the name &lt;em&gt;and&lt;/em&gt; the domain work together.&lt;/p&gt;

&lt;p&gt;Only when the name is locked and the domain is secured do I move to the next step.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 3 — Kickoff
&lt;/h2&gt;

&lt;p&gt;With a name in hand, I scaffold the project using &lt;a href="https://kickoff.my" rel="noopener noreferrer"&gt;kickoff.my&lt;/a&gt; — my own tool for getting Laravel projects started with the right foundation.&lt;/p&gt;

&lt;p&gt;The project name drives everything here: folder name, repo name, namespace, environment config. It all flows from that one decision made in Phase 2.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 4 — The CLAUDE.md (The Blueprint)
&lt;/h2&gt;

&lt;p&gt;Now I write the &lt;code&gt;CLAUDE.md&lt;/code&gt; — inside the project that was just created.&lt;/p&gt;

&lt;p&gt;Think of it as a structured brief — not for a client, but for Claude Code (my AI coding assistant). It covers what I'm building, the tech stack, the architecture decisions, naming conventions, and phase-by-phase plan.&lt;/p&gt;

&lt;p&gt;Hard cap: &lt;strong&gt;40KB&lt;/strong&gt;. That constraint keeps me honest. If I can't describe the product in 40KB of structured markdown, I probably don't understand it well enough yet.&lt;/p&gt;

&lt;p&gt;Everything that doesn't fit goes into a &lt;code&gt;REFERENCE.md&lt;/code&gt; — detailed data models, edge cases, compliance requirements, anything I need visible but not in the main file.&lt;/p&gt;

&lt;p&gt;Between the two files, Claude Code has everything it needs to understand the project &lt;em&gt;cold&lt;/em&gt; — without me explaining context every single session.&lt;/p&gt;

&lt;p&gt;From there, Claude Code reads the blueprint and fills in the &lt;code&gt;docs/&lt;/code&gt; folder — expanding on specs, documenting the data model, breaking down each phase in detail. Full visibility. Nothing hidden.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 5 — GitHub Setup (Before a Single Line of App Code)
&lt;/h2&gt;

&lt;p&gt;This is the part most developers skip entirely.&lt;/p&gt;

&lt;p&gt;Before I write any application code, Claude Code creates the GitHub repo and auto-generates Issues based on the phases in my plan. Milestones. Labels. Everything organised.&lt;/p&gt;

&lt;p&gt;Now I have a project board that reflects the actual plan — not something I backfilled after the fact.&lt;/p&gt;

&lt;p&gt;This alone has saved me hours of context-switching and "wait, what was I building next?"&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 6 — Build Phase by Phase
&lt;/h2&gt;

&lt;p&gt;Only now do I start coding.&lt;/p&gt;

&lt;p&gt;Each phase has defined deliverables. Each GitHub Issue maps to a real milestone. Claude Code works within the documented context — so there's no guesswork, no AI hallucinations about what I "probably meant", and far fewer errors.&lt;/p&gt;

&lt;p&gt;When I finish a phase, I close the issues, review what changed, and update the docs before moving to the next one.&lt;/p&gt;

&lt;p&gt;It feels slow at first. It isn't. It's the fastest way I've ever shipped a product that actually works the way I intended.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus: Agent Skills
&lt;/h2&gt;

&lt;p&gt;On top of all this, I maintain a personal library of Claude Code skills — reusable patterns that encode &lt;em&gt;my&lt;/em&gt; way of doing things.&lt;/p&gt;

&lt;p&gt;Pest testing. Livewire/Flux patterns. API lifecycle. CI/CD pipelines. Package development. Each skill is a structured file that tells Claude Code exactly how I want a thing done.&lt;/p&gt;

&lt;p&gt;If you're curious: &lt;a href="https://github.com/nasrulhazim/agent-skills" rel="noopener noreferrer"&gt;github.com/nasrulhazim/agent-skills&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Lesson
&lt;/h2&gt;

&lt;p&gt;The reason this workflow works isn't Claude. It's not the tools. It's the &lt;em&gt;constraint&lt;/em&gt; of having to think clearly before building.&lt;/p&gt;

&lt;p&gt;When you write a proper blueprint, you catch the problems that would have killed your project at phase 3. You make architecture decisions consciously instead of by accident. You know — before you write a line of code — what done looks like.&lt;/p&gt;

&lt;p&gt;AI just makes the execution faster. The clarity still has to come from you.&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.amazonaws.com%2Fuploads%2Farticles%2Feiz6hj3bw71hhrdl3dm8.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.amazonaws.com%2Fuploads%2Farticles%2Feiz6hj3bw71hhrdl3dm8.png" alt="Figure 1. A structured product development lifecycle emphasising pre-implementation specification and AI-augmented planning." width="800" height="890"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Want to Learn This or Work With Me?
&lt;/h2&gt;

&lt;p&gt;If you're a developer or founder who wants to build smarter — not just faster — here's how we can work together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ Learn:&lt;/strong&gt; I run Laravel training sessions covering architecture, clean code patterns, and production-grade development. &lt;a href="https://devhub.my" rel="noopener noreferrer"&gt;Reach out to me&lt;/a&gt; and ask about upcoming sessions or custom workshops for your team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ Hire:&lt;/strong&gt; Need a solution architect who can take your idea from zero to structured, shippable product? That's exactly what I do. See what I've built at &lt;a href="https://nasrulhazim.com/projects" rel="noopener noreferrer"&gt;nasrulhazim.com/projects&lt;/a&gt; and reach out at &lt;a href="https://nasrulhazim.com" rel="noopener noreferrer"&gt;nasrulhazim.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ Follow along:&lt;/strong&gt; I document the journey — products, patterns, and lessons learned — right here on dev.to. Hit follow so you don't miss the next post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Blueprint first, code later. Build the map before you start the journey.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Laravel Artisan Runner — Run Artisan Commands from the Browser, Safely</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Mon, 30 Mar 2026 08:49:32 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/laravel-artisan-runner-run-artisan-commands-from-the-browser-safely-123m</link>
      <guid>https://dev.to/nasrulhazim/laravel-artisan-runner-run-artisan-commands-from-the-browser-safely-123m</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Every Laravel developer knows the drill. Someone on the team needs to clear the cache, run migrations, or trigger a seeder — but they don't have SSH access. So they ping you on Slack. You SSH in, run the command, confirm the output, and go back to what you were doing.&lt;/p&gt;

&lt;p&gt;Multiply that by a dozen teammates and a handful of environments, and it becomes a real productivity drain.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;Laravel Artisan Runner&lt;/strong&gt; to solve this. It's a package that lets you expose pre-approved Artisan commands through a clean, Livewire-powered web interface — with full audit logging, queued execution, and notifications baked in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;p&gt;At its core, Artisan Runner gives you three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A web UI&lt;/strong&gt; to browse, configure, and execute Artisan commands&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An allowlist system&lt;/strong&gt; so only approved commands can be run&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A complete audit trail&lt;/strong&gt; of every execution — who ran what, when, with what parameters, and what happened&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every command runs through a queued job. No blocking HTTP requests. No timeouts on long-running migrations.&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.amazonaws.com%2Fuploads%2Farticles%2Ftrdc0cs0s869prdvnj3m.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.amazonaws.com%2Fuploads%2Farticles%2Ftrdc0cs0s869prdvnj3m.png" alt="Artisan Runner"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require cleaniquecoders/laravel-artisan-runner

php artisan artisan-runner:install

php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Three commands and you're up.&lt;/p&gt;

&lt;p&gt;The install command publishes the config, migrations, views, and assets in one shot. Then drop the component into any Blade view:&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;livewire:artisan-runner::command-runner /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default route is &lt;code&gt;/artisan-runner&lt;/code&gt;, protected by &lt;code&gt;web&lt;/code&gt; and &lt;code&gt;auth&lt;/code&gt; middleware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration: Three Discovery Modes
&lt;/h2&gt;

&lt;p&gt;The package supports three ways to determine which commands are available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual (Default — Safest)
&lt;/h3&gt;

&lt;p&gt;Only commands you explicitly list in &lt;code&gt;allowed_commands&lt;/code&gt; are available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/artisan-runner.php&lt;/span&gt;

&lt;span class="s1"&gt;'discovery_mode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'manual'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="s1"&gt;'allowed_commands'&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;'cache:clear'&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;'label'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Clear Cache'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Flush the application cache.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'group'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Cache'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'parameters'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'migrate'&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;'label'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Run Migrations'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Run database migrations.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'group'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Database'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'parameters'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'--force'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Force (production)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'--seed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Run seeders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;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;h3&gt;
  
  
  Auto Discovery
&lt;/h3&gt;

&lt;p&gt;Surfaces all Artisan commands minus your exclusion list:&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;'discovery_mode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="s1"&gt;'excluded_commands'&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;'down'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'up'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'serve'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tinker'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'db:wipe'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="s1"&gt;'excluded_namespaces'&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;'make'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'schedule'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'queue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'stub'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Good for development. I wouldn't recommend this for production without a tight exclusion list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Selection
&lt;/h3&gt;

&lt;p&gt;A middle ground — only explicitly included commands are discovered, then merged with your manual entries:&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;'discovery_mode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'selection'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="s1"&gt;'included_commands'&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;'cache:clear'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'migrate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'config:cache'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'route:cache'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a CLI tool to help you build your command list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan artisan-runner:discover
php artisan artisan-runner:discover &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json
php artisan artisan-runner:discover &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How It Works Under the Hood
&lt;/h2&gt;

&lt;p&gt;The architecture follows a clean action-based pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User clicks "Run"
  → Livewire CommandRunner component
    → RunCommandAction (validates against allowlist)
      → Dispatches RunArtisanCommandJob to queue
        → Job calls Artisan::call()
          → Output + exit code saved to CommandLog
            → Notification dispatched (if enabled)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Audit Log
&lt;/h3&gt;

&lt;p&gt;Every execution creates a &lt;code&gt;CommandLog&lt;/code&gt; record:&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="nc"&gt;CommandLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'uuid'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'command'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'migrate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'parameters'&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;'--force'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'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="c1"&gt;// pending → running → completed/failed&lt;/span&gt;
    &lt;span class="s1"&gt;'ran_by_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'App\Models\User'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'ran_by_id'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status lifecycle is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pending → running → completed
                  → failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each log tracks start time, finish time, full output, and exit code. The Livewire component polls every 5 seconds, so you see status updates in real time.&lt;/p&gt;

&lt;p&gt;Query your logs programmatically:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\ArtisanRunner\Models\CommandLog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CleaniqueCoders\ArtisanRunner\Enums\CommandStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Recent failures&lt;/span&gt;
&lt;span class="nc"&gt;CommandLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CommandStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&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="c1"&gt;// Who's been running commands&lt;/span&gt;
&lt;span class="nc"&gt;CommandLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ranBy'&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;latest&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;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Smart Parameter Rendering
&lt;/h3&gt;

&lt;p&gt;The UI automatically renders the right input type based on parameter definitions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Arguments&lt;/strong&gt; (positional) → text inputs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boolean flags&lt;/strong&gt; (&lt;code&gt;--force&lt;/code&gt;, &lt;code&gt;--seed&lt;/code&gt;) → checkboxes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numeric options&lt;/strong&gt; (&lt;code&gt;--step=5&lt;/code&gt;) → number inputs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;String options&lt;/strong&gt; → text inputs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One gotcha I hit early: Livewire doesn't play well with &lt;code&gt;wire:model&lt;/code&gt; bindings that have &lt;code&gt;--&lt;/code&gt; prefixes (like &lt;code&gt;parameterValues.--force&lt;/code&gt;). The fix was to use index-based keys internally and map them back to parameter names when dispatching. Small detail, but it would've bitten anyone building this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notifications
&lt;/h2&gt;

&lt;p&gt;When a command completes or fails, the package can notify a configurable user via any Laravel notification channel:&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;'notification'&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;'enabled'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'channels'&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;'database'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mail'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'notifiable'&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;'model'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;\App\Models\User&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="s1"&gt;'identifier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'value'&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;'ARTISAN_RUNNER_NOTIFY_EMAIL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ops@yourdomain.com'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The notification includes the command name, status, exit code, and execution duration. Wire it up to Slack, Discord, or whatever your team uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: Built Paranoid by Default
&lt;/h2&gt;

&lt;p&gt;This is a package that runs shell commands from a web interface. Security isn't optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowlist-first.&lt;/strong&gt; The default mode is &lt;code&gt;manual&lt;/code&gt;. Nothing runs unless you explicitly approve it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth-protected routes.&lt;/strong&gt; Default middleware is &lt;code&gt;['web', 'auth']&lt;/code&gt;. Tighten it further:&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;'route'&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;'middleware'&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;'web'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'role:admin'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dangerous commands pre-excluded.&lt;/strong&gt; Even in auto-discovery mode, commands like &lt;code&gt;down&lt;/code&gt;, &lt;code&gt;db:wipe&lt;/code&gt;, &lt;code&gt;migrate:fresh&lt;/code&gt;, &lt;code&gt;tinker&lt;/code&gt;, and &lt;code&gt;serve&lt;/code&gt; are excluded out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full audit trail.&lt;/strong&gt; Every execution is logged with who triggered it (polymorphic relation — works with any model, not just &lt;code&gt;User&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No retries.&lt;/strong&gt; Failed jobs stay failed. You investigate, you don't auto-retry &lt;code&gt;migrate --force&lt;/code&gt; in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeout protection.&lt;/strong&gt; Commands are killed after 300 seconds. No runaway processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Livewire 3 + 4 Compatibility
&lt;/h2&gt;

&lt;p&gt;The package supports both Livewire 3 and 4. The service provider handles registration automatically:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;method_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'addNamespace'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Livewire 4&lt;/span&gt;
    &lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'artisan-runner'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/Livewire'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Livewire 3&lt;/span&gt;
    &lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;component&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'artisan-runner::command-runner'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CommandRunner&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;No feature flags. No config toggles. It just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Use This?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Great fit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Admin dashboards where non-technical staff need to trigger maintenance tasks&lt;/li&gt;
&lt;li&gt;Deployment pipelines where you want a "run post-deploy tasks" button&lt;/li&gt;
&lt;li&gt;Multi-tenant apps with per-tenant cache clearing or migrations&lt;/li&gt;
&lt;li&gt;Teams where not everyone has SSH access but everyone needs to clear caches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Not ideal for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Interactive commands (&lt;code&gt;tinker&lt;/code&gt;, &lt;code&gt;make:model&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Long-running batch jobs that exceed 5 minutes&lt;/li&gt;
&lt;li&gt;Anything that needs real-time streaming output (it captures output after completion)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stack Compatibility
&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;Supported&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;8.3+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel&lt;/td&gt;
&lt;td&gt;11, 12, 13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Livewire&lt;/td&gt;
&lt;td&gt;3, 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailwind&lt;/td&gt;
&lt;td&gt;4 (via CDN)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require cleaniquecoders/laravel-artisan-runner
php artisan artisan-runner:install
php artisan migrate
php artisan queue:work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then visit &lt;code&gt;/artisan-runner&lt;/code&gt; in your browser.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/cleaniquecoders/laravel-artisan-runner" rel="noopener noreferrer"&gt;github.com/cleaniquecoders/laravel-artisan-runner&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Nasrul Hazim, a software engineer from Malaysia building Laravel packages and tools. Find more of my work at &lt;a href="https://nasrulhazim.com" rel="noopener noreferrer"&gt;nasrulhazim.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>livewire</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
