<?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: Hafiz</title>
    <description>The latest articles on DEV Community by Hafiz (@hafiz619).</description>
    <link>https://dev.to/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>DEV Community: Hafiz</title>
      <link>https://dev.to/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>Spatie Permission vs Bouncer: One Question Decides Which One You Need</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 10 Sep 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/spatie-permission-vs-bouncer-one-question-decides-which-one-you-need-4d17</link>
      <guid>https://dev.to/hafiz619/spatie-permission-vs-bouncer-one-question-decides-which-one-you-need-4d17</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/spatie-permission-vs-bouncer-laravel-roles" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every roles system starts the same way. You add a permission called &lt;code&gt;edit articles&lt;/code&gt;, give it to the editor role, and ship it. It works for a year.&lt;/p&gt;

&lt;p&gt;Then a client asks for something small. Sarah should be able to edit this one article, the one she wrote with the legal team, and nothing else. Your permission is a string. Strings don't know about article 4,182.&lt;/p&gt;

&lt;p&gt;That request is the moment the decision gets made for you, and it decides which package you should have picked. So here's the question that separates the two, before any code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that decides it
&lt;/h2&gt;

&lt;p&gt;Are your permissions about kinds of things, or about particular things?&lt;/p&gt;

&lt;p&gt;"Editors can edit articles" is a kind of thing. The permission is a name, the same name for every article in the table, and a role carries it. That's &lt;a href="https://packagist.org/packages/spatie/laravel-permission" rel="noopener noreferrer"&gt;spatie/laravel-permission&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;"Sarah can edit article 4,182" is a particular thing. The permission points at one row. That's &lt;a href="https://packagist.org/packages/silber/bouncer" rel="noopener noreferrer"&gt;silber/bouncer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Both register themselves on Laravel's Gate, so &lt;code&gt;$user-&amp;gt;can(...)&lt;/code&gt; works either way and your controllers look identical. The difference sits underneath, in what the database can express.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spatie Permission: permissions are names
&lt;/h2&gt;

&lt;p&gt;Spatie's model is roles and permissions as strings, stored in tables, editable at runtime. You add a trait and you're running:&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;Spatie\Permission\Traits\HasRoles&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="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasRoles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you create the 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Permission\Models\Permission&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;Spatie\Permission\Models\Role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editor'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$editor&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="nc"&gt;Permission&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'edit articles'&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assignRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'editor'&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;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit articles'&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;That is most of the package. There's a query scope for finding users by permission, &lt;code&gt;syncPermissions&lt;/code&gt; and &lt;code&gt;syncRoles&lt;/code&gt; for bulk changes, and &lt;code&gt;getAllPermissions&lt;/code&gt; for showing someone what they hold.&lt;/p&gt;

&lt;p&gt;The adoption gap matters more than people admit. Spatie's package has over 110 million installs on Packagist. Every Laravel developer you hire has used it, every AI assistant knows its API, and every question you'll ever have is already answered on Stack Overflow. That is worth real money on a team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ceiling.&lt;/strong&gt; Permissions are global names. The package does not model a permission attached to one specific row, and this is a documented limitation rather than an oversight, &lt;a href="https://github.com/spatie/laravel-permission/issues/520" rel="noopener noreferrer"&gt;asked and answered on the issue tracker&lt;/a&gt; years ago. Spatie's own docs point you at Laravel policies for row-level rules, which is the right answer and also the moment you notice the package stopped helping.&lt;/p&gt;

&lt;p&gt;So Sarah and article 4,182 become your problem, solved with a policy and a pivot table you build yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bouncer: abilities can point at a row
&lt;/h2&gt;

&lt;p&gt;Bouncer calls them abilities, and an ability can be granted against a class or against one 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="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&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;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Post&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;// any post&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&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;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// this post&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line is the whole reason Bouncer exists. Sarah and article 4,182 take one call and no schema of your own.&lt;/p&gt;

&lt;p&gt;Ownership is built in, which removes a policy method most apps write by hand:&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;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&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;toOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&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;toOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&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;to&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ownedVia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&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;'created_by'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there's a capability Spatie has no answer for. Bouncer can forbid, which beats any allow that would otherwise apply:&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;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;everything&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forbid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toManage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forbid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'banned'&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;everything&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'banned'&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;to&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suspending an account without stripping and later rebuilding someone's roles is a real operational need, and forbidding is the clean way to do it. Note that &lt;code&gt;unforbid&lt;/code&gt; only removes the block. It does not grant the ability back, so the underlying allow has to still be there.&lt;/p&gt;

&lt;p&gt;The cost is adoption. Bouncer sits around 5 million installs against Spatie's 110 million plus, so you'll find fewer examples, fewer colleagues who know it, and thinner coverage when you ask an AI assistant about it. It is maintained, with v1.0.4 released in March 2026 supporting Laravel 11 through 13, but it moves at a slower pace.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/spatie-permission-vs-bouncer-laravel-roles" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Policies still sit on top of both
&lt;/h2&gt;

&lt;p&gt;Neither package replaces policies, and the mistake I see most often is treating them as if it did. Permission checks scattered through controllers and Blade files are the same problem as business logic scattered through controllers, and they cause the same trouble later.&lt;/p&gt;

&lt;p&gt;Put the package check inside the policy:&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&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;$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="s1"&gt;'edit articles'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'locked'&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;Your controller then calls &lt;code&gt;Gate::authorize('update', $post)&lt;/code&gt; and knows nothing about which package you chose. Swap Spatie for Bouncer later and the controllers don't change. Spatie's own documentation recommends exactly this, describing policies as the place where your application logic combines with your permission rules.&lt;/p&gt;

&lt;p&gt;I've covered how policies and gates work in detail in &lt;a href="https://hafiz.dev/blog/laravel-policies-vs-gates-authorization-guide" rel="noopener noreferrer"&gt;the authorization guide&lt;/a&gt;, including the &lt;code&gt;before()&lt;/code&gt; super-admin shortcut and rich response objects, so I won't repeat that ground here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenancy is where they diverge again
&lt;/h2&gt;

&lt;p&gt;Both handle tenants, differently enough that it should influence your choice.&lt;/p&gt;

&lt;p&gt;Spatie has a teams mode you turn on in config before running migrations, then set the active team per request:&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/permission.php&lt;/span&gt;
&lt;span class="s1"&gt;'teams'&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;/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="nf"&gt;setPermissionsTeamId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_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 details cause problems. That middleware has to run before &lt;code&gt;SubstituteBindings&lt;/code&gt; or you'll get 404 responses instead of 403s, which is a confusing afternoon. Spatie's documentation still shows this being set in &lt;code&gt;app/Http/Kernel.php&lt;/code&gt;, and that file hasn't existed since Laravel 11. On Laravel 13 the priority goes in &lt;code&gt;bootstrap/app.php&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withMiddleware&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;Middleware&lt;/span&gt; &lt;span class="nv"&gt;$middleware&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;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prependToPriorityList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;\Illuminate\Routing\Middleware\SubstituteBindings&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="n"&gt;prepend&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;\App\Http\Middleware\TeamsPermission&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you switch teams inside a single request you must clear the loaded relations, or you'll read the previous team's answers:&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;setPermissionsTeamId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$newTeamId&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;unsetRelation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roles'&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;unsetRelation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bouncer scopes everything through one call 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="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;scope&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;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleaner to read, and it scopes abilities and roles together. If you're deciding tenancy strategy at the same time, &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;the tenancy comparison&lt;/a&gt; covers the layer below this one, and &lt;a href="https://hafiz.dev/blog/filament-v5-multi-tenancy-complete-implementation-guide" rel="noopener noreferrer"&gt;Filament's tenancy implementation&lt;/a&gt; shows how it plays out in an admin panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache bug that catches both
&lt;/h2&gt;

&lt;p&gt;Permission checks run on nearly every request, so both packages cache. Both then hand you the same class of production bug, where you change a permission and nothing happens.&lt;/p&gt;

&lt;p&gt;Spatie caches the role and permission registry for 24 hours by default. The helper methods reset it for you, but editing rows directly in the database does not, and neither does a deploy. When permissions look stale:&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 permission:cache-reset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bouncer caches per request by default, which is safe. Turn on cross-request caching for speed and you take on invalidation yourself:&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;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// faster&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;refreshFor&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;// now your job, after every change&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a scoped Bouncer, &lt;code&gt;refreshFor&lt;/code&gt; only clears the current tenant's cache, so a user in three tenants needs three calls. That one has cost me an evening.&lt;/p&gt;

&lt;p&gt;Whichever you pick, log permission changes. An audit trail turns "the permission isn't working" into a question you can answer, and &lt;a href="https://hafiz.dev/blog/laravel-activity-log-v5-audit-trail-guide" rel="noopener noreferrer"&gt;activity logging&lt;/a&gt; is a twenty-minute install.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually choose
&lt;/h2&gt;

&lt;p&gt;Spatie, for most applications. Coarse permissions cover far more real systems than people expect, the ecosystem advantage is large and compounding, and you can express the occasional row-level rule in a policy with a pivot table when it comes up.&lt;/p&gt;

&lt;p&gt;Bouncer when per-row permissions are the product rather than an exception. Shared documents, per-project collaborators, anything where users grant each other access to specific records. If your app has a share button, that's Bouncer.&lt;/p&gt;

&lt;p&gt;Neither, if you have three roles that never change. A &lt;code&gt;role&lt;/code&gt; column and a policy will outlive both packages, and you can add one later when the requirements actually arrive. Reaching for a permissions package on day one is a common way to carry two tables and a cache layer you never needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I migrate from Spatie to Bouncer later?
&lt;/h3&gt;

&lt;p&gt;Yes, and it's less painful than it sounds if your checks live in policies. Both register on the Gate, so &lt;code&gt;can()&lt;/code&gt; calls and &lt;code&gt;@can&lt;/code&gt; directives keep working. You rewrite the seeding and admin screens, migrate the data, and swap the calls inside your policy methods. If permission checks are scattered across controllers and Blade files instead, the migration touches every one of them, which is the strongest practical argument for the policy layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need policies if I use one of these packages?
&lt;/h3&gt;

&lt;p&gt;Yes. Packages answer what a user holds. Policies answer whether an action is allowed right now, which usually combines the permission with state, like a locked post, a closed invoice or an expired subscription. Skipping policies means encoding that state logic into permission names, and you'll end up with strings like &lt;code&gt;edit unlocked articles&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which one for a multi-tenant SaaS?
&lt;/h3&gt;

&lt;p&gt;Either works. Spatie's teams mode needs the config flag set before you migrate, so decide early, and be careful with the middleware ordering. Bouncer's scopes read more cleanly and cover abilities and roles in one call. If your tenants need to grant each other access to individual records, that pushes toward Bouncer regardless of tenancy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Bouncer still maintained?
&lt;/h3&gt;

&lt;p&gt;Yes. Version 1.0.4 shipped in March 2026 with support for Laravel 11, 12 and 13. It moves slower than Spatie's package and has a fraction of the installs, so judge it on release cadence and open issues rather than assuming abandonment. The smaller community is a real cost, just not a correctness one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing worth remembering
&lt;/h2&gt;

&lt;p&gt;The choice comes down to whether your permissions name kinds of things or particular things. That answer comes from the product, not from the code.&lt;/p&gt;

&lt;p&gt;Get that answer from whoever writes the requirements, before you install anything. If nobody can tell you whether users will ever share single records with each other, you don't have enough information to choose, and the safe move is a &lt;code&gt;role&lt;/code&gt; column until you do.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>packages</category>
      <category>php</category>
      <category>security</category>
    </item>
    <item>
      <title>Every AI Word You Keep Hearing, Explained With Laravel Code</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 07 Sep 2026 04:15:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/every-ai-word-you-keep-hearing-explained-with-laravel-code-3ccl</link>
      <guid>https://dev.to/hafiz619/every-ai-word-you-keep-hearing-explained-with-laravel-code-3ccl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/ai-terms-explained-for-laravel-developers" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Someone drops this into your team chat: "we'll put a guardrail on the sub-agent before its tool call hits the vector store." You know every single one of those words. The sentence still means nothing.&lt;/p&gt;

&lt;p&gt;That was me for most of this year. The code was never the hard part. The vocabulary was, because almost every explainer is written in Python for people building models, not for people wiring a model into an app that already has a Stripe integration and a queue that has to stay up.&lt;/p&gt;

&lt;p&gt;So this is the map I wanted. Thirty-one terms, grouped into five layers, each one anchored to code that runs in a Laravel app. No maths beyond what you already remember.&lt;/p&gt;

&lt;p&gt;One thing holds the whole map together, and it's worth saying before the first term. All of it sits on next-token prediction plus plumbing you write yourself. Once you accept that, the fancy words stop being fancy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: what the model actually is
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;LLM&lt;/strong&gt;, or large language model, guesses what comes next. That's the entire job. You give it some text, it produces a probability distribution over what the next chunk of text might be, picks one, appends it, and does the whole thing again.&lt;/p&gt;

&lt;p&gt;That chunk is a &lt;strong&gt;token&lt;/strong&gt;. A token isn't a word. It's closer to a syllable-sized piece of a word, and the model has its own vocabulary of them. OpenAI's &lt;a href="https://developers.openai.com/api/docs/concepts" rel="noopener noreferrer"&gt;rule of thumb for English&lt;/a&gt; is that one token runs to roughly four characters, or about three quarters of a word, so 100 tokens is about 75 words. Short common words are one token. Long or unusual ones split into several.&lt;/p&gt;

&lt;p&gt;You don't have to trust a rule of thumb, though. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-what-it-changes-why-it-matters-and-should-you-use-it" rel="noopener noreferrer"&gt;Laravel AI SDK&lt;/a&gt; hands you the real count on every response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&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;SalesCoach&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Summarise this transcript.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;outputTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;totalTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log those three numbers on your first real agent and the cost model stops being abstract. If your provider supports prompt caching, &lt;code&gt;cacheReadInputTokens&lt;/code&gt; and &lt;code&gt;cacheWriteInputTokens&lt;/code&gt; are there too, and they matter more than people expect once a system prompt gets long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next-token prediction&lt;/strong&gt; is the part that's hardest to accept. Watching a model write a working migration, a test, and a passing implementation, it feels impossible that it's picking one token at a time. It is, though. And knowing that explains most of its failures better than any theory about reasoning does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hallucination&lt;/strong&gt; is the name for the most common of those failures. The model produces something fluent and wrong, and gives you no signal that it's wrong, because nothing in next-token prediction separates true from likely. It isn't lying and it isn't broken. It's doing the only thing it does, with nothing to check the output against. That's the argument for tools and retrieval later in this post. Both exist to give the model something real to work from.&lt;/p&gt;

&lt;p&gt;The thing that made this work at scale was the &lt;strong&gt;transformer&lt;/strong&gt;, from a 2017 paper by Vaswani and seven colleagues called &lt;a href="https://arxiv.org/abs/1706.03762" rel="noopener noreferrer"&gt;Attention Is All You Need&lt;/a&gt;, submitted on 12 June that year. Earlier architectures read text in order, one position at a time. The transformer looks at all positions at once and learns which ones should pay attention to which. That's the breakthrough, compressed into a sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temperature&lt;/strong&gt; controls how adventurous the pick is. When the model has ten plausible next tokens, low temperature makes it take the most likely one nearly every time, and high temperature lets it wander. In the SDK it's an attribute on the agent class:&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;Laravel\Ai\Attributes\Temperature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Temperature(0.2)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoiceClassifier&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&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;Promptable&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;Classification, extraction, anything you're going to parse: keep it low. Naming things and writing copy: raise it.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;context window&lt;/strong&gt; is how much text the model can consider in one go. Everything counts against it. Your system prompt, the conversation so far, retrieved documents, tool definitions, tool results, all of it.&lt;/p&gt;

&lt;p&gt;Here's where I'd push back on the usual advice, which is that bigger is better. Chroma's &lt;a href="https://www.trychroma.com/research/context-rot" rel="noopener noreferrer"&gt;Context Rot report&lt;/a&gt; from July 2025 tested 18 models and found that performance degrades as input grows, well before the window is anywhere near full. NVIDIA's &lt;a href="https://arxiv.org/abs/2404.06654" rel="noopener noreferrer"&gt;RULER benchmark&lt;/a&gt; found something similar and blunter, that plenty of models advertising 32k or more can't actually hold quality across 32k. Advertised window and usable window are different numbers. Give the model what it needs and stop there.&lt;/p&gt;

&lt;p&gt;Last one for this layer. &lt;strong&gt;Open weights&lt;/strong&gt; and &lt;strong&gt;open source&lt;/strong&gt; are not synonyms, though they get used that way constantly. Open weights means you can download the parameters and run the model yourself. Open source, used strictly, would also mean the training data and code are available, which for most so-called open models they aren't. Llama and Mistral are open weights. Very little meets that second definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: what turns a model into an agent
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;chatbot&lt;/strong&gt; answers. An &lt;strong&gt;agent&lt;/strong&gt; acts. That's the whole distinction, and everything else in this layer is machinery for making acting safe and useful.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;tool&lt;/strong&gt; is a function you write, described in a way the model can understand, that the model may choose to call. The model never runs your code. It emits a request to run it, your framework runs it, and the result goes back into the conversation. In the SDK a tool is a class with three methods:&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;Illuminate\Contracts\JsonSchema\JsonSchema&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;Laravel\Ai\Contracts\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Tools\Request&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;Stringable&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;LookupOrder&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Look up an order and its current status by order ID.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;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;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&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="s1"&gt;'order_id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toJson&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;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;integer&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;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;description&lt;/code&gt; is not a comment. It's the only thing the model reads when deciding whether to call this tool, so it does more work than the implementation does.&lt;/p&gt;

&lt;p&gt;There's a second reason tools exist, beyond reaching the outside world. Models are bad at anything that has to be exact. Dates, arithmetic, counting, sorting. They're producing likely-looking tokens, not calculating, so "what date is 45 working days from today" is a guess. Give it a tool. Deterministic work belongs in PHP, where it's just code.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;agent&lt;/strong&gt; bundles instructions and tools together:&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;Laravel\Ai\Contracts\Agent&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;Laravel\Ai\Contracts\HasTools&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;Laravel\Ai\Promptable&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;SupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&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;Promptable&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;instructions&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;'You help customers with order and billing questions.'&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;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LookupOrder&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;IssueRefund&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;strong&gt;agent loop&lt;/strong&gt; is what happens when you prompt that class. The model reads the conversation, decides whether to answer or call a tool, and if it calls one, your code runs and the result is appended. Then it starts again. It keeps going until it produces a final answer or hits the limit you set with &lt;code&gt;#[MaxSteps(10)]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each pass is a &lt;strong&gt;step&lt;/strong&gt;, and the SDK exposes them on the response as &lt;code&gt;$response-&amp;gt;steps&lt;/code&gt;. Steps are where cost lives, because every step resends the whole thing: system prompt, tool definitions, and every previous call and result. Step five is much more expensive than step one. Not because the model got slower, but because the conversation got longer.&lt;/p&gt;

&lt;p&gt;The diagram below is the part I wish someone had drawn for me on day one. The model only ever does one thing, which is decide. Everything else in the loop is code you wrote.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/ai-terms-explained-for-laravel-developers" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;ReAct&lt;/strong&gt; is the name for making the model write its reasoning before it acts. Reasoning and Acting, shortened into one word. When you see a "thinking" panel in a chat interface, that's this. It works because tokens spent explaining the plan condition the tokens that come after, which makes the tool choice better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-agent&lt;/strong&gt; systems put agents inside other agents. In the SDK, an agent becomes callable as a tool by implementing &lt;code&gt;CanActAsTool&lt;/code&gt; and giving itself a name and description:&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;RefundsAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CanActAsTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&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;Promptable&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;name&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;'refunds_specialist'&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;description&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Decide whether an order qualifies for a refund.'&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;Then a parent agent lists &lt;code&gt;new RefundsAgent&lt;/code&gt; among its tools, and delegation is just a tool call. It's elegant. It's also the fastest way to spend money I know of, because every sub-agent carries its own context and its own steps. My honest advice is to reach for it only when one agent's tool list has grown incoherent, not because the architecture diagram looks better. I wrote up the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;sub-agent patterns in detail&lt;/a&gt; if you want the longer version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: what gives it knowledge it wasn't trained on
&lt;/h2&gt;

&lt;p&gt;Models have no memory. None. Every API call starts from nothing, and the illusion of continuity in ChatGPT exists because the interface resends the conversation each time. The first time you call a model from your own code, this is the surprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory&lt;/strong&gt;, then, means deciding what to resend. The SDK gives you conversation persistence out of the box:&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;Laravel\Ai\Concerns\RemembersConversations&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;Laravel\Ai\Contracts\Conversational&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;SupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Conversational&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;Promptable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RemembersConversations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&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;SupportAgent&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;forUser&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Where is my order?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$next&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;SupportAgent&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;continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;as&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'And the one before that?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers short conversations. Long ones need a strategy, because resending everything eventually collides with both the context window and your budget. Summarising older turns while keeping recent ones verbatim is the common answer, and choosing where to cut is still unsolved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG&lt;/strong&gt; stands for retrieval augmented generation, and it's a plain idea hidden behind an acronym. Before answering, go and fetch the relevant bits of your own data, put them in the prompt, and let the model answer from those. No retraining. Your support tickets and internal docs were never in the training data, and this is how they get in front of the model anyway.&lt;/p&gt;

&lt;p&gt;Making it work is a pipeline. Split documents into &lt;strong&gt;chunks&lt;/strong&gt;, because whole documents are too big and single sentences lose their meaning. Convert each chunk into an &lt;strong&gt;embedding&lt;/strong&gt;, which is an array of numbers representing what the text means rather than what it says. Store them. At query time, embed the question, find the closest stored chunks, and put those in the prompt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/ai-terms-explained-for-laravel-developers" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Laravel does all of this natively now, which surprised me when I first went looking. Generating embeddings is one call:&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;Illuminate\Support\Str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$embedding&lt;/span&gt; &lt;span class="o"&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;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Napa Valley has great wine.'&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;toEmbeddings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storing them is a column type, and an HNSW index keeps similarity search fast as the table grows:&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;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ensureVectorExtensionExists&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Schema&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;'documents'&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;id&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;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'content'&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;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'embedding'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1536&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;index&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;timestamps&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 &lt;strong&gt;vector database&lt;/strong&gt; is a database built to store those numeric arrays and find the nearest ones quickly. Pinecone and Qdrant are the well-known standalone ones. But you very likely don't need either, because PostgreSQL with pgvector, MariaDB 11.7 or later, and MongoDB all do this from inside your existing database, and Laravel ships the query method:&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;$documents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Document&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;'team_id'&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="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereVectorSimilarTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'embedding'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'best wineries in Napa Valley'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minSimilarity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.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;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass a string and Laravel embeds it for you. Note the ordinary &lt;code&gt;where&lt;/code&gt; clause sitting next to it, which is the thing a separate vector service makes painful and your own database makes trivial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reranking&lt;/strong&gt; is a cheap trick, and it has done more for my results than anything else in this layer. Retrieve a wide set fast, then have a model reorder the top candidates by actual relevance:&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;$articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Article&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;whereFullText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've covered the retrieval side more thoroughly in &lt;a href="https://hafiz.dev/blog/laravel-search-in-2026-full-text-semantic-and-vector-search-explained" rel="noopener noreferrer"&gt;Laravel search in 2026&lt;/a&gt;, including when plain full-text beats all of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: how it reaches the rest of your system
&lt;/h2&gt;

&lt;p&gt;Tools solve access for your own app. &lt;strong&gt;MCP&lt;/strong&gt;, the Model Context Protocol, solves it for everyone else's.&lt;/p&gt;

&lt;p&gt;Anthropic open-sourced it on 25 November 2024, and the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; describe it as "a USB-C port for AI applications", which is a fair description. Before it, connecting M AI clients to N systems meant writing M times N bespoke integrations. A protocol turns that into M plus N.&lt;/p&gt;

&lt;p&gt;An MCP &lt;strong&gt;server&lt;/strong&gt; exposes tools, resources and prompts. An MCP &lt;strong&gt;client&lt;/strong&gt; consumes them. Your Laravel app can be either, and there's a first-party package:&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;Laravel\Mcp\Facades\Mcp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/support'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SupportServer&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:mcp'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That middleware line deserves more attention than it usually gets. An MCP server is a public API whose consumers are language models, so it needs the same authorisation you'd put on any other endpoint, applied per tool as well as per route. I wrote a &lt;a href="https://hafiz.dev/blog/laravel-mcp-server-security-authorization" rel="noopener noreferrer"&gt;whole post on locking one down&lt;/a&gt; after realising how many public ones ship wide open.&lt;/p&gt;

&lt;p&gt;You'll also see &lt;strong&gt;A2A&lt;/strong&gt; and various agent-to-agent protocols. Several appeared once MCP became popular. Adoption has gone almost entirely one way so far, and agents can already talk through MCP, so I'd wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: how you stop it hurting you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Guardrails&lt;/strong&gt; means checking input on the way in and output on the way out. Prompt injection is the reason for the first, where text from a user or a fetched document tries to overwrite your instructions. Reputation is the reason for the second, since a model trained on the internet will occasionally produce something you don't want appearing under your company's name.&lt;/p&gt;

&lt;p&gt;In Laravel this is middleware, and it works on both directions in one class:&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;Closure&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;Laravel\Ai\Prompts\AgentPrompt&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;Laravel\Ai\Responses\AgentResponse&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;ScreenContent&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;AgentPrompt&lt;/span&gt; &lt;span class="nv"&gt;$prompt&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="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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;looksLikeInjection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;422&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;$prompt&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;then&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;AgentResponse&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'agent.responded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent picks that up by implementing &lt;code&gt;HasMiddleware&lt;/code&gt; and returning the class from a &lt;code&gt;middleware()&lt;/code&gt; method, the same shape as HTTP middleware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human in the loop&lt;/strong&gt; means the agent stops and asks before doing something it can't undo. The SDK makes approval a property of the tool, so the dangerous ones pause and the harmless ones don'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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;needsApproval&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;Approval&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&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;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'amount'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Approval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Refunds above 5,000 need a human.'&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;The agent then returns with &lt;code&gt;hasPendingApprovals()&lt;/code&gt; true, you show a human the pending call and its arguments, and you resume with &lt;code&gt;Decision::approve()&lt;/code&gt; or &lt;code&gt;Decision::reject()&lt;/code&gt;. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-human-in-the-loop-tool-approval" rel="noopener noreferrer"&gt;full workflow is here&lt;/a&gt;, including what happens when generation fails mid-approval.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;sandbox&lt;/strong&gt; is where you run code the model wrote. If your agent generates and executes anything, it runs in a container that can be thrown away, never on the machine holding your database credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evals&lt;/strong&gt; are tests for non-deterministic output. You can't assert on an exact string, so you assert on properties instead: did it call the right tool, is the JSON shaped correctly, does a cheaper model grade the answer as acceptable. Skipping these is the most common mistake I see, because everything feels fine until a model version changes underneath you.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;cost&lt;/strong&gt;. Two attributes cover most of it, since not every task needs your best 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="na"&gt;#[UseCheapestModel]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TagExtractor&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="na"&gt;#[UseSmartestModel]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MigrationPlanner&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see people quote splits like 60/30/10 for cheap, mid and premium models. Treat those as somebody's anecdote rather than a rule. Measure your own &lt;code&gt;totalTokens&lt;/code&gt; per task type and route from that. Local models through Ollama are worth testing for the boring high-volume work, where the marginal cost is zero and the quality is often fine.&lt;/p&gt;

&lt;p&gt;For watching it in production, the same tools you already use apply. Telescope locally, Pulse or Nightwatch in production, plus SDK middleware logging prompts and token counts per run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to build first
&lt;/h2&gt;

&lt;p&gt;Reading about this does very little. The path that worked for me was small and boring, in this order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A tool that adds two numbers.&lt;/strong&gt; Pointless in itself, and the fastest way to see that the model decides when to call it and your PHP does the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tool that hits your own database.&lt;/strong&gt; Read-only. Now the model can answer questions about real data, and you'll immediately want to constrain what it can see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on conversations.&lt;/strong&gt; Add &lt;code&gt;RemembersConversations&lt;/code&gt; and watch your token count per message climb as history accumulates. This teaches context cost better than any article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an approval gate.&lt;/strong&gt; Give a tool a &lt;code&gt;needsApproval&lt;/code&gt; that fires, and build the screen that shows a pending call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then RAG.&lt;/strong&gt; Embed a folder of markdown, store it with &lt;code&gt;vector&lt;/code&gt;, query it with &lt;code&gt;whereVectorSimilarTo&lt;/code&gt;. Doing it manually once is worth more than any framework tour.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only after that should you look at sub-agents or MCP. Both are much easier to reason about once you've felt where the tokens go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd skip
&lt;/h2&gt;

&lt;p&gt;Multi-agent architectures, for most applications. The demos look impressive and the bills are real. One agent with a well-described set of tools beats a hierarchy of specialists for the majority of what people actually build, and you can always split later.&lt;/p&gt;

&lt;p&gt;Standalone vector databases, unless you've measured a reason. Your Postgres already does this, and keeping vectors next to the rows they belong to means you can filter by team, tenant or status in the same query.&lt;/p&gt;

&lt;p&gt;Chasing new protocols. MCP took hold because it solved an actual integration problem and shipped SDKs. Most of what followed is positioning.&lt;/p&gt;

&lt;p&gt;What I wouldn't skip is evals and token logging. They're boring, and they're the difference between an agent you can change with confidence and one nobody wants to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need Python for any of this?
&lt;/h3&gt;

&lt;p&gt;No. Everything in this post runs in PHP through the Laravel AI SDK, including embeddings, vector search, reranking and MCP servers. Python dominates model training and research. Application work, which is what most of us are doing, has first-party PHP support now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a vector database like Pinecone or Qdrant?
&lt;/h3&gt;

&lt;p&gt;Probably not. PostgreSQL with pgvector, MariaDB 11.7 or later, and MongoDB all store vectors and run similarity search, and Laravel's &lt;code&gt;whereVectorSimilarTo&lt;/code&gt; works against them directly. Keeping vectors in your main database also means normal &lt;code&gt;where&lt;/code&gt; clauses compose with similarity search, which is awkward when your vectors live in a separate service. Reach for a dedicated one when you've outgrown that, not before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is RAG still worth it now that context windows are so large?
&lt;/h3&gt;

&lt;p&gt;Yes, for two reasons. Cost, because retrieving five relevant chunks is far cheaper than sending an entire knowledge base on every request. And quality, because &lt;a href="https://www.trychroma.com/research/context-rot" rel="noopener noreferrer"&gt;Chroma's context rot research&lt;/a&gt; shows accuracy falling as input grows, even well inside the advertised window. Less relevant context beats more context.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between open source and open weights?
&lt;/h3&gt;

&lt;p&gt;Open weights means the parameters are downloadable, so you can run the model on your own hardware. Open source, taken literally, would also require the training data and pipeline, which almost no widely used model provides. Most models described as open are open weights. For running something locally the distinction rarely matters, but the words aren't interchangeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing worth remembering
&lt;/h2&gt;

&lt;p&gt;Go back through the terms and you'll notice they fall into two buckets. Some describe next-token prediction and its consequences, which covers tokens, temperature, context windows and hallucination. The rest describe plumbing you write yourself: tools, memory, retrieval, approval gates, guardrails.&lt;/p&gt;

&lt;p&gt;There's no third bucket. Nothing in the list is a machine that thinks. That's why an agent with no tools can't do anything, why it's bad at arithmetic until you hand it a calculator, and why the interesting engineering is almost entirely in the second bucket.&lt;/p&gt;

&lt;p&gt;Which is good news for us, honestly. The second bucket is just software, and you already know how to write that.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aiagents</category>
      <category>laravel</category>
      <category>laravelaisdk</category>
    </item>
    <item>
      <title>Your Query Bindings Are in Your Logs: Laravel 13.27 Can Mask Them, and How to Clean Up What Leaked</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 02 Sep 2026 04:15:11 +0000</pubDate>
      <link>https://dev.to/hafiz619/your-query-bindings-are-in-your-logs-laravel-1327-can-mask-them-and-how-to-clean-up-what-leaked-59p1</link>
      <guid>https://dev.to/hafiz619/your-query-bindings-are-in-your-logs-laravel-1327-can-mask-them-and-how-to-clean-up-what-leaked-59p1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-query-bindings-in-logs-db-mask-bindings" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Here is something most Laravel developers have never noticed. When a database query fails, Laravel takes the SQL, fills in the real values, and puts the whole thing into the error message. Every value. The email someone just typed into your signup form, the name, the password hash, the API token you were saving. That message then goes wherever your errors go, and it stays there.&lt;/p&gt;

&lt;p&gt;I'd been writing Laravel for years before I looked at this properly. Laravel 13.27, released on 26 August 2026, adds a one-line switch that turns it off. This post explains what the leak looks like, why the switch doesn't work the way the announcement suggests, and how to find and delete what's already in your logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a failed query actually writes
&lt;/h2&gt;

&lt;p&gt;Let me show you rather than describe it. A fresh Laravel 13.29 app, SQLite, the default &lt;code&gt;users&lt;/code&gt; table with its unique index on &lt;code&gt;email&lt;/code&gt;. Create a user, then try to create the same one again:&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;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mario Rossi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mario.rossi@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'secret-password'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mario Rossi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mario.rossi@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'secret-password'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second call throws a &lt;code&gt;QueryException&lt;/code&gt; (a &lt;code&gt;UniqueConstraintViolationException&lt;/code&gt;, to be exact). This is its &lt;code&gt;getMessage()&lt;/code&gt;, straight from the terminal:&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="k"&gt;SQLSTATE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;23000&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;Integrity&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;www&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"email"&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="nv"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Mario&lt;/span&gt; &lt;span class="n"&gt;Rossi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rossi&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;hR9&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;29&lt;/span&gt; &lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;57&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;29&lt;/span&gt; &lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;57&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query you wrote had five &lt;code&gt;?&lt;/code&gt; placeholders. The message has the five real values in their place. Laravel does this on purpose, because a message with the values in it is much easier to debug. That's true. It's also a copy of your user's personal data, in plain text, inside an error string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that string ends up
&lt;/h2&gt;

&lt;p&gt;An exception message doesn't stay in memory. It gets written down, usually in more places than you think.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;storage/logs/laravel.log&lt;/code&gt;.&lt;/strong&gt; If the exception isn't caught, the handler logs it. In my test the log line was &lt;code&gt;local.ERROR: SQLSTATE[23000] ...&lt;/code&gt; followed by the full message, email included. Log files get rotated, backed up, rsynced to other servers, and sometimes shipped to a logging service. Each copy carries the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;failed_jobs&lt;/code&gt; table.&lt;/strong&gt; This one surprised me. The &lt;code&gt;exception&lt;/code&gt; column is a &lt;code&gt;longText&lt;/code&gt; that stores the whole exception chain as a string. I dispatched a job that hit the same unique index, ran &lt;code&gt;queue:work --once&lt;/code&gt;, and queried the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'failed_jobs'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'exception'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%mario.rossi@example.com%'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row contains the &lt;code&gt;PDOException&lt;/code&gt;, then "Next Illuminate\Database\UniqueConstraintViolationException" with the full SQL and every value. Failed jobs are kept for 24 hours by default if you prune them, and forever if you don't. Most apps don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error trackers.&lt;/strong&gt; Sentry, Bugsnag, Flare, Nightwatch. They all receive the exception message as the headline of the event. Sentry's default server-side scrubbing removes values in fields &lt;em&gt;named&lt;/em&gt; &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt; and so on, and anything that looks like a credit card number. An email address sitting in the middle of a SQL string in a free-text message is not on that list. So it goes through, and it sits in a third party's database under their retention policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telescope, Slack alerts, email notifications.&lt;/strong&gt; Anywhere you've wired exceptions to go.&lt;/p&gt;

&lt;p&gt;None of this is a bug. It's the default behaviour doing exactly what it says. But if someone asks you "where is customer data stored?" and your answer doesn't include "the error log and the failed_jobs table", the answer is incomplete. If you deal with GDPR, personal data with no retention limit in a log file is exactly the kind of thing an audit finds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The switch in Laravel 13.27
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/laravel/framework/releases/tag/v13.27.0" rel="noopener noreferrer"&gt;Laravel 13.27&lt;/a&gt; adds a per-connection config key, contributed by Lau Josefsen in PR #61326:&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;'mask_bindings_in_exception_messages'&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;'DB_MASK_BINDINGS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it on, the same failure produces this message:&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="k"&gt;SQLSTATE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;23000&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;Integrity&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;www&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"email"&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="nv"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The placeholders stay as placeholders. Nothing else changes. The query still fails the same way, the exception is the same class, and the values are still available on the exception object if you need them (more on that below). Only the message is different.&lt;/p&gt;

&lt;p&gt;It's off by default, so you won't get it unless you turn it on.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part the announcement gets slightly wrong
&lt;/h3&gt;

&lt;p&gt;The release notes say the key ships in the framework's own &lt;code&gt;config/database.php&lt;/code&gt;, so apps can enable it with &lt;code&gt;DB_MASK_BINDINGS=true&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; and nothing else. I tried exactly that on a fresh app and it did nothing. The message still had the values in it.&lt;/p&gt;

&lt;p&gt;The reason is that every Laravel app has its own published &lt;code&gt;config/database.php&lt;/code&gt;, and that file doesn't contain the new key. Laravel does merge your config with the framework's defaults, and for &lt;code&gt;database&lt;/code&gt; it even merges the &lt;code&gt;connections&lt;/code&gt; list. But each connection you define replaces the framework's version of that connection wholesale. Your &lt;code&gt;mysql&lt;/code&gt; array wins over the framework's &lt;code&gt;mysql&lt;/code&gt; array, and yours doesn't have the key. So &lt;code&gt;config('database.connections.sqlite.mask_bindings_in_exception_messages')&lt;/code&gt; came back &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt; means off.&lt;/p&gt;

&lt;p&gt;The fix is to add the line to each connection you use:&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;'mysql'&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;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'mask_bindings_in_exception_messages'&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;'DB_MASK_BINDINGS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;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;Then set &lt;code&gt;DB_MASK_BINDINGS=true&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, clear the config cache, and it works. I verified this by checking &lt;code&gt;config()&lt;/code&gt; before and after. If you skip the config edit, the env var is silently ignored, which is the worst kind of security setting: one that looks on and isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up what's already there
&lt;/h2&gt;

&lt;p&gt;Turning on masking only changes exceptions from now on. Everything that failed before today is still written down. Three places to look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failed jobs.&lt;/strong&gt; Count how many rows contain something that looks like an email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'failed_jobs'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'exception'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%@%.%'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then decide. If those jobs are old and you're never going to retry them, delete them all with &lt;code&gt;php artisan queue:flush&lt;/code&gt;. If some are worth keeping, prune by age instead: &lt;code&gt;php artisan queue:prune-failed --hours=48&lt;/code&gt;. And put that prune command on the scheduler so the table stops being a permanent archive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log files.&lt;/strong&gt; A quick search over whatever is still on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'&lt;/span&gt; storage/logs/&lt;span class="k"&gt;*&lt;/span&gt;.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That counts lines with an email-shaped string per file. Delete the old files or let rotation do it. If you're still on the &lt;code&gt;single&lt;/code&gt; log channel, switch to &lt;code&gt;daily&lt;/code&gt; and set &lt;code&gt;LOG_DAILY_DAYS&lt;/code&gt; to something short. The default is 14. And check where else those files went: server backups, a log shipper, a colleague's laptop after a debugging session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error tracker.&lt;/strong&gt; Search your Sentry or Bugsnag project for &lt;code&gt;insert into&lt;/code&gt; and &lt;code&gt;@&lt;/code&gt;. You can delete individual events and set a shorter retention window. For anything sensitive that went through, the honest step is to treat it as a small incident and note it, because the data left your servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you lose, and how to get it back
&lt;/h2&gt;

&lt;p&gt;Masked messages are harder to debug from a log line alone. When a job fails at 2am and the log says &lt;code&gt;values (?, ?, ?)&lt;/code&gt;, you can't see which row caused it. That's a real cost, and it's why the setting is off by default.&lt;/p&gt;

&lt;p&gt;You don't lose the data, though. The &lt;code&gt;QueryException&lt;/code&gt; object still carries everything:&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;try&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;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;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;QueryException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSql&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// the query with ? placeholders&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBindings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// the real values, as an array&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRawSql&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// the query with values filled in, properly quoted&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the pattern is to keep the values out of the places that persist for a long time and are hard to control (log files, &lt;code&gt;failed_jobs&lt;/code&gt;, third-party trackers), and reach for &lt;code&gt;getBindings()&lt;/code&gt; in the places where a person is actively debugging, behind authentication. &lt;a href="https://hafiz.dev/blog/laravel-telescope-vs-pulse-vs-nightwatch" rel="noopener noreferrer"&gt;Telescope, Pulse or Nightwatch&lt;/a&gt; are the right home for that kind of detail, because they sit behind your login and you control their retention. A log file on disk doesn't have either.&lt;/p&gt;

&lt;p&gt;For queue jobs specifically, log the identifier rather than the payload. A &lt;code&gt;failed()&lt;/code&gt; method on the job that writes "ImportUser failed for row 4812" tells you what to look at without copying the row into the exception column. If you've read my post on &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing 10,000 queued tasks without breaking&lt;/a&gt;, this is the same idea from a different angle: the job should carry an ID, not the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does this affect Laravel 12 or older?
&lt;/h3&gt;

&lt;p&gt;No. The config key exists from Laravel 13.27 onwards. On older versions the values are always interpolated. If you're on 12 and can't upgrade yet, the practical options are to catch &lt;code&gt;QueryException&lt;/code&gt; where personal data flows through, rethrow with a cleaner message, and prune &lt;code&gt;failed_jobs&lt;/code&gt; aggressively.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will masking change how my error tracker groups events?
&lt;/h3&gt;

&lt;p&gt;No. Sentry, for example, groups an exception by its stack trace when one is present, and only falls back to the message text when it has nothing better. Two &lt;code&gt;QueryException&lt;/code&gt;s from the same line group together whether the message contains real values or placeholders. What changes is the title you see on the issue, and a title with &lt;code&gt;(?, ?, ?)&lt;/code&gt; in it is the one you want on a screen other people can see.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it hide the values from &lt;code&gt;dd()&lt;/code&gt; or the debug page in local development?
&lt;/h3&gt;

&lt;p&gt;No. Only the exception message changes. The debug page shows the exception object, and &lt;code&gt;getBindings()&lt;/code&gt; still returns the array. Local debugging is unaffected. You can also leave &lt;code&gt;DB_MASK_BINDINGS=false&lt;/code&gt; in your local &lt;code&gt;.env&lt;/code&gt; and set it to &lt;code&gt;true&lt;/code&gt; only in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this a GDPR requirement?
&lt;/h3&gt;

&lt;p&gt;GDPR doesn't name log files, but it does require that personal data isn't kept longer than needed and is protected appropriately. A log file with no retention limit, copied to backups, containing emails and names, is hard to defend on either point. Masking plus a short log retention is a cheap way to close the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one line, and the second one
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;'mask_bindings_in_exception_messages' =&amp;gt; env('DB_MASK_BINDINGS', false)&lt;/code&gt; to every connection in &lt;code&gt;config/database.php&lt;/code&gt;, set &lt;code&gt;DB_MASK_BINDINGS=true&lt;/code&gt; in production, and clear the config cache. Then spend ten minutes on &lt;code&gt;queue:flush&lt;/code&gt; and your old log files. The switch stops the leak going forward. The cleanup is the part that actually removes the data, and it's the part people skip.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>security</category>
      <category>logging</category>
      <category>queues</category>
    </item>
    <item>
      <title>How I Turned a €5 VPS Into a Dev Workstation I Can Use From Anywhere</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 31 Aug 2026 04:15:12 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-i-turned-a-eu5-vps-into-a-dev-workstation-i-can-use-from-anywhere-4147</link>
      <guid>https://dev.to/hafiz619/how-i-turned-a-eu5-vps-into-a-dev-workstation-i-can-use-from-anywhere-4147</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/remote-dev-workstation-vps-tmux-claude-code" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I wanted to work on my projects from wherever I happen to be. At the desk on the Mac, on the train from my phone, on a borrowed laptop if it came to that. Not just check on things, but write code, review what an AI agent did, and deploy it. A laptop-only setup can't do that, and it has a second problem that's easy to ignore until it bites. The laptop is a single point of failure. Earlier this month a quick check showed finished work on a few projects that existed on exactly one machine, with no copy anywhere else.&lt;/p&gt;

&lt;p&gt;So I built the alternative. One cheap VPS is the workstation, and every device I own is just a window onto it. I'd written about &lt;a href="https://hafiz.dev/blog/code-php-from-your-phone-vps-tmux-termius" rel="noopener noreferrer"&gt;coding PHP from a phone&lt;/a&gt; back in May, but that was one project on one box. This is the version that holds up for a dozen projects, with an AI agent doing most of the typing.&lt;/p&gt;

&lt;p&gt;Everything below was built between 25 and 28 August 2026, in three days of evenings. It's a blueprint, not a tour. Follow it and you end up with the same setup. The part nobody publishes, the table of everything that broke along the way, is near the end, and it's the section I'd bookmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: one box, many windows
&lt;/h2&gt;

&lt;p&gt;One cheap VPS is the workstation. The MacBook and the iPhone are just windows onto it.&lt;/p&gt;

&lt;p&gt;tmux keeps every session alive on the server, one session per project. Claude Code runs inside those sessions and does the actual work. Every project gets a private staging URL so I can look at what it did from any browser. Close the laptop, lock the phone, lose the train's Wi-Fi. Nothing happens to the work, because nothing was running on the device.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/remote-dev-workstation-vps-tmux-claude-code" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The setups you see on Twitter (levelsio's is the famous one) mostly stop at "tmux on a server, Termius on the phone". That part takes an hour. What took three days was making it hold up for many projects, with private HTTPS staging, an agent with shell access, and a way to rebuild the whole thing from a repo. That's what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, and why each piece
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why this one&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Box&lt;/td&gt;
&lt;td&gt;netcup VPS Lite 1 G12s, €5/mo incl. VAT&lt;/td&gt;
&lt;td&gt;2 vCore, 4 GB RAM, 80 GB SSD. 4 GB is the binding constraint, not disk. 2 GB does not fit Claude Code plus a build plus staging sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Debian 13&lt;/td&gt;
&lt;td&gt;The provider's default. Ships PHP 8.4, so PHP 8.3 comes from Sury's repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network in&lt;/td&gt;
&lt;td&gt;Tailscale only&lt;/td&gt;
&lt;td&gt;Port 22 is firewalled to the tailnet's &lt;code&gt;100.64.0.0/10&lt;/code&gt; range. There is no public SSH port at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions&lt;/td&gt;
&lt;td&gt;tmux, one session per project&lt;/td&gt;
&lt;td&gt;Each keeps its own windows, scrollback and running Claude Code. Switching projects disturbs nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;Caddy on loopback + php-fpm&lt;/td&gt;
&lt;td&gt;Caddy listens on &lt;code&gt;127.0.0.1:80&lt;/code&gt; only. One config file per project, generated by a script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;td&gt;Cloudflare Tunnel + Cloudflare Access&lt;/td&gt;
&lt;td&gt;Outbound tunnel, so no inbound port. Access puts an email one-time PIN in front of every staging URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;Claude Code on the box&lt;/td&gt;
&lt;td&gt;The sessions show up in the Claude desktop and iOS apps too, which solves screenshots (more below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;Termius&lt;/td&gt;
&lt;td&gt;One host entry per project, each with a startup snippet that lands in the right tmux session&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those deserve a sentence more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Tailscale instead of a hardened public port.&lt;/strong&gt; I did the public-port version on another box and wrote it up in &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;How I Hardened My VPS in One Afternoon&lt;/a&gt;. This time I skipped straight to closing the port. In the few hours between provisioning and the firewall rule going in, sshd logged 1,904 failed login attempts on a box that didn't exist the day before. In the 18 hours after the rule: zero. No fail2ban, because there's nothing for it to react to. The way back in if Tailscale ever breaks is the provider's web console, which doesn't depend on the network path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a tunnel and not just Tailscale for staging.&lt;/strong&gt; Security is roughly a third of the reason. The rest is that some of my projects have OAuth callbacks, Stripe webhooks and payment flows, and none of those will talk to &lt;code&gt;http://&lt;/code&gt;. The tunnel gives real certificates on real hostnames with no open port and no Let's Encrypt dance. It also works from a phone browser without the Tailscale app, and a staging link can be sent to a client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blueprint
&lt;/h2&gt;

&lt;p&gt;The rule that governs the whole build is simple. Anything typed on the box goes into a git repo first, then onto the box. Four provisioning scripts, a handful of commands in &lt;code&gt;bin/&lt;/code&gt;, the systemd units, the panel source. The box must always be rebuildable from that repo, because a VPS at this price is not something to get attached to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Provisioning: four scripts and four manual steps
&lt;/h3&gt;

&lt;p&gt;Run in order, each idempotent, so re-running is safe.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/00-ssh-bootstrap.sh &amp;lt;ip&amp;gt; devbox   &lt;span class="c"&gt;# local: dedicated key, pin host key, harden sshd&lt;/span&gt;
ssh devbox &lt;span class="s1"&gt;'bash -s'&lt;/span&gt; &amp;lt; scripts/01-base.sh   &lt;span class="c"&gt;# packages, 2G swap, ufw, unattended upgrades&lt;/span&gt;
&lt;span class="c"&gt;# MANUAL.md steps 1 and 2: Tailscale join, Cloudflare tunnel login&lt;/span&gt;
ssh devbox &lt;span class="s1"&gt;'bash -s'&lt;/span&gt; &amp;lt; scripts/02-stack.sh  &lt;span class="c"&gt;# PHP 8.3, Node 22, Composer, Playwright path&lt;/span&gt;
ssh devbox &lt;span class="s1"&gt;'bash -s'&lt;/span&gt; &amp;lt; scripts/03-caddy.sh  &lt;span class="c"&gt;# Caddy bound to loopback&lt;/span&gt;
ssh devbox &lt;span class="s1"&gt;'bash -s'&lt;/span&gt; &amp;lt; scripts/04-tunnel.sh &lt;span class="c"&gt;# cloudflared as a service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SSH bootstrap generates a key just for this box, pins the host key before the first real connection, verifies key auth works, and only then disables password login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base script is where the firewall rule lives. Tailscale and cloudflared are both outbound, so this is the entire inbound policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw default deny incoming
ufw default allow outgoing
ufw allow from 100.64.0.0/10 to any port 22 proto tcp comment &lt;span class="s2"&gt;"SSH via Tailscale"&lt;/span&gt;
ufw &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also adds &lt;code&gt;git config --global --add safe.directory "*"&lt;/code&gt;, and you'll see why in the gotchas table.&lt;/p&gt;

&lt;p&gt;The stack script sets php-fpm to &lt;code&gt;pm = ondemand&lt;/code&gt; with a 30 second idle timeout. That single line is what makes "eight staging sites up at once" cheap. An idle pool spawns no workers, so a registered-but-unused project costs nothing until someone requests a page.&lt;/p&gt;

&lt;p&gt;Four things genuinely cannot be scripted, and the repo says so instead of pretending. Tailscale needs a browser login on the same tailnet as your other devices. The Cloudflare tunnel needs &lt;code&gt;cloudflared tunnel login&lt;/code&gt;. Claude Code needs its account authentication. And &lt;code&gt;.env&lt;/code&gt; files are copied per project, by hand, never from git.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three commands you'll actually use
&lt;/h3&gt;

&lt;p&gt;Everything day to day goes through three small bash scripts installed to &lt;code&gt;/usr/local/bin&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;p                      &lt;span class="c"&gt;# list projects, staging URLs, which sessions are running&lt;/span&gt;
p prompt-optimizer         &lt;span class="c"&gt;# jump into that project's tmux session, already cd'd&lt;/span&gt;

dev up prompt-optimizer    &lt;span class="c"&gt;# serve at prompt-optimizer-staging.hafiz.dev, create DNS if new&lt;/span&gt;
dev down prompt-optimizer  &lt;span class="c"&gt;# stop serving, free its php-fpm workers&lt;/span&gt;
dev list               &lt;span class="c"&gt;# what is up, plus memory and worker count&lt;/span&gt;
dev logs prompt-optimizer  &lt;span class="c"&gt;# follow that project's requests&lt;/span&gt;

project-setup &amp;lt;name&amp;gt; &amp;lt;git-url&amp;gt;   &lt;span class="c"&gt;# clone and bootstrap a new project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;p&lt;/code&gt; is the one that changes how the box feels. It creates the session if it doesn't exist, detached, then either attaches to it or, if you're already inside tmux, switches your client over. No detaching, no &lt;code&gt;cd&lt;/code&gt;, no remembering session names. The one wrinkle worth knowing: &lt;code&gt;tmux switch-client&lt;/code&gt; needs the real client, so the script reads &lt;code&gt;#{client_tty}&lt;/code&gt; and passes it with &lt;code&gt;-c&lt;/code&gt;. Without that, running it from a Termius snippet silently does nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dev up&lt;/code&gt; writes one Caddy vhost per project. This is the whole file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://prompt-optimizer-staging.hafiz.dev {
    root * /var/www/prompt-optimizer/public
    encode gzip
    php_fastcgi unix//run/php/php8.3-fpm.sock {
        env HTTPS on
    }
    file_server
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;env HTTPS on&lt;/code&gt;. TLS terminates at Cloudflare and the tunnel hands Caddy plain HTTP, so without that line Laravel thinks the request is insecure and generates &lt;code&gt;http://&lt;/code&gt; asset URLs. Every XHR on the page then fails with mixed-content errors. That one cost me an evening.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dev up&lt;/code&gt; also creates the DNS record on first run, through &lt;code&gt;cloudflared tunnel route dns&lt;/code&gt;, and then leaves it alone. &lt;code&gt;dev down&lt;/code&gt; removes the vhost but keeps the DNS, because Cloudflare rate-limits record churn and there's no reason to delete it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Staging behind Cloudflare Access
&lt;/h3&gt;

&lt;p&gt;Here's the full path of a request to a staging site. The point of the diagram is what's missing: there is no arrow into the VPS from the internet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/remote-dev-workstation-vps-tmux-claude-code" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;cloudflared&lt;/code&gt; makes an outbound connection to Cloudflare and keeps it open. Requests for &lt;code&gt;*-staging.hafiz.dev&lt;/code&gt; arrive through that connection and get handed to Caddy on the loopback interface. Caddy talks to php-fpm over a Unix socket. Nothing on the box has a port open to the world.&lt;/p&gt;

&lt;p&gt;In front of all of it sits one Access application in Zero Trust: subdomain &lt;code&gt;*-staging&lt;/code&gt;, domain &lt;code&gt;hafiz.dev&lt;/code&gt;, policy "Allow", selector "Emails", one address. Every staging URL then asks for a one-time code by email before serving a byte. I verified it from outside rather than assuming: an unauthenticated request returns a 302 to the sign-in page with zero application content in the body, while the same site serves normally when curled on the box. The free Zero Trust plan is more than enough for one person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hostname shape is not a style choice.&lt;/strong&gt; I wanted &lt;code&gt;prompt-optimizer.dev.hafiz.dev&lt;/code&gt;. It fails the TLS handshake with &lt;code&gt;sslv3 alert handshake failure&lt;/code&gt;, even with DNS and the tunnel healthy, because Cloudflare's free Universal SSL &lt;a href="https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/limitations/" rel="noopener noreferrer"&gt;covers one subdomain level only&lt;/a&gt;. Two levels deep needs Advanced Certificate Manager, a paid add-on. And &lt;code&gt;*-dev.hafiz.dev&lt;/code&gt; looks like a wildcard but isn't one. DNS wildcards only work as a leading &lt;code&gt;*.&lt;/code&gt;, so Cloudflare treats that asterisk literally and it never matches anything. So it's &lt;code&gt;&amp;lt;project&amp;gt;-staging.hafiz.dev&lt;/code&gt;, one CNAME per project, created by &lt;code&gt;dev up&lt;/code&gt;. With fifteen projects that's fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrating a project onto the box
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;project-setup &amp;lt;name&amp;gt; &amp;lt;git-url&amp;gt;&lt;/code&gt; does the boring part and refuses to do the dangerous part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone into &lt;code&gt;/var/www/&amp;lt;name&amp;gt;&lt;/code&gt;, or fast-forward pull if it's already there&lt;/li&gt;
&lt;li&gt;Create the &lt;code&gt;storage/framework/*&lt;/code&gt; skeleton, because not every repo tracks it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;composer install&lt;/code&gt;, with &lt;code&gt;--no-scripts&lt;/code&gt; if there's no &lt;code&gt;.env&lt;/code&gt; yet (more on this below)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm install&lt;/code&gt; and &lt;code&gt;npm run build&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;.env&lt;/code&gt; exists: &lt;code&gt;key:generate&lt;/code&gt; if needed, &lt;code&gt;migrate --force&lt;/code&gt;, &lt;code&gt;storage:link&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chown -R www-data:www-data&lt;/code&gt;, group-writable, setgid on directories&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then the manual steps, deliberately manual:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Copy the &lt;code&gt;.env&lt;/code&gt;, and decide per token. A token that can write to production only goes on the box with a reason. Most of them stay blank, so if the box is ever compromised the blast radius stays small.&lt;/li&gt;
&lt;li&gt;Copy the SQLite database.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rsync&lt;/code&gt; &lt;code&gt;storage/app/public&lt;/code&gt; &lt;strong&gt;from the production box, not from the laptop&lt;/strong&gt;. The laptop copy is missing every image production generated since you last pulled. On one project the local copy was 32 MB and production was 71 MB.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chmod g+rwX storage/app&lt;/code&gt; after that rsync, because &lt;code&gt;-a&lt;/code&gt; preserves production's restrictive directory permissions and the Caddy user can't traverse into a &lt;code&gt;700&lt;/code&gt; directory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dev up &amp;lt;name&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A project is verified when three things are true: &lt;code&gt;curl&lt;/code&gt; on the box returns 200, the public staging URL returns 302 to Access, and a page loads content from the real database.&lt;/p&gt;

&lt;p&gt;Eight projects went through this in three days. The ones I parked (four of them, no active work) are a fifteen-minute job each when one wakes up, which is the point of writing the procedure down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working from the phone
&lt;/h2&gt;

&lt;p&gt;This is the part I built all of it for. The pattern comes from levelsio: instead of one SSH host and a tmux menu, Termius gets &lt;strong&gt;one host entry per project&lt;/strong&gt;. Same address, same key, but the label is the project name and the startup snippet is &lt;code&gt;p &amp;lt;name&amp;gt;&lt;/code&gt;. A fresh SSH connection isn't inside tmux yet, so &lt;code&gt;p&lt;/code&gt; attaches directly and every project becomes a tap-to-open tab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx04inj993cbytujs5lzq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx04inj993cbytujs5lzq.webp" alt="Termius on the phone, one host per project" width="800" height="826"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The plain &lt;code&gt;devbox&lt;/code&gt; host stays for box-wide work and attaches a shared &lt;code&gt;work&lt;/code&gt; session. Only projects that have earned it get their own entry.&lt;/p&gt;

&lt;p&gt;Four snippets cover everything else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Snippet&lt;/th&gt;
&lt;th&gt;Script&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;attach work&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;`[ -n "$TMUX" ] \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;claude&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude --continue&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detach&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tmux detach&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dev list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dev list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the tmux config on the box is short. &lt;code&gt;mouse on&lt;/code&gt; so finger-scrolling works, &lt;code&gt;focus-events on&lt;/code&gt;, a 20,000 line history, and the session picker bound to &lt;code&gt;s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkn7lelyux9sq7juwy33.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkn7lelyux9sq7juwy33.webp" alt="Claude Code running inside a tmux session, from the phone over 4G" width="800" height="765"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Switching projects while Claude Code is running.&lt;/strong&gt; &lt;code&gt;p &amp;lt;name&amp;gt;&lt;/code&gt; only works at a shell prompt. With Claude Code in the foreground it owns the input line, so typing &lt;code&gt;p hafiz-dev&lt;/code&gt; just sends "p hafiz-dev" to Claude as a message. Only two things reach tmux past a running application: the prefix key, which tmux intercepts at the terminal layer, and mouse events. So the switch is &lt;code&gt;Ctrl+B&lt;/code&gt; then &lt;code&gt;S&lt;/code&gt;, which opens the session picker. On the phone, &lt;code&gt;ctrl&lt;/code&gt; is a key in the Termius toolbar. Tap it, press &lt;code&gt;b&lt;/code&gt;, press &lt;code&gt;s&lt;/code&gt;. Verified working through Claude Code, over 4G.&lt;/p&gt;

&lt;p&gt;I tried binding a status-bar tap to the picker so switching would be one touch. Termius on iOS treats the touch as a text-selection gesture and shows its own Copy/Paste menu instead of forwarding a mouse event. Binding removed. The prefix works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots into Claude.&lt;/strong&gt; Raw paste into Claude Code over SSH does nothing, because the image is on the phone's clipboard and the CLI is on the server. The fix is to not use the terminal for that. Sessions running on the box show up in the Claude iOS and desktop apps, grouped by project. Open the session there, attach the image natively, and it travels through Claude's own infrastructure. For the rare case where the file needs to physically exist on the box (a fixture, an asset), the iPhone share sheet to Tailscale drops it into an inbox directory via Taildrop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three equivalent ways to leave.&lt;/strong&gt; Close Termius. Lock the phone. Or &lt;code&gt;Ctrl+B D&lt;/code&gt; if you want to be tidy. tmux notices the connection drop, detaches, and everything keeps running. Termius shows a persistent "One connection" notification while it holds the SSH session open for fast reconnects. Harmless. The only thing that loses work is typing &lt;code&gt;exit&lt;/code&gt; inside a session, because that's the one action that destroys it.&lt;/p&gt;

&lt;p&gt;The proof this works came on day two: a footer change written on the phone over 4G, reviewed, corrected, committed and deployed to a live site. Since then the two-machine problem has mostly dissolved, because there is one checkout. From the Mac, &lt;code&gt;dp prompt-optimizer&lt;/code&gt; attaches the same session the phone uses. Nothing to reconcile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control panel
&lt;/h2&gt;

&lt;p&gt;By day three there were eight staging sites and a growing number of Claude Code instances, and "what's running right now" needed an answer that didn't involve SSH. So the box serves one more Access-protected page, on the same staging pattern as everything else. It shows memory, disk, load, each project with its staging state, every tmux session with a claude/idle badge, every Claude process with its working directory and RSS, and which repos are out of step with their remotes. Then buttons: start and stop per staging site, and "start CC" on any idle session.&lt;/p&gt;

&lt;p&gt;Putting buttons on a web page that runs shell commands on a box with production deploy keys is exactly the kind of thing that goes wrong. The design has three paths, and each one needs less privilege than the one before it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/remote-dev-workstation-vps-tmux-claude-code" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Status is read-only.&lt;/strong&gt; A root systemd timer runs a Python script every 30 seconds that collects everything and writes &lt;code&gt;status.json&lt;/code&gt; with a &lt;code&gt;0644&lt;/code&gt; mode. The page fetches that file every 15 seconds and renders it. The web layer executes nothing for status. If the file goes stale, the page says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actions go through the narrowest sudo I could write.&lt;/strong&gt; The start/stop buttons POST to a small PHP file. It refuses anything without a custom &lt;code&gt;X-Panel&lt;/code&gt; header (browsers won't send custom headers cross-origin without a CORS preflight, which is never allowed), validates the project name against &lt;code&gt;^[a-z0-9][a-z0-9-]{0,31}$&lt;/code&gt;, checks the directory exists under &lt;code&gt;/var/www&lt;/code&gt;, refuses the name &lt;code&gt;panel&lt;/code&gt; so it can't saw off its own branch, and only then runs &lt;code&gt;sudo dev up|down &amp;lt;name&amp;gt;&lt;/code&gt;. The sudoers rule grants &lt;code&gt;www-data&lt;/code&gt; exactly that: one binary, two verbs, a name matching a character class, plus the status refresh. Nothing else. php-fpm runs under systemd's &lt;code&gt;ProtectSystem=full&lt;/code&gt;, with a drop-in that makes exactly one directory writable, the one Caddy vhosts live in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Starting Claude Code needs no sudo at all.&lt;/strong&gt; This is the path I'm happiest with. The button writes a project name into &lt;code&gt;/var/spool/panel/cc-start&lt;/code&gt; and returns. A systemd path unit, running as root, watches for that file. When it appears, a consumer script re-validates the name from scratch, checks the tmux session exists, checks that the foreground process in that session is a bare shell (never a running Claude, never an editor), and types &lt;code&gt;claude --continue || claude&lt;/code&gt; into it. The web layer left a note. Root decided what to do with it.&lt;/p&gt;

&lt;p&gt;Two things the consumer had to learn. Inside a systemd unit, tmux's &lt;code&gt;-t "=name"&lt;/code&gt; target form fails with "can't find pane" while plain &lt;code&gt;-t name&lt;/code&gt; and &lt;code&gt;list-panes -a&lt;/code&gt; work, so it uses those. And &lt;code&gt;claude --continue&lt;/code&gt; on a large old conversation shows a resume picker that self-cancels when no client is attached, exiting 0, so the &lt;code&gt;|| claude&lt;/code&gt; never fires. The script waits six seconds, checks whether Claude is actually running, and starts a fresh conversation if not. The old one stays resumable from a real terminal.&lt;/p&gt;

&lt;p&gt;Stopping Claude Code is deliberately not a button. That stays a human act, from a terminal or the Claude app.&lt;/p&gt;

&lt;p&gt;If you run an agent with shell access anywhere near production keys, &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;this earlier post on keeping it from destroying your app&lt;/a&gt; covers the project-level guardrails. The panel is the box-level version of the same instinct: every new action gets a header check, strict validation, and the least privilege that can possibly do the job, and the spool-file pattern beats a new sudoers line every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything that broke
&lt;/h2&gt;

&lt;p&gt;This is the table I wish someone had published before I started. Every row cost real time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500 on every page of a fresh project&lt;/td&gt;
&lt;td&gt;Checkout was root-owned and php-fpm runs as &lt;code&gt;www-data&lt;/code&gt;. Laravel writes to &lt;code&gt;vendor/&lt;/code&gt; during package discovery, not just &lt;code&gt;storage/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;project-setup&lt;/code&gt; chowns the whole tree to &lt;code&gt;www-data&lt;/code&gt;, group-writable, setgid dirs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dubious ownership&lt;/code&gt; on every git command&lt;/td&gt;
&lt;td&gt;Checkouts owned by &lt;code&gt;www-data&lt;/code&gt;, git runs as root&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git config --global --add safe.directory "*"&lt;/code&gt; in the base script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deploy.sh&lt;/code&gt; can't reach production&lt;/td&gt;
&lt;td&gt;The box's SSH key wasn't on the production servers. This is per box, and I have three&lt;/td&gt;
&lt;td&gt;Add the devbox public key to each production box's &lt;code&gt;authorized_keys&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging URL fails TLS with &lt;code&gt;sslv3 alert handshake failure&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Free Universal SSL covers one subdomain level. &lt;code&gt;foo.dev.hafiz.dev&lt;/code&gt; is two deep&lt;/td&gt;
&lt;td&gt;Hostnames are &lt;code&gt;&amp;lt;project&amp;gt;-staging.hafiz.dev&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;*-dev.hafiz.dev&lt;/code&gt; wildcard matches nothing&lt;/td&gt;
&lt;td&gt;DNS wildcards only work as a leading &lt;code&gt;*.&lt;/code&gt;. The asterisk mid-label is literal&lt;/td&gt;
&lt;td&gt;One CNAME per project, created by &lt;code&gt;dev up&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Ctrl+B D&lt;/code&gt; seems dead on the phone&lt;/td&gt;
&lt;td&gt;Mistimed keystrokes, not interception. The prefix does reach tmux&lt;/td&gt;
&lt;td&gt;Slow down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;project-setup&lt;/code&gt; dies in composer on a fresh clone&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;package:discover&lt;/code&gt; boots the app, and a service provider that needs a secret throws with no &lt;code&gt;.env&lt;/code&gt; (a Stripe service in one project)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;composer install --no-scripts&lt;/code&gt; until &lt;code&gt;.env&lt;/code&gt; exists, then re-run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Please provide a valid cache path" on a fresh clone&lt;/td&gt;
&lt;td&gt;The repo didn't track &lt;code&gt;storage/framework/*&lt;/code&gt;, so the view compiler had nowhere to write&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;project-setup&lt;/code&gt; creates the storage skeleton before composer runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New staging URL dead in the browser for about 30 minutes&lt;/td&gt;
&lt;td&gt;The resolver in the path (the carrier DNS behind an iPhone hotspot) cached NXDOMAIN from a lookup made before &lt;code&gt;dev up&lt;/code&gt; created the record. Negative TTL was 1800 seconds, and flushing the Mac can't clear an upstream cache&lt;/td&gt;
&lt;td&gt;Set the interface DNS to &lt;code&gt;1.1.1.1&lt;/code&gt;, or wait it out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed-content errors on every staging XHR&lt;/td&gt;
&lt;td&gt;TLS ends at Cloudflare, the tunnel delivers plain HTTP, so Laravel generated &lt;code&gt;http://&lt;/code&gt; URLs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;env HTTPS on&lt;/code&gt; in every Caddy vhost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;site.webmanifest&lt;/code&gt; CORS errors on staging&lt;/td&gt;
&lt;td&gt;Browsers fetch manifests without cookies, so the request can't carry the Access session and gets redirected to the login page&lt;/td&gt;
&lt;td&gt;Cosmetic. Inherent to Access-protected staging, ignore it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploaded images 403 on staging&lt;/td&gt;
&lt;td&gt;Two causes stacked: &lt;code&gt;storage:link&lt;/code&gt; never ran, and &lt;code&gt;storage/app/public&lt;/code&gt; is data git doesn't carry&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;project-setup&lt;/code&gt; runs &lt;code&gt;storage:link&lt;/code&gt;. Rsync the directory from production, not the laptop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images still 403 with correct file permissions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;storage/app&lt;/code&gt; itself was &lt;code&gt;700&lt;/code&gt;, so the &lt;code&gt;caddy&lt;/code&gt; user (group &lt;code&gt;www-data&lt;/code&gt;) couldn't traverse into it. &lt;code&gt;rsync -a&lt;/code&gt; preserves production's restrictive directory modes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;chmod g+rwX storage/app&lt;/code&gt; after any rsync from production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playwright can't launch Chromium in one project&lt;/td&gt;
&lt;td&gt;Browser builds are version-pinned. The project's &lt;code&gt;playwright-core&lt;/code&gt; wanted build 1208, the box had 1234&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;node node_modules/playwright-core/cli.js install chromium&lt;/code&gt; from that project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tmux send-keys -t "=name"&lt;/code&gt; fails inside a systemd unit&lt;/td&gt;
&lt;td&gt;"can't find pane" for the &lt;code&gt;=&lt;/code&gt; exact-match form when no client is attached&lt;/td&gt;
&lt;td&gt;Use plain &lt;code&gt;-t name&lt;/code&gt;, and &lt;code&gt;list-panes -a&lt;/code&gt; with a filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;claude --continue&lt;/code&gt; from the panel does nothing&lt;/td&gt;
&lt;td&gt;On a large conversation it shows a resume picker that self-cancels with exit 0 when no client is attached&lt;/td&gt;
&lt;td&gt;Re-check after six seconds and start fresh if Claude isn't running&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Termius status-bar tap that never worked belongs in the same spirit but didn't cost enough to earn a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs and what it feels like
&lt;/h2&gt;

&lt;p&gt;€5 a month, billed six months at a time, so €30 up front. That's the whole bill. For comparison, my main production server is a small box running several sites, and I'd never run a build or an agent there, which is &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;the same reasoning behind separating environments&lt;/a&gt; that applies to any small setup.&lt;/p&gt;

&lt;p&gt;Memory is the number that matters on a 4 GB box, so here's what it actually uses. Baseline with eight staging sites up and nothing else running: about 840 MB. That includes the OS, Caddy, cloudflared, Tailscale, php-fpm pools that spawn no workers while idle, and the panel's timers. Each Claude Code instance adds roughly 450 MB, and that is the only thing that scales with how much you're doing. Three projects with Claude open is comfortable. Six would not be. The 2 GB swap file exists for &lt;code&gt;composer install&lt;/code&gt; and &lt;code&gt;npm run build&lt;/code&gt;, each of which can spike past 500 MB, and OOM mid-task from a phone is the failure I most wanted to avoid.&lt;/p&gt;

&lt;p&gt;Disk was never the constraint. The active projects total under 6 GB, and most of that is &lt;code&gt;vendor/&lt;/code&gt; and &lt;code&gt;node_modules/&lt;/code&gt; that rebuild from lockfiles.&lt;/p&gt;

&lt;p&gt;Latency from Turin to Vienna, where the box landed, is around 20 ms. Typing over SSH feels local. My Helsinki box, at about 40 ms, feels like typing through syrup by comparison, and that difference is a good part of why this got a new box rather than sharing an existing one.&lt;/p&gt;

&lt;p&gt;The rebuild story is the one I care about most. Everything that defines the box is in one repo, and the box's own checkout of that repo is where the panel gets deployed from. If netcup vanished tomorrow, the recovery is order a box anywhere, run four scripts, do the four manual steps, run &lt;code&gt;project-setup&lt;/code&gt; per project, copy secrets. About an hour, plus rsync time for databases. I moved a live SaaS between servers &lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;with two minutes of downtime&lt;/a&gt; earlier this month using the same "write it down as you go" habit, and it's the habit, not the scripts, that makes a box disposable.&lt;/p&gt;

&lt;p&gt;What it feels like is harder to put in a table. The honest version is that reviewing works on a phone and debugging doesn't. Reading a diff, approving a plan, running a deploy, fixing a typo: all fine from a train. Stepping through a failing test on a phone keyboard is miserable, and that's exactly the moment you'll want a real screen. So the Mac still does the heavy work. It just does it as another window onto the same session, which means closing the lid mid-task is free, and the audit script hasn't found stranded commits since.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why not just use Tailscale for the staging sites too?
&lt;/h3&gt;

&lt;p&gt;Because some of my projects have OAuth callbacks and Stripe webhooks, and those refuse to talk to &lt;code&gt;http://&lt;/code&gt; or to a private address. The tunnel gives valid certificates on real hostnames with no open port, works from any browser without a Tailscale client, and lets me send a staging link to someone else. Tailscale carries SSH, the tunnel carries HTTPS. They're complements, and for a project with no third-party callbacks Tailscale alone would do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Claude Code keep running when the phone disconnects?
&lt;/h3&gt;

&lt;p&gt;Yes, and that's the whole point of tmux. Claude Code runs inside a tmux session on the server. When the SSH connection drops, tmux detaches the client and the session keeps running. Reconnect from any device and it's still there, mid-task. The only way to lose work is to type &lt;code&gt;exit&lt;/code&gt; inside the session.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you switch projects while Claude Code is in the foreground?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Ctrl+B&lt;/code&gt; then &lt;code&gt;S&lt;/code&gt; opens tmux's session picker. The prefix key reaches tmux before any application sees it, so it works even while Claude Code owns the input line. Typing a command into the terminal at that point doesn't work, because the characters go to Claude as a message. On the phone, &lt;code&gt;ctrl&lt;/code&gt; is a key in the Termius toolbar.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isn't putting production deploy keys on a box with a web-controlled agent dangerous?
&lt;/h3&gt;

&lt;p&gt;It's a trade, and it's made deliberately. The box can deploy to production because that's what makes it a workstation. What limits the damage is that &lt;code&gt;.env&lt;/code&gt; tokens are copied per project with a reason for each, most of the ones that can write to live sites stay blank, and the panel's actions are validated three times over with the narrowest sudo rule that works. The start-Claude path needs no sudo at all. The remaining risk is an agent with shell access, which is the same risk on the laptop.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if Tailscale breaks and there's no public SSH port?
&lt;/h3&gt;

&lt;p&gt;The provider's web console. It's a VNC session into the box that doesn't depend on the network path at all, which is why I didn't keep a public port open as a fallback. Closing the port is strictly stronger than banning attackers who reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model in one line
&lt;/h2&gt;

&lt;p&gt;Conversations live on the server. You carry glass.&lt;/p&gt;

&lt;p&gt;Every design decision above follows from that. Sessions persist because they never ran on the device. Staging is private because the only way in is a tunnel that dials out. The panel can be trusted because the web layer only ever reads a file or leaves a note. And the box is disposable because everything that made it is in a repo, next to a table of what went wrong the first time.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>claudecode</category>
      <category>tmux</category>
      <category>vps</category>
    </item>
    <item>
      <title>NativePHP v4: Build a Truly Native iOS Screen in Blade, No Xcode Required</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 26 Aug 2026 04:15:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/nativephp-v4-build-a-truly-native-ios-screen-in-blade-no-xcode-required-3cmh</link>
      <guid>https://dev.to/hafiz619/nativephp-v4-build-a-truly-native-ios-screen-in-blade-no-xcode-required-3cmh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/nativephp-v4-supernative-first-native-screen" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;When I wrote my &lt;a href="https://hafiz.dev/blog/build-your-first-mobile-app-with-laravel-and-nativephp-v3-free-step-by-step" rel="noopener noreferrer"&gt;first NativePHP mobile tutorial&lt;/a&gt;, the honest caveat sat in the middle of the post: your Laravel app was running in a webview. A good webview, with real native APIs a bridge call away, but still a browser pretending to be an app.&lt;/p&gt;

&lt;p&gt;NativePHP v4 removes the pretence. Blade components now render as real SwiftUI views on iOS and Jetpack Compose views on Android. No webview, no HTML, no JavaScript bridge. The engine is called &lt;a href="https://nativephp.com/docs/mobile/4/architecture/super-native" rel="noopener noreferrer"&gt;SuperNative&lt;/a&gt;. It debuted at The Vibes, the unofficial extra day of Laracon US, &lt;a href="https://laravel-news.com/nativephp-v4-supernative" rel="noopener noreferrer"&gt;hit Laravel News in mid August&lt;/a&gt;, and I have now built and run a screen with it on a real iPhone.&lt;/p&gt;

&lt;p&gt;This post is that build, start to finish, including the parts where I hit a wall. And the best bit for anyone who bounced off mobile development before: I never opened Xcode.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SuperNative actually does
&lt;/h2&gt;

&lt;p&gt;NativePHP ships its own Blade engine. Instead of compiling your components to HTML, it converts them into a compact binary representation of a UI tree and hands that directly to the native shell. PHP and the native layer share memory, so there is no network hop and no bridge round-trip between your component and the screen.&lt;/p&gt;

&lt;p&gt;On iOS that tree becomes SwiftUI views. On Android it becomes Jetpack Compose. Your Blade file is the single source of truth for both.&lt;/p&gt;

&lt;p&gt;The mental model is Livewire. A screen is a PHP class with public properties and methods. The view is Blade. When a property changes, the screen re-renders. If you have written a Livewire component, you already know how to write a NativePHP screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, and the first wall
&lt;/h2&gt;

&lt;p&gt;Two requirements before anything works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP 8.4.&lt;/strong&gt; v4 requires it, and this is the first wall I hit: my machine defaulted to PHP 8.3 and &lt;code&gt;composer require&lt;/code&gt; failed with a clear enough constraint error. On a Mac with Homebrew, &lt;code&gt;brew install php&lt;/code&gt; gets you 8.4 without touching your default PHP. Point Composer at it explicitly if you keep 8.3 as your daily driver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The starter kit currently scaffolds v3.&lt;/strong&gt; &lt;code&gt;laravel new my-app --using=nativephp/mobile-starter&lt;/code&gt; gave me &lt;code&gt;nativephp/mobile&lt;/code&gt; 3.3.7. One extra require fixes it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;laravel new watch-later &lt;span class="nt"&gt;--using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nativephp/mobile-starter
&lt;span class="nb"&gt;cd &lt;/span&gt;watch-later
composer require &lt;span class="s2"&gt;"nativephp/mobile:^4.2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second command is the actual v4 upgrade. It ran clean for me, which matches the upgrade guide's claim that v4 is additive and needs no application code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the screen
&lt;/h2&gt;

&lt;p&gt;The demo is a Watch Later list, the native cousin of the &lt;a href="https://hafiz.dev/blog/laravel-telegram-bot-ai-watch-later-summaries" rel="noopener noreferrer"&gt;Telegram watch-later bot I built earlier this year&lt;/a&gt;. A list of saved videos, tap a row to mark it watched, a running total of queued minutes.&lt;/p&gt;

&lt;p&gt;v4 ships a generator that creates both halves of a screen:&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 native:make WatchList
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you &lt;code&gt;app/NativeComponents/WatchList.php&lt;/code&gt; and &lt;code&gt;resources/views/native/watch-list.blade.php&lt;/code&gt;, plus the route line to paste:&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;App\NativeComponents\WatchList&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;native&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="nc"&gt;WatchList&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;&lt;code&gt;Route::native()&lt;/code&gt; is the mobile sibling of a Livewire route. Parameters work like web routes, so &lt;code&gt;Route::native('/video/{id}', VideoDetail::class)&lt;/code&gt; matches a path segment and the screen reads it with &lt;code&gt;$this-&amp;gt;param('id')&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The data layer is just Laravel
&lt;/h3&gt;

&lt;p&gt;A full PHP runtime with SQLite runs on the device, so the model and migration are exactly what you would write in any Laravel app:&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;Schema&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;'videos'&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;id&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;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&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;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'channel'&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;unsignedSmallInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minutes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&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;boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'watched'&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;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&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 on-device quirk worth knowing: there is no &lt;code&gt;db:seed&lt;/code&gt; on the phone. Migrations run once on app start, so starter data goes into the migration's &lt;code&gt;up()&lt;/code&gt; method as plain inserts. It feels wrong for about a minute and then makes complete sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  The component
&lt;/h3&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;App\NativeComponents&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;App\Models\Video&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;Illuminate\View\View&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;Native\Mobile\Edge\NativeComponent&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;WatchList&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;NativeComponent&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;toggleWatched&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;$id&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;$video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Video&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;watched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;watched&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$video&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;render&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Video&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'watched'&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;get&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'native.watch-list'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'videos'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$videos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queuedMinutes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$videos&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;'watched'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minutes'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="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;Eloquent, on a phone, feeding SwiftUI. That sentence still feels strange to type.&lt;/p&gt;

&lt;h3&gt;
  
  
  The view
&lt;/h3&gt;

&lt;p&gt;The view uses EDGE elements, Blade tags under the &lt;code&gt;native:&lt;/code&gt; namespace that map one-to-one onto native UI. Styling is Tailwind utility classes, parsed by the engine into native modifiers:&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;native:top-bar title="Watch Later" /&amp;gt;

&amp;lt;native:scroll-view class="w-full h-full bg-zinc-100"&amp;gt;
    &amp;lt;native:column class="w-full p-4 gap-3"&amp;gt;
        &amp;lt;native:text class="text-sm text-zinc-500"&amp;gt;
            {{ $videos-&amp;gt;count() }} videos saved, {{ $queuedMinutes }} minutes queued
        &amp;lt;/native:text&amp;gt;

        @foreach ($videos as $video)
            &amp;lt;native:pressable key="video-{{ $video-&amp;gt;id }}" @tap="toggleWatched({{ $video-&amp;gt;id }})"&amp;gt;
                &amp;lt;native:row class="w-full items-center gap-3 p-4 bg-white rounded-2xl"&amp;gt;
                    &amp;lt;native:icon
                        ios="{{ $video-&amp;gt;watched ? 'checkmark.circle.fill' : 'circle' }}"
                        android="{{ $video-&amp;gt;watched ? 'check_circle' : 'radio_button_unchecked' }}"
                        size="24"
                        color="{{ $video-&amp;gt;watched ? '#16A34A' : '#A1A1AA' }}"
                    /&amp;gt;
                    &amp;lt;native:column class="flex-1 gap-1"&amp;gt;
                        &amp;lt;native:text class="text-base font-semibold {{ $video-&amp;gt;watched ? 'text-zinc-400' : 'text-zinc-900' }}"&amp;gt;
                            {{ $video-&amp;gt;title }}
                        &amp;lt;/native:text&amp;gt;
                        &amp;lt;native:text class="text-sm text-zinc-500"&amp;gt;
                            {{ $video-&amp;gt;channel }}@if ($video-&amp;gt;minutes), {{ $video-&amp;gt;minutes }} min @endif
                        &amp;lt;/native:text&amp;gt;
                    &amp;lt;/native:column&amp;gt;
                &amp;lt;/native:row&amp;gt;
            &amp;lt;/native:pressable&amp;gt;
        @endforeach
    &amp;lt;/native:column&amp;gt;
&amp;lt;/native:scroll-view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that earn a comment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@tap&lt;/code&gt; takes arguments.&lt;/strong&gt; &lt;code&gt;@tap="toggleWatched({{ $video-&amp;gt;id }})"&lt;/code&gt; calls the method with the id, Livewire style. &lt;code&gt;@foreach&lt;/code&gt; is normal Blade, because it is normal Blade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;native:top-bar&amp;gt;&lt;/code&gt; is real chrome.&lt;/strong&gt; It hoists onto the actual NavigationStack, so you get native back gestures and large-title behaviour for free. The docs are explicit about never hand-rolling a nav bar out of rows, and they are right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Icons are per-platform names.&lt;/strong&gt; There is no shared icon dictionary in core. The &lt;code&gt;ios&lt;/code&gt; attribute takes an SF Symbols name, &lt;code&gt;android&lt;/code&gt; takes a Material name, and whatever you pass through goes straight to the platform. Get one wrong and the icon silently does not render.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on your phone without Xcode
&lt;/h2&gt;

&lt;p&gt;This is the part of v4 that changes who can use it. Compiling an iOS app still requires a Mac with Xcode. Running one during development no longer does:&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 native:jump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jump starts a dev server and prints a QR code. Scan it with your phone's camera and the free Jump app opens your Laravel app as a native iOS app, rendering your actual Blade over the network. No compilation, no provisioning profiles, no Apple Developer account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2y8e54vnxtoz5a5jc47.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2y8e54vnxtoz5a5jc47.webp" alt="The Watch Later screen rendering as native SwiftUI via Jump" width="800" height="1740"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every element in that screenshot is a SwiftUI view. Tap a row and the checkmark fills, the row title dims, and the queued-minutes counter recalculates, all driven by the PHP component:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9chm1192549iw9yl0y8u.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9chm1192549iw9yl0y8u.webp" alt="Rows toggled watched, the counter updated" width="800" height="1740"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Edit the Blade file and the screen hot-reloads on the device. The feedback loop is genuinely faster than my Livewire browser workflow, which I did not expect to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can test screens without a device
&lt;/h2&gt;

&lt;p&gt;The sleeper feature of v4 is the testing harness. &lt;code&gt;php artisan native:make-test WatchList&lt;/code&gt; scaffolds a Pest test, and the API reads like Livewire's:&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;App\NativeComponents\WatchList&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;Native\Mobile\Testing\Native&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;'toggles a video watched when its row is tapped'&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;$video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Video&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;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Laracon US 2026 Keynote'&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;firstOrFail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Native&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WatchList&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;tap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"video-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&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;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'66 minutes queued'&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;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&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;watched&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs on your machine in milliseconds, no simulator involved. It can fire taps, long-presses, text input, toggles, swipes and navigation, and assert against the rendered tree. Mobile UI you can put in CI is not something the PHP ecosystem had last year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sharp edges
&lt;/h2&gt;

&lt;p&gt;I promised the walls, so here they are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Form elements live in a plugin, and the plugin is not on Packagist.&lt;/strong&gt; Core v4 registers layout, text, icons, pressables and navigation chrome. &lt;code&gt;button&lt;/code&gt;, &lt;code&gt;text-input&lt;/code&gt;, &lt;code&gt;toggle&lt;/code&gt; and &lt;code&gt;bottom-sheet&lt;/code&gt; come from a separate &lt;code&gt;nativephp/native-ui&lt;/code&gt; package distributed through NativePHP's own channels rather than Packagist. My original demo had an add-video form in a bottom sheet, and it died with &lt;code&gt;Unknown native element type: bottom_sheet&lt;/code&gt; until I read the service provider source and found the comment explaining the split. For a list-and-tap screen core is plenty. For forms, budget time to sort out plugin access first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v4 is moving fast.&lt;/strong&gt; The version number tells the story: 4.0.0, 4.0.1, 4.1.0 and 4.2.0 all shipped within weeks of each other, and 4.2.0 was current when I built this. Nothing broke for me across that churn, but I would not bet a client deadline on the surface staying identical yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PHP 8.4 floor will surprise people.&lt;/strong&gt; Plenty of Laravel developers are on 8.3 today. The error is clear, the fix is quick, but it is the first thing you will hit.&lt;/p&gt;

&lt;p&gt;None of these change the verdict. They are the normal texture of a framework feature that is one month old.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you build with it?
&lt;/h2&gt;

&lt;p&gt;If you shipped something on v3, the upgrade is safe and additive. Your webview screens keep working, and you can convert them one at a time. That migration-friendly posture is the same pattern I liked when &lt;a href="https://hafiz.dev/blog/how-i-built-macos-menu-bar-app-nativephp-laravel-livewire" rel="noopener noreferrer"&gt;I compared the desktop side of NativePHP&lt;/a&gt; to Electron: NativePHP consistently chooses paths that let you adopt incrementally.&lt;/p&gt;

&lt;p&gt;If you are starting fresh: for an internal tool, a companion app for an existing Laravel product, or anything list-and-detail shaped, this is now the fastest route from Laravel skills to a real native app. For a consumer app with heavy custom UI or deep platform integration, native Swift or Kotlin still wins, and NativePHP's own plugin system is the escape hatch when you need one native capability rather than a native rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need a Mac to try this?
&lt;/h3&gt;

&lt;p&gt;Not for development. Jump runs your app on a real device without compiling anything, so any machine that runs Laravel works. You need a Mac with Xcode only when you compile a distributable iOS build for the App Store.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is NativePHP v4 free?
&lt;/h3&gt;

&lt;p&gt;The core &lt;code&gt;nativephp/mobile&lt;/code&gt; package installed from Packagist with no license key, and the Jump app is free. Some UI and capability plugins are distributed separately through NativePHP's own channels, with paid tiers for premium plugins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does my existing Livewire knowledge transfer?
&lt;/h3&gt;

&lt;p&gt;Almost embarrassingly well. Public properties are state, methods are actions, &lt;code&gt;@tap&lt;/code&gt; and friends bind events to methods, and re-rendering happens when state changes. The view layer is different tags, not a different mental model.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is this different from React Native or Flutter?
&lt;/h3&gt;

&lt;p&gt;Same destination, different vehicle. React Native bridges JavaScript to native views and Flutter paints its own widgets. NativePHP runs an actual PHP runtime on the device, converts Blade to a native UI tree in shared memory, and renders platform-real SwiftUI and Compose views. You keep Eloquent, migrations and the whole Laravel toolbox.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I still use a webview for some screens?
&lt;/h3&gt;

&lt;p&gt;Yes. The webview element remains for legacy screens and edge cases, and v3 apps upgrade without rewriting them. The docs are blunt that new screens should be native, and after building one I see no reason to disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;v3 asked you to accept a webview in exchange for staying in Laravel. v4 stops asking. Blade in, SwiftUI out, Eloquent on the phone, tests in CI, and a QR code instead of Xcode.&lt;/p&gt;

&lt;p&gt;The plugin split and the release pace are real costs, and I would wait a quarter before shipping a revenue-critical app on it. But the direction is now unmistakable: the gap between "I know Laravel" and "I shipped a native app" has never been this small.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>nativephp</category>
      <category>mobile</category>
      <category>php</category>
    </item>
    <item>
      <title>Laravel Lock vs Cache::lock: When Entity-Scoped Locking Earns a Package</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 24 Aug 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-lock-vs-cachelock-when-entity-scoped-locking-earns-a-package-52a8</link>
      <guid>https://dev.to/hafiz619/laravel-lock-vs-cachelock-when-entity-scoped-locking-earns-a-package-52a8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-lock-vs-cache-lock" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Two workers pick up the same job at the same moment. Both read a stock count of 1. Both decide there is enough. Both allocate it.&lt;/p&gt;

&lt;p&gt;You now owe someone an apology email.&lt;/p&gt;

&lt;p&gt;Laravel has shipped &lt;code&gt;Cache::lock()&lt;/code&gt; for years and it solves this. So when &lt;a href="https://github.com/zaber-dev/laravel-lock" rel="noopener noreferrer"&gt;Laravel Lock&lt;/a&gt; turned up, a package whose entire job is distributed locking, my first reaction was that we already have this. Then I read what it actually does, and the answer got more interesting.&lt;/p&gt;

&lt;p&gt;This is a post about when a thin wrapper earns its place in your &lt;code&gt;composer.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you already have
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Cache::lock()&lt;/code&gt; is the built-in answer, and for a lot of cases it is the right 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;$lock&lt;/span&gt; &lt;span class="o"&gt;=&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;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stock_allocation_SKU-1180'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;$lock&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works. It is atomic against Redis or Memcached, it has a TTL so a crashed worker cannot hold the lock forever, and it costs you nothing extra.&lt;/p&gt;

&lt;p&gt;But look at the key. &lt;code&gt;'stock_allocation_SKU-1180'&lt;/code&gt; is a string you built by hand. Somewhere else in the codebase, someone else builds &lt;code&gt;'stock-allocation-' . $sku-&amp;gt;id&lt;/code&gt; and now you have two locks guarding the same resource, which is the same as having none.&lt;/p&gt;

&lt;p&gt;That is the actual problem. Not atomicity, naming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the package adds
&lt;/h2&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;ZaberDev\Lock\Facades\Lock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Lock&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;'shipment_dispatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$shipment&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;ttl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&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;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$carrier&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shipment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&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;Lock::for('shipment_dispatch', $shipment)&lt;/code&gt; builds the key from the name plus the model, so the same target always produces the same key. You cannot typo your way into a second lock on the same row.&lt;/p&gt;

&lt;p&gt;There is a &lt;code&gt;block()&lt;/code&gt; form that handles acquire and release for you:&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;$manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Lock&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;'shipment_dispatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$shipment&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;block&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;$shipment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$carrier&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;$carrier&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shipment&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;Models can carry their own locks with a trait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;ZaberDev\Lock\HasLocks&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;Shipment&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&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;HasLocks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$shipment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dispatch'&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;ttl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And routes can be guarded with middleware, which is the piece I have hand-rolled more than once:&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;'/warehouse/reconcile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReconcileController&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;'store'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lock:warehouse_reconcile,300'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/shipments/{shipment}/dispatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ShipmentController&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;'dispatch'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lock:shipment_dispatch:{shipment},60'&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 one binds the lock to the route parameter, so two requests for different shipments do not block each other while two requests for the same one do. When the lock is already held, the middleware does not queue the request. It rejects it with a 429 before your controller runs.&lt;/p&gt;

&lt;p&gt;Install:&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 zaber-dev/laravel-lock
php artisan vendor:publish &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ZaberDev&lt;/span&gt;&lt;span class="se"&gt;\L&lt;/span&gt;&lt;span class="s2"&gt;ock&lt;/span&gt;&lt;span class="se"&gt;\L&lt;/span&gt;&lt;span class="s2"&gt;ockServiceProvider"&lt;/span&gt;
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs PHP 8.2 or newer and works on Laravel 11, 12 and 13. Cache drivers or a database table, so Redis, Memcached or your existing SQL database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-lock-vs-cache-lock" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The diagram is the short version. The longer version is that these three tools solve genuinely different problems and people reach for the wrong one constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A database transaction&lt;/strong&gt; is right when the thing you are protecting is a database write and nothing else. &lt;code&gt;lockForUpdate()&lt;/code&gt; inside a transaction is stronger than any application lock, because the database enforces it. If your race is two workers updating the same row, stop reading and use a transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Cache::lock()&lt;/code&gt;&lt;/strong&gt; is right when the work spans more than the database. Calling a payment API, writing a file, sending a webhook. A transaction cannot protect those because they are not transactional. One lock key, one place in the code, no ceremony.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel Lock&lt;/strong&gt; starts to earn its place when the same logical resource gets locked from several places. A dispatch that can be triggered by a controller, a queued job and an artisan command is three chances to build the key differently. Entity scoping makes that impossible by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I would not use it
&lt;/h2&gt;

&lt;p&gt;If you have exactly one lock in your application, this is a dependency you do not need. &lt;code&gt;Cache::lock()&lt;/code&gt; with a well-named constant does the same job.&lt;/p&gt;

&lt;p&gt;If your race is purely a database one, both of these are the wrong layer. Use &lt;code&gt;lockForUpdate()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And if the goal is to let a few workers through rather than exactly one, that is not a lock at all, it is a funnel. Different tool, different failure modes. I covered that in &lt;a href="https://hafiz.dev/blog/laravel-cache-funnel-concurrency-limiting" rel="noopener noreferrer"&gt;concurrency limiting with cache funnels&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And be careful with the database driver. It writes a row per lock and every acquire runs a transaction with a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; before the insert. That is correct, and it is also slower than Redis by a wide margin. If you are locking in a hot path, use a cache driver.&lt;/p&gt;

&lt;p&gt;The version is also worth a glance. v1.0.1 landed in July 2026, so it is young. The surface is small enough that a breaking change would be cheap to absorb, but I would not put it in the path of anything that cannot fail on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locks are not a substitute for idempotency
&lt;/h2&gt;

&lt;p&gt;The failure I see most often is not a missing lock. It is a lock treated as a guarantee.&lt;/p&gt;

&lt;p&gt;A lock with a TTL can expire while the work is still running. The worker holding it does not find out. A second worker acquires the lock and starts the same work, and now you have the exact race you were preventing, except harder to reproduce because it only happens under load.&lt;/p&gt;

&lt;p&gt;Set the TTL longer than the worst realistic runtime, not the average. And make the work idempotent anyway, so that if it does run twice the second run is harmless. The same reasoning applies to &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue jobs that must not double-process&lt;/a&gt;, and it is the same discipline I used when &lt;a href="https://hafiz.dev/blog/multi-tenancy-queues-three-bugs-laravel-saas" rel="noopener noreferrer"&gt;three multi-tenancy queue bugs&lt;/a&gt; turned out to be concurrency problems wearing a different hat.&lt;/p&gt;

&lt;p&gt;A lock narrows the window. It does not close it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is this different from ShouldBeUnique on a job?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;ShouldBeUnique&lt;/code&gt; stops a duplicate job being dispatched at all, at dispatch time. A lock protects a section of code at execution time, whoever runs it. Use the first to keep the queue clean, the second to protect the resource.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work without Redis?
&lt;/h3&gt;

&lt;p&gt;Yes. It supports cache drivers and a database table. The database driver writes a row per lock and takes a transaction with &lt;code&gt;lockForUpdate()&lt;/code&gt; on each acquire, which is correct but slower. Redis or Memcached for anything hot.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if the process dies while holding a lock?
&lt;/h3&gt;

&lt;p&gt;The TTL releases it. That is why the TTL matters: too short and a second worker starts before the first has finished, too long and a crashed job blocks the resource until it expires. Pick a value longer than your worst realistic runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I lock something that is not an Eloquent model?
&lt;/h3&gt;

&lt;p&gt;Yes. The package resolves models, strings and integers into keys out of the box, so &lt;code&gt;Lock::for('stock_allocation', 'SKU-1180')&lt;/code&gt; is valid. For your own value objects, implement the package's &lt;code&gt;Lockable&lt;/code&gt; interface and the string it returns becomes the second half of the key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use this or just Cache::lock?
&lt;/h3&gt;

&lt;p&gt;If you have one or two locks, use &lt;code&gt;Cache::lock()&lt;/code&gt;. If the same resource is locked from several entry points, the entity scoping stops a whole class of key-mismatch bug and is worth the dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Cache::lock()&lt;/code&gt; is not broken and this package does not replace it. What it replaces is the string key you were building by hand in four different files.&lt;/p&gt;

&lt;p&gt;That is a real problem in a codebase of any size, and a boring one to solve yourself. Whether it is worth a dependency comes down to how many places lock the same thing.&lt;/p&gt;

&lt;p&gt;The honest test is to count. If one place locks the resource, &lt;code&gt;Cache::lock()&lt;/code&gt; and a named constant is the whole answer. If it is four places across a controller, a job and two commands, the key is going to drift, and that is what you are actually buying.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>concurrency</category>
      <category>packages</category>
      <category>php</category>
    </item>
    <item>
      <title>Sitemap Timing Test</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 20 Aug 2026 13:02:18 +0000</pubDate>
      <link>https://dev.to/hafiz619/sitemap-timing-test-1f0d</link>
      <guid>https://dev.to/hafiz619/sitemap-timing-test-1f0d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/sitemap-timing-test" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;x&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Your Laravel MCP Server Is a Public API for Robots. Here's How to Lock It Down.</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 19 Aug 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/your-laravel-mcp-server-is-a-public-api-for-robots-heres-how-to-lock-it-down-2bej</link>
      <guid>https://dev.to/hafiz619/your-laravel-mcp-server-is-a-public-api-for-robots-heres-how-to-lock-it-down-2bej</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-mcp-server-security-authorization" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;There are about six good tutorials on building a Laravel MCP server. Every one of them ends at the same place: you make a tool, you connect Claude, the agent calls your tool, everyone claps. Then the tutorial stops.&lt;/p&gt;

&lt;p&gt;That's the exact moment the interesting part starts. Because what you just built is an HTTP endpoint that lets a language model run your code. &lt;code&gt;Mcp::web('/mcp/orders', OrderServer::class)&lt;/code&gt; is a public route by default, and the tool behind it might refund a payment, delete a record, or read a customer's data. The getting-started guides walk you right up to that door and then wave goodbye before anyone locks it.&lt;/p&gt;

&lt;p&gt;This post is the lock. Authentication, per-tool authorization, the annotation trap that looks like a safety feature and isn't, rate limiting an endpoint an AI can call in a loop, and how to test that all of it actually holds. If you've read the &lt;a href="https://laravel.com/docs/13.x/mcp" rel="noopener noreferrer"&gt;Laravel MCP getting-started guide&lt;/a&gt; or built the app-friendly version I covered in &lt;a href="https://hafiz.dev/blog/how-to-make-your-laravel-app-ai-agent-friendly-the-complete-2026-guide" rel="noopener noreferrer"&gt;making your Laravel app AI-agent-friendly&lt;/a&gt;, this is the next step you actually need before any of it goes near production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually exposing
&lt;/h2&gt;

&lt;p&gt;Start by being honest about the threat model, because it's different from a normal API.&lt;/p&gt;

&lt;p&gt;A normal REST endpoint is called by code you wrote or a client you documented. An MCP tool is called by a language model interpreting natural language, sometimes from a user you've never met, sometimes with arguments the model invented to fit what it thought you meant. The caller is non-deterministic by design. That's the whole point of the protocol, and it's also the whole problem.&lt;/p&gt;

&lt;p&gt;So three things are true at once. The endpoint is public unless you protect it. The arguments are model-generated, so they can be malformed or adversarial in ways a normal client never would be. And the tool descriptions you write are read by the model to decide what to call, which means a badly scoped tool gets invoked in situations you didn't picture. A &lt;code&gt;deleteRecords&lt;/code&gt; tool with a vague description is a loaded gun with a helpful label.&lt;/p&gt;

&lt;p&gt;None of this means MCP is unsafe. It means the safety is your job, and Laravel gives you every piece you need. The pieces just aren't assembled anywhere, so let's assemble them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: authentication, and why local is not exempt
&lt;/h2&gt;

&lt;p&gt;A web server registered with &lt;code&gt;Mcp::web()&lt;/code&gt; is reachable by anyone who finds the URL. Authenticate it. The docs give you two real options and one trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanctum&lt;/strong&gt; is the pragmatic choice for most apps. Add the middleware and require a bearer token:&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;App\Mcp\Servers\OrderServer&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;Laravel\Mcp\Facades\Mcp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request now needs &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;, and inside your tools &lt;code&gt;$request-&amp;gt;user()&lt;/code&gt; resolves to the token's owner. For an internal server, or one your own product's agents call, this is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth via Passport&lt;/strong&gt; is what the MCP spec actually standardizes on, and it's the right call when third-party MCP clients (someone else's Claude, a tool you don't control) need to connect:&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;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;oauthRoutes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth:api'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the part worth internalizing before you rely on it: Laravel MCP uses OAuth as a translation layer to your authenticatable model, and it advertises a single &lt;code&gt;mcp:use&lt;/code&gt; scope. Custom scopes aren't supported. So OAuth authenticates &lt;em&gt;who&lt;/em&gt; the agent is acting as, but it does not carve up &lt;em&gt;what&lt;/em&gt; they can do. If your mental model of OAuth includes fine-grained scopes gating individual tools, drop it here. Authentication tells you the user. Authorization is still entirely on you, and that's the next layer.&lt;/p&gt;

&lt;p&gt;The trap is thinking &lt;strong&gt;local servers&lt;/strong&gt; don't need any of this. A local server runs as an Artisan command for agents on the same machine, which feels safe. But it runs with your application's full privileges, every binding in your container, your database, your filesystem. The security boundary for a local server is whatever can start that process. If that's a coding agent executing on your dev machine with your credentials, the blast radius is your entire local environment. Treat the machine boundary as the auth boundary, and don't register a local server that does anything you wouldn't let a shell script do unattended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: authorization, per tool, inside handle
&lt;/h2&gt;

&lt;p&gt;Authentication gets you a user. Now decide what that user can do, and do it in two places, because they guard different things.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;shouldRegister&lt;/code&gt; controls whether a tool is &lt;em&gt;visible&lt;/em&gt; in the server's tool 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;shouldRegister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refund-orders'&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;This is good practice. It keeps tools a user can't use out of the list the model sees, which means the model won't try to call them and won't hallucinate around their existence. But understand exactly what it does: it hides the tool. Hiding is not blocking. A caller who knows the tool name can still attempt to invoke it, and &lt;code&gt;shouldRegister&lt;/code&gt; returning false is not a guaranteed rejection at the point of execution the way an authorization check is.&lt;/p&gt;

&lt;p&gt;So the actual gate goes inside &lt;code&gt;handle()&lt;/code&gt;, every time, on every tool that does anything sensitive:&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;Laravel\Mcp\Request&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;Laravel\Mcp\Response&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refund-orders'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;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="s1"&gt;'You are not authorized to issue refunds.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$validated&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;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'order_id'&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;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'exists:orders,id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'amount'&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;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'min:1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:500000'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two things are happening there and both matter. The &lt;code&gt;can()&lt;/code&gt; check is your real authorization boundary, the one that actually stops execution. And the validation is not optional politeness, it's your defense against model-generated arguments. The &lt;code&gt;exists&lt;/code&gt; rule stops a refund against an order that isn't there. The &lt;code&gt;max&lt;/code&gt; stops the model refunding fifty thousand euros because it misread "500" as cents-or-not. Model input is untrusted input. Validate it exactly as hard as you'd validate a public form, because functionally that's what it is.&lt;/p&gt;

&lt;p&gt;The pattern I hold to: &lt;code&gt;shouldRegister&lt;/code&gt; for visibility, &lt;code&gt;can()&lt;/code&gt; for the gate, &lt;code&gt;validate()&lt;/code&gt; for the arguments. Skip any one of them and you've left a hole that the other two don't cover. This is the same principle behind &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;stopping an AI agent from destroying your Laravel app&lt;/a&gt;, applied at the protocol boundary instead of the SDK. If you want the review-before-execution version of the same instinct, the AI SDK's &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-human-in-the-loop-tool-approval" rel="noopener noreferrer"&gt;human-in-the-loop tool approval&lt;/a&gt; pauses a call for a person; this is the authorization layer underneath it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-mcp-server-security-authorization" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The annotation trap
&lt;/h2&gt;

&lt;p&gt;Laravel MCP lets you tag tools with annotations: &lt;code&gt;#[IsReadOnly]&lt;/code&gt;, &lt;code&gt;#[IsDestructive]&lt;/code&gt;, &lt;code&gt;#[IsIdempotent]&lt;/code&gt;, &lt;code&gt;#[IsOpenWorld]&lt;/code&gt;. They look like access controls. They are not.&lt;/p&gt;

&lt;p&gt;These are &lt;em&gt;hints to the client&lt;/em&gt;. They travel to the AI client as metadata so it can make smarter decisions, like warning a user before calling a destructive tool or preferring a read-only one. That's useful for a well-behaved client. But nothing in your server enforces them. A client can ignore &lt;code&gt;#[IsReadOnly]&lt;/code&gt; completely, and a compromised or hostile client absolutely will. Marking a tool &lt;code&gt;#[IsReadOnly]&lt;/code&gt; does not prevent it writing if its &lt;code&gt;handle()&lt;/code&gt; writes.&lt;/p&gt;

&lt;p&gt;So use annotations, they improve the experience with honest clients and they're good documentation of intent. Just never let one stand in for a check. If a tool must not modify data, the guarantee lives in what &lt;code&gt;handle()&lt;/code&gt; does and what &lt;code&gt;can()&lt;/code&gt; allows, not in an attribute the client is free to disregard. The annotation describes the tool's behavior; it doesn't constrain it.&lt;/p&gt;

&lt;p&gt;This is the single most likely place for a false sense of security in the whole feature, because the attribute reads like a policy and sits right there in the class looking authoritative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: rate limiting an endpoint a robot can loop
&lt;/h2&gt;

&lt;p&gt;A human hits your API at human speed. An agent in a retry loop, or a streaming tool processing a batch, can hit it as fast as the network allows. And MCP tools can return generators that hold an SSE stream open, which is a different resource profile from a normal request-response.&lt;/p&gt;

&lt;p&gt;Throttle the server route like you'd throttle a login endpoint:&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;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:mcp'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then define the limiter against the authenticated user, not the IP, because many agents sit behind shared egress addresses:&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;Illuminate\Cache\RateLimiting\Limit&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;Illuminate\Support\Facades\RateLimiter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;RateLimiter&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;'mcp'&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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;?&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;by&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;ip&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;Sixty a minute is a starting point, not a recommendation. Set it from what a legitimate session actually needs, then add headroom. The point is that an unbounded MCP endpoint is a cost and availability risk the moment a model decides to call your tool in a loop, and models do decide that. An agent will happily call the same lookup tool a dozen times in one turn, each result making it think of a new question. Cap it.&lt;/p&gt;

&lt;p&gt;For anything expensive behind a tool, the throttle is the first line, not the only one. Push the heavy work to a queue and return a job reference, the same way you would for any slow endpoint, which I went through in &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing thousands of queued jobs without breaking&lt;/a&gt;. A tool that kicks off a job and returns immediately can't hold a worker hostage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing that the locks hold
&lt;/h2&gt;

&lt;p&gt;The reason this feature survives contact with production is that all of it is testable without a live model or a network call. Laravel MCP ships test helpers that let you invoke a tool as a specific user and assert on the result.&lt;/p&gt;

&lt;p&gt;The tests that matter here aren't the happy path. They're the negative cases, the ones that prove your gates actually reject:&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 refunds for unauthorized 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="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 refund permission&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;OrderServer&lt;/span&gt;&lt;span class="o"&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;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundOrderTool&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="s1"&gt;'order_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="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertHasErrors&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;'rejects an amount above the ceiling'&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;$manager&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;refundManager&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&lt;/span&gt;&lt;span class="o"&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;$manager&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;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundOrderTool&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="s1"&gt;'order_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="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;99999999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertHasErrors&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;Write the "unauthorized user is refused" test for every sensitive tool. Write the "bad arguments are rejected" test for every tool that takes model input, which is all of them. If you set up your suite the way I described in the &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest testing guide&lt;/a&gt;, these drop straight into it. A green suite here means the model can throw whatever it wants at your server and the boundaries hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick word on tool descriptions as an attack surface
&lt;/h2&gt;

&lt;p&gt;One thing that isn't obvious until it bites you: the description you write on a tool is instructions to the model. A vague or overreaching description causes the model to call the tool in situations you didn't intend, which is a security issue dressed as a copywriting issue.&lt;/p&gt;

&lt;p&gt;"Manages orders" invites the model to reach for that tool for anything order-shaped. "Issues a refund against a specific order, for a specific amount, when a customer reports a defect" tells the model exactly when this fires and when it doesn't. Narrow descriptions are narrow attack surfaces. Write them like you're briefing an over-eager junior who takes every instruction literally, because you are.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;The rule I'd publish on the team wiki: authenticate the server, authorize inside every sensitive &lt;code&gt;handle()&lt;/code&gt;, validate every argument, throttle the route, and never trust an annotation as a control. Five things, all of them boring, all of them enforced in code you can test.&lt;/p&gt;

&lt;p&gt;The one I'd emphasize hardest is that authentication and authorization are different jobs and MCP only hands you the first one cleanly. The single &lt;code&gt;mcp:use&lt;/code&gt; scope means the framework knows &lt;em&gt;who&lt;/em&gt; is calling but has no opinion on &lt;em&gt;what&lt;/em&gt; they may do. Every "what" decision is a &lt;code&gt;can()&lt;/code&gt; check you write. Miss that distinction and you'll ship a server that's authenticated and wide open, which is arguably worse than one with no auth at all, because it looks secure in the code review.&lt;/p&gt;

&lt;p&gt;I'll also say the honest thing: MCP is young, the security patterns around it are younger, and the most dangerous tools are the ones that felt harmless in isolation. A read tool that exposes one customer's data is a read tool that exposes every customer's data if the authorization is wrong. Start with your least dangerous tool, get the five layers right on it, and use it as the template for everything else. Don't expose the refund tool until the lookup tool's tests are green.&lt;/p&gt;

&lt;p&gt;Build the server. Then lock it before anyone, human or model, walks through the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does a local MCP server need authentication?
&lt;/h3&gt;

&lt;p&gt;Not in the HTTP sense, since it runs as an Artisan command rather than a route. But it runs with your full application privileges, so the security boundary becomes whatever can start the process. On a dev machine driven by a coding agent, that's your entire local environment. Treat the machine access as the auth boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use OAuth scopes to control which tools an agent can call?
&lt;/h3&gt;

&lt;p&gt;Not with Laravel MCP's built-in OAuth. It uses OAuth as a translation layer to your authenticatable model and advertises a single &lt;code&gt;mcp:use&lt;/code&gt; scope; custom scopes aren't supported. Per-tool authorization is done with &lt;code&gt;can()&lt;/code&gt; checks inside each tool's &lt;code&gt;handle()&lt;/code&gt; method, not with scopes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do the &lt;code&gt;#[IsReadOnly]&lt;/code&gt; and &lt;code&gt;#[IsDestructive]&lt;/code&gt; annotations prevent a tool from doing damage?
&lt;/h3&gt;

&lt;p&gt;No. They're advisory metadata sent to the AI client to help it make decisions. Your server doesn't enforce them, and a client can ignore them. A tool is only read-only if its &lt;code&gt;handle()&lt;/code&gt; method doesn't write. Never rely on an annotation as an access control.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop an AI agent from calling my tool in a loop?
&lt;/h3&gt;

&lt;p&gt;Apply Laravel's &lt;code&gt;throttle&lt;/code&gt; middleware to the MCP route and define a rate limiter keyed by the authenticated user rather than the IP, since agents often share egress addresses. For expensive operations, push the work to a queue and return a job reference so a single call can't tie up a worker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is validation really necessary if I've defined an input schema?
&lt;/h3&gt;

&lt;p&gt;Yes. The JSON schema gives the model a shape to aim for, but it isn't enforcement, and model-generated arguments can still be malformed or adversarial. Validate inside &lt;code&gt;handle()&lt;/code&gt; with Laravel's validator exactly as you would for a public form, including existence and range checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Building an MCP server is a solved problem with six good tutorials. Securing one isn't, because the docs hand you the pieces (Sanctum, Passport, &lt;code&gt;can()&lt;/code&gt;, &lt;code&gt;shouldRegister&lt;/code&gt;, annotations, throttling, test helpers) without assembling them into a threat model. The model is simple once you see it: the endpoint is public, the caller is non-deterministic, the arguments are untrusted, and the annotations are hints. Everything else follows.&lt;/p&gt;

&lt;p&gt;Authenticate the route, authorize inside &lt;code&gt;handle()&lt;/code&gt;, validate every argument, throttle the endpoint, and test the negative cases. Do that and you've got an MCP server you'd actually put on the internet.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mcp</category>
      <category>aiagents</category>
      <category>security</category>
    </item>
    <item>
      <title>How I Moved a Live Laravel SaaS to a New Server With 2 Minutes of Downtime</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:54:18 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-i-moved-a-live-laravel-saas-to-a-new-server-with-2-minutes-of-downtime-bf2</link>
      <guid>https://dev.to/hafiz619/how-i-moved-a-live-laravel-saas-to-a-new-server-with-2-minutes-of-downtime-bf2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Last Thursday morning, I moved a production Laravel SaaS with paying customers from one server to another. The maintenance page was up for 1 minute and 55 seconds. I know the exact number because I checked the nginx access log on the new box afterwards: old server frozen at 09:19:59 UTC, first live request served by the new one at 09:21:54. The whole server migration, planning included, took one morning.&lt;/p&gt;

&lt;p&gt;The app is &lt;a href="https://promptoptimizer.tools" rel="noopener noreferrer"&gt;Prompt Optimizer&lt;/a&gt;, a prompt optimization tool with a free tier doing tens of thousands of optimizations a month and a paid tier billing real money through Stripe. So this wasn't a hobby project where downtime means nobody notices. A botched cutover here means failed checkouts and a Chrome extension that stops working for people who paid for it.&lt;/p&gt;

&lt;p&gt;This post is the full playbook: why I moved, how the cutover stayed under two minutes, and more importantly, the four things that almost broke silently. Because the rsync commands are the easy part. The dangerous part is everything that lives outside your app directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I moved at all
&lt;/h2&gt;

&lt;p&gt;The old server was a 1GB DigitalOcean droplet hosting several apps at once. It worked fine until the app grew, and then it didn't.&lt;/p&gt;

&lt;p&gt;Two incidents forced the decision. First, PHP-FPM worker starvation: my optimize endpoint calls an AI provider that can take up to 45 seconds to respond, and with &lt;code&gt;pm.max_children = 5&lt;/code&gt;, five slow AI calls meant the entire site stopped answering. Health checks stayed green the whole time, which made it worse. Second, an earlier out-of-memory crash had already forced me to ban every-minute cron jobs on that box, which quietly meant my Laravel scheduler never ran in production. More on that later, because it hid a broken feature for weeks.&lt;/p&gt;

&lt;p&gt;You can patch around a resource ceiling for a while. Raise a worker count here, cache a query there. But when the fixes start fighting each other, the honest answer is a bigger box. I went with a small Hetzner server with 4GB of RAM and two vCPUs, which I compared against the managed options in &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;Laravel Cloud vs Forge vs a plain VPS&lt;/a&gt;. For a solo developer running multiple small apps, the VPS still wins on cost by a wide margin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unfair advantage: Cloudflare was already in front
&lt;/h2&gt;

&lt;p&gt;Here's the thing that made a two-minute cutover possible at all: the domain was already proxied through Cloudflare.&lt;/p&gt;

&lt;p&gt;When your DNS is proxied, visitors never connect to your server's IP directly. They connect to Cloudflare's edge, and Cloudflare connects to whatever origin IP you've configured. Changing that origin is a dashboard edit that takes effect in seconds. No TTL waiting, no propagation anxiety, no "some users see the old server for six hours" nonsense. The classic migration problem simply doesn't exist.&lt;/p&gt;

&lt;p&gt;If your production domain is not behind a proxy like this, set that up weeks before you migrate, not the day of. I covered the base setup in &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;How I Hardened My VPS in One Afternoon&lt;/a&gt;, and the same stack carried this migration: Cloudflare in front, Tailscale between the boxes for private server-to-server transfers.&lt;/p&gt;

&lt;p&gt;For TLS on the new origin I used a Cloudflare &lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/" rel="noopener noreferrer"&gt;origin certificate&lt;/a&gt; instead of certbot. It's valid for 15 years, only Cloudflare needs to trust it, and there's no renewal cron to migrate. One less moving part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing the new server while the old one serves traffic
&lt;/h2&gt;

&lt;p&gt;Everything in this phase happened while the site ran normally. Zero risk, no time pressure.&lt;/p&gt;

&lt;p&gt;The new box got a dedicated PHP-FPM pool with 12 workers, sized so those 45-second AI calls can't starve anyone again. The queue worker became a proper systemd service instead of a supervisor config. Nginx got the vhost with the origin cert. Then I synced the whole deployed app tree over Tailscale: 227MB of code and vendor directory, about 20 seconds.&lt;/p&gt;

&lt;p&gt;The database needed more thought. The app runs on SQLite in production (yes, really, 123MB serving a six-figure monthly request count without complaint). You can't just copy a SQLite file that's being written to, because you might catch it mid-transaction. SQLite has a clean answer: &lt;a href="https://www.sqlite.org/lang_vacuum.html" rel="noopener noreferrer"&gt;&lt;code&gt;VACUUM INTO&lt;/code&gt;&lt;/a&gt;, available since 3.27, which writes a consistent snapshot to a new file without blocking writers.&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;// One consistent snapshot, no write freeze, no downtime&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;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"VACUUM INTO '/tmp/snapshot.sqlite'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That snapshot went to the new server as a rehearsal database. And then came the step I'd argue is the single most valuable trick in this whole post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rehearse through the real edge with a probe subdomain
&lt;/h2&gt;

&lt;p&gt;Testing the new server with &lt;code&gt;curl --resolve&lt;/code&gt; proves your nginx config works. It does not prove that Cloudflare's edge can talk to your new origin. SSL mode mismatches and origin cert problems only show up on that hop, and the classic failure is discovering them after you flip DNS, live, while your site throws 526 errors.&lt;/p&gt;

&lt;p&gt;So before touching the real records, I added one DNS entry: &lt;code&gt;neworigin.mydomain.com&lt;/code&gt;, proxied, pointing at the new server's IP. The origin cert was a wildcard, so it covered the probe subdomain for free. Then I opened it in a browser and ran a full user flow against the rehearsal database, through Cloudflare's actual edge, TLS handshake and all.&lt;/p&gt;

&lt;p&gt;It worked. Which meant the cutover would change exactly one variable: the origin IP behind a path already proven end to end. That's what makes a migration boring, and boring is the goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cutover: 1 minute 55 seconds
&lt;/h2&gt;

&lt;p&gt;Here's the full sequence. My part was scripted; the human part was clicking two DNS records.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stop the queue worker on the old server, then &lt;code&gt;php artisan down&lt;/code&gt;. Writers stopped first, then the maintenance page. The order matters: a worker that keeps processing jobs during your final sync is how you lose data.&lt;/li&gt;
&lt;li&gt;Take a fresh &lt;code&gt;VACUUM INTO&lt;/code&gt; snapshot. Writes are frozen now, so it's the final, complete state.&lt;/li&gt;
&lt;li&gt;Rsync the snapshot plus the storage delta to the new server. The pre-sync days earlier meant this final pass moved almost nothing.&lt;/li&gt;
&lt;li&gt;On the new box: &lt;code&gt;php artisan config:cache&lt;/code&gt;, fix file ownership, start the queue worker, install the crontab. The full cache command list is in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan commands reference&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Flip the two A records in Cloudflare to the new origin.&lt;/li&gt;
&lt;li&gt;Verify on the live domain: health endpoint, homepage, pricing, one real optimization, and a live &lt;a href="https://hafiz.dev/blog/stripe-integration-in-laravel-complete-guide-to-subscriptions-one-time-payments" rel="noopener noreferrer"&gt;Stripe checkout&lt;/a&gt; session to confirm billing quotes the right amount.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The old server stayed exactly as it was, frozen in maintenance mode. That's the rollback plan, and it's beautifully simple: flip the two records back, run &lt;code&gt;php artisan up&lt;/code&gt;, restart the worker. The point of no return is the first real payment on the new box, because after that, rolling back means losing data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that almost broke
&lt;/h2&gt;

&lt;p&gt;This is the part I wish someone had written before I started. Every one of these was invisible in my planning and got caught by testing rather than foresight.&lt;/p&gt;

&lt;h3&gt;
  
  
  The maintenance flag stowed away in the rsync
&lt;/h3&gt;

&lt;p&gt;Right after the final sync, the new server started answering 503. Confusing, until I realized why: &lt;code&gt;php artisan down&lt;/code&gt; creates a flag file at &lt;code&gt;storage/framework/down&lt;/code&gt;, and my final rsync had faithfully copied it to the new server. The new box wasn't broken. It was dutifully in maintenance mode because I'd shipped the maintenance flag along with the data. One &lt;code&gt;php artisan up&lt;/code&gt; on the new server fixed it, and cost about 30 seconds of the downtime window.&lt;/p&gt;

&lt;h3&gt;
  
  
  The SSR service that existed nowhere in the app tree
&lt;/h3&gt;

&lt;p&gt;The app uses Inertia SSR, which runs as a separate node process managed by a systemd unit. That unit isn't in the git repo. It's not in the crontab. It's not in the supervisor config. Its name didn't even contain the app's name. My server inventory missed it completely, and the site ran client-side-only for about 40 minutes before a deploy script warning surfaced it. For an app where most traffic comes from organic search, silently losing server-side rendering is a real SEO problem.&lt;/p&gt;

&lt;p&gt;The lesson: list every systemd service on the old box and ask what each one does. An app's runtime can include services you forgot you created.&lt;/p&gt;

&lt;h3&gt;
  
  
  The backup that would have failed silently every night at 2:30
&lt;/h3&gt;

&lt;p&gt;The database backup script ships snapshots offsite to Cloudflare R2 on a cron. After the migration I ran it by hand once instead of waiting for the schedule. Exit code 127. The new server didn't have the &lt;code&gt;sqlite3&lt;/code&gt; CLI installed, and the cron line ends in &lt;code&gt;&amp;gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt;, so the nightly run would have failed silently forever. I'd have discovered it the day I actually needed a backup, which is the one day you can't afford to.&lt;/p&gt;

&lt;p&gt;Run every cron job by hand once on the new machine. Crons that discard output don't fail loudly. They just stop existing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The midnight log rotation trap
&lt;/h3&gt;

&lt;p&gt;Laravel's daily log driver creates a fresh log file at the first write after midnight. Whoever writes first owns the file. If that's ever root (a stray artisan command over SSH is enough), the web user can't write to it, and every request starts failing at midnight while you sleep. I'd been bitten by this before, so this time I set a default ACL on the log directory that makes any new file writable by the web user no matter who creates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setfacl &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; u:www-data:rwX storage/logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I proved it: created a file as root, appended to it as www-data, watched it succeed. The first unattended night passed clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to do differently
&lt;/h2&gt;

&lt;p&gt;Honestly? Not much about the process. But I hold two opinions after this that I didn't hold as strongly before.&lt;/p&gt;

&lt;p&gt;First, rehearse through the real path, not a simulation of it. The probe subdomain took two minutes to set up and removed the only scary unknown in the plan. Every migration guide tells you to test the new server. Almost none tell you to test the edge-to-origin hop, and that's the one that bites.&lt;/p&gt;

&lt;p&gt;Second, the boring deploy stack held up. My deploy is a bash script doing a local build and an rsync. No containers, no orchestration. There are sharper tools, and I've compared some of them in &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;Scotty vs Laravel Envoy&lt;/a&gt;, but a deploy you fully understand beats a sophisticated one you half understand, especially at 11 in the morning with customers on the site. The same deploy script also caught the missing SSR service, which is a strong argument for running a no-change deploy as a post-migration test.&lt;/p&gt;

&lt;p&gt;And one bonus: moving to a box that could afford an every-minute &lt;code&gt;schedule:runcron&lt;/code&gt; revealed that some of my scheduled tasks had never actually run on the old server. Nothing was broken in the code. The entries existed in the scheduler, but no cron was firing them. Run &lt;code&gt;php artisan schedule:list&lt;/code&gt; on your new box and check every line against what you believed was running.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need to lower DNS TTLs before migrating?
&lt;/h3&gt;

&lt;p&gt;Not if your domain is proxied through Cloudflare or a similar edge. Visitors connect to the edge, not your origin, so changing the origin IP takes effect in seconds regardless of TTL. If your DNS points directly at your server, then yes, drop TTLs to 60 seconds at least a day before.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you copy a SQLite database that's in use?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;VACUUM INTO&lt;/code&gt; writes a consistent snapshot without blocking writers, which is perfect for rehearsal copies while the site runs. For the final sync, stop your writers first (queue worker, then maintenance mode), take one last snapshot, and ship that. Never plain-copy a SQLite file under active writes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just use Laravel Forge or Laravel Cloud?
&lt;/h3&gt;

&lt;p&gt;Both are good answers for teams that value their time over their invoice. I run several small apps on one box, I already had the hardened VPS setup, and the economics of a fixed-price server win at my scale. The trade is that every one of the gotchas in this post becomes your job. That trade is worth it to me. It might not be to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should the old server stay available?
&lt;/h3&gt;

&lt;p&gt;I froze mine for a week: maintenance mode on, workers stopped, crons commented out, nothing deleted. Rollback is a DNS flip away for the whole window. Decommission only after the new box has survived real traffic, a real payment, and at least one full backup cycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  What breaks most often after a server move?
&lt;/h3&gt;

&lt;p&gt;Things that live outside your app directory: systemd services, cron entries, CLI packages your scripts assume exist, credentials in &lt;code&gt;/root&lt;/code&gt;, log rotation permissions. Your code survives the rsync fine. The runtime around it is what gets forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist is the takeaway
&lt;/h2&gt;

&lt;p&gt;The migration took a morning, the downtime took 1 minute and 55 seconds, and the four near-misses took a healthy dose of paranoia to catch. If you're planning the same move, steal the sequence: prepare everything while the old server runs, rehearse through the real edge with a probe subdomain, freeze writers before the final sync, and then run every cron and service by hand once on the other side.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>laravel</category>
      <category>devops</category>
      <category>deployment</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>Laravel Head vs SEOTools vs Your Hand-Rolled Meta Partial: Who Should Actually Migrate</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:34:06 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-head-vs-seotools-vs-your-hand-rolled-meta-partial-who-should-actually-migrate-5h9o</link>
      <guid>https://dev.to/hafiz619/laravel-head-vs-seotools-vs-your-hand-rolled-meta-partial-who-should-actually-migrate-5h9o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-head-vs-seotools-migration-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every production Laravel app solved the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; problem years ago, one of three ways: artesaos/seotools with its family of facades, ralphjsmit/laravel-seo with SEO models in the database, or a hand-rolled Blade partial stuffed with &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; lines that grew one tag at a time since 2021. This blog runs the third kind.&lt;/p&gt;

&lt;p&gt;Then Taylor showed &lt;a href="https://github.com/laravel/head" rel="noopener noreferrer"&gt;Laravel Head&lt;/a&gt; on stage during the day-one Laracon US keynote, and now there's a first-party answer: &lt;code&gt;composer require laravel/head&lt;/code&gt;, a fluent API for titles, descriptions, canonicals, Open Graph, X cards, robots directives, JSON-LD, resource hints, and favicons, resolved per request across Blade, Livewire, and Inertia.&lt;/p&gt;

&lt;p&gt;Laravel News already covered how to use it, and the &lt;a href="https://laravel.com/docs/13.x/head" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; are thorough. The question nobody has answered is the one existing apps actually have: I have SEOTools calls in thirty controllers, or SEO rows in my database, or a 120-line partial. Is switching worth it, and what does the mapping look like? That answer is different for each of the three groups, so let's take them one at a time.&lt;/p&gt;

&lt;p&gt;First, the gate everyone hits before the interesting questions: Laravel Head requires PHP 8.3 and Laravel 13.17 or later. Below that, this whole post is academic until you upgrade. It needs 13.17 specifically because &lt;code&gt;withHead()&lt;/code&gt; is built on the native route metadata API that shipped in that release, which I covered in &lt;a href="https://hafiz.dev/blog/laravel-route-metadata-5-real-problems-it-finally-solves" rel="noopener noreferrer"&gt;the route metadata breakdown&lt;/a&gt; when it landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Head actually is, in ninety seconds
&lt;/h2&gt;

&lt;p&gt;Metadata resolves through five layers, lowest to highest priority: page defaults, route group metadata, route metadata, runtime metadata, and error metadata. Higher layers replace lower ones one field at a time, so a runtime title overrides the route's title without touching the route's description.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-head-vs-seotools-migration-guide" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Site-wide defaults live in a service provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Head\Enums\OgType&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;Laravel\Head\Enums\TwitterCard&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;Laravel\Head\Facades\Head&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;Laravel\Head\HeadBuilder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Head&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaults&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;HeadBuilder&lt;/span&gt; &lt;span class="nv"&gt;$head&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$head&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hafiz.dev'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;' - hafiz.dev'&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Laravel, shipped honestly.'&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;canonical&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;og&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;siteName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'hafiz.dev'&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;OgType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Website&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;twitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TwitterCard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;SummaryWithLargeImage&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;searchableByRobots&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;Static pages attach metadata to the route itself, and it survives route caching because &lt;code&gt;withHead()&lt;/code&gt; writes plain arrays through the route metadata API:&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/contact'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'contact'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'contact'&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;withHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Contact'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Get in touch.'&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;Dynamic pages set the rest at runtime:&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Head&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&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;ogImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;featuredImageUrl&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;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isDraft&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$head&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;$head&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hiddenFromRobots&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the layout renders everything with one directive:&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;head&amp;gt;
    &amp;lt;meta charset="utf-8"&amp;gt;
    @head
&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole model. Now the migration questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group one: artesaos/seotools apps
&lt;/h2&gt;

&lt;p&gt;SEOTools is the incumbent at 5.2 million installs, and if you've maintained a Laravel app for more than a few years, odds are decent you're in this group. The migration is the most mechanical of the three, and it's also the one with the clearest payoff, because of something I'll call the duplication tax.&lt;/p&gt;

&lt;p&gt;Here's a representative SEOTools controller method, straight out of the pattern its own README teaches:&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;Artesaos\SEOTools\Facades\SEOMeta&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;Artesaos\SEOTools\Facades\OpenGraph&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;Artesaos\SEOTools\Facades\TwitterCard&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;Artesaos\SEOTools\Facades\JsonLd&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;SEOMeta&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;SEOMeta&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;SEOMeta&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setCanonical&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;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setUrl&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;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;featuredImageUrl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addProperty&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="s1"&gt;'article'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;TwitterCard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;TwitterCard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;featuredImageUrl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;JsonLd&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;JsonLd&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;JsonLd&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;featuredImageUrl&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&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;Four facades, and the title appears four times. That's the duplication tax: SEOTools treats meta, Open Graph, Twitter, and JSON-LD as four independent surfaces, so every value you care about gets set on each of them, and every future edit happens in four places. Miss one and your &lt;code&gt;og:title&lt;/code&gt; silently drifts from your &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, which is exactly the class of bug nobody notices until a shared link renders wrong.&lt;/p&gt;

&lt;p&gt;The same method under Head:&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;Laravel\Head\Facades\Head&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;Laravel\Head\Facades\Schema&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Head&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&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;ogImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;featuredImageUrl&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;og&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;OgType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Article&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;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;blogPosting&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&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 document title and description fill in &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, and the Twitter card values automatically, and the canonical comes from &lt;code&gt;canonical()&lt;/code&gt; in your defaults, which uses the current request URL and normalizes it to https without being told. Fifteen lines became six, and the four-surface synchronization problem is gone because there's one source of truth.&lt;/p&gt;

&lt;p&gt;The layout side shrinks the same way. SEOTools renders through four separate calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{!! SEOMeta::generate() !!}
{!! OpenGraph::generate() !!}
{!! Twitter::generate() !!}
{!! JsonLd::generate() !!}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of that becomes &lt;code&gt;@head&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's also a maintenance argument that's uncomfortable to say out loud but belongs in the decision. SEOTools has been community-maintained since 2015, sits at v1.4.1, and its Packagist page currently lists three security advisories in its history. It works, and the maintainers deserve credit for a decade of service. But a first-party package that just got keynote billing is going to out-develop it from here, and the direction of that gap only points one way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for this group: migrate, and don't drag it out.&lt;/strong&gt; The mapping is mechanical, each page gets shorter, and running both packages during a transition is safe as long as only one of them renders a given tag. Move page by page, and delete the SEOTools generate calls from the layout last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group two: ralphjsmit/laravel-seo apps
&lt;/h2&gt;

&lt;p&gt;This one is a different animal, and anyone telling you it's the same migration hasn't read what the package does. ralphjsmit/laravel-seo stores SEO as Eloquent models: a &lt;code&gt;HasSEO&lt;/code&gt; trait on your &lt;code&gt;Post&lt;/code&gt; creates and associates an SEO row in the database, editable through whatever admin you've built, retrievable through a &lt;code&gt;seo&lt;/code&gt; relationship.&lt;/p&gt;

&lt;p&gt;Laravel Head has no persistence layer at all. It resolves metadata from code, per request, and stores nothing. That's a deliberate design choice, not a gap they forgot, but it means the thing ralphjsmit users actually bought (SEO as content, editable without a deploy) doesn't come in the box.&lt;/p&gt;

&lt;p&gt;The honest migration for this group keeps the database and swaps the rendering. Your SEO columns or the existing SEO model rows stay exactly where they are, and Head becomes the output layer:&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Head&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;seo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;seo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&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;ogImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;seo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;featuredImageUrl&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&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 works, and it's less code than it looks once you extract it into a small &lt;code&gt;HasHeadMetadata&lt;/code&gt; trait of your own. But notice what happened: you rebuilt the package's model integration yourself, thinly. Whether that trade is worth it depends on how much you use the rest of what Head brings, especially the route-level metadata and the Inertia story below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for this group: no urgency.&lt;/strong&gt; Your package is actively maintained (1.8.1 shipped in March, Laravel 13 supported), and the DB-backed workflow is a real feature Head doesn't replace. Migrate if you want the first-party trajectory and are willing to own a thin bridge trait, or when your admin-editable SEO turns out to be three fields nobody edits, which is more common than anyone admits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group three: the hand-rolled partial
&lt;/h2&gt;

&lt;p&gt;This is the group I'm in. hafiz.dev renders its meta tags from a Blade partial fed by the Post model: title, description, canonical, OG image with dimensions, article timestamps, Twitter card. It predates both packages' current versions, it works, and like every hand-rolled meta partial it has accumulated conditionals nobody remembers the reason for.&lt;/p&gt;

&lt;p&gt;For this group the migration case isn't about deleting a dependency, since there's no dependency to delete. It's about three things the partial approach can't do well:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static pages stop needing controller ceremony.&lt;/strong&gt; Contact, about, pricing, legal, the whole marketing shell around a &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS panel&lt;/a&gt;: every hand-rolled setup either hardcodes their meta in per-page views or threads variables through view composers. &lt;code&gt;withHead()&lt;/code&gt; on the route definition replaces both, and it's cacheable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The admin noindex problem gets one line.&lt;/strong&gt; Every app has route groups that should never be indexed. With a partial, that's a conditional inside the partial checking the route name, which is exactly the kind of accumulated conditional I just complained about. With Head it's &lt;code&gt;Route::withHead(robots: 'noindex, nofollow')&lt;/code&gt; on the group, next to the routes it describes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error pages get real metadata.&lt;/strong&gt; Hand-rolled partials almost never handle 404 and 500 pages properly because the layout context is different there. Head lets you register per-status title, description, and robots values once in a service provider, and that's a category of page most of us silently ship with default or broken meta.&lt;/p&gt;

&lt;p&gt;The migration itself is pleasant for this group because you're not translating an API, you're replacing string soup with structure. My plan for this blog is exactly the page-by-page approach: defaults into a provider first, then the blog post route, diffing the rendered &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; against the current partial's output before and after. The &lt;code&gt;Head::toArray()&lt;/code&gt; method is quietly useful here, since it gives you the resolved metadata as a structured array you can assert against in a test instead of regex-matching HTML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for this group: migrate at your own pace, newest pages first.&lt;/strong&gt; Nothing is broken today, but every new page you add to the partial is a page you'll migrate later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Head doesn't do, so you don't uninstall the wrong thing
&lt;/h2&gt;

&lt;p&gt;Three jobs commonly live next to meta tags, and none of them moved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sitemaps.&lt;/strong&gt; Head has &lt;code&gt;paginate()&lt;/code&gt;, &lt;code&gt;alternates()&lt;/code&gt;, and &lt;code&gt;feed()&lt;/code&gt; links, but it doesn't generate sitemap.xml. spatie/laravel-sitemap keeps its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OG image generation.&lt;/strong&gt; Head references image URLs, it doesn't create the images. If you generate OG images dynamically, the setup from &lt;a href="https://hafiz.dev/blog/generate-beautiful-og-images-laravel-spatie-og-image" rel="noopener noreferrer"&gt;my spatie/laravel-og-image walkthrough&lt;/a&gt; still owns that step, and its output URL is what you hand to &lt;code&gt;ogImage()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored, admin-editable SEO content.&lt;/strong&gt; As covered above: no database, no admin UI, by design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your mental model is "Head replaces my SEO stack", adjust it to "Head replaces the rendering layer of my SEO stack". Everything that produces the values still exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Inertia part, which is quietly the biggest upgrade
&lt;/h2&gt;

&lt;p&gt;If you run Inertia, this is the section that should decide you, because the old world was bad: metadata managed by a client-side &lt;code&gt;&amp;lt;Head&amp;gt;&lt;/code&gt; component means crawlers and link-preview bots that don't execute JavaScript see whatever your SSR setup happens to emit, which for many apps is nothing.&lt;/p&gt;

&lt;p&gt;Head shares the resolved metadata as rendered element strings on a &lt;code&gt;head&lt;/code&gt; prop, and with Inertia's &lt;code&gt;serverHead&lt;/code&gt; option (Inertia 3.5+) those tags land in the initial HTML response. Each element carries a stable &lt;code&gt;data-inertia&lt;/code&gt; key that Inertia adopts and keeps synchronized across visits and back/forward navigation. Preview bots read real tags without running a line of JavaScript, and you delete your client-side &lt;code&gt;&amp;lt;Head&amp;gt;&lt;/code&gt; usage entirely, which the docs are explicit about: don't let both systems manage the same element.&lt;/p&gt;

&lt;p&gt;Session-static tags (viewport, favicons, manifest) register once through &lt;code&gt;Head::inertiaGlobals()&lt;/code&gt; and stay out of the per-page prop. It's a carefully thought-through design, and it solves a problem the community packages never fully cracked because it needed cooperation from Inertia itself. First-party coordination is the whole pitch, and this is what it looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;For new apps this isn't a decision, it's the default: Head from day one, spatie/laravel-sitemap next to it, done.&lt;/p&gt;

&lt;p&gt;For existing apps, the three verdicts above compress to one sentence each. SEOTools apps should migrate because they're paying the duplication tax on every page and holding a package whose maintenance trajectory now points the wrong way. ralphjsmit apps should wait until the DB-backed workflow stops earning its keep, then bridge. Hand-rolled apps should migrate opportunistically, newest pages first, because the partial only grows.&lt;/p&gt;

&lt;p&gt;And one caution against the enthusiasm direction: don't migrate the week before something matters. Meta tags are the definition of quiet infrastructure, and the failure mode of a botched migration is invisible until search traffic dips or a shared link renders blank. Diff the rendered &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; per page type before and after, and keep the old system rendering until the diff is clean. This is a change you make boring on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What are Laravel Head's minimum requirements?
&lt;/h3&gt;

&lt;p&gt;PHP 8.3 and Laravel 13.17 or later. The 13.17 floor exists because route-level metadata through &lt;code&gt;withHead()&lt;/code&gt; is built on the native route metadata API introduced in that release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel Head replace artesaos/seotools completely?
&lt;/h3&gt;

&lt;p&gt;For rendering meta tags, Open Graph, Twitter cards, and JSON-LD, yes, and with less code because titles and descriptions propagate to the social tags automatically. It doesn't generate sitemaps, which SEOTools never did either.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run Laravel Head and my current SEO package at the same time?
&lt;/h3&gt;

&lt;p&gt;Yes, during a migration. The rule is that only one system may render a given tag on a given page, so move page by page and remove the old package's render calls from the layout only when every page has switched.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel Head work with route caching?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;withHead()&lt;/code&gt; stores plain arrays through Laravel's native route metadata API, which is fully compatible with &lt;code&gt;route:cache&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel Head store SEO data in the database?
&lt;/h3&gt;

&lt;p&gt;No. It resolves everything from code at request time. If you need admin-editable SEO content, keep your existing storage (columns or a package like ralphjsmit/laravel-seo's models) and feed those values into Head at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The interesting thing about Laravel Head isn't any single feature, it's that the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; element finally has a first-party owner, which means the Blade, Livewire, and Inertia rendering paths all got solved by one team with access to all three codebases. That's the thing no community package could ever fully deliver, and it's why the Inertia integration is the best part of the release.&lt;/p&gt;

&lt;p&gt;Figure out which of the three groups you're in, apply that group's verdict, and whichever it is, diff your rendered head before and after. Quiet infrastructure deserves boring migrations.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>seo</category>
      <category>packages</category>
      <category>php</category>
    </item>
    <item>
      <title>Filament v4.12 and v5.7: 92% Faster Forms, and the Security Fixes You Still Have to Turn On</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:49:53 +0000</pubDate>
      <link>https://dev.to/hafiz619/filament-v412-and-v57-92-faster-forms-and-the-security-fixes-you-still-have-to-turn-on-47d</link>
      <guid>https://dev.to/hafiz619/filament-v412-and-v57-92-faster-forms-and-the-security-fixes-you-still-have-to-turn-on-47d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/filament-v4-12-v5-7-performance-security-breakdown" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Filament shipped its biggest performance release since the v4 launch on August 6. Versions v4.12.6 and v5.7.6 promote the beta the team asked the community to test in late June onto the stable channels for both major versions, and the headline numbers are real: form fields render up to 92% faster, tables up to 52% faster, all in a non-breaking minor release.&lt;/p&gt;

&lt;p&gt;So run &lt;code&gt;composer update&lt;/code&gt;. Today. The same release resolves what the team describes as a handful of CVEs, and staying behind on a release with security content is not a position you want to defend.&lt;/p&gt;

&lt;p&gt;But here's the thing the announcement undersells, and the reason this post exists: two of the three new security features in this release do nothing until you write code. Updating gets you the patched vulnerabilities and all of the performance. It does not turn on the CSV formula-injection protection, and it does not sanitize a single CSS color value. If you read "please update to keep your application secure" and stop there, you'll be running the new version with the new protections sitting unused.&lt;/p&gt;

&lt;p&gt;Let's take the release apart properly: what's automatic, what's opt-in, and what the benchmark numbers actually mean for a real admin panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The performance numbers, and what they measure
&lt;/h2&gt;

&lt;p&gt;The team published internal benchmarks with &lt;a href="https://filamentphp.com/insights/alexandersix-major-performance-improvements-and-security-patches-for-filament-v4-12-and-v5-7" rel="noopener noreferrer"&gt;the release&lt;/a&gt;, and they're worth reading precisely because of what they measure: median render time per component, not page load.&lt;/p&gt;

&lt;p&gt;For forms and schema components:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Previous&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TextInput&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.559 ms&lt;/td&gt;
&lt;td&gt;0.044 ms&lt;/td&gt;
&lt;td&gt;~92% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Select&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.634 ms&lt;/td&gt;
&lt;td&gt;0.052 ms&lt;/td&gt;
&lt;td&gt;~92% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FileUpload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.586 ms&lt;/td&gt;
&lt;td&gt;0.083 ms&lt;/td&gt;
&lt;td&gt;~86% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Repeater&lt;/code&gt; (10 items, 2 fields)&lt;/td&gt;
&lt;td&gt;28.077 ms&lt;/td&gt;
&lt;td&gt;2.885 ms&lt;/td&gt;
&lt;td&gt;~90% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Checkbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.314 ms&lt;/td&gt;
&lt;td&gt;0.033 ms&lt;/td&gt;
&lt;td&gt;~90% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Half a millisecond per text input doesn't sound like much until you multiply it by a real resource form, the kind you end up with on any serious &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;admin dashboard&lt;/a&gt;. Thirty fields at ~0.5 ms each was 15 ms of pure framework rendering overhead before your queries, your Livewire round trip, or your validation ran at all. That's now about 1.5 ms. And every Livewire interaction that re-renders the form pays this cost again, so the win compounds across a session, not just on first load.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Repeater&lt;/code&gt; row is the one that matters most in practice. Repeaters are where Filament forms get heavy: line items on an invoice, variants on a product, questions on a quiz. Going from 28 ms to under 3 ms for a ten-item repeater changes the feel of exactly the forms people complain about.&lt;/p&gt;

&lt;p&gt;Tables got a second pass too (the team already did a big tables performance round in an earlier version):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Previous&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50 rows, 5 &lt;code&gt;TextColumn&lt;/code&gt;s, 3 row &lt;code&gt;Action&lt;/code&gt;s&lt;/td&gt;
&lt;td&gt;27.53 ms&lt;/td&gt;
&lt;td&gt;13.11 ms&lt;/td&gt;
&lt;td&gt;~52% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 rows, 5 columns, 1 &lt;code&gt;ActionGroup&lt;/code&gt; with 3 &lt;code&gt;Action&lt;/code&gt;s&lt;/td&gt;
&lt;td&gt;33.09 ms&lt;/td&gt;
&lt;td&gt;16.73 ms&lt;/td&gt;
&lt;td&gt;~49% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One honest caveat the team includes and most coverage will drop: these are internal development benchmarks, and your results will vary. Component-level medians are a fair way to measure framework overhead, but your panel's bottleneck might still be an N+1 query the renderer can't save you from.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they did it, and why it's a little funny
&lt;/h2&gt;

&lt;p&gt;The forms speedup comes from ripping out Blade component rendering paths and replacing them with direct PHP rendering. &lt;a href="https://github.com/filamentphp/filament/pull/19550" rel="noopener noreferrer"&gt;The pull request&lt;/a&gt; is refreshingly blunt about the technique: large portions of repeated Blade component calls were replaced with, in the team's own words, our dear friend the old &lt;code&gt;&amp;lt;?php&lt;/code&gt; tag.&lt;/p&gt;

&lt;p&gt;There's a real lesson in that. Blade components are a beautiful authoring experience, and every single one carries framework overhead: resolving the component class, building the data array, rendering the view. That cost is invisible until you're rendering hundreds of them per request, which is exactly what a Filament form does. The fastest Blade component is the one you don't render, and the Filament team just applied that at framework scale. If you've ever profiled a slow Filament page and wondered why so much time sat inside view rendering rather than your own code, this release is the answer arriving.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/filamentphp/filament/pull/19551" rel="noopener noreferrer"&gt;tables work&lt;/a&gt; is subtler: lighter rendering paths for common actions, more efficient attribute handling, and per-record caching of visibility and authorization decisions. That last one deserves a highlight. If your table calls a policy or a visibility closure per action, per record, those decisions were being recomputed repeatedly within a single render. Now they're cached per record. Tables with lots of conditional visibility and authorization logic, which describes most serious multi-user panels, benefit the most. If your panel gates actions by role or tenant the way I set up in the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS panel guide&lt;/a&gt;, this is the part of the release working for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security half, sorted by what actually happens when you update
&lt;/h2&gt;

&lt;p&gt;This is where the release notes needed a second read. Sorted by what you get automatically versus what you have to do:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic when you update: the patched CVEs.&lt;/strong&gt; The release resolves several vulnerabilities. The team hasn't itemized them in the announcement beyond "a handful of CVEs", which is normal for coordinated disclosure, and it's also the entire argument for updating now rather than during next month's maintenance window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Also already in if you're current: the MFA ordering fix.&lt;/strong&gt; A week before this release, v4.12.5 and v5.7.5 quietly shipped a fix titled "Check panel access before presenting MFA challenge." Before that patch, the MFA challenge screen could be presented before panel authorization ran. If you use Filament's multi-factor authentication, that ordering fix alone justified the update, and it rode in with no announcement at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Opt-in: CSV formula-injection protection.&lt;/strong&gt; When a spreadsheet app opens a CSV, cell values starting with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, or a tab character can execute as formulas. An attacker who controls a value that ends up in your admin export (a display name, a note field) can plant a formula that fires on the machine of whoever opens the file. Filament now ships protection that prefixes dangerous cells with a &lt;code&gt;"&lt;/code&gt; so they stay text.&lt;/p&gt;

&lt;p&gt;It's off by default, and for a defensible reason: legitimate values like international phone numbers start with &lt;code&gt;+&lt;/code&gt;, and silently rewriting them would corrupt real exports. The team made the right call. But that means the protection only exists in panels where someone consciously enabled it, and "someone" is you, this week, for every exporter that includes user-controlled text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual: CSS color sanitization.&lt;/strong&gt; There's a new &lt;code&gt;Str::sanitizeCssColor()&lt;/code&gt; helper for the case where user input ends up inside a &lt;code&gt;style=""&lt;/code&gt; attribute. A malicious "color" value can break out of the attribute and inject extra CSS or attributes. The helper closes that, but it's a helper. It sanitizes nothing until you call it at your injection points. Grep your custom columns, entries, and blade views for &lt;code&gt;style=&lt;/code&gt; interpolations before deciding you don't have any.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configure it: query builder limits.&lt;/strong&gt; New &lt;code&gt;maxRules()&lt;/code&gt; and &lt;code&gt;maxNestingDepth()&lt;/code&gt; methods bound how many rules and how much nesting the Query Builder accepts, enforced in the UI and against tampered payloads. The team files this under performance, but read the failure mode: a hand-crafted payload with an enormous rule tree could previously burn CPU and memory until response times cratered or the server fell over. That's a denial-of-service vector through a form, and if you expose the Query Builder to any non-admin user tier, setting these limits is a security decision, not a tuning knob.&lt;/p&gt;

&lt;p&gt;So the accurate summary of this release is: the performance is free, the patched CVEs are free, and the three new defenses are tools you now own but haven't picked up. Update first, then budget fifteen minutes for the checklist below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fifteen-minute audit after updating
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep for CSV exporters.&lt;/strong&gt; Anywhere you export tables containing user-controlled text, enable the formula-injection protection, then check whether any legitimate values (phone numbers, negative amounts) get mangled and handle those columns explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for &lt;code&gt;style=&lt;/code&gt; interpolations.&lt;/strong&gt; Custom columns, infolist entries, and blade views that build inline styles from stored values get wrapped in &lt;code&gt;Str::sanitizeCssColor()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound your query builders.&lt;/strong&gt; If non-admin users can touch a Query Builder, set &lt;code&gt;maxRules()&lt;/code&gt; and &lt;code&gt;maxNestingDepth()&lt;/code&gt; to the most restrictive values your real use cases allow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm you're on at least .5 of your branch.&lt;/strong&gt; That's the MFA ordering fix. The current .6 includes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-run your slowest panel page and enjoy.&lt;/strong&gt; No action needed, this one's the reward.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The quality-of-life additions worth knowing
&lt;/h2&gt;

&lt;p&gt;The release also carries a set of smaller changes, a few of which solve real irritations. User and tenant menu items can now be registered in groups instead of one flat list, and the menu actions get memoized once per request as a bonus. Nested modals get &lt;code&gt;modalDismissesParentActions()&lt;/code&gt;, so closing an inner modal can abandon a whole multi-step flow instead of forcing users to close each layer individually. Chart widgets can define proper empty states, which finally distinguishes "no data yet" from "broken". And a scattering of dynamic configuration wins: panel content width, sidebar width, dark mode availability, and the theme switcher can all take closures at runtime now, and navigation children can reference parents by a stable key instead of the displayed label, which stops translations from silently breaking your nav hierarchy.&lt;/p&gt;

&lt;p&gt;None of these changes the argument for updating, but the grouped menus and the stable navigation keys are the kind of small fixes that let you delete workarounds from real codebases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you update today?
&lt;/h2&gt;

&lt;p&gt;Yes, and this is an easier call than most minor releases.&lt;/p&gt;

&lt;p&gt;The case is unusually clean: it's a non-breaking minor on both branches, the beta ran in community applications for over a month before promotion, there's security content, and the performance work requires zero code changes to benefit from. The team explicitly designed the release so the upgrade is nothing more than bumping to the next minor version.&lt;/p&gt;

&lt;p&gt;Two groups should look slightly closer before hitting update in production. If you maintain a panel with heavy custom theming or custom field components, the rendering path changed underneath you, so click through your custom components in staging first; direct PHP rendering should be visually identical, but "should" is doing work in that sentence when your CSS targets Blade component markup. And if you maintain or depend on plugins, check them against the new version before rolling out, since anything that hooked into the old rendering paths is the most likely thing to notice the refactor. Maintaining a &lt;a href="https://hafiz.dev/blog/filament-billing-the-missing-stripe-admin-layer-for-your-panel" rel="noopener noreferrer"&gt;Filament billing plugin&lt;/a&gt; myself, running the plugin test suite against both new branches is the first item on my list before shipping the bump to clients.&lt;/p&gt;

&lt;p&gt;If you're still on v3, this release doesn't reach you, and that's the quiet message in it: the performance investment is going into v4 and v5 only. The earlier security round in June patched v3.3 alongside v4 and v5, so v3 still gets security fixes, but the gap between the branches just got wider. If you've been deferring the upgrade decision, the &lt;a href="https://hafiz.dev/blog/filament-v5-released-whats-new-what-changed-and-should-you-upgrade" rel="noopener noreferrer"&gt;v5 breakdown I wrote at release&lt;/a&gt; covers what the jump involves, and this release just added 92% faster forms to the "upgrade" side of the ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I get the performance improvements without changing any code?
&lt;/h3&gt;

&lt;p&gt;Yes. The rendering optimizations are internal to Filament. Update to v4.12.6 or v5.7.6 and every form, schema component, and table renders through the faster paths automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the CSV formula-injection protection enabled by default?
&lt;/h3&gt;

&lt;p&gt;No, it's deliberately opt-in, because prefixing cells that start with characters like &lt;code&gt;+&lt;/code&gt; would corrupt legitimate values such as international phone numbers. You need to enable it for your exports and verify your real data survives it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which versions contain the MFA fix?
&lt;/h3&gt;

&lt;p&gt;The "check panel access before presenting MFA challenge" fix shipped in v4.12.5 and v5.7.5 on July 31. The current v4.12.6 and v5.7.6 include it. If you're on any earlier patch of these branches and use Filament's MFA, update now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this release affect Filament v3?
&lt;/h3&gt;

&lt;p&gt;No. The performance work and the new security features target v4.12 and v5.7 only. The June security round was the last one to include a v3 patch (v3.3.52). If you're on v3, the case for planning the v4/v5 upgrade keeps getting stronger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will the update break my custom theme or plugin?
&lt;/h3&gt;

&lt;p&gt;The release is designed as a non-breaking minor, and it went through a month-long community beta. That said, the forms refactor replaced Blade component rendering with direct PHP rendering, so custom components and CSS that target Filament's internal markup deserve a staging pass before production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;This is what a good maintenance release looks like: a real performance refactor, security content, and no breaking changes, shipped through a public beta rather than straight to stable. The one improvement I'd ask of the announcement is honesty of emphasis. "Update to keep your application secure" is true for the CVEs and incomplete for the rest, because the most interesting defenses in this release are ones you have to reach for.&lt;/p&gt;

&lt;p&gt;So do both halves. Run the update today, then spend the fifteen minutes on the audit. The panels that get the full value of this release are the ones where someone read past the benchmark tables.&lt;/p&gt;

</description>
      <category>filament</category>
      <category>laravel</category>
      <category>performance</category>
      <category>security</category>
    </item>
    <item>
      <title>Laravel's New Image API Doesn't Replace Intervention or Spatie Media Library</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 05 Aug 2026 07:46:39 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravels-new-image-api-doesnt-replace-intervention-or-spatie-media-library-20ca</link>
      <guid>https://dev.to/hafiz619/laravels-new-image-api-doesnt-replace-intervention-or-spatie-media-library-20ca</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-image-api-vs-intervention-vs-spatie-media-library" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel 13.20 landed on 14 July with a first-party image API, and within a week the takes were everywhere. Laravel replaces Intervention Image. Drop your Spatie dependency. One less package in your &lt;code&gt;composer.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the first line of the installation section in the official docs:&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 intervention/image:^4.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot use Laravel's image manipulation without installing Intervention Image, short of writing your own driver from scratch. The GD and Imagick drivers are both backed by it. &lt;code&gt;Illuminate\Image\Drivers\InterventionDriver&lt;/code&gt; is the only driver that ships. Laravel didn't replace Intervention, it adopted it and put a facade in front.&lt;/p&gt;

&lt;p&gt;So the comparison people are reaching for doesn't hold. What you actually have to decide is which layer of a four-layer stack your particular job belongs at, and that's a more useful question anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, honestly
&lt;/h2&gt;

&lt;p&gt;Four layers, and most of the confusion comes from treating them as competitors when three of them sit on top of each other.&lt;/p&gt;

&lt;p&gt;At the bottom are the PHP extensions, GD and Imagick, which do the pixel work. Above them sit the manipulation libraries: &lt;code&gt;intervention/image&lt;/code&gt; and &lt;code&gt;spatie/image&lt;/code&gt;, each wrapping the extensions in a sane API. Above those is &lt;code&gt;Illuminate\Image&lt;/code&gt;, Laravel's new fluent wrapper, which drives Intervention. And off to one side is &lt;code&gt;spatie/laravel-medialibrary&lt;/code&gt;, which builds on &lt;code&gt;spatie/image&lt;/code&gt; and does something categorically different: attaching files to Eloquent models.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-image-api-vs-intervention-vs-spatie-media-library" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that diagram and the question answers itself. If your job is "transform this image right now", you want the middle of the stack. If your job is "this Product has five photos, each needing a thumbnail and a web-sized version", you want Media Library, and Laravel 13.20 changed nothing for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 13.20 actually replaces
&lt;/h2&gt;

&lt;p&gt;Not a package. A class you wrote yourself.&lt;/p&gt;

&lt;p&gt;Nearly every Laravel app that handles uploads has one. It's called &lt;code&gt;ImageService&lt;/code&gt; or &lt;code&gt;ImageManager&lt;/code&gt; or &lt;code&gt;HandlesImageUploads&lt;/code&gt;, it lives in &lt;code&gt;app/Services&lt;/code&gt;, and it's about forty lines of Intervention calls glued to a &lt;code&gt;Storage::put()&lt;/code&gt;. Most of them were written against Intervention v3, so they look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImageService&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;storeAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UploadedFile&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$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;ImageManager&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;Driver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$image&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;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRealPath&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;cover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&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="s1"&gt;'avatars/'&lt;/span&gt;&lt;span class="mf"&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="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public'&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;put&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toWebp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&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;$path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That whole class is now one chain in your controller:&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;$path&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;image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'avatar'&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;cover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&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;toWebp&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;quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&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;storePublicly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'avatars'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public'&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;$request-&amp;gt;image()&lt;/code&gt; returns an &lt;code&gt;Illuminate\Image\Image&lt;/code&gt; or null when the field is absent, so validate the upload first as you normally would. Every transformation returns a new instance, nothing executes until you ask for output, and the storage methods generate a unique filename and hand back the path exactly like &lt;code&gt;store()&lt;/code&gt; does for ordinary uploads.&lt;/p&gt;

&lt;p&gt;Deleting a service class is a small win, but it's a real one, and it's the kind of decision I've written about before in the &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;service, action, or job decision tree&lt;/a&gt;. A wrapper that exists only to make a package feel Laravel-native stops earning its keep the moment the framework ships the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't touch: Spatie Media Library
&lt;/h2&gt;

&lt;p&gt;Media Library is at v11.23.2 and past 44 million installs, and none of what it does overlaps with the new API.&lt;/p&gt;

&lt;p&gt;It associates files with Eloquent models. It registers named conversions on the model that generate automatically on upload and queue themselves by default. It generates responsive image variants along with the &lt;code&gt;srcset&lt;/code&gt; markup to serve them. It handles multiple collections per model, per-collection disks, ordering, and a &lt;code&gt;media-library:regenerate&lt;/code&gt; command for when you change a conversion and need to reprocess everything you've already stored.&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;Product&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;HasMedia&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;InteractsWithMedia&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;registerMediaConversions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?Media&lt;/span&gt; &lt;span class="nv"&gt;$media&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;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;addMediaConversion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'thumb'&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;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;368&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;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;232&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;sharpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;addMediaConversion&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;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1200&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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webp'&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;quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&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;Try to rebuild that on top of &lt;code&gt;Illuminate\Image&lt;/code&gt; and you're writing a media table, a conversions registry, a queue pipeline, a URL generator, and a regeneration command. That's not a weekend. Media Library is a different product that happens to also resize images.&lt;/p&gt;

&lt;p&gt;The one honest thing to say is that some apps pulled in Media Library purely to resize an avatar and never touched a conversion or a collection. If that's you, the new API is a genuine simplification. If you have &lt;code&gt;registerMediaConversions&lt;/code&gt; anywhere in your codebase, it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you still drop to Intervention directly
&lt;/h2&gt;

&lt;p&gt;The Laravel API covers &lt;code&gt;resize&lt;/code&gt;, &lt;code&gt;scale&lt;/code&gt;, &lt;code&gt;cover&lt;/code&gt;, &lt;code&gt;contain&lt;/code&gt;, &lt;code&gt;crop&lt;/code&gt;, &lt;code&gt;orient&lt;/code&gt;, &lt;code&gt;rotate&lt;/code&gt;, &lt;code&gt;blur&lt;/code&gt;, &lt;code&gt;grayscale&lt;/code&gt;, &lt;code&gt;sharpen&lt;/code&gt;, both flips, seven output formats, &lt;code&gt;quality&lt;/code&gt;, and &lt;code&gt;optimize&lt;/code&gt;. That's the common set, and for most apps it's everything.&lt;/p&gt;

&lt;p&gt;What it doesn't expose is most of what makes Intervention v4 interesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text and fonts.&lt;/strong&gt; No &lt;code&gt;text()&lt;/code&gt; method, no font files, no wrapping, alignment, or stroke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition.&lt;/strong&gt; No &lt;code&gt;insert()&lt;/code&gt; or &lt;code&gt;place()&lt;/code&gt;, so no watermarks and no layering one image over another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drawing.&lt;/strong&gt; No rectangles, circles, lines, or polygons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animation.&lt;/strong&gt; No frame-by-frame work on animated GIFs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colorspaces and ICC profiles.&lt;/strong&gt; Nothing for print-accurate color.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Need any of those and you use Intervention directly, which you already have installed. There's no conflict in doing both: the fluent API for uploads, the library for the one endpoint that stamps a watermark.&lt;/p&gt;

&lt;p&gt;There is a middle path worth knowing about. You can register a custom transformation as a value object and teach a driver how to apply 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;App\Images\Transformations\Pixelate&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;Illuminate\Support\Facades\Image&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;Intervention\Image\Interfaces\ImageInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transformUsing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gd'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Pixelate&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;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ImageInterface&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Pixelate&lt;/span&gt; &lt;span class="nv"&gt;$transformation&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;$image&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pixelate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$transformation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;size&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;Your transformation class implements &lt;code&gt;Illuminate\Contracts\Image\Transformation&lt;/code&gt; and carries its own arguments. After registering the handler in a service provider, &lt;code&gt;-&amp;gt;transform(new Pixelate(12))&lt;/code&gt; drops into any pipeline. That's how you get watermarking into the fluent chain without abandoning it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare driver that never shipped
&lt;/h2&gt;

&lt;p&gt;If you've read about a Cloudflare driver for this API, complete with &lt;code&gt;Image::pruneOrphaned('cloudflare')&lt;/code&gt; on a schedule and validation rules to keep BMP files away from it, that's real code but it isn't in Laravel.&lt;/p&gt;

&lt;p&gt;It was in the pull request during review and got pulled before merge. The PR is explicit that the Cloudflare-specific driver and support were removed to keep the first version focused on local processing through GD and Imagick via Intervention. The shipped &lt;code&gt;config/images.php&lt;/code&gt; supports two drivers. Setting &lt;code&gt;IMAGE_DRIVER=cloudflare&lt;/code&gt; gets you an exception, not remote processing.&lt;/p&gt;

&lt;p&gt;Some of the write-ups circulating were based on the PR description rather than the released code, which is an easy mistake to make when a feature changes shape during review. Check &lt;code&gt;php artisan config:publish images&lt;/code&gt; and read what's actually in the file. While you're in there, the full command list lives in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part is what the removal left behind. Custom drivers are a documented 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;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\Image\Driver&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;Illuminate\Image\ImagePipeline&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;VipsDriver&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Driver&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;process&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;$contents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ImagePipeline&lt;/span&gt; &lt;span class="nv"&gt;$pipeline&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="c1"&gt;// Apply the pipeline's transformations and output options...&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$contents&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;transformUsing&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;$transformation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register that with &lt;code&gt;Image::extend('vips', fn () =&amp;gt; new VipsDriver)&lt;/code&gt; and switch per image with &lt;code&gt;-&amp;gt;using('vips')&lt;/code&gt; or globally with &lt;code&gt;IMAGE_DRIVER=vips&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That example name isn't arbitrary. libvips is a fast, low-memory image library that outperforms both GD and Imagick, and Intervention already maintains an official driver for it in &lt;code&gt;Intervention/image-driver-vips&lt;/code&gt;. Spatie Media Library already accepts &lt;code&gt;vips&lt;/code&gt; as an &lt;code&gt;image_driver&lt;/code&gt; value too. Laravel's &lt;code&gt;InterventionDriver&lt;/code&gt; only exposes &lt;code&gt;gd&lt;/code&gt; and &lt;code&gt;imagick&lt;/code&gt;, so you can't reach vips through &lt;code&gt;IMAGE_DRIVER&lt;/code&gt; today. But the hard part is done. A Laravel driver for it is a thin adapter over a maintained package, not a from-scratch build, and it's the most obvious gap in the ecosystem right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things the docs don't make obvious
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The queueing advice contradicts the API.&lt;/strong&gt; The introduction tells you to move heavy processing to a queued job, which is correct advice. But &lt;code&gt;Image&lt;/code&gt; instances cannot be serialized, and passing one to a job throws an &lt;code&gt;ImageException&lt;/code&gt;. You store first and pass the path, then rebuild with &lt;code&gt;Image::fromStorage()&lt;/code&gt; inside the job. Worth knowing before you dispatch, and if you're pushing real volume through, the patterns in &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing 10,000 queued tasks without breaking&lt;/a&gt; apply here too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Processing is lazy and cached, which cuts both ways.&lt;/strong&gt; Nothing runs until you call &lt;code&gt;toBytes()&lt;/code&gt;, &lt;code&gt;width()&lt;/code&gt;, &lt;code&gt;store()&lt;/code&gt;, or cast to string. After that first output the result is reused, so calling &lt;code&gt;width()&lt;/code&gt; then &lt;code&gt;store()&lt;/code&gt; doesn't process twice. The trap is the other direction: because instances are immutable, branching off a base image to make three variants means three separate pipelines, each doing its own decode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage failures return false, they don't throw.&lt;/strong&gt; &lt;code&gt;store()&lt;/code&gt;, &lt;code&gt;storeAs()&lt;/code&gt;, and the public variants return &lt;code&gt;false&lt;/code&gt; if the image couldn't be stored. If you assign the result straight to a database column you'll write a boolean into a string field and find out later. Check it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your job&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resize or convert an upload in a controller&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Illuminate\Image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate a few variants from one source&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Illuminate\Image&lt;/code&gt;, one pipeline each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model has attached files with named sizes&lt;/td&gt;
&lt;td&gt;Spatie Media Library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Responsive images with &lt;code&gt;srcset&lt;/code&gt; markup&lt;/td&gt;
&lt;td&gt;Spatie Media Library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watermarks, text, drawing, animated GIFs&lt;/td&gt;
&lt;td&gt;Intervention Image directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, but inside the fluent chain&lt;/td&gt;
&lt;td&gt;Custom &lt;code&gt;Transformation&lt;/code&gt; class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large volumes, memory pressure&lt;/td&gt;
&lt;td&gt;Custom driver over libvips&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory and CPU are the constraint that decides most of this in production. If you're processing anything sizeable in the request cycle you'll hit it, and the same advice applies as with any big upload path, which I've covered in &lt;a href="https://hafiz.dev/blog/handling-large-file-uploads-in-laravel-without-crashing-your-server" rel="noopener noreferrer"&gt;handling large file uploads without crashing your server&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;Use it. For the resize-an-upload case it's cleaner than what you have, it's one less abstraction you maintain, and the immutable pipeline is a better design than the mutable managers both underlying libraries expose.&lt;/p&gt;

&lt;p&gt;The criticism raised during review deserves an airing, though. A reviewer argued that bolting image manipulation onto &lt;code&gt;Request&lt;/code&gt;, a class already at critical mass, adds bloat, and that image processing is a resource hog that shouldn't be quite this reachable. Both points are fair. &lt;code&gt;$request-&amp;gt;image('avatar')-&amp;gt;cover(400, 400)-&amp;gt;store('avatars')&lt;/code&gt; is a very short path to doing something expensive inside a web request, and the docs' warning about queueing is one sentence in an introduction most people skim.&lt;/p&gt;

&lt;p&gt;My rule: if the source is user-supplied and unbounded in size, it goes to a queue regardless of how convenient the one-liner looks. The API being easy doesn't make the work cheap.&lt;/p&gt;

&lt;p&gt;The part I'd actually watch is the driver contract. A fluent, Laravel-native API where the engine underneath is swappable is more interesting than the transformation methods, because it means the ceiling on this component isn't set by what GD can do. That's also why the Cloudflare removal reads as deliberate rather than a retreat. Ship the extension point, let the drivers arrive later.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Laravel 13.20 mean I can remove Intervention Image?
&lt;/h3&gt;

&lt;p&gt;No, the opposite. You have to install &lt;code&gt;intervention/image:^4.0&lt;/code&gt; for the GD and Imagick drivers to work. It isn't a hard requirement in the framework's own dependencies, but it's required in practice for anything the shipped drivers do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I replace Spatie Media Library with the new API?
&lt;/h3&gt;

&lt;p&gt;Only if you never used what Media Library is for. If your codebase has &lt;code&gt;registerMediaConversions&lt;/code&gt;, media collections, or responsive images, you'd be rebuilding a mature package by hand. If you pulled it in just to crop an avatar, switching is a reasonable simplification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I pass an image instance to a queued job?
&lt;/h3&gt;

&lt;p&gt;No. Image instances aren't serializable and attempting it throws an &lt;code&gt;ImageException&lt;/code&gt;. Store the image first, pass the path, and rebuild inside the job with &lt;code&gt;Image::fromStorage()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a Cloudflare driver?
&lt;/h3&gt;

&lt;p&gt;Not in the released framework. It existed in the pull request and was removed before merge so the first version could focus on GD and Imagick. Only &lt;code&gt;gd&lt;/code&gt; and &lt;code&gt;imagick&lt;/code&gt; are supported out of the box, though the driver contract is public if you want to build your own.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add a watermark with the new API?
&lt;/h3&gt;

&lt;p&gt;Not with the built-in methods, since there's no composition or text support. Either use Intervention Image directly for that operation, or write a custom &lt;code&gt;Transformation&lt;/code&gt; class and register a handler with &lt;code&gt;Image::transformUsing()&lt;/code&gt; so it works inside the fluent chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The question worth asking isn't which of these three to pick. It's how far up the stack your problem lives. Transforming bytes right now is the bottom, attaching media to models is the top, and 13.20 filled a gap in the middle that most of us had been filling with a service class of our own.&lt;/p&gt;

&lt;p&gt;If you're picking between the three for an existing app, start by grepping for &lt;code&gt;registerMediaConversions&lt;/code&gt;. If it's there, nothing changes for you. If it isn't, and you've got a hand-rolled image wrapper somewhere in &lt;code&gt;app/Services&lt;/code&gt;, you can probably delete it this afternoon. That's also the moment to revisit whether your upload path belongs in the request at all, something I went through in detail while &lt;a href="https://hafiz.dev/blog/building-a-full-stack-file-upload-system-with-laravel-vuejs-and-s3" rel="noopener noreferrer"&gt;building a full-stack upload system with Vue and S3&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>images</category>
      <category>spatie</category>
      <category>php</category>
    </item>
  </channel>
</rss>
