<?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>Slow Job Alerts in Laravel 13.40: What whenJobRunsLongerThan Catches and What It Misses</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 12 Nov 2026 05:15:13 +0000</pubDate>
      <link>https://dev.to/hafiz619/slow-job-alerts-in-laravel-1340-what-whenjobrunslongerthan-catches-and-what-it-misses-1g53</link>
      <guid>https://dev.to/hafiz619/slow-job-alerts-in-laravel-1340-what-whenjobrunslongerthan-catches-and-what-it-misses-1g53</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-slow-job-alerts" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel 13.40 shipped on 3 November with &lt;code&gt;Queue::whenJobRunsLongerThan()&lt;/code&gt;, a one-liner that tells you when a queued job runs slower than it should. The scheduler got a twin, &lt;code&gt;Schedule::whenTaskRunsLongerThan()&lt;/code&gt;, in the same &lt;a href="https://github.com/laravel/framework/releases/tag/v13.40.0" rel="noopener noreferrer"&gt;release&lt;/a&gt;. The queue docs don't cover either one yet, so I tested them.&lt;/p&gt;

&lt;p&gt;On a fresh Laravel 13.40.0 install with the database queue driver and PHP 8.3, I pushed five jobs through one worker: a fast one, a slow one, one that fails, one that releases itself, and one that runs past the worker's &lt;code&gt;--timeout&lt;/code&gt;. Four did what you'd expect. The fifth is the one to know about before you build alerting on this. All output below is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 13.40 added
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/laravel/framework/pull/62141" rel="noopener noreferrer"&gt;PR #62141&lt;/a&gt; adds the queue version. You register it once, 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="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Carbon&lt;/span&gt;\&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Queue&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="nf"&gt;boot&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="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whenJobRunsLongerThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="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="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$duration&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;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"{$job-&amp;gt;resolveName()} took {$duration}ms"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$duration&lt;/code&gt; is in milliseconds. It comes from the &lt;code&gt;JobAttempted&lt;/code&gt; event, which gained a &lt;code&gt;duration&lt;/code&gt; property in 13.39. And that event is the whole mechanism. This is the core of the method in &lt;code&gt;QueueManager&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'events'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Events&lt;/span&gt;\&lt;span class="nc"&gt;JobAttempted&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&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="nv"&gt;$event&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;$threshold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_null&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;duration&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;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So it isn't a timer. Nothing happens when a running job crosses the threshold. The check runs once, after the attempt is over, and only if a duration was recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five jobs through one worker
&lt;/h2&gt;

&lt;p&gt;The test app registers two thresholds side by side. One uses &lt;code&gt;CarbonInterval::seconds(2)&lt;/code&gt;. The other passes a plain &lt;code&gt;5&lt;/code&gt;, which is easy to type when you mean five seconds:&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;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whenJobRunsLongerThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="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="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$duration&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;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"[2s threshold] {$job-&amp;gt;resolveName()} took {$duration}ms"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whenJobRunsLongerThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$duration&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;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"[plain 5] {$job-&amp;gt;resolveName()} took {$duration}ms"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job sleeps, then optionally throws or releases itself:&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="nf"&gt;handle&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="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;usleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s1"&gt;'fail'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Failed after sleeping'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'release'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&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="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run is a 0.5 second job, then three 3 second jobs. One finishes, one throws, one releases itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;$ php artisan queue:work --tries=1 --stop-when-empty
 2026-11-06 11:20:35 App\Jobs\SleepJob .. RUNNING
 2026-11-06 11:20:36 App\Jobs\SleepJob .. 514.71ms DONE
 2026-11-06 11:20:36 App\Jobs\SleepJob .. RUNNING
 2026-11-06 11:20:39 App\Jobs\SleepJob .. 3s DONE
 2026-11-06 11:20:39 App\Jobs\SleepJob .. RUNNING
 2026-11-06 11:20:42 App\Jobs\SleepJob .. 3s FAIL
 2026-11-06 11:20:42 App\Jobs\SleepJob .. RUNNING
 2026-11-06 11:20:45 App\Jobs\SleepJob .. 3s RELEASED
 2026-11-06 11:20:48 Worker STOPPED Queue empty
$ grep WARNING storage/logs/laravel.log
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:36&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;plain 5&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob took 513.2ms
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:39&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;2s threshold&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob took 3009.45ms
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:39&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;plain 5&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob took 3009.45ms
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:42&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;2s threshold&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob took 3003.32ms
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:42&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;plain 5&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob took 3003.32ms
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:45&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;2s threshold&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob took 3005.84ms
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:45&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;plain 5&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob took 3005.84ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 2 second threshold fired for all three slow jobs, including the one that failed and the one that was released. A job that keeps failing slowly and retrying is exactly what you'd want flagged. (13.40 also changed the worker output to say &lt;code&gt;RELEASED&lt;/code&gt; instead of &lt;code&gt;DONE&lt;/code&gt;, which you can see on the last job.)&lt;/p&gt;

&lt;p&gt;The plain &lt;code&gt;5&lt;/code&gt; fired for the 514ms job. That's the unit trap. A plain number is milliseconds here, so &lt;code&gt;whenJobRunsLongerThan(5, ...)&lt;/code&gt; means five milliseconds, and it will fire for almost every job you run.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;sync&lt;/code&gt; driver behaves the same way. &lt;code&gt;SleepJob::dispatchSync(3)&lt;/code&gt; logged &lt;code&gt;took 3006.12ms&lt;/code&gt; on both thresholds, because the sync queue dispatches &lt;code&gt;JobAttempted&lt;/code&gt; with a duration too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scheduler version counts in seconds
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/laravel/framework/pull/62233" rel="noopener noreferrer"&gt;PR #62233&lt;/a&gt; adds &lt;code&gt;Schedule::whenTaskRunsLongerThan()&lt;/code&gt;. Same shape, different unit:&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;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Console&lt;/span&gt;\&lt;span class="nc"&gt;Scheduling&lt;/span&gt;\&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whenTaskRunsLongerThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$runtime&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;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"{$task-&amp;gt;getSummaryForDisplay()} took {$runtime}s"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here a plain &lt;code&gt;2&lt;/code&gt; means two seconds. So the same number in your queue setup and your scheduler setup means two different things, a thousand times apart. Pass a &lt;code&gt;CarbonInterval&lt;/code&gt; to both and the problem goes away. The queue version converts it with &lt;code&gt;totalMilliseconds&lt;/code&gt; and the scheduler version with &lt;code&gt;totalSeconds&lt;/code&gt;, so &lt;code&gt;CarbonInterval::seconds(30)&lt;/code&gt; means 30 seconds in both places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job that hits the timeout
&lt;/h2&gt;

&lt;p&gt;The fifth job sleeps for 10 seconds on a worker started with &lt;code&gt;--timeout=5&lt;/code&gt;. It's the slowest job in the whole test, by a distance.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;$ php artisan queue:work --tries=1 --timeout=5
 2026-11-06 11:20:18 App\Jobs\SleepJob .. RUNNING
 2026-11-06 11:20:23 App\Jobs\SleepJob .. 5s FAIL
 2026-11-06 11:20:23 Worker STOPPED Job timed out
$ &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
137
$ grep WARNING storage/logs/laravel.log
&lt;span class="o"&gt;[&lt;/span&gt;2026-11-06 11:20:23&lt;span class="o"&gt;]&lt;/span&gt; local.WARNING: &lt;span class="o"&gt;[&lt;/span&gt;timed out&lt;span class="o"&gt;]&lt;/span&gt; App\Jobs\SleepJob after 5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the &lt;code&gt;JobTimedOut&lt;/code&gt; listener wrote anything. The worker exited with code 137, which is 128 plus signal 9, &lt;code&gt;SIGKILL&lt;/code&gt;. The order inside &lt;code&gt;Worker::registerTimeoutHandler()&lt;/code&gt; explains it. The &lt;code&gt;SIGALRM&lt;/code&gt; handler marks the job as failed (it's in &lt;code&gt;failed_jobs&lt;/code&gt; as a &lt;code&gt;TimeoutExceededException&lt;/code&gt;), dispatches &lt;code&gt;JobTimedOut&lt;/code&gt;, then calls &lt;code&gt;kill()&lt;/code&gt;, which sends &lt;code&gt;SIGKILL&lt;/code&gt; to its own process. &lt;code&gt;JobAttempted&lt;/code&gt; is dispatched from the &lt;code&gt;finally&lt;/code&gt; block in &lt;code&gt;Worker::process()&lt;/code&gt;, and a process killed with &lt;code&gt;SIGKILL&lt;/code&gt; never reaches a &lt;code&gt;finally&lt;/code&gt; block. No &lt;code&gt;JobAttempted&lt;/code&gt; means no duration, and no duration means no alert.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-slow-job-alerts" 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;
  
  
  What I'd register instead
&lt;/h2&gt;

&lt;p&gt;Keep the slow job alert, but don't make it your only timeout signal. This is the setup I'd put in &lt;code&gt;AppServiceProvider&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="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Carbon&lt;/span&gt;\&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Queue&lt;/span&gt;\&lt;span class="nc"&gt;Events&lt;/span&gt;\&lt;span class="nc"&gt;JobTimedOut&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Queue&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="nf"&gt;boot&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="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Jobs that finish, but slower than they should&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whenJobRunsLongerThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&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="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$duration&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;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Slow job: {$job-&amp;gt;resolveName()} took {$duration}ms"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Jobs the worker had to kill&lt;/span&gt;
    &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JobTimedOut&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&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="nc"&gt;JobTimedOut&lt;/span&gt; &lt;span class="nv"&gt;$event&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Job timed out: {$event-&amp;gt;job-&amp;gt;resolveName()} after {$event-&amp;gt;timeout}s"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pass a &lt;code&gt;CarbonInterval&lt;/code&gt;, never a plain number.&lt;/strong&gt; It means the same thing in both APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the threshold well under your timeout.&lt;/strong&gt; The alert only sees jobs that finish, so it's an early warning. With the default &lt;code&gt;--timeout=60&lt;/code&gt;, a 30 second threshold tells you a job is drifting toward the limit while it still completes. The timeout itself has to stay below &lt;code&gt;retry_after&lt;/code&gt;, which &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;the 10,000 tasks post&lt;/a&gt; explains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Listen for &lt;code&gt;JobTimedOut&lt;/code&gt; for the jobs that don't finish.&lt;/strong&gt; The worker dispatches it on every timeout, before the kill, whether or not the job has retries left. Keep the listener quick, though. It runs inside a process that's about to kill itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A timeout isn't the only way a worker dies before it can tidy up. A deploy sends &lt;code&gt;SIGTERM&lt;/code&gt; and then &lt;code&gt;SIGKILL&lt;/code&gt; after the grace period, which I walked through in &lt;a href="https://hafiz.dev/blog/laravel-queue-worker-sigterm-interruptible-jobs" rel="noopener noreferrer"&gt;the queue worker SIGTERM post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background scheduled tasks are never measured
&lt;/h2&gt;

&lt;p&gt;The scheduler version listens for &lt;code&gt;ScheduledTaskFinished&lt;/code&gt; and &lt;code&gt;ScheduledTaskFailed&lt;/code&gt;. &lt;code&gt;schedule:run&lt;/code&gt; times each task by wrapping the call that starts it, and for a task with &lt;code&gt;runInBackground()&lt;/code&gt; that call returns as soon as the process is spawned. Two tasks, a 3 second one in the foreground and a 4 second one in the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php artisan schedule:run
 2026-11-06 11:20:51 Running ['artisan' app:slow-task 3] .. 3s DONE
 2026-11-06 11:20:55 Running ['artisan' app:slow-task 4] in background  5.56ms DONE
$ grep WARNING storage/logs/laravel.log
[2026-11-06 11:20:55] local.WARNING: [schedule 2] '/opt/homebrew/Cellar/php@8.3/8.3.22/bin/php' 'artisan' app:slow-task 3 &amp;gt; '/dev/null' 2&amp;gt;&amp;amp;1 took 3.23s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The foreground task fired at 3.23s. The background one never did. The scheduler recorded the 5.56ms it took to start the process, not the four seconds the command ran, and nothing compares the real runtime later. If a long task has to run in the background, time it inside the command itself. (&lt;a href="https://laravel.com/docs/13.x/scheduling#background-tasks" rel="noopener noreferrer"&gt;Background tasks&lt;/a&gt; in the docs covers how they run.)&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does whenJobRunsLongerThan fire while the job is still running?
&lt;/h3&gt;

&lt;p&gt;No. It listens for &lt;code&gt;JobAttempted&lt;/code&gt;, which the worker dispatches after an attempt ends. A job stuck at minute 20 of a 30 minute timeout produces nothing until it finishes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What unit does the threshold use?
&lt;/h3&gt;

&lt;p&gt;Milliseconds for &lt;code&gt;Queue::whenJobRunsLongerThan()&lt;/code&gt; and seconds for &lt;code&gt;Schedule::whenTaskRunsLongerThan()&lt;/code&gt;. Both convert a &lt;code&gt;CarbonInterval&lt;/code&gt; correctly, so use one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it alert on a job that hits the worker timeout?
&lt;/h3&gt;

&lt;p&gt;No. The worker kills its own process with &lt;code&gt;SIGKILL&lt;/code&gt; before &lt;code&gt;JobAttempted&lt;/code&gt; is dispatched. Listen for &lt;code&gt;JobTimedOut&lt;/code&gt; instead, which fires just before the kill. The &lt;a href="https://laravel.com/docs/13.x/queues#timeout" rel="noopener noreferrer"&gt;queue timeout docs&lt;/a&gt; cover how the timeout itself works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work with Horizon?
&lt;/h3&gt;

&lt;p&gt;I didn't run Horizon for this test. Horizon's &lt;code&gt;horizon:work&lt;/code&gt; command extends Laravel's own &lt;code&gt;queue:work&lt;/code&gt; command (I checked in Horizon 5.50.1), so jobs go through the same &lt;code&gt;Worker&lt;/code&gt; class and the same timeout path. I'd expect the same results, but confirm on your setup. If you're still choosing between the two, &lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;Horizon vs Cloud's managed queues&lt;/a&gt; covers the trade-offs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work with the sync driver?
&lt;/h3&gt;

&lt;p&gt;Yes. The sync queue dispatches &lt;code&gt;JobAttempted&lt;/code&gt; with a duration too, so a slow &lt;code&gt;dispatchSync()&lt;/code&gt; call triggers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A slow alert needs a timeout listener next to it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;whenJobRunsLongerThan&lt;/code&gt; is a good alert for jobs that finish slowly, and it costs one line. But it can't see the job that never finishes, and that's the one that hurts. And if you use the scheduler version, a task with &lt;code&gt;runInBackground()&lt;/code&gt; is invisible to it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>queues</category>
      <category>laravel13</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Herd 2 Workspaces for Parallel Coding Agents: What Is Isolated and What Is Not</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 09 Nov 2026 05:15:08 +0000</pubDate>
      <link>https://dev.to/hafiz619/herd-2-workspaces-for-parallel-coding-agents-what-is-isolated-and-what-is-not-492j</link>
      <guid>https://dev.to/hafiz619/herd-2-workspaces-for-parallel-coding-agents-what-is-isolated-and-what-is-not-492j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/herd-2-workspaces-parallel-coding-agents" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Herd 2 came out on 5 November, and its headline feature is workspaces: "isolated copies of a site that live on their own git branch and are served on their own domain." The pitch is parallel coding agents. Give each agent its own copy of the app and let them work without stepping on each other.&lt;/p&gt;

&lt;p&gt;I ran parallel Claude Code sessions on &lt;a href="https://hafiz.dev/blog/git-worktrees-for-laravel-developers-run-multiple-claude-code-sessions-in-parallel" rel="noopener noreferrer"&gt;git worktrees&lt;/a&gt; back in March, and the files were never the hard part. The hard part was everything the files point at. So I installed Herd 2.1.0 (Homebrew already ships 2.1), created a fresh Laravel 13.40 app, made five workspaces from it and tried to break them. Herd prints a database warning every time you create one, so I tested whether it holds. And the database wasn't the only thing two copies of the app ended up sharing. All output below is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a workspace actually is
&lt;/h2&gt;

&lt;p&gt;The app lives in &lt;code&gt;~/Herd/orders&lt;/code&gt;, so Herd serves it at &lt;code&gt;orders.test&lt;/code&gt;. One command creates a workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ herd workspace feature-a
✓ Creating Workspace
✓ Cloning Directory (this can take a moment)
✓ Linking Site
✓ Updating .env
✓ Finishing Up
Workspace [feature-a] is ready at [http://feature-a.orders.test].
 WARNING  The workspace shares the database of [orders] — migrations and destructive operations affect the parent site.
  Pass `--database=clone` for a copy of the parent database or `--database=empty` for a fresh one.
  cd "/Users/hafizzeeshanriaz/Library/Application Support/Herd/devtools/clones/8543c00e/feature-a-aa198b31"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It isn't a git worktree. Herd makes a copy-on-write clone of the whole folder (APFS clones files without duplicating the bytes until one side changes them), puts it in Herd's own application support folder, and gives it a full &lt;code&gt;.git&lt;/code&gt; directory on a new branch named after the workspace. The new site answers at &lt;code&gt;feature-a.orders.test&lt;/code&gt; straight away.&lt;/p&gt;

&lt;p&gt;The clone takes everything, untracked files included. So the workspace has its own &lt;code&gt;.env&lt;/code&gt; and its own &lt;code&gt;vendor/&lt;/code&gt;. Herd then rewrites the &lt;code&gt;.env&lt;/code&gt;, but only the keys that already exist. Here's the whole difference between the two files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ diff .env &amp;lt;workspace&amp;gt;/.env
5c5
&amp;lt; APP_URL=http://localhost:8000
---
&amp;gt; APP_URL=http://feature-a.orders.test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. Herd's &lt;a href="https://herd.laravel.com/docs/macos/sites/workspaces" rel="noopener noreferrer"&gt;workspace docs&lt;/a&gt; say it also touches &lt;code&gt;SESSION_DOMAIN&lt;/code&gt;, &lt;code&gt;SANCTUM_STATEFUL_DOMAINS&lt;/code&gt; and &lt;code&gt;DB_DATABASE&lt;/code&gt; when those are set, and only &lt;code&gt;DB_DATABASE&lt;/code&gt; when the workspace gets its own database. A fresh Laravel app sets none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database warning, tested
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;herd workspace&lt;/code&gt; run ends with the same warning: the workspace shares the parent's database, so migrations and destructive operations affect the parent site. &lt;code&gt;herd workspaces&lt;/code&gt; agrees and lists &lt;code&gt;feature-a&lt;/code&gt; with a database of &lt;code&gt;shared&lt;/code&gt;. So I put one user in the parent and ran the most destructive command an agent is likely to run.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/herd-2-workspaces-parallel-coding-agents" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php artisan tinker --execute="echo App\\Models\\User::count();"   # parent
1
$ cd &amp;lt;workspace&amp;gt; &amp;amp;&amp;amp; php artisan migrate:fresh --force
 Dropping all tables .. 4.81ms DONE
 INFO Preparing database.
 Creating migration table .. 2.00ms DONE
 INFO Running migrations.
 0001_01_01_000000_create_users_table .. 3.35ms DONE
 0001_01_01_000001_create_cache_table .. 1.65ms DONE
$ php artisan tinker --execute="echo App\\Models\\User::count();"   # parent again
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parent still has its user. Laravel's default SQLite connection doesn't set &lt;code&gt;DB_DATABASE&lt;/code&gt;, so each app reads &lt;code&gt;database/database.sqlite&lt;/code&gt; relative to its own folder, and the clone took its own copy of that file (different inode, same 98,304 bytes). Herd's warning describes the database Herd thinks it's sharing. The app was never sharing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  With an absolute database path
&lt;/h2&gt;

&lt;p&gt;That changes the moment &lt;code&gt;DB_DATABASE&lt;/code&gt; holds a value that means the same thing in both folders. An absolute SQLite path does. So does the database name of every MySQL or PostgreSQL app, and Herd copies the &lt;code&gt;.env&lt;/code&gt; as-is in shared mode. I added an absolute path to the parent's &lt;code&gt;.env&lt;/code&gt; and created a second workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_CONNECTION=sqlite
DB_DATABASE=/Users/hafizzeeshanriaz/Herd/orders/database/database.sqlite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php artisan tinker --execute="echo App\\Models\\User::count();"   # parent
1
$ cd &amp;lt;workspace feature-b&amp;gt; &amp;amp;&amp;amp; php artisan migrate:fresh --force
 Dropping all tables .. 3.29ms DONE
 INFO Preparing database.
 Creating migration table .. 2.10ms DONE
$ php artisan tinker --execute="echo App\\Models\\User::count();"   # parent again
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gone. The workspace dropped every table in the parent's database. I only tested SQLite, because Herd's MySQL and PostgreSQL services need &lt;a href="https://herd.laravel.com/" rel="noopener noreferrer"&gt;Herd Pro&lt;/a&gt;, but the mechanism is the same &lt;code&gt;.env&lt;/code&gt; line. A &lt;code&gt;DB_DATABASE=orders&lt;/code&gt; on MySQL points both sites at the same &lt;code&gt;orders&lt;/code&gt; database.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--database=clone&lt;/code&gt; fixes it. Herd copies the database and points the workspace at the copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ herd workspace feature-c --database=clone
✓ Creating Workspace
✓ Cloning Directory (this can take a moment)
✓ Linking Site
✓ Preparing Database
✓ Updating .env
✓ Finishing Up
Workspace [feature-c] is ready at [http://feature-c.orders.test].
The workspace uses its own SQLite database — a copy of the parent site's.
  cd "/Users/hafizzeeshanriaz/Library/Application Support/Herd/devtools/clones/8543c00e/feature-c-fe2986fe"
$ cd &amp;lt;workspace feature-c&amp;gt; &amp;amp;&amp;amp; php artisan migrate:fresh --force
 Dropping all tables .. 3.26ms DONE
$ php artisan tinker --execute="echo App\\Models\\User::count();"   # parent
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workspace's &lt;code&gt;.env&lt;/code&gt; now has &lt;code&gt;DB_DATABASE&lt;/code&gt; set to the copy inside the workspace folder, so &lt;code&gt;migrate:fresh&lt;/code&gt; wiped the copy and left the parent alone. There's also &lt;code&gt;--database=empty&lt;/code&gt;, which creates a fresh database and runs your migrations unless you pass &lt;code&gt;--no-migrate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Herd Studio, the browser panel Herd 2 adds for running agents next to a site, shows the same list. The cloned workspace gets a DB copy badge. The others get nothing, &lt;code&gt;feature-a&lt;/code&gt; included, even though it was the one with its own database all along.&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%2Fozn4h1a0wavvh4twt4fu.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%2Fozn4h1a0wavvh4twt4fu.webp" alt="Herd Studio's workspace switcher for orders.test, with only feature-c marked DB copy" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The queue goes wherever the database goes
&lt;/h2&gt;

&lt;p&gt;This is the one I didn't expect. A fresh Laravel app uses &lt;code&gt;QUEUE_CONNECTION=database&lt;/code&gt;, so a shared database also means a shared &lt;code&gt;jobs&lt;/code&gt; table. In a shared workspace, I did what an agent building a feature would do. I created a job class that only exists in the workspace and dispatched it. Then I ran the parent site's worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cd &amp;lt;workspace feature-b&amp;gt; &amp;amp;&amp;amp; php artisan tinker --execute="App\\Jobs\\SendWeeklyReport::dispatch();"
$ cd ~/Herd/orders &amp;amp;&amp;amp; php artisan queue:work --once   # the parent site's worker
 2026-11-06 12:17:24 App\Jobs\SendWeeklyReport .. RUNNING
 2026-11-06 12:17:24 App\Jobs\SendWeeklyReport .. 2.44ms FAIL
$ php artisan queue:failed   # parent
 2026-11-06 12:17:24 01e3dde9-e667-4bd1-8971-f810f14bc45d database@default App\Jobs\SendWeeklyReport
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parent's worker picked up the workspace's job and failed on it, because the class only exists in the workspace. The exception in the parent's &lt;code&gt;failed_jobs&lt;/code&gt; reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ErrorException: Illuminate\Queue\CallQueuedHandler::commandShouldBeDebounced(): The script tried to access a property on an incomplete object. Please ensure tha
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the same class of bug as &lt;a href="https://hafiz.dev/blog/multi-tenancy-queues-three-bugs-laravel-saas" rel="noopener noreferrer"&gt;a tenant's job running in the wrong tenant&lt;/a&gt;. The job is fine. It just ran in the wrong app. With two agents changing job payloads in parallel, the failures land in the parent's &lt;code&gt;failed_jobs&lt;/code&gt;, and the parent never dispatched any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unpushed commits are left out
&lt;/h2&gt;

&lt;p&gt;The last surprise isn't about the database. Herd's settings have &lt;code&gt;workspaceBaseFromOrigin&lt;/code&gt; turned on (Settings → Advanced in the app), so a workspace starts from the last pushed state of your branch. I pushed &lt;code&gt;main&lt;/code&gt;, committed an &lt;code&gt;Invoice&lt;/code&gt; model without pushing, then created two workspaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git log --oneline -2
97a52bc Add Invoice model (not pushed)
c8084d6 Fresh Laravel app
$ git status -sb | head -1
## main...origin/main [ahead 1]
$ herd workspace feature-d
Workspace [feature-d] is ready at [http://feature-d.orders.test].
 WARNING  The workspace shares the database of [orders] — migrations and destructive operations affect the parent site.
 WARNING  The workspace starts from the last pushed state of [main] — your 1 unpushed commit(s) are not included. Push first or re-create it with `herd workspace --local` if you need them.
$ cd &amp;lt;workspace feature-d&amp;gt; &amp;amp;&amp;amp; git log --oneline -2 &amp;amp;&amp;amp; ls app/Models
c8084d6 Fresh Laravel app
User.php
$ herd workspace feature-e --local
Workspace [feature-e] is ready at [http://feature-e.orders.test].
$ cd &amp;lt;workspace feature-e&amp;gt; &amp;amp;&amp;amp; ls app/Models
Invoice.php
User.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Herd does warn about it, and &lt;code&gt;--local&lt;/code&gt; includes the unpushed commit. But an agent handed a workspace without that flag starts from code that's missing your latest commit, and nothing in the workspace itself tells it so.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd set it up for agents
&lt;/h2&gt;

&lt;p&gt;Four of the six results come down to one line in &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/herd-2-workspaces-parallel-coding-agents" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create every agent workspace with &lt;code&gt;--database=clone&lt;/code&gt;.&lt;/strong&gt; It isolated the database in the setup where shared mode wiped the parent, and the queue follows, since the &lt;code&gt;jobs&lt;/code&gt; table lives in that database. &lt;code&gt;--database=empty&lt;/code&gt; gives a fresh one instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't read the warning as the answer.&lt;/strong&gt; It said "shared" for a workspace that wasn't, and it would say the same for one that was. Check &lt;code&gt;DB_DATABASE&lt;/code&gt; in the workspace's &lt;code&gt;.env&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push first, or pass &lt;code&gt;--local&lt;/code&gt;.&lt;/strong&gt; Otherwise the agent works on your last pushed commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete workspaces when the agent is done.&lt;/strong&gt; &lt;code&gt;herd workspace:delete&lt;/code&gt; drops a workspace's own database unless you pass &lt;code&gt;--keep-database&lt;/code&gt;. Deleting a shared workspace left the parent's database alone in my test, and Herd kept the branch in the parent repository.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you start agents from Herd Studio instead of the CLI, the choice moves into a menu under the prompt box. A new workspace there takes its database mode from Herd's settings, under Workspaces, so check that setting before you start an agent there.&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%2Fakm7n7iuznfd0ws6bmc0.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%2Fakm7n7iuznfd0ws6bmc0.webp" alt="The Herd Studio menu for where a prompt runs, with New workspace taking its database from Settings" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One caveat for my own setup. Herd only runs on macOS and Windows, and its FAQ says Linux isn't ready yet. I moved my day-to-day development to a &lt;a href="https://hafiz.dev/blog/cloud-only-laravel-development-7-weeks-dev-box" rel="noopener noreferrer"&gt;remote dev box&lt;/a&gt; at the end of August, so workspaces only help me when I'm working on the Mac.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Are Herd 2 workspaces git worktrees?
&lt;/h3&gt;

&lt;p&gt;Not by default. &lt;code&gt;herd workspace&lt;/code&gt; makes a copy-on-write clone of the folder with a full &lt;code&gt;.git&lt;/code&gt; directory on a new branch. Herd can also serve a worktree you created yourself with &lt;code&gt;git worktree add&lt;/code&gt;, and &lt;code&gt;herd workspaces&lt;/code&gt; lists those as worktrees that aren't served yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Herd 2 workspaces need Herd Pro?
&lt;/h3&gt;

&lt;p&gt;Not for what I tested. Workspaces, &lt;code&gt;--database=clone&lt;/code&gt; and the delete command all worked on the free version of Herd 2.1.0 with SQLite. The MySQL and PostgreSQL services themselves are part of Herd Pro.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does each workspace get its own database?
&lt;/h3&gt;

&lt;p&gt;Only with &lt;code&gt;--database=clone&lt;/code&gt; or &lt;code&gt;--database=empty&lt;/code&gt;. The default is &lt;code&gt;shared&lt;/code&gt;, which shares whatever &lt;code&gt;DB_DATABASE&lt;/code&gt; points at. For Laravel's default SQLite file inside the project, that happens to be a separate copy anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does deleting a workspace delete my database?
&lt;/h3&gt;

&lt;p&gt;A workspace with its own database has that database dropped unless you pass &lt;code&gt;--keep-database&lt;/code&gt;. Deleting a shared workspace didn't touch the parent's database in my test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can two agents run migrations at the same time?
&lt;/h3&gt;

&lt;p&gt;Yes, if each workspace has its own database. In shared mode they both migrate the parent's database, and the second &lt;code&gt;migrate:fresh&lt;/code&gt; wipes whatever the first one just built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolated files, shared state
&lt;/h2&gt;

&lt;p&gt;Workspaces isolate the code well. Every workspace got its own files, branch and domain, with no setup. The database and the queue are only as isolated as &lt;code&gt;DB_DATABASE&lt;/code&gt; makes them, and Herd's warning can't tell you which case you're in.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelherd</category>
      <category>aiagents</category>
      <category>localdevelopment</category>
    </item>
    <item>
      <title>One Request Waited 5 Seconds to Clean Up Laravel Sessions. Here Is What 13.39's session:garbage-collect Changes</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 05 Nov 2026 05:15:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/one-request-waited-5-seconds-to-clean-up-laravel-sessions-here-is-what-1339s-5c0g</link>
      <guid>https://dev.to/hafiz619/one-request-waited-5-seconds-to-clean-up-laravel-sessions-here-is-what-1339s-5c0g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-session-garbage-collection-lottery" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I put a million expired sessions into the &lt;code&gt;sessions&lt;/code&gt; table of a fresh Laravel 13.39 app and set the session lottery to always win. The next page view took 5.2 seconds. Every other page view took 6 milliseconds.&lt;/p&gt;

&lt;p&gt;That's the lottery. By default, about 2 in every 100 requests that start a session also clean up expired sessions, and they do it before your route runs. Laravel 13.39 added &lt;a href="https://github.com/laravel/framework/pull/62104" rel="noopener noreferrer"&gt;&lt;code&gt;session:garbage-collect&lt;/code&gt;&lt;/a&gt; so a scheduled job can do that work instead. I measured both on the default setup, the &lt;code&gt;database&lt;/code&gt; session driver on SQLite. The command fixed the unlucky-visitor problem and created a new one. All output below is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the lottery works
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;StartSession&lt;/code&gt; middleware starts the session, then rolls the dice, then calls your route:&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setLaravelSession&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;startSession&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="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;collectGarbage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;collectGarbage()&lt;/code&gt; checks the &lt;code&gt;lottery&lt;/code&gt; value in &lt;code&gt;config/session.php&lt;/code&gt;, which ships as &lt;code&gt;[2, 100]&lt;/code&gt;. On a win, the database handler runs one query:&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;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQuery&lt;/span&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;'last_activity'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$lifetime&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;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skeleton's migration indexes &lt;code&gt;last_activity&lt;/code&gt;, so finding the expired rows is cheap. Deleting them is not, once there are enough of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a million expired sessions come from
&lt;/h2&gt;

&lt;p&gt;Any request to a &lt;code&gt;web&lt;/code&gt; route without a session cookie gets a new session, and the database driver writes it as a row. I sent 50 requests with plain &lt;code&gt;curl&lt;/code&gt;, then 50 more that kept their cookies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows before: 0
after 50 requests without cookies: 50
after 50 more with one cookie jar: 51
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crawlers, uptime monitors and most scripts don't keep cookies. Each hit is a row that sits there until it expires and something deletes it. With steady traffic and the lottery on, cleanup runs often enough that each win only has a few rows to delete. A backlog builds when cleanup stops, for example when the lottery is set to &lt;code&gt;[0, 100]&lt;/code&gt; with nothing scheduled in its place, or on an old table that was never cleaned.&lt;/p&gt;

&lt;p&gt;I seeded a backlog directly in SQLite, with rows shaped like real ones (same payload size, a crawler user agent, &lt;code&gt;last_activity&lt;/code&gt; three days back):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;seeded in 2.4s
1000051|1000000
db size: 404M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 1,000,000 expired rows and 51 live ones, in a 404 MB database file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the winning request pays
&lt;/h2&gt;

&lt;p&gt;With the lottery off, page views took 6 to 10 milliseconds. Then I set it to &lt;code&gt;[100, 100]&lt;/code&gt;, so the next request would win:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lottery [0,100], 5 requests: 0.010s 0.006s 0.006s 0.006s 0.006s
lottery [100,100], request that runs the cleanup: 5.199s
rows after: 57
next requests (nothing left to delete): 0.007s 0.006s 0.006s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reseeded and ran the winning request twice more, at 6.26 and 5.40 seconds. Then I tried smaller backlogs, each seeded fresh:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Expired rows&lt;/th&gt;
&lt;th&gt;Lottery-winning request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;27 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;223 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;5.2 to 6.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Up to a few thousand rows, nobody notices. At a million, one visitor stares at a blank page for five seconds and never knows why. If you've seen a random slow request in your logs that you couldn't reproduce, a lottery win is a candidate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What session:garbage-collect changes
&lt;/h2&gt;

&lt;p&gt;The new command runs the same handler method on your schedule instead of inside a 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;// routes/console.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Schedule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'session:garbage-collect'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hourly&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then turn the lottery off in &lt;code&gt;config/session.php&lt;/code&gt;, so requests stop doing it as well:&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;'lottery'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the same million-row backlog, the command did the work in about the same time, with no visitor attached to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;php artisan session:garbage-collect
&lt;span class="go"&gt; INFO Session garbage collector ran successfully.
real 5.84
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't report how many rows it deleted. Count the table before and after if you want to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  On SQLite, the scheduler doesn't save your visitors
&lt;/h2&gt;

&lt;p&gt;I ran the command again on the full backlog and sent a request every 200 milliseconds while it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request 1: 200 0.009059s
request 2: 200 5.231497s
request 3: 200 5.032121s
request 4: 200 4.831341s
request 5: 200 4.627754s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request 1 got in before the delete started. The other four waited for it to finish. Every web request writes its session at the end, and SQLite allows one writer at a time, so a five-second DELETE holds up every page view that arrives during it. Moving the cleanup to the scheduler turned one unlucky visitor into everyone who showed up in those five seconds.&lt;/p&gt;

&lt;p&gt;That only bites with a big backlog. Run the command often enough that each run deletes a few thousand rows, and each one takes milliseconds, as the table above shows. The problem is the first run on a table that already has a backlog.&lt;/p&gt;

&lt;p&gt;I only measured SQLite here, which is what a new Laravel app uses out of the box. MySQL and PostgreSQL don't lock the whole database for a delete, so check your own driver before assuming either result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clear an existing backlog in chunks
&lt;/h2&gt;

&lt;p&gt;For the first cleanup, delete in batches and pause between them, so other writes get a turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/console.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="no"&gt;Illuminate\Support\Facades\DB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sessions:prune-chunked {--chunk=5000}'&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;$cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'session.lifetime'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sessions'&lt;/span&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;'last_activity'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cutoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'chunk'&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;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$deleted&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;usleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$batch&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Deleted &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$deleted&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; expired sessions."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same million rows, same requests arriving every half second while it ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request 1: 200 0.017038s
request 2: 200 0.028787s
request 3: 200 0.027196s
request 4: 200 0.026492s
request 5: 200 0.029231s
request 6: 200 0.030707s
request 7: 200 0.028865s
request 8: 200 0.027779s
Deleted 1000000 expired sessions.
real 17.41
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took 17 seconds instead of 6, and no request went over 31 milliseconds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-session-garbage-collection-lottery" 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;Run it once, then let &lt;code&gt;session:garbage-collect&lt;/code&gt; keep the table small from then on. If chunked deletes are new to you, the same idea shows up in &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;my query optimization post&lt;/a&gt;, and &lt;a href="https://hafiz.dev/blog/database-indexing-in-laravel-boost-mysql-performance-with-smart-indexes" rel="noopener noreferrer"&gt;the indexing guide&lt;/a&gt; covers why the &lt;code&gt;last_activity&lt;/code&gt; index matters for the &lt;code&gt;where&lt;/code&gt; half of it. &lt;a href="https://hafiz.dev/blog/eloquent-patterns-silently-break-mysql-index" rel="noopener noreferrer"&gt;Six Eloquent patterns that quietly skip an index&lt;/a&gt; shows how that half can go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file driver pays even when nothing has expired
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;file&lt;/code&gt; driver cleans up with Symfony Finder. It walks every file in &lt;code&gt;storage/framework/sessions&lt;/code&gt; and checks each modification time. I created 100,000 session files and ran the command twice, first with all of them fresh, then with all of them three days old:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;SESSION_DRIVER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;file php artisan session:garbage-collect   &lt;span class="c"&gt;# 100,000 fresh files&lt;/span&gt;
&lt;span class="go"&gt;real 0.98
files left: 100000

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;SESSION_DRIVER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;file php artisan session:garbage-collect   &lt;span class="c"&gt;# 100,000 expired files&lt;/span&gt;
&lt;span class="go"&gt;real 12.67
files left: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost a second to delete nothing. On the file driver, every lottery win pays for that scan, however few sessions have actually expired. Twelve seconds to delete them all. That's the strongest case for the new command on the file driver, because the scan cost never goes away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd set up
&lt;/h2&gt;

&lt;p&gt;For the &lt;code&gt;database&lt;/code&gt; and &lt;code&gt;file&lt;/code&gt; drivers:&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/session.php&lt;/span&gt;
&lt;span class="s1"&gt;'lottery'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/console.php&lt;/span&gt;
&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'session:garbage-collect'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;everyFifteenMinutes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every fifteen minutes keeps each run small on a busy site. If you run several servers against one database, add &lt;code&gt;-&amp;gt;onOneServer()&lt;/code&gt;, which needs a cache store that supports atomic locks. With the file driver, each server has its own sessions folder, so the command runs on each one. If the table already has a backlog, run the chunked version once first.&lt;/p&gt;

&lt;p&gt;Or skip the question. The cache-backed drivers, Redis among them, write each session with a TTL, and their &lt;code&gt;gc()&lt;/code&gt; method returns 0. Expired sessions disappear on their own. If you already run Redis for queues or cache, it's a one-line &lt;code&gt;.env&lt;/code&gt; change.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What does Laravel's session lottery do?
&lt;/h3&gt;

&lt;p&gt;On each request that starts a session, Laravel rolls the &lt;code&gt;lottery&lt;/code&gt; odds from &lt;code&gt;config/session.php&lt;/code&gt;, &lt;code&gt;[2, 100]&lt;/code&gt; by default. On a win, it deletes expired sessions before your route runs, so that request waits for the delete.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I turn off the session lottery?
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;'lottery' =&amp;gt; [0, 100]&lt;/code&gt; in &lt;code&gt;config/session.php&lt;/code&gt;. Then schedule &lt;code&gt;session:garbage-collect&lt;/code&gt;, which Laravel 13.39 added, or expired sessions will pile up forever with the &lt;code&gt;database&lt;/code&gt; and &lt;code&gt;file&lt;/code&gt; drivers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does session:garbage-collect work with Redis sessions?
&lt;/h3&gt;

&lt;p&gt;It runs, but it has nothing to do. The cache-backed handlers store sessions with a TTL, so they expire on their own, and their &lt;code&gt;gc()&lt;/code&gt; method returns 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my sessions table so big?
&lt;/h3&gt;

&lt;p&gt;Every request without a session cookie creates a new row, and crawlers, uptime checks and scripts rarely send cookies. Rows stay until cleanup deletes them, so if the lottery is off and nothing is scheduled, the table only grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the first run of session:garbage-collect safe on a big table?
&lt;/h3&gt;

&lt;p&gt;On SQLite with a big backlog, not during traffic. A single delete of a million rows held up every request for about five seconds in my test. Clear the backlog in chunks first, then let the scheduled command keep up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;On SQLite, moving the cleanup to the scheduler is only half the fix. Check how many expired rows you have before the first scheduled run. If the answer is in the hundreds of thousands, clear them in chunks once, then let a small, frequent &lt;code&gt;session:garbage-collect&lt;/code&gt; keep it from happening again.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>sessions</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Composer 2.11's Cooldown in a Laravel App: Safer Updates, Except When the Update Is the Security Fix</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 02 Nov 2026 05:15:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/composer-211s-cooldown-in-a-laravel-app-safer-updates-except-when-the-update-is-the-security-fix-24e6</link>
      <guid>https://dev.to/hafiz619/composer-211s-cooldown-in-a-laravel-app-safer-updates-except-when-the-update-is-the-security-fix-24e6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/composer-2-11-cooldown-laravel" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;On 31 October I turned on Composer 2.11's new cooldown in a Laravel 13 project, set it to seven days, and ran &lt;code&gt;composer update&lt;/code&gt;. It failed. Three days earlier Symfony had shipped a fix for CVE-2026-45070 in &lt;code&gt;symfony/mime&lt;/code&gt;. The cooldown was holding the fix back, and Composer's advisory policy was blocking every older version. My project had nothing it was allowed to install.&lt;/p&gt;

&lt;p&gt;The release notes do say the cooldown applies to security fixes too. What they can't show is how fast that bites. The cooldown gives malware scanners time to flag a malicious version before &lt;code&gt;composer update&lt;/code&gt; ever offers it to you. But the same rule applies to the version that fixes your vulnerability.&lt;/p&gt;

&lt;p&gt;I tried it on a clean Laravel project with Composer 2.11.1, with &lt;code&gt;laravel/framework&lt;/code&gt; locked at 13.37.0. Here's what it changed, where it broke, and the setup I'd use. All output below is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the cooldown does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://blog.packagist.com/composer-2-11-release/" rel="noopener noreferrer"&gt;Composer 2.11.0&lt;/a&gt; shipped on 29 October, and 2.11.1 followed a day later. The cooldown is part of the &lt;a href="https://getcomposer.org/doc/06-config.md#cooldown" rel="noopener noreferrer"&gt;&lt;code&gt;policy&lt;/code&gt; config&lt;/a&gt;. It's off by default, and it takes effect once you set a period:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer config policy.cooldown.period &lt;span class="s2"&gt;"7 days"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"cooldown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7 days"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The period takes values like &lt;code&gt;"6 hours"&lt;/code&gt;, &lt;code&gt;"1 day"&lt;/code&gt; or &lt;code&gt;"1 week"&lt;/code&gt;, measured from the publish time the repository reports for each version. It applies when Composer resolves dependencies, so &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;require&lt;/code&gt; and &lt;code&gt;create-project&lt;/code&gt;. &lt;code&gt;composer install&lt;/code&gt; still installs exactly what's in &lt;code&gt;composer.lock&lt;/code&gt;. Dev versions like &lt;code&gt;dev-main&lt;/code&gt; are never withheld.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it did to a Laravel 13 project
&lt;/h2&gt;

&lt;p&gt;13.39.0 came out on 27 October, four days before I ran this. With a seven-day period, updating the framework stopped one release short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ composer update laravel/framework
Loading composer repositories with package information
31 package version(s) withheld by the cooldown policy (run with -vv to list them).
Updating dependencies
Lock file operations: 0 installs, 1 update, 0 removals
  - Upgrading laravel/framework (v13.37.0 =&amp;gt; v13.38.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;31 versions for one package, because the list also counts the &lt;code&gt;illuminate/*&lt;/code&gt; packages Laravel publishes alongside each release, a few seconds apart. &lt;code&gt;-vv&lt;/code&gt; lists them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;31 package version(s) withheld by the cooldown policy:
  - laravel/framework (v13.39.0) published 2026-10-27T14:05:37+00:00, available in 3 days 4 hours
  - illuminate/bus (v13.39.0) published 2026-10-27T14:04:59+00:00, available in 3 days 4 hours
  - illuminate/bus (v12.70.2) published 2026-10-27T14:08:11+00:00, available in 3 days 4 hours
  - illuminate/collections (v13.39.0) published 2026-10-27T14:05:01+00:00, available in 3 days 4 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;composer outdated&lt;/code&gt; has a new marker for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ composer outdated --direct
Legend:
! patch or minor release available - update recommended
~ major release available - update possible
c newer release still in the cooldown period - not installable yet
laravel/framework 13.38.0 c 13.39.0 (3d 4h left) The Laravel Framework.
phpunit/phpunit   12.5.42 c 13.4.4 (2d 17h left) The PHP Unit Testing framew...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;--no-cooldown&lt;/code&gt; skips it for one package, one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;composer update &lt;span class="nt"&gt;--no-cooldown&lt;/span&gt; laravel/framework
  - Upgrading laravel/framework &lt;span class="o"&gt;(&lt;/span&gt;v13.38.0 &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; v13.39.0&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So far it does what it says. The surprises started with a full update.&lt;/p&gt;

&lt;h2&gt;
  
  
  A full update downgraded 23 packages
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;laravel/framework&lt;/code&gt; now at 13.39.0, still inside the cooldown, I ran a plain &lt;code&gt;composer update&lt;/code&gt;. I'll get to why it failed in the next section. With the package behind that failure exempted, here is what the same update planned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ composer update --dry-run
78 package version(s) withheld by the cooldown policy (run with -vv to list them).
Updating dependencies
Lock file operations: 0 installs, 23 updates, 0 removals
  - Downgrading doctrine/inflector (2.1.1 =&amp;gt; 2.1.0)
  - Downgrading guzzlehttp/guzzle (8.3.1 =&amp;gt; 8.3.0)
  - Downgrading laravel/framework (v13.39.0 =&amp;gt; v13.38.0)
  - Downgrading league/commonmark (2.10.6 =&amp;gt; 2.10.4)
  - Downgrading nesbot/carbon (3.15.1 =&amp;gt; 3.15.0)
  - Downgrading symfony/console (v7.4.22 =&amp;gt; v7.4.21)
  - Downgrading symfony/http-kernel (v7.4.22 =&amp;gt; v7.4.21)
  - Downgrading symfony/mailer (v7.4.22 =&amp;gt; v7.4.19)
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Packagist announcement warns about this. A full &lt;code&gt;composer update&lt;/code&gt; re-checks every package, and anything locked at a version still inside the window gets swapped for an older one. A partial update leaves unrelated packages alone. If you add a cooldown to a project that's already up to date, your first full update moves it backwards. Run it with &lt;code&gt;--dry-run&lt;/code&gt; first and read the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the update is the security fix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://symfony.com/cve-2026-45070" rel="noopener noreferrer"&gt;CVE-2026-45070&lt;/a&gt; is an email header injection in &lt;code&gt;symfony/mime&lt;/code&gt;, rated medium. The advisory was published on 28 October, alongside the fixed releases, 7.4.22 and 8.1.10. &lt;code&gt;laravel/framework&lt;/code&gt; requires &lt;code&gt;symfony/mime&lt;/code&gt;, so every Laravel app has it.&lt;/p&gt;

&lt;p&gt;Composer also blocks versions with known advisories during &lt;code&gt;update&lt;/code&gt; by default (&lt;code&gt;policy.advisories.block&lt;/code&gt; defaults to &lt;code&gt;true&lt;/code&gt;). Put the two policies together and a project still on the vulnerable 7.4.19 has nowhere to go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ composer update symfony/mime
2 package version(s) withheld by the cooldown policy (run with -vv to list them).
Updating dependencies
Your requirements could not be resolved to an installable set of packages.
  Problem 1
    - laravel/framework v13.39.0 requires symfony/mime ^7.4.0 || ^8.0.0 -&amp;gt; found symfony/mime[v7.4.0, ..., v7.4.22, v8.0.0, ..., v8.1.10] but these were not loaded, because:
      - v7.4.12, v7.4.13, v7.4.15, v7.4.16, v7.4.17, v7.4.18, v7.4.19, v8.0.12, v8.0.13, v8.0.15, v8.1.0, v8.1.2, v8.1.4, v8.1.5, v8.1.6, v8.1.7: affected by security advisories ("PKSA-wtxr-p26d-nn42")
      - v7.4.22, v8.1.10: still in the cooldown period configured in "policy.cooldown" (available in 4 days 9 hours)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Composer refuses to move you to a version it knows is vulnerable, and it refuses to move you to the fix because it's too new. So you stay on the version you're on, which is also vulnerable. And nothing at deploy time tells you. &lt;code&gt;composer install&lt;/code&gt; installed 7.4.19 from the lock file without a word and exited 0.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;composer audit&lt;/code&gt; is the one that speaks up. It found the advisory and exited 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ composer audit
Found 1 security vulnerability advisory affecting 1 package:
+-------------------+----------------------------------------------------------------------------------+
| Package           | symfony/mime                                                                     |
| Severity          | medium                                                                           |
| Advisory ID       | PKSA-wtxr-p26d-nn42                                                              |
| CVE               | CVE-2026-45070                                                                   |
| Title             | CVE-2026-45070: Email Header Injection via Non-Token Characters in Mime          |
|                   | Parameter Names                                                                  |
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The way out is one targeted command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ composer update symfony/mime --no-cooldown
  - Upgrading symfony/mime (v7.4.19 =&amp;gt; v7.4.22)
No security vulnerability advisories found.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;--no-cooldown&lt;/code&gt; here, not &lt;code&gt;--no-blocking&lt;/code&gt;. Composer 2.11 has both, and the help text explains the difference. &lt;code&gt;--no-blocking&lt;/code&gt; "disables all policy blocking during this command", which includes the advisory and malware checks you want to keep. &lt;code&gt;--no-cooldown&lt;/code&gt; turns off the cooldown only, and the other policies still apply.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/composer-2-11-cooldown-laravel" 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;New projects fail the same way. With a seven-day period set through the environment, a fresh install broke that morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ COMPOSER_POLICY_COOLDOWN_PERIOD="7 days" composer create-project laravel/laravel fresh-under-cooldown
Installing laravel/laravel (v13.11.0)
81 package version(s) withheld by the cooldown policy (run with -vv to list them).
Your requirements could not be resolved to an installable set of packages.
      - v7.4.22: still in the cooldown period configured in "policy.cooldown" (available in 4 days 8 hours)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;laravel new&lt;/code&gt; runs &lt;code&gt;composer create-project&lt;/code&gt; under the hood, so a cooldown in your global Composer config affects it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your own packages
&lt;/h2&gt;

&lt;p&gt;Two kinds of repository came up in my test, and they behaved differently.&lt;/p&gt;

&lt;p&gt;A path repository, the usual setup for a package that lives in your monorepo, installed fine. Composer warned that it couldn't verify the publish date and moved on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The publication date of 1 package version(s) from path repo (packages/acme-helpers) could not be verified, the cooldown policy relied on the time field set by the package authors (run with -vv to list them).
  - Installing acme/helpers (1.0.0): Symlinking from packages/acme-helpers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A git repository with a tag I'd created a minute earlier did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Problem 1
  - Root composer.json requires acme/sdk ^1.0, found acme/sdk[v1.0.0] but these were not loaded, because they are still in the cooldown period configured in "policy.cooldown" (available in 6 days 23 hours, based on the package-supplied time field as the repository provides no published-time).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the in-house SDK your team tagged this morning, blocked for a week. The &lt;code&gt;ignore&lt;/code&gt; list fixes it, with a reason that ends up in &lt;code&gt;composer.json&lt;/code&gt; for the next person:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"cooldown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ignore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"acme/*"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Our own packages, tagged by us"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, the same &lt;code&gt;composer require acme/sdk&lt;/code&gt; locked v1.0.0 straight away. If you serve private packages from your own Composer repository, check what it reports. The Composer docs say a repository that doesn't provide &lt;code&gt;published-time&lt;/code&gt; makes the command error out while a cooldown is set, and a trusted repository can opt back into the older &lt;code&gt;time&lt;/code&gt; field with &lt;code&gt;require-published-time&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which period to pick
&lt;/h2&gt;

&lt;p&gt;The Packagist announcement suggests a few hours to a day. I ran the same full-update dry run with four periods, at 09:21 UTC on 31 October:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;Versions withheld&lt;/th&gt;
&lt;th&gt;Full &lt;code&gt;composer update&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Resolves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 day&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Resolves (one bugfix release of &lt;code&gt;league/commonmark&lt;/code&gt; rolled back)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 days&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;Fails on &lt;code&gt;symfony/mime&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;Fails on &lt;code&gt;symfony/mime&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A day matches Packagist's own suggestion, and a security fix stops blocking you by the next working day. With three days, my full update stayed broken until the evening of 31 October. With seven, it stays broken until 4 November, all because one package every Laravel app depends on shipped a security fix.&lt;/p&gt;

&lt;p&gt;In CI you don't have to commit the setting. &lt;code&gt;COMPOSER_POLICY_COOLDOWN_PERIOD&lt;/code&gt; sets it from the environment. With nothing in &lt;code&gt;composer.json&lt;/code&gt;, this was enough for &lt;code&gt;outdated&lt;/code&gt; to apply it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ COMPOSER_POLICY_COOLDOWN_PERIOD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"7 days"&lt;/span&gt; composer outdated &lt;span class="nt"&gt;--direct&lt;/span&gt;
Cannot use laravel/framework&lt;span class="s1"&gt;'s latest version v13.39.0 as it is still in the cooldown period configured in "policy.cooldown" (available in 3 days 4 hours).
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The setup I'd use
&lt;/h2&gt;

&lt;p&gt;A one-day period, with your own vendor name exempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"cooldown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1 day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"ignore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"acme/*"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Our own packages, tagged by us"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;composer audit&lt;/code&gt; to CI as its own step, because &lt;code&gt;composer install&lt;/code&gt; won't tell you about a vulnerable lock file and &lt;code&gt;audit&lt;/code&gt; exits 1 when it finds one. If you don't have a pipeline yet, &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;my GitHub Actions guide&lt;/a&gt; has a starting point. And write the escape hatch down before you need it. When &lt;code&gt;audit&lt;/code&gt; fails and the fix is still in cooldown, run &lt;code&gt;composer update vendor/package --no-cooldown&lt;/code&gt; for that one package.&lt;/p&gt;

&lt;p&gt;The cooldown and the audit cover different risks. The cooldown keeps a brand new malicious version out of your lock file. The audit catches a known vulnerability already in it. I wrote about the first problem after &lt;a href="https://hafiz.dev/blog/fake-laravel-packages-targeting-your-env-how-to-audit-composer-dependencies" rel="noopener noreferrer"&gt;fake Laravel packages started targeting .env files&lt;/a&gt;. A cooldown only helps if the fake is caught inside the window, so it doesn't replace reading what you install.&lt;/p&gt;

&lt;p&gt;If you're mid-upgrade, the cooldown adds one more kind of line to your conflict output. &lt;a href="https://hafiz.dev/blog/4-composer-conflicts-blocking-laravel-13-upgrade" rel="noopener noreferrer"&gt;The usual Laravel 13 upgrade conflicts&lt;/a&gt; still come first, and a release you're waiting for may now also be "still in the cooldown period".&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does the Composer cooldown affect composer install?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;composer install&lt;/code&gt; installs exactly what's in &lt;code&gt;composer.lock&lt;/code&gt;, whatever the cooldown says. The cooldown applies when Composer resolves versions, during &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;require&lt;/code&gt; and &lt;code&gt;create-project&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the cooldown hold back security fixes?
&lt;/h3&gt;

&lt;p&gt;Yes. Composer doesn't exempt them. The Packagist announcement explains why. Any rule that exempted releases based on advisory data would give attackers a way around the cooldown. Run &lt;code&gt;composer update vendor/package --no-cooldown&lt;/code&gt; when you need a fix sooner.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between --no-cooldown and --no-blocking?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--no-cooldown&lt;/code&gt; turns off the cooldown for one command and keeps the advisory and malware policies active. &lt;code&gt;--no-blocking&lt;/code&gt; turns off all policy blocking, including those. For a security fix, &lt;code&gt;--no-cooldown&lt;/code&gt; is the one you want.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does composer update downgrade packages after I enable a cooldown?
&lt;/h3&gt;

&lt;p&gt;A full &lt;code&gt;composer update&lt;/code&gt; re-evaluates every package against the cooldown. A version you already have locked that is still inside the window gets replaced with the newest version outside it. A partial update, like &lt;code&gt;composer update vendor/package&lt;/code&gt;, leaves other packages alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do my production servers need Composer 2.11?
&lt;/h3&gt;

&lt;p&gt;Not for the cooldown. It only matters where you run &lt;code&gt;update&lt;/code&gt; or &lt;code&gt;require&lt;/code&gt;, which is usually your machine or CI. Deploys that run &lt;code&gt;composer install&lt;/code&gt; from the lock file aren't affected. Upgrade anyway, though, because 2.11 also ships security fixes of its own, listed in the release notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Set the cooldown to a day, exempt your own packages, and run &lt;code&gt;composer audit&lt;/code&gt; in CI. Then keep &lt;code&gt;composer update vendor/package --no-cooldown&lt;/code&gt; somewhere your team will find it. In the week I tried it, the cooldown's first real cost was a security fix, and only the audit told me it mattered.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>composer</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Laravel's Built-In JSON:API Resources vs timacdonald/json-api vs laravel-json-api: Which One Your API Needs</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 29 Oct 2026 05:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravels-built-in-jsonapi-resources-vs-timacdonaldjson-api-vs-laravel-json-api-which-one-your-17ml</link>
      <guid>https://dev.to/hafiz619/laravels-built-in-jsonapi-resources-vs-timacdonaldjson-api-vs-laravel-json-api-which-one-your-17ml</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-json-api-resources-vs-timacdonald-vs-laravel-json-api" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;On Laravel 13.38.0, a request for 20 posts with &lt;code&gt;?include=author,comments&lt;/code&gt; ran 41 queries through the framework's own JSON:API resources. The fix is merged into the 13.x branch. It just isn't in a release yet.&lt;/p&gt;

&lt;p&gt;I found that while building the same small API three ways: with Laravel's built-in &lt;code&gt;JsonApiResource&lt;/code&gt;, with &lt;a href="https://github.com/timacdonald/json-api" rel="noopener noreferrer"&gt;timacdonald/json-api&lt;/a&gt;, and with &lt;a href="https://laraveljsonapi.io" rel="noopener noreferrer"&gt;laravel-json-api/laravel&lt;/a&gt;. Same three models (users, posts, comments), same 20 posts and 100 comments, on a clean Laravel 13.38.0 project with SQLite. Each request went through a small middleware that counts queries. All output below is real.&lt;/p&gt;

&lt;p&gt;Two of the three look almost the same from the outside. The third is a different kind of tool. One question tells you which you need. (If you're still deciding how the API itself should behave, &lt;a href="https://hafiz.dev/blog/laravel-api-development-restful-best-practices" rel="noopener noreferrer"&gt;my production API practices post&lt;/a&gt; is the place to start.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your version first
&lt;/h2&gt;

&lt;p&gt;If you already use the built-in resources, three things depend on your Laravel version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GHSA-5wcm-3v2w-8362.&lt;/strong&gt; A &lt;a href="https://github.com/laravel/framework/security/advisories/GHSA-5wcm-3v2w-8362" rel="noopener noreferrer"&gt;moderate advisory&lt;/a&gt;, published on 19 October. A crafted request to a read-only endpoint could trigger a destructive operation on a related table through relationship inclusion. It affects 12.45.0 up to 12.68.0, and every 13.x release before 13.27.0. Fixed in 12.68.0 and 13.27.0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nested includes from 13.27 to 13.36.&lt;/strong&gt; Release 13.27.0 also changed how resources load relationships (&lt;a href="https://github.com/laravel/framework/pull/61322" rel="noopener noreferrer"&gt;#61322&lt;/a&gt;). From then on, nested includes ran one query per related model until &lt;a href="https://github.com/laravel/framework/pull/62000" rel="noopener noreferrer"&gt;#62000&lt;/a&gt; fixed it in 13.37.0. I installed each version and ran the same request, a user with 20 posts and 5 comments per post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/builtin/users/1?include=posts.comments.author

13.26.0    4 queries
13.36.0  122 queries
13.38.0    4 queries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Collection includes, on every release so far.&lt;/strong&gt; &lt;a href="https://github.com/laravel/framework/pull/62000" rel="noopener noreferrer"&gt;#62000&lt;/a&gt; fixed single resources. Collections still load each item's includes one model at a time. This one predates the regression. &lt;a href="https://github.com/laravel/framework/issues/61252" rel="noopener noreferrer"&gt;Issue #61252&lt;/a&gt; reported 101 queries for 100 records on 13.24. The fix is &lt;a href="https://github.com/laravel/framework/pull/62152" rel="noopener noreferrer"&gt;#62152&lt;/a&gt;, merged on 22 October, two days after 13.38.0 shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                  13.26.0   13.36.0   13.38.0   13.x-dev
GET /posts?include=author              21        21        21          2
GET /posts?include=author,comments     41        41        41          3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until the next tag, eager-load whatever the client asked for. The resource skips relations that are already loaded:&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/posts'&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="nv"&gt;$includes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_intersect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'include'&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'author'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'comments'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'comments.author'&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;PostResource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;collection&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="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$includes&lt;/span&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="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 http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/builtin/eager/posts?include=author             2 queries
GET /api/builtin/eager/posts?include=author,comments    3 queries
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I diffed the response bodies against the version without &lt;code&gt;with()&lt;/code&gt;, and they were identical. Keep the allow-list. Eloquent resolves each name passed to &lt;code&gt;with()&lt;/code&gt; by calling it as a method on a fresh model instance, so a raw &lt;code&gt;include&lt;/code&gt; string lets the client choose which method runs. (If you want the wider picture on spotting N+1 queries, &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;my query optimization post&lt;/a&gt; walks through it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The built-in JsonApiResource
&lt;/h2&gt;

&lt;p&gt;It arrived in Laravel 12.45.0 (&lt;a href="https://github.com/laravel/framework/pull/57571" rel="noopener noreferrer"&gt;#57571&lt;/a&gt;) and is documented in the &lt;a href="https://laravel.com/docs/13.x/eloquent-resources#jsonapi-resources" rel="noopener noreferrer"&gt;API resources docs&lt;/a&gt;. &lt;code&gt;php artisan make:resource PostResource --json-api&lt;/code&gt; generates a class with two properties, and filling them in is most of the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Http\Resources&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\Http\Resources\JsonApi\JsonApiResource&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;PostResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JsonApiResource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$attributes&lt;/span&gt; &lt;span class="o"&gt;=&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;'body'&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="nv"&gt;$relationships&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UserResource&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;'comments'&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;Both relationships resolve on their own. The built-in finds &lt;code&gt;CommentResource&lt;/code&gt; and &lt;code&gt;UserResource&lt;/code&gt; from the related models, so the explicit class on &lt;code&gt;author&lt;/code&gt; is optional. I kept it to show the syntax. A route returns it like any resource, and sparse fieldsets and includes work from the query string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/builtin/posts/1?include=author&amp;amp;fields[posts]=title&amp;amp;fields[users]=name
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"posts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Post 1"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"relationships"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"users"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"included"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hafiz"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response goes out as &lt;code&gt;application/vnd.api+json&lt;/code&gt;. A paginator gives you &lt;code&gt;first&lt;/code&gt;, &lt;code&gt;last&lt;/code&gt;, &lt;code&gt;prev&lt;/code&gt; and &lt;code&gt;next&lt;/code&gt; links, with Laravel's usual pagination meta and a plain &lt;code&gt;?page=2&lt;/code&gt; parameter. Nesting stops at five levels by default, and &lt;code&gt;JsonApiResource::maxRelationshipDepth()&lt;/code&gt; changes that. &lt;code&gt;toLinks()&lt;/code&gt; and &lt;code&gt;toMeta()&lt;/code&gt; add per-resource links and meta.&lt;/p&gt;

&lt;p&gt;That's the whole job. The built-in shapes responses. It doesn't read JSON:API requests or enforce the rest of the spec, and three gaps showed up straight away.&lt;/p&gt;

&lt;p&gt;It ignores include paths it doesn't know. &lt;code&gt;?include=secrets&lt;/code&gt; returned 200 with the post and no error. The &lt;a href="https://jsonapi.org/format/" rel="noopener noreferrer"&gt;JSON:API spec&lt;/a&gt; says a server that can't identify a relationship path "MUST respond with 400 Bad Request".&lt;/p&gt;

&lt;p&gt;Errors come out in Laravel's own format, not as JSON:API error objects. Here's a 422 from a POST route I wrote by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"The data.attributes.title field is required. (and 1 more error)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"data.attributes.title"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"The data.attributes.title field is required."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"data.attributes.body"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"The data.attributes.body field is required."&lt;/span&gt;&lt;span class="p"&gt;]}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And writes are yours to build. That route validates &lt;code&gt;data.type&lt;/code&gt; and &lt;code&gt;data.attributes.*&lt;/code&gt; with ordinary rules, then returns the new resource with a 201:&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;'/posts'&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="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;'data.type'&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;'in:posts'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'data.attributes.title'&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;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:120'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'data.attributes.body'&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;'string'&lt;/span&gt;&lt;span class="p"&gt;],&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;=&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;posts&lt;/span&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;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'attributes'&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;PostResource&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStatusCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. But relationships in the request body, error objects with JSON pointers, and content negotiation are all on you. For filters and sorting, the docs point you at &lt;a href="https://spatie.be/docs/laravel-query-builder/" rel="noopener noreferrer"&gt;Spatie's Laravel Query Builder&lt;/a&gt;, which supports Laravel 12 and 13 in its current 7.x release (it needs PHP 8.4).&lt;/p&gt;

&lt;h2&gt;
  
  
  timacdonald/json-api
&lt;/h2&gt;

&lt;p&gt;Tim MacDonald's package has been around since its first release in November 2021, and the built-in uses the same property-based 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;TiMacDonald\JsonApi\JsonApiResource&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;PostResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JsonApiResource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$attributes&lt;/span&gt; &lt;span class="o"&gt;=&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;'body'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$relationships&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UserResource&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;'comments'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CommentResource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I compared the package and the built-in on three requests (a single post with includes, the collection, and a user with sparse fieldsets). Once the keys were sorted, the documents were identical. Then I changed only the &lt;code&gt;use&lt;/code&gt; line in my three package resources to the built-in &lt;code&gt;Illuminate\Http\Resources\JsonApi\JsonApiResource&lt;/code&gt;, and the same three requests still returned identical documents.&lt;/p&gt;

&lt;p&gt;The other direction needs care. The package guesses the resource class from the relationship key, not from the related model, so &lt;code&gt;'author'&lt;/code&gt; with no class failed with a 500, because there's no &lt;code&gt;AuthorResource&lt;/code&gt;. Moving from the package to the built-in was the &lt;code&gt;use&lt;/code&gt; line. Moving from the built-in to the package can mean adding explicit classes.&lt;/p&gt;

&lt;p&gt;The difference is loading. The package doesn't eager-load by design, and its README says so plainly. It recommends Spatie's query builder for that job. Without it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/tim/users/1?include=posts.comments.author    123 queries
GET /api/tim/posts?include=author                      22 queries
GET /api/tim/posts?include=author,comments             43 queries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the same &lt;code&gt;with()&lt;/code&gt; allow-list from earlier, those collection requests dropped to 2 and 3 queries.&lt;/p&gt;

&lt;p&gt;It's still a pre-release. The latest tag is 1.0.0-beta.12, from March 2026. The repository isn't archived, and its constraints cover Laravel 11, 12 and 13. That makes it the right choice on Laravel 11, or on a 12.x app older than 12.45.0. On anything newer I wouldn't start a new API on it, because the built-in gives you the same API from the framework itself, and the N+1 fix lands in the framework too. If you're already on it, my migration for these three resources was the &lt;code&gt;use&lt;/code&gt; line.&lt;/p&gt;

&lt;h2&gt;
  
  
  laravel-json-api/laravel
&lt;/h2&gt;

&lt;p&gt;This is a different kind of tool. It isn't a resource class, it's a full JSON:API server. Version 5.3.0 (July 2026) supports Laravel 12.61.1 and up and 13.x.&lt;/p&gt;

&lt;p&gt;Setup is more work. I published the config, generated a server with &lt;code&gt;php artisan jsonapi:server v1&lt;/code&gt;, registered it in &lt;code&gt;config/jsonapi.php&lt;/code&gt;, generated three schemas and a request class, registered routes, and added its exception renderer in &lt;code&gt;bootstrap/app.php&lt;/code&gt;. A schema describes fields instead of output:&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;PostSchema&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;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;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt; &lt;span class="o"&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="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$maxDepth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;fields&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="no"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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;sortable&lt;/span&gt;&lt;span class="p"&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;make&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="nc"&gt;BelongsTo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'author'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;HasMany&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comments'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'createdAt'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&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;readOnly&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updatedAt'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&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;readOnly&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;filters&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="nc"&gt;WhereIdIn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Where&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'author'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?Paginator&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;PagePagination&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;JsonApiRoute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resources&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="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;JsonApiController&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;relationships&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="nv"&gt;$relations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$relations&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'author'&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;readOnly&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$relations&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comments'&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;readOnly&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;JsonApiController&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;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'show'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That route block registered create, update and delete for posts, plus the related-resource and relationship endpoints (&lt;code&gt;/posts/{post}/author&lt;/code&gt;, &lt;code&gt;/posts/{post}/relationships/comments&lt;/code&gt;), with no controller code of mine.&lt;/p&gt;

&lt;p&gt;Then it does the things the built-in leaves to you. Includes are eager-loaded with no help:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/v1/users/1?include=posts.comments.author    4 queries
GET /api/v1/posts?include=author                     2 queries
GET /api/v1/posts?include=author,comments            3 queries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Include paths are validated. The default &lt;code&gt;$maxDepth&lt;/code&gt; is 1, which is why the schema above raises it. Before I did, the nested request came back like this, and so does any path the schema doesn't declare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonapi"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1.0"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Include path posts.comments.author is not allowed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"parameter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"include"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"400"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Invalid Query Parameter"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;?filter[author]=2&lt;/code&gt;, &lt;code&gt;?sort=-title&lt;/code&gt; and &lt;code&gt;?page[number]=2&amp;amp;page[size]=5&lt;/code&gt; worked straight away. Writes are validated against the request class, and the errors point at the exact member of the request document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;422 Unprocessable Entity | The title field is required. | /data/attributes/title
422 Unprocessable Entity | The body field is required. | /data/attributes/body
422 Unprocessable Entity | The author field is required. | /data/relationships/author
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A POST with a plain &lt;code&gt;application/json&lt;/code&gt; body was refused with a 415. A proper document, with the author passed as a relationship, created the post and returned it with a 201.&lt;/p&gt;

&lt;p&gt;Two costs to know about. Authorization runs through your policies by default. I switched it off for this test with &lt;code&gt;authorizable()&lt;/code&gt; returning false on the server, but in a real app you'd write the policies (my &lt;a href="https://hafiz.dev/blog/laravel-policies-vs-gates-authorization-guide" rel="noopener noreferrer"&gt;policies vs gates guide&lt;/a&gt; covers those). And it brings its own conventions, like camelCase &lt;code&gt;createdAt&lt;/code&gt;, its own controller, and a schema layer your team has to learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one your API needs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Built-in &lt;code&gt;JsonApiResource&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;timacdonald/json-api&lt;/th&gt;
&lt;th&gt;laravel-json-api/laravel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Resource classes&lt;/td&gt;
&lt;td&gt;Resource classes&lt;/td&gt;
&lt;td&gt;Full server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum Laravel&lt;/td&gt;
&lt;td&gt;12.45.0&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;12.61.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eager-loads includes&lt;/td&gt;
&lt;td&gt;Single resources (13.37+), collections not yet&lt;/td&gt;
&lt;td&gt;No, by design&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unknown include path&lt;/td&gt;
&lt;td&gt;Ignored, 200&lt;/td&gt;
&lt;td&gt;Ignored, 200&lt;/td&gt;
&lt;td&gt;400 error object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filters, sorting&lt;/td&gt;
&lt;td&gt;Spatie Query Builder&lt;/td&gt;
&lt;td&gt;Spatie Query Builder&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writes and validation&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;td&gt;Request classes, JSON pointers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error format&lt;/td&gt;
&lt;td&gt;Laravel's&lt;/td&gt;
&lt;td&gt;Laravel's&lt;/td&gt;
&lt;td&gt;JSON:API error objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;First-party&lt;/td&gt;
&lt;td&gt;1.0.0-beta.12&lt;/td&gt;
&lt;td&gt;5.3.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The question that decides it is whether clients write through the API.&lt;/p&gt;

&lt;p&gt;If they only read, and what you need is the JSON:API shape for a front end or a mobile app, use the built-in. You get includes, sparse fieldsets and links from three small classes, and the remaining N+1 is a framework bug with a fix already merged. Add the &lt;code&gt;with()&lt;/code&gt; allow-list until it ships.&lt;/p&gt;

&lt;p&gt;If clients create and update resources through it, or a third party will build against your API and expects the spec enforced (error objects, rejected include paths, filters and pagination by the book), use laravel-json-api. Its setup cost is real, but you'd spend more than that rebuilding its validation and errors by hand on top of the built-in.&lt;/p&gt;

&lt;p&gt;And timacdonald/json-api is for apps that can't run the built-in yet.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does Laravel's built-in JSON:API resource support filtering and sorting?
&lt;/h3&gt;

&lt;p&gt;No. It shapes responses and handles &lt;code&gt;include&lt;/code&gt; and &lt;code&gt;fields&lt;/code&gt;. The Laravel docs point to Spatie's Laravel Query Builder for filters and sorts, and its current 7.x release supports Laravel 12 and 13 on PHP 8.4.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can the built-in JsonApiResource accept JSON:API documents in POST or PATCH?
&lt;/h3&gt;

&lt;p&gt;Only through your own code. You validate &lt;code&gt;data.attributes.*&lt;/code&gt; with normal rules and create the model yourself. Validation errors come back in Laravel's default format, not as JSON:API error objects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is timacdonald/json-api abandoned?
&lt;/h3&gt;

&lt;p&gt;No. The repository isn't archived and the latest release, 1.0.0-beta.12, supports Laravel 11 to 13. It's still a pre-release, though, and on Laravel 12.45.0 or newer the built-in offers the same property-based API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does laravel-json-api reject my include with a 400?
&lt;/h3&gt;

&lt;p&gt;Either the path isn't a relationship on the schema, or it's deeper than the schema's &lt;code&gt;$maxDepth&lt;/code&gt;, which defaults to 1. The JSON:API spec requires a 400 when a server can't support an include path, and the package follows it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Laravel version fixes the JSON:API N+1 queries?
&lt;/h3&gt;

&lt;p&gt;Nested includes on a single resource are fixed in 13.37.0. Includes on collections are fixed on the 13.x branch by #62152, which was merged after 13.38.0 and should ship in the next 13.x release. Check the release notes for #62152 before you remove a &lt;code&gt;with()&lt;/code&gt; workaround.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Whichever you pick, run &lt;code&gt;composer show laravel/framework&lt;/code&gt; before you ship it. Anything from 13.27.0 to 13.36.0 loads nested includes one model at a time, and every release up to 13.38.0 does the same for collection includes until #62152 is tagged.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>apidevelopment</category>
      <category>eloquent</category>
      <category>performance</category>
    </item>
    <item>
      <title>Smoke</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:48:04 +0000</pubDate>
      <link>https://dev.to/hafiz619/smoke-4b1g</link>
      <guid>https://dev.to/hafiz619/smoke-4b1g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/smoke" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Hi
&lt;/h1&gt;

&lt;p&gt;Body.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Your Users Save and Still See Old Data: Laravel 13.38 Fixes Read Replica Lag, Except in 3 Places</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 26 Oct 2026 05:15:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/your-users-save-and-still-see-old-data-laravel-1338-fixes-read-replica-lag-except-in-3-places-6a</link>
      <guid>https://dev.to/hafiz619/your-users-save-and-still-see-old-data-laravel-1338-fixes-read-replica-lag-except-in-3-places-6a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-read-replica-lag-prevent-stale-database-reads" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I changed a post title to "Q4 pricing update", got the green "Saved" banner, and the heading right under it still said "Q3 pricing update". The debug line on the same page said the request had read from the primary. That was Laravel 13.38.0, with the new &lt;code&gt;PreventStaleDatabaseReads&lt;/code&gt; middleware installed exactly the way the docs show it.&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%2Fbd7hmw3qpt7a1a868j5h.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%2Fbd7hmw3qpt7a1a868j5h.webp" alt="After saving " width="800" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The middleware is a good idea. &lt;a href="https://github.com/laravel/framework/pull/62078" rel="noopener noreferrer"&gt;Jack Bayliss wrote it&lt;/a&gt; after seeing his replicas lag 0 to 1 seconds on average and up to 15 seconds under some workloads. That's long enough for a user to edit a title, refresh, see the old one, and file a bug report. It shipped in &lt;a href="https://github.com/laravel/framework/releases/tag/v13.38.0" rel="noopener noreferrer"&gt;v13.38.0&lt;/a&gt; on 20 October.&lt;/p&gt;

&lt;p&gt;To see what it covers, I set up a MySQL 8.4 primary and a replica in Docker, held the replica 5 seconds behind with &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/replication-delayed.html" rel="noopener noreferrer"&gt;delayed replication&lt;/a&gt; (&lt;code&gt;SOURCE_DELAY=5&lt;/code&gt;), and pointed a clean Laravel 13.38.0 project at both. Five seconds is slow for a healthy replica. That's the point. It makes every gap reproducible on a laptop. All output below is real.&lt;/p&gt;

&lt;p&gt;The documented setup needs one extra call before it protects the most common kind of page. And three things the middleware never touches still read stale data after you fix it. API routes, queued jobs, and the database cache store, which is where your rate limiter keeps its counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What sticky already did, and what 13.38 adds
&lt;/h2&gt;

&lt;p&gt;Laravel has split reads and writes for years. You give the connection a &lt;code&gt;read&lt;/code&gt; and a &lt;code&gt;write&lt;/code&gt; server, and set &lt;code&gt;sticky&lt;/code&gt; so a request that writes also reads its own writes:&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;'read'&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;'port'&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_READ_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'3306'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="s1"&gt;'write'&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;'port'&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_WRITE_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'3306'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="s1"&gt;'sticky'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// host, database, credentials...&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of my servers run on 127.0.0.1, so only the port differs. In production you'd override &lt;code&gt;host&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;sticky&lt;/code&gt; only lasts for one request. Save a form, redirect, and the GET that follows is a new request on a clean connection. It reads the replica.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PreventStaleDatabaseReads&lt;/code&gt; carries the write across requests through the session. This is the core of it, from the framework source:&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;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;useWriteConnectionWhenReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_database_modified_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subSeconds&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="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="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasModifiedRecords&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_database_modified_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in those lines come back later. It calls &lt;code&gt;$request-&amp;gt;session()&lt;/code&gt;, so it needs a session. It only touches the default connection. And it compares whole-second timestamps, so a 2 second window stays open for somewhere between 2 and 3 seconds.&lt;/p&gt;

&lt;p&gt;The same release added &lt;a href="https://github.com/laravel/framework/pull/62099" rel="noopener noreferrer"&gt;&lt;code&gt;usingWritePdo()&lt;/code&gt;&lt;/a&gt;. It runs a callback with every read on that connection sent to the primary, then restores the previous setting, even if the callback throws. Most of the fixes below use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The documented setup still served the old title
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://laravel.com/docs/13.x/database#read-and-write-connections" rel="noopener noreferrer"&gt;docs&lt;/a&gt; tell you to append the middleware to the &lt;code&gt;web&lt;/code&gt; group:&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;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;PreventStaleDatabaseReads&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;I did that, saved a new title, and reloaded the page every second with the same session cookie. The page prints its title and the &lt;code&gt;@@server_id&lt;/code&gt; of whichever server answered a &lt;code&gt;select&lt;/code&gt; in the route closure. Server 1 is the primary, server 2 the replica.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.0s PUT  title="Q4 pricing update"
+0.1s GET  title="Q3 pricing update"  read from server_id 1 (primary)
+1.2s GET  title="Q3 pricing update"  read from server_id 1 (primary)
+2.3s GET  title="Q3 pricing update"  read from server_id 1 (primary)
+3.4s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+4.5s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+5.6s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+6.7s GET  title="Q4 pricing update"  read from server_id 2 (replica)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middleware did its job. For the first three reloads the query in the closure went to the primary. The title was stale anyway, because it came from &lt;code&gt;$post&lt;/code&gt;, and &lt;code&gt;$post&lt;/code&gt; came from route model binding.&lt;/p&gt;

&lt;p&gt;Route model binding happens in &lt;code&gt;SubstituteBindings&lt;/code&gt;, which is already in the &lt;code&gt;web&lt;/code&gt; group. Appending puts the new middleware after it. Laravel does sort some middleware by a priority list, and &lt;code&gt;SubstituteBindings&lt;/code&gt; is on that list. &lt;code&gt;PreventStaleDatabaseReads&lt;/code&gt; isn't, so it keeps its appended position. By the time it switches the connection to the primary, the model has already been loaded from the replica.&lt;/p&gt;

&lt;p&gt;So add it to the priority 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="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;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;PreventStaleDatabaseReads&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="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;AuthenticatesRequests&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;PreventStaleDatabaseReads&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;&lt;code&gt;AuthenticatesRequests&lt;/code&gt; is the contract the &lt;code&gt;auth&lt;/code&gt; middleware implements, and it sits ahead of &lt;code&gt;SubstituteBindings&lt;/code&gt; in Laravel's priority list. Going in before it means the user lookup that &lt;code&gt;auth&lt;/code&gt; does reads the primary too. It still runs after &lt;code&gt;StartSession&lt;/code&gt;, which it needs. This is the order Laravel resolved for &lt;code&gt;GET /posts/{post}&lt;/code&gt; after the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EncryptCookies
AddQueuedCookiesToResponse
StartSession
ShareErrorsFromSession
PreventRequestForgery
PreventStaleDatabaseReads
SubstituteBindings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested the &lt;code&gt;auth&lt;/code&gt; part too. A logged-in user renames themselves, then reloads a route behind &lt;code&gt;auth&lt;/code&gt; that prints &lt;code&gt;auth()-&amp;gt;user()-&amp;gt;name&lt;/code&gt; and the server that answered its own query. With the middleware only appended, as in the docs, the rename from "Hafiz R" to "Hafiz Riaz" didn't show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.1s PUT  name="Hafiz Riaz"
+0.1s GET  Hafiz R (server_id 1)
+1.1s GET  Hafiz R (server_id 1)
+2.2s GET  Hafiz R (server_id 1)
+3.3s GET  Hafiz R (server_id 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the name in your nav bar staying wrong after a profile save, on requests that read the primary for everything else. With the priority call in place, the earlier rename from "Hafiz" to "Hafiz R" showed on the first reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.1s PUT  name="Hafiz R"
+0.1s GET  Hafiz R (server_id 1)
+1.1s GET  Hafiz R (server_id 1)
+2.2s GET  Hafiz R (server_id 1)
+3.3s GET  Hafiz R (server_id 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back to the post title. Same save, same reloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.0s PUT  title="Q4 pricing update"
+0.1s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+1.2s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+2.3s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+3.4s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+4.5s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+5.6s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+6.7s GET  title="Q4 pricing update"  read from server_id 2 (replica)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better, and in one way worse. The user now sees the new title, then the old one again, then the new one. The default window closed after about 3 seconds, and my replica needed about 6.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-read-replica-lag-prevent-stale-database-reads" 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 window has to outlast your replica lag. &lt;code&gt;for()&lt;/code&gt; sets it in seconds:&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;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;PreventStaleDatabaseReads&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="mi"&gt;6&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 priority entry can stay as the plain class name. I printed the resolved order again to check, and &lt;code&gt;PreventStaleDatabaseReads:6&lt;/code&gt; still landed before &lt;code&gt;SubstituteBindings&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.0s PUT  title="Q4 pricing update"
+0.1s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+1.2s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+2.3s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+3.4s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+4.5s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+5.7s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+6.8s GET  title="Q4 pricing update"  read from server_id 2 (replica)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy522j1hn2uljhinru6m5.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%2Fy522j1hn2uljhinru6m5.webp" alt="With the middleware moved ahead of route binding and a 6 second window, the heading shows the new title straight after the save" width="800" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pick the number from your worst observed lag, not your average. On MySQL, &lt;code&gt;Seconds_Behind_Source&lt;/code&gt; in &lt;code&gt;SHOW REPLICA STATUS&lt;/code&gt; is the figure to watch. Jack's 15 second peak would need &lt;code&gt;for(15)&lt;/code&gt; or more. Also, any write on the default connection during a request reopens the window. If a middleware updates &lt;code&gt;last_seen_at&lt;/code&gt; on every request, an active user stays on the primary for as long as they keep clicking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 1: API routes
&lt;/h2&gt;

&lt;p&gt;A stateless API route has no session. Put the middleware in the &lt;code&gt;api&lt;/code&gt; group and every request fails (output trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "message": "Session store not set on request.",
    "exception": "RuntimeException",
    ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jack flags this in the PR himself ("It's session based, so not ideal for API stuff"). Leave it off, and an API client that saves and reads straight back gets the old value for the whole lag. That part I expected. Sanctum was the surprise.&lt;/p&gt;

&lt;h3&gt;
  
  
  New Sanctum tokens fail until the replica catches up
&lt;/h3&gt;

&lt;p&gt;Sanctum looks up a personal access token with a plain &lt;code&gt;find()&lt;/code&gt;, which reads the replica. So a token issued a moment ago doesn't exist yet as far as the next request can tell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.1s POST /api/tokens -&amp;gt; new token issued
+0.1s GET  /api/me -&amp;gt; {"message":"Unauthenticated."} 401
+1.2s GET  /api/me -&amp;gt; {"message":"Unauthenticated."} 401
+2.3s GET  /api/me -&amp;gt; {"message":"Unauthenticated."} 401
+3.3s GET  /api/me -&amp;gt; {"message":"Unauthenticated."} 401
+4.4s GET  /api/me -&amp;gt; {"message":"Unauthenticated."} 401
+5.5s GET  /api/me -&amp;gt; {"message":"Unauthenticated."} 401
+6.5s GET  /api/me -&amp;gt; {"name":"Hafiz"} 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mobile app that logs in and immediately loads the profile screen hits exactly this. I fixed it with a token model that tries the replica first and only falls back to the primary on a miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Models&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\Sanctum\PersonalAccessToken&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;SanctumToken&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;PersonalAccessToken&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;SanctumToken&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&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;static&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConnection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;usingWritePdo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tokenable'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;Sanctum&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;usePersonalAccessTokenModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PersonalAccessToken&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;My first version had no &lt;code&gt;load('tokenable')&lt;/code&gt;. Login worked, but a signup that returned a token still got 401 for 6 seconds. On signup the user row is as new as the token, and Sanctum loads the user later, outside the callback, from the replica. Loading it inside the callback fixed both flows. (If you're still choosing an auth package, &lt;a href="https://hafiz.dev/blog/laravel-passport-vs-sanctum-which-one-do-you-actually-need" rel="noopener noreferrer"&gt;Passport vs Sanctum&lt;/a&gt; covers that decision.)&lt;/p&gt;

&lt;p&gt;Sanctum gets one thing right here already. When it updates &lt;code&gt;last_used_at&lt;/code&gt;, it puts the connection's "modified" flag back the way it found it, so an authenticated GET doesn't count as a write.&lt;/p&gt;

&lt;h3&gt;
  
  
  Read-your-writes for API clients
&lt;/h3&gt;

&lt;p&gt;For the save-then-read case I wrote a version of the middleware keyed by the authenticated user instead of the session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Http\Middleware&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;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;Illuminate\Http\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;Illuminate\Support\Facades\Cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="no"&gt;Illuminate\Support\Facades\DB&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;PreventStaleApiReads&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'recent-write:'&lt;/span&gt;&lt;span class="mf"&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;getAuthIdentifier&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;useWriteConnectionWhenReading&lt;/span&gt;&lt;span class="p"&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;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasModifiedRecords&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&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;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$seconds&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;$response&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;It needs the user, so it has to run after &lt;code&gt;auth&lt;/code&gt; and before route binding:&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;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;appendToPriorityList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AuthenticatesRequests&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;append&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PreventStaleApiReads&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// routes/api.php&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;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="nc"&gt;PreventStaleApiReads&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':6'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/posts/{post}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/posts/{post}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A token client with no cookies, saving and then reading every second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.0s PUT  {"title":"Saved by an API client"}
+0.1s GET  {"title":"Saved by an API client","server_id":1}
+1.2s GET  {"title":"Saved by an API client","server_id":1}
+2.3s GET  {"title":"Saved by an API client","server_id":1}
+3.3s GET  {"title":"Saved by an API client","server_id":1}
+4.4s GET  {"title":"Saved by an API client","server_id":1}
+5.5s GET  {"title":"Saved by an API client","server_id":1}
+6.7s GET  {"title":"Saved by an API client","server_id":2}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flag lives in the cache, so the cache must not read from the replica either. If it does, &lt;code&gt;Cache::has()&lt;/code&gt; misses for the whole lag and the middleware does nothing. That's place 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 2: Queued jobs
&lt;/h2&gt;

&lt;p&gt;A job runs in a worker process with its own connection. No session, no HTTP middleware, and none of the sticky state from the request that dispatched it. (It's the same reason tenant context goes missing in workers, one of the &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; I wrote about in June.)&lt;/p&gt;

&lt;p&gt;Laravel already covers part of this. When a worker rebuilds a model from the job payload, &lt;code&gt;restoreModel()&lt;/code&gt; calls &lt;code&gt;useWritePdo()&lt;/code&gt;, so &lt;code&gt;$this-&amp;gt;post&lt;/code&gt; itself comes from the primary. Relations don't. The relations you eager-loaded before dispatching are restored with &lt;code&gt;loadMissing()&lt;/code&gt;, which reads the replica. So does every query inside &lt;code&gt;handle()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My test job logs the post title, the comment count through the restored relation, and a direct count query. The route adds a comment, then dispatches the job straight away with &lt;code&gt;$post-&amp;gt;load('comments')&lt;/code&gt;. The primary had 3 comments when the job ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SummarisePost {"title":"Saved through the API","comments_relation":2,"comments_query":2}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fresh title. Both counts stale.&lt;/p&gt;

&lt;p&gt;My first run didn't show this at all, and it took me a minute to see why. I was on the default &lt;code&gt;database&lt;/code&gt; queue driver, and reserving a job runs an UPDATE on the &lt;code&gt;jobs&lt;/code&gt; table. That marks the connection as modified, and with &lt;code&gt;sticky&lt;/code&gt; on, everything the job reads afterwards goes to the primary. When I switched the queue to Redis, the stale reads appeared. Any driver that doesn't write to your replicated connection (Redis, SQS) behaves the same way. The 13.36 option &lt;code&gt;Worker::$flushStickyConnections&lt;/code&gt; (&lt;a href="https://github.com/laravel/framework/pull/61915" rel="noopener noreferrer"&gt;#61915&lt;/a&gt;) doesn't change the database-driver case either, because the worker flushes before it reserves the next job, and the reserve marks the connection again.&lt;/p&gt;

&lt;p&gt;The fix I'd use is a job middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Jobs\Middleware&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;Closure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="no"&gt;Illuminate\Support\Facades\DB&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;ReadFromPrimary&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;object&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;usingWritePdo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$job&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;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;SummarisePost&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="na"&gt;#[WithoutRelations]&lt;/span&gt;
        &lt;span class="k"&gt;public&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="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;middleware&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReadFromPrimary&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// handle() unchanged&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 plaintext"&gt;&lt;code&gt;SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":4}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Job middleware runs after the job has been unserialized, so without &lt;code&gt;#[WithoutRelations]&lt;/code&gt; a relation stored in the payload is already loaded from the replica by then. Leaving the attribute off, with 5 comments on the primary, I got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":5}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it, nothing is restored early, and the relation loads lazily inside the middleware's callback.&lt;/p&gt;

&lt;p&gt;Put &lt;code&gt;ReadFromPrimary&lt;/code&gt; on the jobs you dispatch right after a write, not on every job. A report that runs ten minutes later doesn't need it, and routing every job to the primary defeats the point of having a replica. If a job can wait, &lt;code&gt;-&amp;gt;delay(now()-&amp;gt;addSeconds(10))&lt;/code&gt; costs nothing at all. Workers have enough lifecycle rules of their own already (like &lt;a href="https://hafiz.dev/blog/laravel-queue-worker-sigterm-interruptible-jobs" rel="noopener noreferrer"&gt;the SIGTERM they get on every deploy&lt;/a&gt;), and if you're tuning them for volume, &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing 10,000 queue jobs without breaking&lt;/a&gt; covers the worker side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 3: The database cache store, and your rate limiter
&lt;/h2&gt;

&lt;p&gt;A fresh Laravel 13 project ships with &lt;code&gt;CACHE_STORE=database&lt;/code&gt; on the default connection. The database cache store reads through that connection with no write-PDO override, so on a replicated connection, cache reads hit the replica. Sessions don't have this problem, because &lt;code&gt;DatabaseSessionHandler&lt;/code&gt; adds &lt;code&gt;useWritePdo()&lt;/code&gt; to its queries. The cache store doesn't.&lt;/p&gt;

&lt;p&gt;Rate limiting is where that hurts. I put &lt;code&gt;throttle:3,1&lt;/code&gt; on a stateless login route and sent a request every half second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+0.1s attempt 1 -&amp;gt; 200
+0.7s attempt 2 -&amp;gt; 200
+1.3s attempt 3 -&amp;gt; 200
+1.9s attempt 4 -&amp;gt; 200
+2.5s attempt 5 -&amp;gt; 200
+3.1s attempt 6 -&amp;gt; 200
+3.7s attempt 7 -&amp;gt; 200
+4.3s attempt 8 -&amp;gt; 200
+4.9s attempt 9 -&amp;gt; 200
+5.5s attempt 10 -&amp;gt; 200
+6.1s attempt 11 -&amp;gt; 200
+6.7s attempt 12 -&amp;gt; 200
+7.3s attempt 13 -&amp;gt; 429
+7.9s attempt 14 -&amp;gt; 429
+8.4s attempt 15 -&amp;gt; 429
+9.0s attempt 16 -&amp;gt; 429
allowed: 12 of 16 (limit is 3 per minute)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The increments run inside a locked transaction, so they go to the primary and the counter itself is right. But &lt;code&gt;tooManyAttempts()&lt;/code&gt; reads the count with a plain cache get, from the replica, which still shows the count from 5 seconds ago. The limiter only starts refusing once the replica has caught up with the third hit. So an attacker gets the limit plus whatever they can send during the lag. At two requests a second that was 12 instead of 3, and a script sends a lot more than two. &lt;code&gt;PreventStaleDatabaseReads&lt;/code&gt; doesn't help, because an attacker just drops the session cookie.&lt;/p&gt;

&lt;p&gt;Point the cache at a connection without a replica:&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/database.php&lt;/span&gt;
&lt;span class="s1"&gt;'mysql_primary'&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;'host'&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_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'port'&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_WRITE_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'3306'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'username'&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_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'root'&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="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'charset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8mb4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'collation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8mb4_unicode_ci'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'prefix'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DB_CACHE_CONNECTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mysql_primary
&lt;span class="nv"&gt;DB_CACHE_LOCK_CONNECTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mysql_primary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production that connection gets your primary's host. Same burst afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allowed: 3 of 16 (limit is 3 per minute)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Redis cache avoids the problem as well. Either way, the &lt;code&gt;PreventStaleApiReads&lt;/code&gt; flag from place 1 depends on this.&lt;/p&gt;

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

&lt;p&gt;Move the cache off the replicated connection first, even if you skip everything else in this post. A rate limiter that can be outrun is a security bug, not a UX one, and it's a two-line &lt;code&gt;.env&lt;/code&gt; change.&lt;/p&gt;

&lt;p&gt;Then add &lt;code&gt;PreventStaleDatabaseReads&lt;/code&gt; with the priority call and a window longer than your worst lag. Without the priority call it protects the queries in your controllers and misses the model your route is named after.&lt;/p&gt;

&lt;p&gt;For APIs, the token fallback is cheap. It costs one extra primary query, and only when the replica misses. The user-keyed middleware is worth it only if your clients read right after they write. If your PUT responses already return the saved resource, your clients may never need it.&lt;/p&gt;

&lt;p&gt;For jobs, wrap the few that run straight after a write and leave the rest alone.&lt;/p&gt;

&lt;p&gt;Every one of these moves reads onto the primary that the replica was supposed to take. That's the trade. If your lag is routinely more than a few seconds, a longer window hides a replica problem rather than fixing it.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does PreventStaleDatabaseReads stop other users from seeing stale data?
&lt;/h3&gt;

&lt;p&gt;No. It only sends reads to the primary for the session that made the write. Another user viewing the same post during the lag still reads the replica. That's usually fine, because they never saw the new value to begin with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need the sticky option?
&lt;/h3&gt;

&lt;p&gt;Yes. They cover different requests. &lt;code&gt;sticky&lt;/code&gt; sends reads to the primary later in the same request that wrote. The middleware covers the requests that come after. I had both on for every test in this post.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it cover a second database connection?
&lt;/h3&gt;

&lt;p&gt;No. It only tracks and switches the default connection, and the docs say so. If tenant or reporting data lives on another connection, wrap those reads in that connection's &lt;code&gt;usingWritePdo()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a 2 second window last longer than 2 seconds?
&lt;/h3&gt;

&lt;p&gt;The middleware stores and compares whole-second timestamps. A write at 10.0 and one at 10.9 are both stored as 10, and the window closes when the clock reaches 13. So it stays open between 2 and 3 seconds after the write. In my runs, a reload at 2.3 seconds still went to the primary with the default window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the database queue driver protect jobs from stale reads?
&lt;/h3&gt;

&lt;p&gt;By accident, when the &lt;code&gt;jobs&lt;/code&gt; table lives on the same replicated connection and &lt;code&gt;sticky&lt;/code&gt; is on. Reserving the job is a write, so the job reads the primary. Don't rely on it. Switch to Redis or SQS later and the stale reads show up with no code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PreventStaleDatabaseReads&lt;/code&gt; fixes the bug Jack described, as long as you add the priority call and size the window to your lag. Both are one-line fixes. Everything else in this post sits outside what a session-based middleware can reach, and none of it needed production traffic to show up. If you run read replicas, start one with &lt;code&gt;SOURCE_DELAY=5&lt;/code&gt; on your laptop and click through your own app before your users do.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mysql</category>
      <category>database</category>
      <category>queues</category>
    </item>
    <item>
      <title>I Ran Larastan at Every Level on Agent-Written Laravel Code: 4 Real Bugs, 3 Kinds of Noise</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 22 Oct 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/i-ran-larastan-at-every-level-on-agent-written-laravel-code-4-real-bugs-3-kinds-of-noise-1cec</link>
      <guid>https://dev.to/hafiz619/i-ran-larastan-at-every-level-on-agent-written-laravel-code-4-real-bugs-3-kinds-of-noise-1cec</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/larastan-every-level-agent-written-laravel-code" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;On October 12, Nuno Maduro &lt;a href="https://x.com/enunomaduro/status/2109629377118077315" rel="noopener noreferrer"&gt;posted&lt;/a&gt; that "it's kind of insane how much AI agents love nullables." His maths was simple. Every nullable doubles the number of states your code can be in, so a constructor with ten of them has over a thousand combinations to reason about.&lt;/p&gt;

&lt;p&gt;I wanted to see what that looks like in a real codebase, so I ran Larastan against one of mine at every level from 0 to 10. It's a production Laravel SaaS on Laravel 12 and Filament 4, about 25,000 lines across roughly 250 files in &lt;code&gt;app/&lt;/code&gt;, built over the last fifteen months. Most of that code was written by Claude Code, with me reviewing it.&lt;/p&gt;

&lt;p&gt;The four real bugs all showed up at level 0. Almost everything above that was typing noise with three causes, and two of them took one line each to fix. The nullables were there too. Some &lt;code&gt;?-&amp;gt;&lt;/code&gt; operators guarded values that could never be null, and some nullable values had no guard at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, and the run that died first
&lt;/h2&gt;

&lt;p&gt;Larastan 3.13.0 came out on October 7 and pulls in PHPStan 2.3.1. I cloned the repo into a scratch directory, so nothing here touched the real project:&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 &lt;span class="nt"&gt;--dev&lt;/span&gt; larastan/larastan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# phpstan.neon&lt;/span&gt;
&lt;span class="na"&gt;includes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vendor/larastan/larastan/extension.neon&lt;/span&gt;

&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app/&lt;/span&gt;
    &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run didn't finish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internal error: $config must be a string or an array while analysing file
app/Console/Commands/SendActivateDormantCampaign.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Larastan boots your application, and to type a command's arguments it resolves every Artisan command you have. The file in the error was just the one being analysed. The culprit was another command whose constructor asked the container for a Stripe service, and the service built its Stripe client in its own constructor from &lt;code&gt;config('services.stripe.secret')&lt;/code&gt;. With no &lt;code&gt;.env&lt;/code&gt;, the secret was null and the Stripe SDK threw. Copying &lt;code&gt;.env.example&lt;/code&gt; didn't help either, because an empty &lt;code&gt;STRIPE_SECRET=&lt;/code&gt; gives an empty string, and the run died again with "api_key cannot be the empty string".&lt;/p&gt;

&lt;p&gt;So static analysis needs a booting app, and in CI that means an &lt;code&gt;.env&lt;/code&gt; with non-empty dummy values for anything a constructor reads. The cleaner fix is in the code. A service that talks to an external API shouldn't build its client until something calls it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 0 found four real bugs
&lt;/h2&gt;

&lt;p&gt;Level 0 is meant to be the gentle one. It found the only findings in the whole run that would break in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Filament v3 class in a Filament v4 app.&lt;/strong&gt; One admin View page called &lt;code&gt;Section::make()&lt;/code&gt; eight times, imported from &lt;code&gt;Filament\Infolists\Components\Section&lt;/code&gt;. In Filament 4 that class lives in &lt;code&gt;Filament\Schemas\Components&lt;/code&gt;, and the old one doesn't exist (I checked &lt;code&gt;vendor/&lt;/code&gt; on 4.5.2). The page throws the moment anyone opens it. Larastan said so in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Call to static method make() on an unknown class Filament\Infolists\Components\Section.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the import an agent writes when it remembers Filament v3. The rest of the file was v4 code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;env()&lt;/code&gt; outside the config directory.&lt;/strong&gt; Two places read an admin email 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="nv"&gt;$adminEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mail.admin_email'&lt;/span&gt;&lt;span class="p"&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;'ADMIN_EMAIL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fallback@example.com'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no &lt;code&gt;mail.admin_email&lt;/code&gt; key, so it always falls through to &lt;code&gt;env()&lt;/code&gt;. And &lt;a href="https://laravel.com/docs/13.x/configuration#configuration-caching" rel="noopener noreferrer"&gt;once the config is cached&lt;/a&gt;, "the &lt;code&gt;.env&lt;/code&gt; file will not be loaded", so &lt;code&gt;env()&lt;/code&gt; only sees real system variables. In production the &lt;code&gt;ADMIN_EMAIL&lt;/code&gt; value was ignored and the hardcoded fallback won. Larastan's message names the exact problem: "Called 'env' outside of the config directory which returns null when the config is cached, use 'config'."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enum cases that no longer exist.&lt;/strong&gt; Two model methods compared a property against enum cases that had been removed from the enum at some point. Nothing calls either method today, which is the only reason nothing broke. The first caller would get an &lt;code&gt;Error&lt;/code&gt;, because PHP throws on an undefined enum case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A duplicate array key.&lt;/strong&gt; An import command had a lookup map with the same key twice, so the second entry silently replaced the first. Nothing crashes. One mapping just quietly never happens.&lt;/p&gt;

&lt;p&gt;None of these four were exotic. All four would have been caught by &lt;code&gt;vendor/bin/phpstan&lt;/code&gt; at its lowest setting, on any day in the last year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of noise, and the one-line fixes
&lt;/h2&gt;

&lt;p&gt;From level 2 upwards the counts climbed fast, and almost none of it was a bug. Three causes covered most of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Relations without generics
&lt;/h3&gt;

&lt;p&gt;At level 2, almost half the findings mentioned a plain &lt;code&gt;Illuminate\Database\Eloquent\Model&lt;/code&gt;. Things like &lt;code&gt;HasMany::valid()&lt;/code&gt; being an "undefined method", when &lt;code&gt;valid&lt;/code&gt; is a perfectly good scope on the related model. The app has 34 relation methods. Not one of them had a generic return type, so Larastan couldn't tell which model a relation returned.&lt;/p&gt;

&lt;p&gt;One docblock per relation fixes 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="cd"&gt;/** @return HasMany&amp;lt;ShareToken, $this&amp;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;shareTokens&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;HasMany&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ShareToken&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line removed five level-2 errors in the files that used the relation. It's tedious to add 34 times, which is exactly the kind of work to hand back to the agent that skipped it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Casts that Larastan didn't read
&lt;/h3&gt;

&lt;p&gt;Larastan kept saying a strict comparison against an enum case would "always evaluate to false", on a model whose &lt;code&gt;casts()&lt;/code&gt; method clearly casts that column to the enum. A quick &lt;code&gt;\PHPStan\dumpType()&lt;/code&gt; showed Larastan thought the property was a plain &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/larastan/larastan/blob/3.x/docs/custom-config-parameters.md" rel="noopener noreferrer"&gt;Larastan docs&lt;/a&gt; explain why. By default it relies on the return type of the &lt;code&gt;casts()&lt;/code&gt; method, and a method declared as returning &lt;code&gt;array&lt;/code&gt; says nothing about which cast goes where. Parsing the method body is opt-in, because it's slower:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;parseModelCastsMethod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning it on cut the level 4 findings by 37% and the level 2 findings by about a quarter. If your models use &lt;code&gt;casts()&lt;/code&gt; rather than the old &lt;code&gt;$casts&lt;/code&gt; property, set this before you read a single result.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;mixed&lt;/code&gt; at levels 9 and 10
&lt;/h3&gt;

&lt;p&gt;Every new finding at level 9 was about &lt;code&gt;mixed&lt;/code&gt;, and 93% at level 10. A common source was &lt;code&gt;config()&lt;/code&gt;, which returns &lt;code&gt;mixed&lt;/code&gt;, so &lt;code&gt;config('app.url').'/about'&lt;/code&gt; becomes &lt;code&gt;Binary operation "." between mixed and '/about' results in an error.&lt;/code&gt; Laravel already has typed getters for 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.url'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/about'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That cleared the finding on that line. It also throws if the value isn't a string, which is better than concatenating null into a URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nullable part
&lt;/h2&gt;

&lt;p&gt;So, did the agent love nullables? Yes, but in two opposite ways.&lt;/p&gt;

&lt;p&gt;At level 4, Larastan flagged eight nullsafe operators the code didn't need. Two were on values that &lt;a href="https://phpstan.org/error-identifiers/nullsafe.neverNull" rel="noopener noreferrer"&gt;can never be null&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Using nullsafe property access on non-nullable type App\Models\User. Use -&amp;gt; instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other six sat on the left of &lt;code&gt;??&lt;/code&gt;, which already handles null. Each &lt;code&gt;?-&amp;gt;&lt;/code&gt; tells the next reader to worry about a null that is either impossible or already handled. That's Nuno's point exactly.&lt;/p&gt;

&lt;p&gt;Level 8 is where PHPStan starts reporting method calls on nullable types, and there the agent did the reverse. It called methods on values that really are nullable by type, without narrowing them. Most came from Laravel APIs where the app knows more than the type does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$request-&amp;gt;user()&lt;/code&gt; inside routes behind auth middleware, which returns &lt;code&gt;User|null&lt;/code&gt; by type. Code using that user accounted for 33 level-8 findings.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-&amp;gt;first()&lt;/code&gt; straight after an &lt;code&gt;exists:&lt;/code&gt; validation rule, so the row is there but the type says it might not be.&lt;/li&gt;
&lt;li&gt;Filament's &lt;code&gt;$this-&amp;gt;record&lt;/code&gt;, typed &lt;code&gt;Model|int|string|null&lt;/code&gt; on page classes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same root cause both times. The agent handled null from habit instead of reading the types. And the fixes share one idea. Make the type say what the code already knows. For the controllers, Laravel's &lt;code&gt;#[CurrentUser]&lt;/code&gt; attribute injects a non-null user:&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\Models\User&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\Container\Attributes\CurrentUser&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;me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;CurrentUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nc"&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="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$activeSub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;activeSubscription&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;That change cleared all nine level-8 findings in that controller. For the lookups, &lt;code&gt;firstOrFail()&lt;/code&gt; instead of &lt;code&gt;first()&lt;/code&gt; does the same job and turns a would-be null error into a 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  PHPStan now talks to your agent
&lt;/h2&gt;

&lt;p&gt;When I ran PHPStan from inside a Claude Code session, the output opened with a block of instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before fixing the error, fetch the documentation page for its identifier.

The error usually indicates a real bug or incorrect type in the code. Fix the underlying cause, do not just make the error go away.
Do not add `@phpstan-ignore` comments, `@phpstan-ignore-next-line` comments, or baseline entries to suppress the error.
Do not use assert() or inline @var PHPDoc tag to override PHPStan's inferred type.
Do not add type casts just to silence errors.
Do not widen parameter or return types just to make the error go away.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHPStan added this in &lt;a href="https://github.com/phpstan/phpstan/releases/tag/2.1.41" rel="noopener noreferrer"&gt;2.1.41&lt;/a&gt; in March, and later versions detect more agents. It checks for environment variables like &lt;code&gt;CLAUDECODE&lt;/code&gt;, &lt;code&gt;AMP_CURRENT_THREAD_ID&lt;/code&gt; and &lt;code&gt;CURSOR_AGENT&lt;/code&gt;, and only prints the block when one is set and there are errors. You won't see it in a normal terminal session.&lt;/p&gt;

&lt;p&gt;The quickest way for an agent to make a nullable error disappear is to add a &lt;code&gt;?-&amp;gt;&lt;/code&gt;, an &lt;code&gt;@var&lt;/code&gt; or a cast. PHPStan now rules out the &lt;code&gt;@var&lt;/code&gt; and the cast by name, in the same output that lists the errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  A baseline and a cache for CI
&lt;/h2&gt;

&lt;p&gt;Fixing everything at level 8 in one go isn't realistic, and you don't need to. Generate a baseline, commit it, and from then on only new code has to be clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vendor/bin/phpstan analyse &lt;span class="nt"&gt;--generate-baseline&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# phpstan.neon&lt;/span&gt;
&lt;span class="na"&gt;includes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vendor/larastan/larastan/extension.neon&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;phpstan-baseline.neon&lt;/span&gt;

&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app/&lt;/span&gt;
    &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;
    &lt;span class="na"&gt;parseModelCastsMethod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tmpDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.phpstan-cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHPStan also won't let the baseline go stale. When I fixed one of the baselined findings by switching a &lt;code&gt;first()&lt;/code&gt; to &lt;code&gt;firstOrFail()&lt;/code&gt;, the next run failed with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ignored error pattern #^Cannot access property \$id on App\\Models\\AffiliateProgram\|null\.$#
(property.nonObject) in path app/Http/Controllers/AffiliateClickController.php
was not matched in reported errors. [identifier=ignore.unmatched]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error can't be ignored. It's PHPStan asking you to regenerate the baseline, so fixed problems can't quietly come back.&lt;/p&gt;

&lt;p&gt;Then the cache. On an M1 Pro, a cold run at level 6 took 3 to 4 seconds. Limited to two processes, like a small CI runner, a cold run at level 10 took 4.2 to 4.7 seconds. With a warm result cache, the same run took 1.1 seconds. After I edited one central service class, it took 2.1 to 2.6 seconds, because PHPStan re-analyses the changed file and everything that references it. It's the same idea as &lt;a href="https://hafiz.dev/blog/pest-5-tia-run-only-tests-your-change-touched" rel="noopener noreferrer"&gt;running only the tests your change touched&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Honestly, at 25,000 lines a cold run is cheap. The cache starts to matter on bigger apps. If you want it in GitHub Actions anyway, the &lt;a href="https://phpstan.org/user-guide/result-cache" rel="noopener noreferrer"&gt;PHPStan docs&lt;/a&gt; give the recipe. Put &lt;code&gt;tmpDir&lt;/code&gt; inside the workspace (the config above does), then restore and save it with a unique key per run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Restore&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache"&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache/restore@v5&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.phpstan-cache&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phpstan-result-cache-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.run_id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;phpstan-result-cache-&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PHPStan"&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor/bin/phpstan"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Save&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache"&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache/save@v5&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ !cancelled() }}&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.phpstan-cache&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phpstan-result-cache-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.run_id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the docs' version with the path changed to match. I haven't run this workflow, so treat it as the documented starting point. The &lt;code&gt;.env&lt;/code&gt; with dummy secrets from the setup section goes in the step before it. For the rest of the workflow, see my &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;GitHub Actions pipeline for Laravel&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which level to run
&lt;/h2&gt;

&lt;p&gt;Run level 0 today, with no baseline. It's fast, it found every real bug in this app, and this agent-written codebase had stale framework APIs in it.&lt;/p&gt;

&lt;p&gt;Then add &lt;code&gt;parseModelCastsMethod&lt;/code&gt;, generics on your relations and typed config getters. Those three remove most of what makes the higher levels unreadable.&lt;/p&gt;

&lt;p&gt;After that, level 8 with a baseline. That's the level where nullable handling gets checked, which is the habit this codebase most needed correcting. Levels 9 and 10 were all about &lt;code&gt;mixed&lt;/code&gt;, mostly from config and decoded JSON. Worth it later. Not first.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What Larastan level should I start with on an existing Laravel app?
&lt;/h3&gt;

&lt;p&gt;Start at level 0 without a baseline and fix what it finds, because those are the findings most likely to be real bugs. Then pick the highest level you want new code to meet, often 5 or 8, and generate a baseline for everything that already fails it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Larastan ignore my Eloquent casts?
&lt;/h3&gt;

&lt;p&gt;If your model uses the &lt;code&gt;casts()&lt;/code&gt; method, Larastan only reads its declared return type by default, and &lt;code&gt;array&lt;/code&gt; tells it nothing. Set &lt;code&gt;parseModelCastsMethod: true&lt;/code&gt; in &lt;code&gt;phpstan.neon&lt;/code&gt; so it parses the method body. Models using the &lt;code&gt;$casts&lt;/code&gt; property don't need this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Larastan need a database or an .env file?
&lt;/h3&gt;

&lt;p&gt;It doesn't need a database connection for this, but it does boot your application. Any service provider or command constructor that reads a secret and fails on null or an empty string will crash the analysis. Give CI an &lt;code&gt;.env&lt;/code&gt; with non-empty dummy values, or build API clients lazily.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does PHPStan print instructions for AI agents?
&lt;/h3&gt;

&lt;p&gt;Since PHPStan 2.1.41, when it detects a coding agent through environment variables such as &lt;code&gt;CLAUDECODE&lt;/code&gt; or &lt;code&gt;CURSOR_AGENT&lt;/code&gt;, it prints guidance telling the agent to fix the cause instead of suppressing errors. It only appears when there are errors and an agent variable is set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PHPStan won't catch
&lt;/h2&gt;

&lt;p&gt;PHPStan only checks types. Keep &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;the guardrails that stop an agent doing damage&lt;/a&gt; in place too, because a clean level 8 won't stop a migration from dropping a table.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>phpstan</category>
      <category>larastan</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Going Cloud-Only With Laravel: What 7 Weeks on a €5 Dev Box Taught Me</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:15:08 +0000</pubDate>
      <link>https://dev.to/hafiz619/going-cloud-only-with-laravel-what-7-weeks-on-a-eu5-dev-box-taught-me-476n</link>
      <guid>https://dev.to/hafiz619/going-cloud-only-with-laravel-what-7-weeks-on-a-eu5-dev-box-taught-me-476n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/cloud-only-laravel-development-7-weeks-dev-box" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;On October 12, Taylor Otwell &lt;a href="https://x.com/taylorotwell/status/2109648310059278767" rel="noopener noreferrer"&gt;posted&lt;/a&gt; that if he bought a new laptop today, he didn't think he'd install any local dev tools or languages at all. "I think I could do everything I currently do with nothing but Laravel Cloud and @AmpCode Orbs," he wrote, and said he was going to try it. The post pulled 71 replies, and three questions kept coming up. What does it cost? How do you debug when nothing runs on your machine? And can you trust what the agent ships?&lt;/p&gt;

&lt;p&gt;I started a version of this experiment seven weeks before he posted. Not with cloud agents, though. With one €5 VPS that holds my projects, a tmux session per project, and Claude Code running inside them. I wrote up &lt;a href="https://hafiz.dev/blog/remote-dev-workstation-vps-tmux-claude-code" rel="noopener noreferrer"&gt;the setup and the first round of failures&lt;/a&gt; at the end of August.&lt;/p&gt;

&lt;p&gt;One correction first. I'm not cloud-only, and that turned out to be the most useful finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to put development in the cloud
&lt;/h2&gt;

&lt;p&gt;From a phone, Taylor's setup and mine look the same. A terminal, an agent, a staging URL. Underneath they're opposite shapes.&lt;/p&gt;

&lt;p&gt;Amp's &lt;a href="https://ampcode.com/what-are-orbs" rel="noopener noreferrer"&gt;Orbs&lt;/a&gt; are ephemeral remote machines. Every new thread gets its own one, with the repository cloned and whatever the agent needs installed. An orb pauses when the agent goes idle, and a paused orb costs nothing. Amp's own description is that "only the diff leaves the orb." Deploys then go through Laravel Cloud, which builds from the repository.&lt;/p&gt;

&lt;p&gt;My box keeps everything. One persistent machine, each project checked out once, a tmux session per project that survives any disconnect. The Mac and the iPhone are windows onto it. Staging runs on the box behind a Cloudflare Tunnel with an Access login in front, and production deploys go out through each project's &lt;code&gt;deploy.sh&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/cloud-only-laravel-development-7-weeks-dev-box" 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 real difference is state. An orb starts from the repository and keeps only the diff. My box holds SQLite copies of production data, queue workers that never stop, a Telegram bot that's always polling, and whatever work is in progress. That persistence is the reason the box is useful, and the cause of most of what broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'm not cloud-only, and the Mac proves it
&lt;/h2&gt;

&lt;p&gt;The Mac still has PHP, Composer and Node. I ran &lt;code&gt;php artisan&lt;/code&gt; on it today. A few kinds of work never moved. NativePHP sessions run on the Mac, where the phone and the dev server share a Wi-Fi network. The repo I use for client demos stays local on purpose. So two machines still hold checkouts.&lt;/p&gt;

&lt;p&gt;And two checkouts drift, which is why I built the box in the first place. A sync script that reports what's out of step helps. The real fix was a rule instead. A project lives in exactly one place. For anything that has moved to the box, the Mac doesn't keep a copy at all. It attaches to the box's tmux session with one alias, and the laptop becomes the same kind of window the phone is.&lt;/p&gt;

&lt;p&gt;Push still matters, for a different reason now. The box is one machine that can die, so every project's &lt;code&gt;CLAUDE.md&lt;/code&gt; tells the agent to pull at session start and push after every commit. Each &lt;code&gt;deploy.sh&lt;/code&gt; also refuses to run with unpushed commits, because it ships files rather than pulling from git. Without that gate, production can end up running code that exists on no remote. That happened once.&lt;/p&gt;

&lt;p&gt;Orbs make this whole section disappear. Nothing lives outside the repository, so there's nothing to drift. That's the first point in Taylor's favour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs after 52 days
&lt;/h2&gt;

&lt;p&gt;These are the box's numbers on October 16, read straight from it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;€5 a month, VAT included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime&lt;/td&gt;
&lt;td&gt;52 days, no reboot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM in use&lt;/td&gt;
&lt;td&gt;1.3 GB of 3.9 GB, with two Claude Code instances open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One Claude Code instance&lt;/td&gt;
&lt;td&gt;330 to 355 MB resident&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swap in use&lt;/td&gt;
&lt;td&gt;178 MB of 2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;15 GB of 78 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load average&lt;/td&gt;
&lt;td&gt;0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In August I estimated roughly 450 MB per Claude Code instance and called 4 GB the binding constraint. The real figure is lower, and the box spends most of its time waiting on the model, which is why the load average sits near zero.&lt;/p&gt;

&lt;p&gt;Taylor's side, from the public price lists. I haven't used Orbs, so read this as a reading of pricing pages, not a review.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Amp's &lt;a href="https://ampcode.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; lists an Individual tier at $20 a month that includes 45,000 minutes of orb time, and tokens can come from your own Claude or ChatGPT subscription. Those minutes are counted at &lt;code&gt;a1.small&lt;/code&gt;, the size closest to my box. That's 750 hours, which Amp says covers three at once for eight hours a day.&lt;/li&gt;
&lt;li&gt;Pay-as-you-go &lt;a href="https://ampcode.com/what-are-orbs" rel="noopener noreferrer"&gt;orb rates&lt;/a&gt; run from $0.08 an hour (1 CPU, 2 GB) to $2.13 an hour for the largest size. The closest match to my box, &lt;code&gt;a1.small&lt;/code&gt; with 2 CPUs and 4 GB, is $0.17 an hour.&lt;/li&gt;
&lt;li&gt;Laravel Cloud's &lt;a href="https://laravel.com/cloud/pricing" rel="noopener noreferrer"&gt;Starter plan&lt;/a&gt; is $5 a month plus usage, with $5 of usage credit included and compute that scales to zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Careful with the comparison, though. My €5 covers development only. Production runs on servers I was already paying for, which is its own trade-off and the subject of &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;my Cloud vs Forge vs VPS breakdown&lt;/a&gt;. So the fair line is Amp's $20 against my €5. The gap is real and small. The other difference is the shape of the bill. Mine is flat whether I work two hours or twelve. Amp's is flat too until the included minutes run out, and orb minutes grow with hours and with parallel threads. And parallel threads are the point of orbs. They run several agents at once, each in its own machine. My box runs them side by side in one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke after the first post
&lt;/h2&gt;

&lt;p&gt;The August post had a table of everything that broke during setup. These came later, from daily use. Several are Laravel-specific, and a few would hit anyone working on a remote machine.&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;Livewire buttons did nothing on a tunnelled app. No error, state never saved&lt;/td&gt;
&lt;td&gt;Laravel generated &lt;code&gt;http://&lt;/code&gt; URLs behind the tunnel, and Livewire's update request never landed&lt;/td&gt;
&lt;td&gt;Force the scheme and root URL in &lt;code&gt;AppServiceProvider&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A queued job died on a config key that was right there in the file&lt;/td&gt;
&lt;td&gt;The worker booted before the edit and still held the old config&lt;/td&gt;
&lt;td&gt;Restart the queue unit after any change, or run &lt;code&gt;queue:listen&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram couldn't deliver webhooks to the bot&lt;/td&gt;
&lt;td&gt;Cloudflare Access guards every path, so Telegram got the login page&lt;/td&gt;
&lt;td&gt;Long polling with &lt;code&gt;getUpdates&lt;/code&gt; from a systemd unit, no inbound route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search Console data on one site froze for a week, silently&lt;/td&gt;
&lt;td&gt;A deploy's &lt;code&gt;rsync --delete&lt;/code&gt; removed a production-only credential under &lt;code&gt;storage/app/private&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Exclude &lt;code&gt;storage/app/private/&lt;/code&gt; wholesale in the rsync exclude file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deploy.sh&lt;/code&gt; said "Cannot connect to server" while the site stayed up&lt;/td&gt;
&lt;td&gt;The production box's Tailscale key had expired six days earlier, and &lt;code&gt;deploy.sh&lt;/code&gt; connected over Tailscale&lt;/td&gt;
&lt;td&gt;Disable key expiry on servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deploy.sh&lt;/code&gt; hung, then timed out on port 22&lt;/td&gt;
&lt;td&gt;I closed public SSH on a production box, but the dev box's SSH alias still pointed at the public IP&lt;/td&gt;
&lt;td&gt;Update every client alias the day a box is hardened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cloudflared&lt;/code&gt; put a second domain's DNS record inside the wrong zone&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;cert.pem&lt;/code&gt; from &lt;code&gt;cloudflared tunnel login&lt;/code&gt; is scoped to the zone picked at login&lt;/td&gt;
&lt;td&gt;Add the CNAME by hand in the other zone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every request to Reddit returned 403 "Blocked"&lt;/td&gt;
&lt;td&gt;Reddit blocks requests from the hosting provider's IP range&lt;/td&gt;
&lt;td&gt;No direct fix. A Reddit front-end mirror loaded in headless Chromium works from the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playwright couldn't find its browser inside Claude Code's shell&lt;/td&gt;
&lt;td&gt;The agent's shells don't source &lt;code&gt;/etc/environment&lt;/code&gt;, so &lt;code&gt;PLAYWRIGHT_BROWSERS_PATH&lt;/code&gt; is unset&lt;/td&gt;
&lt;td&gt;Set the variable on the command itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Livewire one is a trusted-proxy trap
&lt;/h3&gt;

&lt;p&gt;TLS ends at Cloudflare, and the tunnel hands Caddy plain HTTP. Caddy's reverse proxy, which &lt;code&gt;php_fastcgi&lt;/code&gt; builds on, sets &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; to the scheme it saw and &lt;a href="https://caddyserver.com/docs/caddyfile/directives/reverse_proxy" rel="noopener noreferrer"&gt;ignores incoming values&lt;/a&gt; unless the sender is a trusted proxy. So PHP receives &lt;code&gt;X-Forwarded-Proto: http&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My vhosts also pass &lt;code&gt;HTTPS=on&lt;/code&gt; to PHP-FPM, and that fixed mixed-content errors everywhere else. But this app had &lt;code&gt;trustProxies(at: '*')&lt;/code&gt; in &lt;code&gt;bootstrap/app.php&lt;/code&gt;, so Laravel believed the forwarded header over the server variable and generated &lt;code&gt;http://&lt;/code&gt; URLs. Livewire's update endpoint was one of them, so from an HTTPS page its update requests went to an &lt;code&gt;http://&lt;/code&gt; URL and never landed. Nothing logged an error.&lt;/p&gt;

&lt;p&gt;The fix that's running now, on Laravel 13.30 with Livewire 4:&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;// app/Providers/AppServiceProvider.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// TLS ends at Cloudflare and the tunnel hands Caddy plain http. Every&lt;/span&gt;
    &lt;span class="c1"&gt;// generated URL (Livewire update endpoint included) must still be https,&lt;/span&gt;
    &lt;span class="c1"&gt;// or Always-Use-HTTPS turns Livewire's POST into a dead GET.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.url'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'https://'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="no"&gt;\Illuminate\Support\Facades\URL&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forceScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="no"&gt;\Illuminate\Support\Facades\URL&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forceRootUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.url'&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 guard on &lt;code&gt;app.url&lt;/code&gt; keeps a plain &lt;code&gt;http://localhost&lt;/code&gt; setup working.&lt;/p&gt;

&lt;h3&gt;
  
  
  The queue worker one gets worse on a dev box
&lt;/h3&gt;

&lt;p&gt;In production, the deploy restarts the workers. On a dev box nothing deploys. You edit &lt;code&gt;config/services.php&lt;/code&gt;, the worker running under systemd keeps the config it booted with, and the next job reads the new key as null. Mine died 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="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Argument&lt;/span&gt; &lt;span class="c1"&gt;#2 ($array) must be of type array, null given&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://laravel.com/docs/13.x/queues#the-queue-work-command" rel="noopener noreferrer"&gt;queue docs&lt;/a&gt; explain why. Workers "store the booted application state in memory" and "will not notice changes in your code base after they have been started." There are two ways out. Restart the unit after every code or config change, which is what I do now. Or run &lt;code&gt;queue:listen&lt;/code&gt; on the dev box, which reloads your code for each job and is, in the docs' words, "significantly less efficient."&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust question, seven weeks in
&lt;/h2&gt;

&lt;p&gt;The August FAQ covered production deploy keys on a box with an agent on it. What's new is isolation between projects.&lt;/p&gt;

&lt;p&gt;When the Telegram bot needed its own agent login and its own credentials, sharing root's was wrong and sharing &lt;code&gt;www-data&lt;/code&gt;'s wasn't much better. It got a dedicated system user, its own PHP-FPM pool on its own socket, its own queue unit and its own cron line. And it's deliberately kept out of the &lt;code&gt;www-data&lt;/code&gt; group, because the other projects' &lt;code&gt;.env&lt;/code&gt; files are group-readable.&lt;/p&gt;

&lt;p&gt;That's isolation an orb gives you for free. Each thread is its own machine, and only the diff comes out. On a shared box, you build isolation per project, by hand, after you notice you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance nobody demos
&lt;/h2&gt;

&lt;p&gt;While I was collecting the numbers above, the box reported a pending reboot. It's running kernel 6.12.101. Three kernel updates have landed since it booted, and the newest, 6.12.111, is installed and waiting. I found the same trap on a production server in September, where unattended upgrades had installed five kernels that never ran. Uptime looks great on a status page. It also means the running kernel is whatever booted last.&lt;/p&gt;

&lt;p&gt;Then there's the Tailscale key row in the table. The &lt;a href="https://tailscale.com/kb/1028/key-expiry" rel="noopener noreferrer"&gt;default node key expiry&lt;/a&gt; is 180 days, and when it passes, "connections to/from the given endpoint will stop working." On a server with SSH locked to the tailnet, that's admin access gone while the site keeps serving. The production boxes I hardened in September have expiry disabled now, and so does the dev box.&lt;/p&gt;

&lt;p&gt;None of this is hard. But it's mine to remember. With orbs and Laravel Cloud, patching and access are the provider's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each setup wins
&lt;/h2&gt;

&lt;p&gt;Taylor's setup wins on these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Isolation per task, and several agents running in parallel&lt;/li&gt;
&lt;li&gt;No drift, because nothing exists outside the repository&lt;/li&gt;
&lt;li&gt;No box to patch, reboot or keep reachable&lt;/li&gt;
&lt;li&gt;Git-based deploys, which remove the whole &lt;code&gt;rsync --delete&lt;/code&gt; class of failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The box wins on these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A flat €5 whatever the hours&lt;/li&gt;
&lt;li&gt;State that outlives a thread, like production data copies, a bot that polls all day and workers that are always up&lt;/li&gt;
&lt;li&gt;Root, any language, any tool, at no extra cost&lt;/li&gt;
&lt;li&gt;Staging on real data behind a login, without a per-environment price&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your work is one or two apps already on Laravel Cloud, Taylor's setup removes failure classes I spent seven weeks fixing by hand. If you run several small apps on your own VPSes, with SQLite and long-running processes, one persistent box is cheaper and fits that shape better. I'm keeping the box.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can you run Laravel queue workers on a remote dev box?
&lt;/h3&gt;

&lt;p&gt;Yes, with one habit. Workers keep the application state they booted with, so restart the worker's unit after any code or config change. If you'd rather not think about it, run &lt;code&gt;php artisan queue:listen&lt;/code&gt; on the dev box instead. It reloads code for every job at a performance cost that rarely matters in development.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much RAM does a dev box running Claude Code need?
&lt;/h3&gt;

&lt;p&gt;On mine, two Claude Code instances use 330 to 355 MB each, and the whole box uses 1.3 GB of 4 GB with a tmux session per project. A 2 GB swap file covers spikes from &lt;code&gt;composer install&lt;/code&gt; and &lt;code&gt;npm run build&lt;/code&gt;. 4 GB is comfortable for a few agents at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do Livewire actions fail silently behind Cloudflare Tunnel?
&lt;/h3&gt;

&lt;p&gt;Usually because Laravel generates &lt;code&gt;http://&lt;/code&gt; URLs. With &lt;code&gt;trustProxies(at: '*')&lt;/code&gt;, Laravel trusts &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, and a local proxy behind the tunnel may report &lt;code&gt;http&lt;/code&gt;. Livewire then sends its updates from an &lt;code&gt;https://&lt;/code&gt; page to an &lt;code&gt;http://&lt;/code&gt; URL, and they never reach the component. Forcing the scheme and root URL in &lt;code&gt;AppServiceProvider::boot()&lt;/code&gt; fixes it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are Amp Orbs cheaper than a VPS for Laravel development?
&lt;/h3&gt;

&lt;p&gt;Not on the price list. Amp's Individual tier is $20 a month with 45,000 orb minutes (750 hours at the 4 GB size), against €5 for a 4 GB VPS. Orbs buy isolation, parallel agents and zero maintenance. A VPS buys persistent state and a flat bill. I haven't used Orbs, so this comes from Amp's published pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real bill
&lt;/h2&gt;

&lt;p&gt;Money was never the expensive part. Attention was. Orbs take most of that off your plate for $20 a month instead of €5, and Laravel Cloud takes the rest on its own bill. Whether that's worth it depends on one question. Do your projects need state that outlives a thread? Mine do.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>laravel</category>
      <category>laravelcloud</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Stop Sharing .env Files in Slack: Laravel's Built-In env:encrypt Workflow</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 15 Oct 2026 04:15:08 +0000</pubDate>
      <link>https://dev.to/hafiz619/stop-sharing-env-files-in-slack-laravels-built-in-envencrypt-workflow-2hjn</link>
      <guid>https://dev.to/hafiz619/stop-sharing-env-files-in-slack-laravels-built-in-envencrypt-workflow-2hjn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-env-encrypt-share-secrets-team" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;A recent r/laravel thread asked &lt;a href="https://www.reddit.com/r/laravel/comments/1wvh2f4/how_do_you_share_env_secrets_with_teammates_and/" rel="noopener noreferrer"&gt;how teams share .env secrets with teammates and across environments&lt;/a&gt;. One of the most upvoted answers was just the word Slack. It was a joke, but the replies made clear it wasn't entirely one. And the comment that explained Laravel's built-in answer got a reply from someone who'd never heard of it.&lt;/p&gt;

&lt;p&gt;It exists, and it's been in the framework since Laravel 9. &lt;code&gt;php artisan env:encrypt&lt;/code&gt; turns a &lt;code&gt;.env&lt;/code&gt; file into something you can commit, and &lt;code&gt;env:decrypt&lt;/code&gt; turns it back on the server. Two recent releases made it much better for teams. Laravel 12.45 added a &lt;code&gt;--readable&lt;/code&gt; mode that keeps variable names visible, and Laravel 13.32 (15 September 2026) made updates to that file leave unchanged values alone. Together they turn the encrypted file into something you can actually review in a pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, most people shouldn't have these values at all
&lt;/h2&gt;

&lt;p&gt;Before encrypting anything, shrink the problem. The best reply in that thread made this point, and it's right. A new developer doesn't need staging or production credentials. They need &lt;code&gt;.env.example&lt;/code&gt; and values that work on their own machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
php artisan key:generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Sail, Herd or Docker, the local database, mail catcher and queue have known defaults. Stripe sandboxes give each developer their own test keys. Most third-party APIs let you create a personal sandbox key. If a value only exists in production, it should only exist where production runs.&lt;/p&gt;

&lt;p&gt;That leaves a much smaller set of secrets that really need sharing. The staging values a few people deploy with, and the production values the deploy pipeline needs. That's what &lt;code&gt;env:encrypt&lt;/code&gt; is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How env:encrypt works
&lt;/h2&gt;

&lt;p&gt;The command reads &lt;code&gt;.env&lt;/code&gt; (or &lt;code&gt;.env.{name}&lt;/code&gt; with &lt;code&gt;--env&lt;/code&gt;), encrypts it with AES-256-CBC by default, and writes the same filename with &lt;code&gt;.encrypted&lt;/code&gt; on the end. It prints the key once. Laravel doesn't store it anywhere, so it goes straight into your password manager.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://laravel.com/docs/13.x/configuration#encrypting-environment-files" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; cover the basics. Here's the version worth using in 2026, with &lt;code&gt;--readable&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;php artisan &lt;span class="nb"&gt;env&lt;/span&gt;:encrypt &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production &lt;span class="nt"&gt;--readable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   INFO  Environment successfully encrypted.

  Key .................... base64:BTdpjAo5VKt267gpICiMwq/F+/jvc4iTM0OjufNjmrc=
  Cipher ......................................................... AES-256-CBC
  Encrypted file ................................... .env.production.encrypted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--readable&lt;/code&gt;, the output is one opaque blob. With it, every variable keeps its name and only the value is encrypted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APP_NAME=eyJpdiI6Ikl1T2hnelpQMkFkWSttUGluNUZ1MFE9PSIsInZhbHVlIjoiQ3NldFRnS0Y3YXl...
APP_ENV=eyJpdiI6IkZkN3VXaGYrMnNDZVBkallEVzRweUE9PSIsInZhbHVlIjoiek1mOFhXTGM4c2gx...
APP_KEY=eyJpdiI6Ijkra0dpVlRuaWMvQ0dHdjFPbFVMaUE9PSIsInZhbHVlIjoicUxaQVpnOVVTamww...
DB_PASSWORD=eyJpdiI6IjZVeVRmbHBzMmI4V1BkVVhFMUtRUlE9PSIsInZhbHVlIjoiL2RRTlNLOEYx...
STRIPE_SECRET=eyJpdiI6IkYzN3ZJeitrUVBQNlZOMlVadmVPcVE9PSIsInZhbHVlIjoiTzJWa1I0bj...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone can see which keys exist. Nobody without the key can see a single value. Comments and blank lines from the source file are dropped, so keep your documentation in &lt;code&gt;.env.example&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the 13.32 change matters for pull requests
&lt;/h2&gt;

&lt;p&gt;Before Laravel 13.32, re-encrypting rewrote every value, because each encryption uses a fresh random IV. Change one password and the diff showed every line changed. You couldn't tell from the PR what actually happened.&lt;/p&gt;

&lt;p&gt;Now, when the encrypted file already exists, &lt;code&gt;--readable&lt;/code&gt; asks for the existing key, decrypts what's there, and keeps the ciphertext for any value that didn't change. Here I rotated the database password and added a Postmark token, then re-encrypted with the same key:&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 &lt;span class="nb"&gt;env&lt;/span&gt;:encrypt &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production &lt;span class="nt"&gt;--readable&lt;/span&gt; &lt;span class="nt"&gt;--key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"base64:BTdp..."&lt;/span&gt;
git diff &lt;span class="nt"&gt;--stat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; .env.production.encrypted | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; STRIPE_SECRET=eyJpdiI6IkYzN3ZJeitrUVBQNlZOMlVadmVPcVE9PSIsInZhbHVlIjo...
&lt;span class="gd"&gt;-DB_PASSWORD=eyJpdiI6IjZVeVRmbHBzMmI4V1BkVVhFMUtRUlE9PSIsInZhbHVlIjoiL...
&lt;/span&gt;&lt;span class="gi"&gt;+DB_PASSWORD=eyJpdiI6ImpmRHBWR2djcnlqQlhSK3VGekUyZVE9PSIsInZhbHVlIjoiU...
+POSTMARK_TOKEN=eyJpdiI6ImdVcjYxNjBybGM0NHlHVno5bWpkNlE9PSIsInZhbHVlIj...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reviewer sees exactly what changed. One password rotated, one new key added.&lt;/p&gt;

&lt;p&gt;Try it without the key and it refuses rather than silently re-encrypting everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ERROR  The existing encryption key is required to update the encrypted environment file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That also gives you access control for free. A developer adding a new variable for their feature puts it in &lt;code&gt;.env.example&lt;/code&gt;. Only someone who holds the production key can put the real value into &lt;code&gt;.env.production.encrypted&lt;/code&gt;. To re-encrypt everything on purpose (a new key, say), pass &lt;code&gt;--force&lt;/code&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  1. One key per environment
&lt;/h3&gt;

&lt;p&gt;Generate separate keys for staging and production. Someone who needs to deploy staging shouldn't be able to read production, and with one shared key they could. Each key goes into your password manager, and into the deploy platform for that environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Editing a file
&lt;/h3&gt;

&lt;p&gt;The encrypted file is the source of truth. The plaintext only exists while you're editing:&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 &lt;span class="nb"&gt;env&lt;/span&gt;:decrypt &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;staging &lt;span class="nt"&gt;--key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"base64:..."&lt;/span&gt;
&lt;span class="c"&gt;# edit .env.staging&lt;/span&gt;
php artisan &lt;span class="nb"&gt;env&lt;/span&gt;:encrypt &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;staging &lt;span class="nt"&gt;--readable&lt;/span&gt; &lt;span class="nt"&gt;--key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"base64:..."&lt;/span&gt; &lt;span class="nt"&gt;--prune&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--prune&lt;/code&gt; deletes the plaintext &lt;code&gt;.env.staging&lt;/code&gt; after encrypting. Use it every time, because of the first gotcha below.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Decrypting at deploy
&lt;/h3&gt;

&lt;p&gt;On the server (or in the pipeline step that runs there), the key comes from an environment variable called &lt;code&gt;LARAVEL_ENV_ENCRYPTION_KEY&lt;/code&gt;. &lt;code&gt;env:decrypt&lt;/code&gt; reads it automatically, so the key never appears in a command or a log line.&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 &lt;span class="nb"&gt;env&lt;/span&gt;:decrypt &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production &lt;span class="nt"&gt;--filename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan config:cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In GitHub Actions, the key is a repository secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Decrypt production environment&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php artisan env:decrypt --env=production --filename=.env --force&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;LARAVEL_ENV_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.LARAVEL_ENV_ENCRYPTION_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Forge, Laravel Cloud or a plain VPS with a deploy script, set the variable in whatever environment that script runs in. If you're comparing those platforms anyway, I wrote up &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;Cloud vs Forge vs a VPS&lt;/a&gt; earlier this year. And if your deploy runs through a task runner like Envoy or Scotty (&lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;I compared both here&lt;/a&gt;), the decrypt is just one more line in the task.&lt;/p&gt;

&lt;p&gt;Here's the whole flow in one picture:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-env-encrypt-share-secrets-team" 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;
  
  
  Three gotchas I hit while testing this
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The default .gitignore doesn't cover .env.staging
&lt;/h3&gt;

&lt;p&gt;A fresh Laravel 13 app ignores &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.backup&lt;/code&gt; and &lt;code&gt;.env.production&lt;/code&gt;. That's it. Create &lt;code&gt;.env.staging&lt;/code&gt; to encrypt it and &lt;code&gt;git status&lt;/code&gt; shows this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;?? .env.staging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Untracked, not ignored. One &lt;code&gt;git add -A&lt;/code&gt; and your staging secrets are in the repo history in plain text, right next to the encrypted copy. Either use &lt;code&gt;--prune&lt;/code&gt; every time, or add a line to &lt;code&gt;.gitignore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env.*
!.env.example
!.env.*.encrypted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secrets in git history are the same class of problem as &lt;a href="https://hafiz.dev/blog/laravel-query-bindings-in-logs-db-mask-bindings" rel="noopener noreferrer"&gt;secrets in your logs&lt;/a&gt;. Once they're written somewhere durable, deleting the file doesn't undo it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Without --filename, the app silently ignores what you decrypted
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;env:decrypt --env=production&lt;/code&gt; writes &lt;code&gt;.env.production&lt;/code&gt;, not &lt;code&gt;.env&lt;/code&gt;. Laravel only loads &lt;code&gt;.env.production&lt;/code&gt; if &lt;code&gt;APP_ENV=production&lt;/code&gt; is already set outside the file, as a server or process variable. If it isn't, Laravel looks for &lt;code&gt;.env&lt;/code&gt;, doesn't find it, and runs on config defaults.&lt;/p&gt;

&lt;p&gt;I tested both. Without &lt;code&gt;APP_ENV&lt;/code&gt; set, &lt;code&gt;config('app.name')&lt;/code&gt; returned &lt;code&gt;'Laravel'&lt;/code&gt;. With &lt;code&gt;APP_ENV=production&lt;/code&gt; exported, it returned &lt;code&gt;Acme&lt;/code&gt;. No error either way. That's the dangerous part. So always pass &lt;code&gt;--filename=.env&lt;/code&gt; in the deploy step, unless you deliberately set &lt;code&gt;APP_ENV&lt;/code&gt; at the server level.&lt;/p&gt;

&lt;h3&gt;
  
  
  config:cache hides anything you read with env() outside config files
&lt;/h3&gt;

&lt;p&gt;After decrypting and running &lt;code&gt;config:cache&lt;/code&gt;, I checked a value straight from the environment:&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;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'STRIPE_SECRET'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decrypted file had it. But once config is cached, Laravel stops loading &lt;code&gt;.env&lt;/code&gt;, and &lt;code&gt;env()&lt;/code&gt; only sees real system variables. The &lt;a href="https://laravel.com/docs/13.x/configuration#configuration-caching" rel="noopener noreferrer"&gt;docs say this plainly&lt;/a&gt;, yet right after switching to encrypted files it's easy to assume the decrypt step broke. It didn't. Read secrets through a config file:&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/services.php&lt;/span&gt;
&lt;span class="s1"&gt;'stripe'&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;'secret'&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;'STRIPE_SECRET'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;

&lt;span class="c1"&gt;// anywhere else&lt;/span&gt;
&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.stripe.secret'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to rotate when someone leaves
&lt;/h2&gt;

&lt;p&gt;Rotating the encryption key alone does almost nothing. Every old version of &lt;code&gt;.env.production.encrypted&lt;/code&gt; is still in git history, and the person who left still has the old key. They can check out last month's commit and decrypt it.&lt;/p&gt;

&lt;p&gt;So the order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rotate the actual secrets they could read.&lt;/strong&gt; Database passwords, payment provider keys, mail tokens, cloud credentials. This is the step that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate a new encryption key&lt;/strong&gt; and re-encrypt with &lt;code&gt;--readable --force --key=&amp;lt;new key&amp;gt;&lt;/code&gt;. Update &lt;code&gt;LARAVEL_ENV_ENCRYPTION_KEY&lt;/code&gt; on the platform in the same deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate &lt;code&gt;APP_KEY&lt;/code&gt; if they had production access.&lt;/strong&gt; Laravel's &lt;a href="https://laravel.com/docs/13.x/encryption#gracefully-rotating-encryption-keys" rel="noopener noreferrer"&gt;&lt;code&gt;APP_PREVIOUS_KEYS&lt;/code&gt;&lt;/a&gt; lets you do it without logging every user out. Put the old key there, and Laravel encrypts with the new key while still decrypting with the old ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is also the best argument for keeping the circle small. Every person with the production key is a full rotation when they leave. A developer who only ever had local values costs you nothing.&lt;/p&gt;

&lt;p&gt;The same logic applies to tools, not just people. If an AI agent in your terminal can run &lt;code&gt;env:decrypt&lt;/code&gt; with the key in scope, it can read production. &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;Keep production credentials out of its reach&lt;/a&gt; the same way you would with a contractor.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to outgrow it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;env:encrypt&lt;/code&gt; is a good fit for a small team with one or two environments. It costs nothing, needs no new service, and the secrets move with the code. Its weak point is the shared key. Everyone who can decrypt production holds the same key, so you can't revoke one person without rotating it for everyone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Where the values live&lt;/th&gt;
&lt;th&gt;Fits when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;env:encrypt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Encrypted in your repo, key in the deploy platform&lt;/td&gt;
&lt;td&gt;Small team, a few environments, no new service wanted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://getsops.io/docs/" rel="noopener noreferrer"&gt;SOPS&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Encrypted in your repo, keys in age, PGP or a cloud KMS&lt;/td&gt;
&lt;td&gt;You want per-person keys instead of one shared key, still in git&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.1password.dev/cli/secrets-environment-variables/" rel="noopener noreferrer"&gt;1Password CLI&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Your 1Password vault, referenced as &lt;code&gt;op://vault/item/field&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The team already lives in 1Password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.doppler.com/docs/cli" rel="noopener noreferrer"&gt;Doppler&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Doppler's hosted service, injected with &lt;code&gt;doppler run --&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Many services and environments, one place to manage them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/Infisical/infisical" rel="noopener noreferrer"&gt;Infisical&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Hosted, or self-hosted (MIT-licensed core)&lt;/td&gt;
&lt;td&gt;You want a Doppler-style service on your own infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SOPS is the closest step up, because the workflow barely changes. It supports dotenv files and is a CNCF sandbox project. The others move secrets out of the repo entirely, which pays off at scale, and before that is just one more service to run.&lt;/p&gt;

&lt;p&gt;If you're already sending &lt;code&gt;.env&lt;/code&gt; files over Slack, any of these is an improvement. &lt;code&gt;env:encrypt&lt;/code&gt; is the one that's already installed.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is it safe to commit .env.encrypted files to git?
&lt;/h3&gt;

&lt;p&gt;Yes, as long as the key isn't in the repo. The values are encrypted with AES-256-CBC and signed, so they can't be read or tampered with without the key. Decrypting with the wrong key fails with "The MAC is invalid." The real risk is the plaintext file next to it, so use &lt;code&gt;--prune&lt;/code&gt; or ignore &lt;code&gt;.env.*&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where should LARAVEL_ENV_ENCRYPTION_KEY be stored?
&lt;/h3&gt;

&lt;p&gt;In two places. A password manager, so a person can recover it, and the deploy platform's secret store, so the pipeline can use it. Never in the repo, and never in a plain &lt;code&gt;.env&lt;/code&gt; that gets committed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does env:decrypt run automatically when Laravel boots?
&lt;/h3&gt;

&lt;p&gt;No. Laravel never decrypts at runtime. You run &lt;code&gt;env:decrypt&lt;/code&gt; as a deploy step, and the app then reads the plain &lt;code&gt;.env&lt;/code&gt; it wrote, like any other deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does the --readable flag do in env:encrypt?
&lt;/h3&gt;

&lt;p&gt;It encrypts each value separately and keeps variable names in plain text. Since Laravel 13.32, updating an existing readable file with the same key also keeps the ciphertext of unchanged values, so a git diff shows only the variables that changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do developers need the production key?
&lt;/h3&gt;

&lt;p&gt;Almost never. Local development runs on &lt;code&gt;.env.example&lt;/code&gt; and local defaults. Only the people who change production configuration and the deploy pipeline need it, and keeping that list short is what makes offboarding cheap.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>security</category>
      <category>devops</category>
      <category>environmentvariables</category>
    </item>
    <item>
      <title>Filament 5.10 Security Fixes: The Livewire Scope Gap Your Upgrade Doesn't Close</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/filament-510-security-fixes-the-livewire-scope-gap-your-upgrade-doesnt-close-36h5</link>
      <guid>https://dev.to/hafiz619/filament-510-security-fixes-the-livewire-scope-gap-your-upgrade-doesnt-close-36h5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/filament-5-10-security-fixes-livewire-scope-gap" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;On 8 October, Filament published four security advisories. The fixes shipped two days earlier, in Filament 5.10.0 and 4.15.0. All four are rated medium, and for most apps the answer is simple. Upgrade.&lt;/p&gt;

&lt;p&gt;But one of them is different. The advisory about records staying reachable after they leave a user's tenant has its root cause in Livewire, not Filament. Filament 5.10 closes it for resource pages. It says plainly that relation managers, widgets and your own components are not covered. And when I ran &lt;code&gt;composer audit&lt;/code&gt; on an affected install, it said everything was fine.&lt;/p&gt;

&lt;p&gt;I installed Filament 5.9.0 on a clean Laravel 13.35 project, reproduced the scope problem with a plain Livewire component, and tested two fixes against it. All output below is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four advisories at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Advisory&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Affected&lt;/th&gt;
&lt;th&gt;Fixed in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/filamentphp/filament/security/advisories/GHSA-6p72-cx74-63f5" rel="noopener noreferrer"&gt;GHSA-6p72-cx74-63f5&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Records stay reachable after a scope or tenant change&lt;/td&gt;
&lt;td&gt;4.x, 5.x&lt;/td&gt;
&lt;td&gt;4.15.0, 5.10.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/filamentphp/filament/security/advisories/GHSA-jpwg-8rw9-v3jj" rel="noopener noreferrer"&gt;GHSA-jpwg-8rw9-v3jj&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Email MFA bypass in multi-panel apps with colliding user IDs&lt;/td&gt;
&lt;td&gt;4.x, 5.x&lt;/td&gt;
&lt;td&gt;4.15.0, 5.10.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/filamentphp/filament/security/advisories/GHSA-q2ph-j76w-c7qx" rel="noopener noreferrer"&gt;GHSA-q2ph-j76w-c7qx&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FileUpload&lt;/code&gt; kept the client's file extension, so an image could be stored as &lt;code&gt;.html&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3.x, 4.x, 5.x&lt;/td&gt;
&lt;td&gt;3.3.56, 4.15.0, 5.10.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/filamentphp/filament/security/advisories/GHSA-9g92-9qj5-cqjc" rel="noopener noreferrer"&gt;GHSA-9g92-9qj5-cqjc&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Pasted RichEditor custom blocks could run JavaScript&lt;/td&gt;
&lt;td&gt;4.x, 5.x&lt;/td&gt;
&lt;td&gt;4.15.0, 5.10.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The current patch releases are 5.10.1 and 4.15.1, both from 7 October. If you're still on v3, only the &lt;code&gt;FileUpload&lt;/code&gt; issue applies, and 3.3.56 fixes it.&lt;/p&gt;

&lt;p&gt;This is the second round of Filament security fixes in two months. I covered the &lt;a href="https://hafiz.dev/blog/filament-v4-12-v5-7-performance-security-breakdown" rel="noopener noreferrer"&gt;August round of MFA fixes&lt;/a&gt; when 4.12 and 5.7 shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why composer audit may say you're fine
&lt;/h2&gt;

&lt;p&gt;Here's a Filament 5.9.0 install, which all four advisories affect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer show filament/filament | &lt;span class="nb"&gt;grep &lt;/span&gt;versions
composer audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;versions : * v5.9.0
No security vulnerability advisories found.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;composer audit&lt;/code&gt; reads Packagist's advisory database. When I checked it on 10 October, the newest Filament entry was from 5 October. The four advisories from 8 October weren't there yet. The previous Filament advisory took 12 days to show up after GitHub published it.&lt;/p&gt;

&lt;p&gt;So check the version yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer show filament/filament | &lt;span class="nb"&gt;grep &lt;/span&gt;versions
composer update &lt;span class="s2"&gt;"filament/*"&lt;/span&gt; &lt;span class="nt"&gt;-W&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why a record outlives its tenant
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/filamentphp/filament/security/advisories/GHSA-6p72-cx74-63f5" rel="noopener noreferrer"&gt;advisory&lt;/a&gt; describes the symptom. A user opens a record. The record then moves to another tenant, or out of a scope the user is limited to. The page is still open, and the next Livewire request still loads the record. Depending on the page's actions, the user can still view or change it.&lt;/p&gt;

&lt;p&gt;It needs an open page and existing access, so this isn't an attacker reaching into arbitrary records. But it is exactly the case multi-tenant apps worry about. Someone moves a client to another team, and the person who just lost access can keep editing from a tab they never closed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The root cause is in Livewire
&lt;/h3&gt;

&lt;p&gt;A Livewire component can't send an Eloquent model to the browser. When you declare &lt;code&gt;public Project $project&lt;/code&gt;, Livewire stores the model's class and key in the page snapshot. On every later request, it loads the model again. This is the line that does it, from Livewire 4.4.7's &lt;code&gt;ModelSynth&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="nv"&gt;$model&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="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;newQueryForRestoration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;useWritePdo&lt;/span&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is &lt;code&gt;newQueryForRestoration()&lt;/code&gt; in Laravel 13.35:&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;newQueryForRestoration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;newQueryWithoutScopes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ids&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;newQueryWithoutScopes()&lt;/code&gt;. Your tenant scope runs on the first request, when the page mounts. It doesn't run when the model is restored on every request after that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing it without Filament
&lt;/h3&gt;

&lt;p&gt;To reproduce it, I added a &lt;code&gt;Project&lt;/code&gt; model with a team scope:&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;booted&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addGlobalScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team'&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;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;current_team_id&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 a plain Livewire component with a public model property:&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;ShowProject&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Project&lt;/span&gt; &lt;span class="nv"&gt;$project&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;rename&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;$name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="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 test mounts the component as a user on team 1. It then moves the project to team 2 and calls &lt;code&gt;rename()&lt;/code&gt; from the still-open component:&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;$component&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ShowProject&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;'project'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&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;'Roadmap'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projects'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$project&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="nv"&gt;$component&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rename'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Renamed after the move'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projects'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&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="s1"&gt;'name'&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 plaintext"&gt;&lt;code&gt;null
{#3176
 +"team_id": 2
 +"name": "Renamed after the move"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scoped query says the project doesn't exist for this user. The component renamed it anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Filament 5.10 fixes, and what it doesn't
&lt;/h3&gt;

&lt;p&gt;The fix (&lt;a href="https://github.com/filamentphp/filament/pull/20616" rel="noopener noreferrer"&gt;PR #20616&lt;/a&gt;) adds a Livewire component hook. On every request it loads the record again through the resource's own query, scopes included, and returns a 404 if the record is no longer there. This is the hook's &lt;code&gt;skip()&lt;/code&gt; method in 5.10.1:&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;skip&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="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;component&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ResourcePage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;component&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;EditTenantProfile&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;Resource pages and the tenant profile page. Nothing else. The advisory says the same: the fix "does not automatically revalidate models passed into separate components, such as relation managers or widgets. Applications must enforce any required scope restrictions when those components access the models."&lt;/p&gt;

&lt;p&gt;Here's where that leaves each kind of component after the upgrade:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/filament-5-10-security-fixes-livewire-scope-gap" 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;So after you upgrade, three places still need your attention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Relation managers need an owner-record check
&lt;/h3&gt;

&lt;p&gt;A relation manager holds its parent in &lt;code&gt;public Model $ownerRecord&lt;/code&gt;, so the same restoration happens. Filament 5.10 does add one useful thing here (&lt;a href="https://github.com/filamentphp/filament/pull/20587" rel="noopener noreferrer"&gt;PR #20587&lt;/a&gt;). &lt;code&gt;canViewForRecord()&lt;/code&gt; now runs before the relation manager mounts, and again before every later Livewire update. If it returns false, the request gets a 403.&lt;/p&gt;

&lt;p&gt;The default implementation only checks &lt;code&gt;viewAny&lt;/code&gt; on the related model class. It never asks whether the owner record still belongs to the current tenant. So add that 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Facades\Filament&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\Database\Eloquent\Model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;canViewForRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Model&lt;/span&gt; &lt;span class="nv"&gt;$ownerRecord&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;$pageClass&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;$ownerRecord&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="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;canViewForRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ownerRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pageClass&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;Adjust the comparison to however your tenant relationship is named. The point is to check the record you were given, not to trust a query that already ran without your scope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Widgets have no record in their static check
&lt;/h3&gt;

&lt;p&gt;A widget's &lt;code&gt;canView()&lt;/code&gt; is static and receives no record, so it can't do this check. If a widget declares &lt;code&gt;public ?Model $record&lt;/code&gt;, verify it in &lt;code&gt;hydrate()&lt;/code&gt;. The next section shows that pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two fixes for your own Livewire components
&lt;/h3&gt;

&lt;p&gt;I ran both against the same scenario. Each one stopped the rename, and the name stayed "Roadmap".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: keep the key, resolve through the scope on every request.&lt;/strong&gt; The model never becomes a public property, so Livewire never restores it without scopes:&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;#[Locked]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$projectId&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;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Project&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;projectId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;#[Computed]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Project&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;Project&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;projectId&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;After the move, the next request returned &lt;strong&gt;404&lt;/strong&gt;. &lt;code&gt;findOrFail()&lt;/code&gt; runs through the global scope, so the project simply isn't found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: keep the model property, check ownership in &lt;code&gt;hydrate()&lt;/code&gt;.&lt;/strong&gt; Livewire calls &lt;code&gt;hydrate()&lt;/code&gt; on every request after the first, before any action runs:&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="kt"&gt;Project&lt;/span&gt; &lt;span class="nv"&gt;$project&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;hydrate&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="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;project&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="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;current_team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&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;After the move, the next request returned &lt;strong&gt;403&lt;/strong&gt;. The restored model carries the fresh &lt;code&gt;team_id&lt;/code&gt; from the database, so the check sees the move straight away.&lt;/p&gt;

&lt;p&gt;I prefer option 1 for new code. It can't be forgotten in one action, because the scope is the only way to get the model. Option 2 is the smaller change for an existing component.&lt;/p&gt;

&lt;p&gt;The same lesson applies to any &lt;a href="https://hafiz.dev/blog/livewire-public-properties-security-audit" rel="noopener noreferrer"&gt;Livewire public property&lt;/a&gt;. What the browser holds between requests is a reference, and the server rebuilds it on its own terms. Authorization belongs in &lt;a href="https://hafiz.dev/blog/laravel-policies-vs-gates-authorization-guide" rel="noopener noreferrer"&gt;a policy or an explicit check&lt;/a&gt; that runs on every request, not in a scope that only ran once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multi-panel MFA bypass
&lt;/h2&gt;

&lt;p&gt;This one only affects apps with more than one panel, where the panels use different auth guards and different user models, and email-based MFA is on.&lt;/p&gt;

&lt;p&gt;Filament stored the email code and its send rate limit under keys built only from the user's primary key. Two panels share one browser session. So an admin with ID 1 and a customer with ID 1 shared the same MFA state. The &lt;a href="https://github.com/filamentphp/filament/security/advisories/GHSA-jpwg-8rw9-v3jj" rel="noopener noreferrer"&gt;advisory&lt;/a&gt; explains how an attacker who knows the victim's password and controls a colliding account could use that shared state to pass the victim's email challenge. App-based MFA and single-panel apps aren't affected.&lt;/p&gt;

&lt;p&gt;The fix scopes all of this state by guard, user model class and identifier. Filament 5.10 also exposes the helper it uses, which is handy if you've built a custom MFA 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;Filament\Facades\Filament&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getUserScopedAuthIdentifier&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;The same mistake is easy to make in your own code. If your app has two guards (say &lt;code&gt;web&lt;/code&gt; for customers and &lt;code&gt;admin&lt;/code&gt; for staff), any key built from &lt;code&gt;$user-&amp;gt;id&lt;/code&gt; alone can collide. That covers session keys, cache keys and rate limiter keys. Include the guard and the model 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;Illuminate\Support\Facades\Auth&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="nv"&gt;$guard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getDefaultDriver&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="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'send-code:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$guard&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAuthIdentifier&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;callback&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCode&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One related detail from the &lt;a href="https://filamentphp.com/docs/5.x/users/multi-factor-authentication" rel="noopener noreferrer"&gt;MFA docs&lt;/a&gt;. Filament keeps challenge and replay-protection state in your default cache store, and uses an atomic lock when the store supports one. Without lock support, concurrent requests may accept the same code twice. So if you run MFA on more than one server, every server needs the same cache backend, and it should support locks (database and Redis both do).&lt;/p&gt;

&lt;p&gt;If you're building &lt;a href="https://hafiz.dev/blog/filament-v5-multi-tenancy-complete-implementation-guide" rel="noopener noreferrer"&gt;a multi-tenant Filament panel&lt;/a&gt; with a separate customer panel, this is the combination the advisory describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two XSS fixes
&lt;/h2&gt;

&lt;p&gt;Both are closed by upgrading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FileUpload&lt;/code&gt; kept the client's extension.&lt;/strong&gt; Filament generated a random file name, but took the extension from the name the browser sent. Validation checks the file's detected MIME type. So a file that passed as an image could be stored as &lt;code&gt;.html&lt;/code&gt;. If uploads go to a public disk served from your app's own domain, opening that URL could run JavaScript. The fix switches to Laravel's &lt;code&gt;hashName()&lt;/code&gt;, which takes the extension from the detected MIME type instead. This affects v3 as well, which makes it the one fix to backport.&lt;/p&gt;

&lt;p&gt;Two things to know. &lt;code&gt;preserveFilenames()&lt;/code&gt; still uses the client's file name on purpose, so only use it for trusted uploaders. And files uploaded before you upgrade keep their names. It's worth a quick look for active extensions on your public disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find storage/app/public &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.html'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.htm'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.svg'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.xml'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;RichEditor trusted pasted block previews.&lt;/strong&gt; Custom blocks carry a preview in a &lt;code&gt;data-preview&lt;/code&gt; attribute. When someone pasted HTML containing a custom block, the editor rendered that preview without sanitizing it. Copying from a malicious page into the editor was enough. The fix regenerates previews on the server instead of trusting the pasted ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upgrade checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Check the installed version with &lt;code&gt;composer show filament/filament&lt;/code&gt;. Don't rely on &lt;code&gt;composer audit&lt;/code&gt; until these advisories reach Packagist.&lt;/li&gt;
&lt;li&gt;Upgrade to 5.10.1 or 4.15.1. For v3, upgrade to at least 3.3.56.&lt;/li&gt;
&lt;li&gt;Search your relation managers for &lt;code&gt;canViewForRecord()&lt;/code&gt;. If records are tenant-scoped, add an ownership check on &lt;code&gt;$ownerRecord&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Search for &lt;code&gt;public ?Model $record&lt;/code&gt; and other public model properties in widgets and custom Livewire components. Either resolve them through a scoped query, or check ownership in &lt;code&gt;hydrate()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you run several panels with email MFA, confirm your cache store supports atomic locks. Then search your own code for session, cache and rate limiter keys built from a user ID alone.&lt;/li&gt;
&lt;li&gt;Look for &lt;code&gt;.html&lt;/code&gt;, &lt;code&gt;.svg&lt;/code&gt; and similar files on the public disk that were uploaded through &lt;code&gt;FileUpload&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your panel picked up &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;custom widgets&lt;/a&gt; and pages over time, step 4 is the one to schedule properly.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Which Filament versions are affected by the October 2026 advisories?
&lt;/h3&gt;

&lt;p&gt;All four advisories affect Filament 4.x before 4.15.0 and 5.x before 5.10.0. The &lt;code&gt;FileUpload&lt;/code&gt; extension issue also affects 3.x before 3.3.56. The current patch releases are 5.10.1 and 4.15.1.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't composer audit report the new Filament advisories?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;composer audit&lt;/code&gt; uses Packagist's advisory database, which receives GitHub advisories with a delay. On 10 October, the four advisories published on 8 October weren't in it yet. Check your installed version directly until they appear.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does upgrading to Filament 5.10 fix the tenant scope issue completely?
&lt;/h3&gt;

&lt;p&gt;For resource pages and the tenant profile page, yes. For relation managers, widgets and custom Livewire components, no. The advisory says applications must enforce scope restrictions there themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Livewire components apply global scopes when restoring a model property?
&lt;/h3&gt;

&lt;p&gt;No. Livewire restores public model properties with &lt;code&gt;newQueryForRestoration()&lt;/code&gt;, which uses &lt;code&gt;newQueryWithoutScopes()&lt;/code&gt;. Global scopes run when the page mounts, but not on later requests. Resolve the model through a scoped query, or check ownership in &lt;code&gt;hydrate()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who is affected by the Filament multi-panel MFA bypass?
&lt;/h3&gt;

&lt;p&gt;Apps with two or more panels that use different auth guards and user models, share a browser session, and have email-based MFA enabled. App-based MFA and single-panel apps aren't affected.&lt;/p&gt;

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

&lt;p&gt;Steps 1 and 2 take five minutes. Steps 3 and 4 are where the time goes, and they're also the ones no update will do for you.&lt;/p&gt;

</description>
      <category>filament</category>
      <category>laravel</category>
      <category>livewire</category>
      <category>security</category>
    </item>
    <item>
      <title>Laravel AI SDK Skills: Load Agent Instructions Only When They're Needed</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 08 Oct 2026 04:15:08 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-ai-sdk-skills-load-agent-instructions-only-when-theyre-needed-20o3</link>
      <guid>https://dev.to/hafiz619/laravel-ai-sdk-skills-load-agent-instructions-only-when-theyre-needed-20o3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-agent-skills" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most support agents start with a short &lt;code&gt;instructions()&lt;/code&gt; method. Then the refund rules go in. Then the tone guide, then the escalation steps for enterprise customers, then the edge case that bit you last Tuesday. A few months later the agent sends pages of rules with every prompt, and most of them have nothing to do with the email it's answering.&lt;/p&gt;

&lt;p&gt;Laravel AI SDK v1.1.0 shipped on October 5 with a fix for exactly that. Agents can now have skills. A skill is a folder of instructions the model loads only when the current task calls for it. Until then, the model sees one line per skill.&lt;/p&gt;

&lt;p&gt;I built a small support agent to see how it behaves, with skills from files, from a Blade view, and from the database per team. This post covers what the model actually receives, the three ways to load skills, and a step-limit default that made my first run return an empty reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills for your app's agents, not your coding agent
&lt;/h2&gt;

&lt;p&gt;If you read my post on the &lt;a href="https://hafiz.dev/blog/laravel-skills-directory-how-to-use-ai-agent-skills-and-build-your-own" rel="noopener noreferrer"&gt;Laravel skills directory&lt;/a&gt;, that was about skills for Claude Code and Cursor, the agents that write your code. This is the other side. These skills belong to the agents running inside your application, answering your customers.&lt;/p&gt;

&lt;p&gt;The file format is the same. Both follow the open &lt;a href="https://agentskills.io" rel="noopener noreferrer"&gt;Agent Skills&lt;/a&gt; standard, a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; file and optional supporting files. The &lt;a href="https://laravel.com/docs/13.x/ai-sdk#skills" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; point out that one skill folder can serve both your app's agents and your coding agents. I haven't found a good reason to share them yet, but it works.&lt;/p&gt;

&lt;p&gt;If you haven't used the SDK before, start with &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;what the Laravel AI SDK changes&lt;/a&gt;. The rest of this post assumes you've built at least one agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model actually receives
&lt;/h2&gt;

&lt;p&gt;I read the source before writing any code.&lt;/p&gt;

&lt;p&gt;When your agent implements &lt;code&gt;HasSkills&lt;/code&gt;, the SDK adds one tool to it, &lt;code&gt;LoadSkill&lt;/code&gt;. Your skill instructions don't go into the system prompt. Each skill's name and description go into that tool's description instead. Here's the exact text my demo agent sends with every prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Load a skill's instructions before performing a task matching the skill's description. Pass a path to read one of the skill's bundled files instead.

Available skills:
- enterprise-outage: Use when a customer on the Enterprise plan reports that invoices are not sending.
- house-style: Use when writing any reply a customer will read.
- refund-policy: Use when a customer asks for a refund, disputes a charge, or wants to cancel a paid plan.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool's &lt;code&gt;name&lt;/code&gt; parameter is a JSON schema enum of those three names, so the model can't ask for a skill that doesn't exist. It also has an optional &lt;code&gt;path&lt;/code&gt; parameter for reading bundled files.&lt;/p&gt;

&lt;p&gt;When the model calls &lt;code&gt;LoadSkill&lt;/code&gt; with a name, it gets the body of &lt;code&gt;SKILL.md&lt;/code&gt; wrapped in a &lt;code&gt;&amp;lt;skill_content&amp;gt;&lt;/code&gt; tag, followed by a list of the files bundled with that skill. If it needs one of those files, it calls the tool again with the &lt;code&gt;path&lt;/code&gt;. That's three tiers, and you only pay for the first one on every prompt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-agent-skills" 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;So the description is doing all the routing work. A vague description like "Refund stuff" means the model either loads the skill for everything or never loads it. Write each one as a trigger, starting with "Use when".&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to give an agent skills
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;skills()&lt;/code&gt; method returns an iterable of sources. Each source is a directory path, a &lt;code&gt;Skill&lt;/code&gt; object, or a closure that returns skills. You can mix all three, and my demo does.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. SKILL.md folders
&lt;/h3&gt;

&lt;p&gt;This is the default and the one you'll use most. Each skill is a folder under &lt;code&gt;resources/skills&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resources/skills/
└── refund-policy/
    ├── SKILL.md
    └── references/
        └── EDGE-CASES.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;SKILL.md&lt;/code&gt; file starts with YAML frontmatter, then the instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;refund-policy&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Use when a customer asks for a refund, disputes a charge, or wants to cancel a paid plan.&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Refund policy&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Monthly plans: refund the current month in full if the request comes within 7 days of the charge. After 7 days, cancel at period end and refund nothing.
&lt;span class="p"&gt;2.&lt;/span&gt; Annual plans: refund the unused full months, minus a 10% processing fee.
&lt;span class="p"&gt;3.&lt;/span&gt; Never promise a refund for a charge older than 60 days. Escalate it to billing@example.com instead.
&lt;span class="p"&gt;4.&lt;/span&gt; If the customer mentions a chargeback or their bank, read &lt;span class="sb"&gt;`references/EDGE-CASES.md`&lt;/span&gt; before replying.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule 4 is the interesting one. The edge cases live in a separate file that the model reads only when a chargeback comes up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Refund edge cases&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Open chargeback: do not issue a refund. A refund plus a won chargeback pays the customer twice. Tell them we will match the bank's decision.
&lt;span class="p"&gt;-&lt;/span&gt; Duplicate charge on the same day: refund the duplicate immediately, no 7-day check.
&lt;span class="p"&gt;-&lt;/span&gt; Plan downgraded mid-cycle: no partial refund, the credit applies to the next invoice.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you leave out &lt;code&gt;name&lt;/code&gt;, the folder name is used. If you leave out &lt;code&gt;description&lt;/code&gt;, the skill is skipped without any warning. I'd add a test that counts your skills, because a typo in the frontmatter makes a skill vanish quietly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Inline skills from a Blade view
&lt;/h3&gt;

&lt;p&gt;Some instructions depend on runtime data. The tone guide in my demo needs the customer-facing brand name, which differs per team. A &lt;code&gt;Skill&lt;/code&gt; object takes a name, a description and the instructions, and the instructions can be any &lt;code&gt;Stringable&lt;/code&gt;, including a view:&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;new&lt;/span&gt; &lt;span class="nc"&gt;Skill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'house-style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Use when writing any reply a customer will read.'&lt;/span&gt;&lt;span class="p"&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;'skills.house-style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'brand'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write as the {{ $brand }} support team.

- Use the customer's first name once, in the greeting.
- Short paragraphs, no more than three sentences each.
- Never write "unfortunately". State what we can do instead.
- Sign off as "The {{ $brand }} team".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Skill&lt;/code&gt; also accepts a &lt;code&gt;files&lt;/code&gt; array (filename to contents) if you want bundled files without a folder on disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Per-team skills from the database
&lt;/h3&gt;

&lt;p&gt;This is the feature I'd build a product around. Let each team write its own skills in your admin panel, store them in a table, and hand them to the agent at run time. No deploy needed when a customer changes their escalation process.&lt;/p&gt;

&lt;p&gt;The table is plain:&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;'team_skills'&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;foreignId&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&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;'name'&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;'description'&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;'instructions'&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;The model converts itself:&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\Skills\Skill&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;TeamSkill&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toSkill&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Skill&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Skill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&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="nv"&gt;$this&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="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;instructions&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;And the agent returns a closure:&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;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;skills&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toSkill&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 full agent with all three sources:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Agents&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\Team&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\Attributes\MaxSteps&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\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\HasSkills&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Skills\Skill&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[MaxSteps(6)]&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;HasSkills&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Team&lt;/span&gt; &lt;span class="nv"&gt;$team&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;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 answer support emails for '&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'. Load a matching skill before you reply.'&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;skills&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="nf"&gt;resource_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'skills'&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;Skill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'house-style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'Use when writing any reply a customer will read.'&lt;/span&gt;&lt;span class="p"&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;'skills.house-style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'brand'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;

            &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;skills&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toSkill&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;I seeded one team skill, &lt;code&gt;enterprise-outage&lt;/code&gt;, for a team called Ledgerly, and created a second team with none. Ledgerly's agent lists three skills. The other team's agent lists two. One agent class serves both teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The step limit that ate my first reply
&lt;/h2&gt;

&lt;p&gt;Look at the &lt;code&gt;#[MaxSteps(6)]&lt;/code&gt; attribute above. My first run didn't have it, and the agent loaded two skills and then returned an empty string.&lt;/p&gt;

&lt;p&gt;The cause is in &lt;code&gt;TextGenerationLoop&lt;/code&gt;. Without an explicit &lt;code&gt;MaxSteps&lt;/code&gt;, the SDK sets the step budget to 1.5 times the number of tools, rounded and capped at 25. An agent with no tools of its own has exactly one tool, &lt;code&gt;LoadSkill&lt;/code&gt;. So &lt;code&gt;round(1 * 1.5)&lt;/code&gt; gives it 2 steps.&lt;/p&gt;

&lt;p&gt;Loading &lt;code&gt;refund-policy&lt;/code&gt; takes one step. Loading &lt;code&gt;house-style&lt;/code&gt; takes the second. There's no step left to write the reply. Reading a bundled file costs a step too, so a chargeback email needs at least four.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;MaxSteps&lt;/code&gt; on any agent with skills. Count one step per skill the model might load in a single prompt, one per bundled file it might read, and one for the reply. I use 6 for this agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;I wrapped the agent in an Artisan command that listens for the SDK's &lt;code&gt;InvokingTool&lt;/code&gt; and &lt;code&gt;ToolInvoked&lt;/code&gt; events and prints each call. Here's a real run against OpenAI with the refund email:&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%2Fhgn4xhrp3ts1rptvh9f2.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%2Fhgn4xhrp3ts1rptvh9f2.webp" alt="Terminal output of the support agent calling LoadSkill three times before writing the reply" width="799" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model loads &lt;code&gt;house-style&lt;/code&gt; and &lt;code&gt;refund-policy&lt;/code&gt;, sees &lt;code&gt;references/EDGE-CASES.md&lt;/code&gt; in the file list, and reads it because the email mentions a chargeback. The reply follows the edge case rule. It doesn't promise a refund while the chargeback is open, and it signs off as the Ledgerly team. The order of the first two loads changed between runs, but the third call was there every time.&lt;/p&gt;

&lt;p&gt;The enterprise outage email for Ledgerly loaded &lt;code&gt;enterprise-outage&lt;/code&gt; and &lt;code&gt;house-style&lt;/code&gt;, and the reply included the status page link and the 30-minute promise from the database skill. Nothing about refunds entered that conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things I found in the source
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The first source wins on a name clash.&lt;/strong&gt; I gave the second team a database skill called &lt;code&gt;refund-policy&lt;/code&gt; to override the file version. The agent kept the file version, with no warning. If you want teams to override defaults, put the closure first in the array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The closure runs on every prompt.&lt;/strong&gt; The docs say closures are "only invoked once the agent needs them". In practice the agent needs them on every prompt, because the tool can't list skill names without resolving them. I counted the calls. A prompt that loaded no skill still ran the closure once. Keep that query cheap, and eager load the relation if you build agents in a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bundled files have limits.&lt;/strong&gt; Files over 256 KB are refused, and so are files that aren't valid UTF-8. The &lt;code&gt;path&lt;/code&gt; is resolved with &lt;code&gt;realpath()&lt;/code&gt; and has to stay inside the skill's folder, so &lt;code&gt;../../.env&lt;/code&gt; doesn't work. Good, since the model chooses that path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can use &lt;code&gt;LoadSkill&lt;/code&gt; without &lt;code&gt;HasSkills&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;new LoadSkill&lt;/code&gt; with no arguments reads &lt;code&gt;resource_path('skills')&lt;/code&gt;. If you declare both, the SDK merges your skills into the existing tool and logs a warning. Pick one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider-hosted skills are a separate thing.&lt;/strong&gt; Anthropic's API can run its own skills in a code container. This feature doesn't touch that. The &lt;a href="https://github.com/laravel/ai/pull/1054" rel="noopener noreferrer"&gt;pull request&lt;/a&gt; notes you can still pass Anthropic's &lt;code&gt;container.skills&lt;/code&gt; option through provider options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you switch from the community packages?
&lt;/h2&gt;

&lt;p&gt;Before this release, two packages filled the gap, &lt;a href="https://packagist.org/packages/anilcancakir/laravel-ai-sdk-skills" rel="noopener noreferrer"&gt;anilcancakir/laravel-ai-sdk-skills&lt;/a&gt; and &lt;a href="https://packagist.org/packages/truthanb/laravel-ai-skills" rel="noopener noreferrer"&gt;truthanb/laravel-ai-skills&lt;/a&gt;. Both use &lt;code&gt;SKILL.md&lt;/code&gt; folders under &lt;code&gt;resources/skills&lt;/code&gt;, so your skill files should move over without changes.&lt;/p&gt;

&lt;p&gt;For new code, use the built-in version. It's maintained with the SDK, so it stays in step with changes to the tool loop.&lt;/p&gt;

&lt;p&gt;If you're on truthanb's package, switching is mostly deleting code. Its &lt;code&gt;HasSkills&lt;/code&gt; is a trait (&lt;code&gt;Truthanb\LaravelAiSkills\HasSkills&lt;/code&gt;) that you wire up by calling &lt;code&gt;skillPrompt()&lt;/code&gt; in &lt;code&gt;instructions()&lt;/code&gt; and &lt;code&gt;skillTools()&lt;/code&gt; in &lt;code&gt;tools()&lt;/code&gt;. The SDK's &lt;code&gt;HasSkills&lt;/code&gt; is an interface with the same short name, so check your imports when you swap. One behaviour changes too. The package puts skill descriptions in the system prompt, while the SDK puts them in the tool description.&lt;/p&gt;

&lt;p&gt;The anilcancakir package is a harder call. It has features the built-in version doesn't, such as a mode that injects full skill content up front, a per-agent allowlist of skills, and helpers that order your prompt for prefix caching. If you rely on any of those, stay on it for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills, instructions or sub-agents?
&lt;/h2&gt;

&lt;p&gt;Skills don't replace everything else in an agent. This is how I split things now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instructions&lt;/strong&gt; hold what applies to every single prompt. Identity, hard rules, output format. Keep this short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills&lt;/strong&gt; hold procedures that apply to some prompts. Refund rules, escalation steps, a tone guide for one channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt; do things. Look up an order, issue the refund. A skill can tell the model when to call a tool, and v1.1.0 also lets built-in tools require approval, which pairs well with the setup in my &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; post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-agents&lt;/strong&gt; handle work that needs its own model, its own tools or its own context window. If a "skill" needs five tools and a different model, it's a &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;sub-agent&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off with skills is reliability. The model decides whether to load a skill. In every run I did, about half a dozen, it loaded the right ones for my test emails. But a description that doesn't match the email's wording leaves the model guessing. If a rule must apply every time, it belongs in &lt;code&gt;instructions()&lt;/code&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Which Laravel AI SDK version added skills?
&lt;/h3&gt;

&lt;p&gt;Version 1.1.0, released October 5, 2026. Run &lt;code&gt;composer require laravel/ai:^1.1&lt;/code&gt;. The &lt;code&gt;HasSkills&lt;/code&gt; interface, the &lt;code&gt;Skill&lt;/code&gt; class and the &lt;code&gt;LoadSkill&lt;/code&gt; tool all arrived in that release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do skills work with every provider?
&lt;/h3&gt;

&lt;p&gt;Skills work with any provider that supports tool calling, because &lt;code&gt;LoadSkill&lt;/code&gt; is an ordinary tool. I tested with OpenAI. The model decides when to call the tool, so test the routing with the exact model you'll run in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do skills reduce token usage?
&lt;/h3&gt;

&lt;p&gt;They reduce what's sent on every prompt to one line per skill, plus the tool definition. A loaded skill costs the same tokens it would in the system prompt, plus one tool round trip. You save the most when you have many skills and each prompt needs one or two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can users edit skills without a deploy?
&lt;/h3&gt;

&lt;p&gt;Yes, with the closure source. Store skills in a table, return them from a closure in &lt;code&gt;skills()&lt;/code&gt;, and the next prompt picks up the change. Validate the description field in your admin panel. The description is the only thing the model sees before it decides, so a weak one means the skill never loads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I share skills between my app and Claude Code?
&lt;/h3&gt;

&lt;p&gt;The format is the same Agent Skills standard, so a folder like &lt;code&gt;.agents/skills&lt;/code&gt; can be read by both. The docs show &lt;code&gt;base_path('.agents/skills')&lt;/code&gt; as a source. Keep in mind that a coding skill like "use Pest for tests" is noise for a support agent, so share folders only where the content makes sense for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two settings to get right before production
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;MaxSteps&lt;/code&gt; yourself, because the default gives a skills-only agent 2 steps. And write each description as a trigger the model can match, because the description is the only part the model sees until it decides to look.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelaisdk</category>
      <category>aiagents</category>
      <category>php</category>
    </item>
  </channel>
</rss>
