<?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>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>
    <item>
      <title>How I Set Up Encrypted Off-Site Backups for My MacBook With restic and Backblaze B2</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 05 Oct 2026 04:15:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-i-set-up-encrypted-off-site-backups-for-my-macbook-with-restic-and-backblaze-b2-17gn</link>
      <guid>https://dev.to/hafiz619/how-i-set-up-encrypted-off-site-backups-for-my-macbook-with-restic-and-backblaze-b2-17gn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/macbook-offsite-backups-restic-backblaze-b2" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Earlier this month I finally asked myself a question I'd been avoiding for years. If this MacBook died tonight, what would I lose? The honest answer was everything that wasn't in a git remote. Documents, dotfiles, SSH keys, my notes, a decade of downloads I'd never sorted. My Mac had zero backups. Not a stale one, not an old Time Machine drive in a drawer. Zero.&lt;/p&gt;

&lt;p&gt;That's a strange position for a developer who worries about production databases for a living. I'd even written about the risk. When I built my &lt;a href="https://hafiz.dev/blog/remote-dev-workstation-vps-tmux-claude-code" rel="noopener noreferrer"&gt;remote dev workstation&lt;/a&gt;, a quick check turned up finished work that existed on exactly one machine. I fixed that for code by pushing everything. The laptop itself stayed unprotected, and it's the machine where I let Claude Code run shell commands every day (which is its own &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;risk worth planning for&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;So on 24 September I set up restic, Backblaze B2 and Healthchecks.io. The Mac now backs itself up every day, encrypted before anything leaves the machine, and I get an email if it ever stops. This is the third post in the series after the workstation and &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;How I Hardened My VPS in One Afternoon&lt;/a&gt;. Same format. The setup, why each piece, the real numbers, and the things that broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why restic and B2, and not the obvious options
&lt;/h2&gt;

&lt;p&gt;I looked at three easier paths first. Each one fails for a specific reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backblaze Personal Backup&lt;/strong&gt; is the one most people would pick, and it's a good product. It's $9 a month or $99 a year on &lt;a href="https://www.backblaze.com/cloud-backup/pricing" rel="noopener noreferrer"&gt;Backblaze's pricing page&lt;/a&gt;, with native Mac and Windows clients. That's the problem. There's no Linux client, and I have three servers that need backing up too. I wanted one system, one bucket and one set of commands for every machine I own, not a consumer app for the laptop and something else for the servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iCloud&lt;/strong&gt; syncs. It doesn't back up. If I delete a folder or a script mangles a file, iCloud copies the mistake to every device as faithfully as it copies my work. It also only covers what lives in iCloud Drive, and my code lives in &lt;code&gt;~/Sites&lt;/code&gt; and &lt;code&gt;~/side-projects&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An external drive alone&lt;/strong&gt; sits on the same desk as the laptop. A theft, a fire or a spilled coffee takes both. A local Time Machine copy is still on my list for fast restores and Migration Assistant, but as the second copy, not the only one.&lt;/p&gt;

&lt;p&gt;restic splits the job in two. It's the backup program that encrypts, deduplicates and keeps snapshots. B2 is only the storage it writes to. If Backblaze ever raises prices or disappears, I change one environment variable and point restic somewhere else. The passphrase never leaves the Mac, so Backblaze only ever holds ciphertext.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backup program&lt;/td&gt;
&lt;td&gt;restic 0.19.1 from Homebrew&lt;/td&gt;
&lt;td&gt;Client-side encryption, dedup, snapshots, one binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Backblaze B2, EU Central (Amsterdam)&lt;/td&gt;
&lt;td&gt;Close to Italy, keeps the data in the EU, cheap per GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedule&lt;/td&gt;
&lt;td&gt;launchd, daily at 12:30&lt;/td&gt;
&lt;td&gt;Runs on wake if the Mac was asleep, unlike cron&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;macOS Keychain&lt;/td&gt;
&lt;td&gt;Nothing sensitive in any file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;Healthchecks.io, free plan&lt;/td&gt;
&lt;td&gt;Emails me when a run fails or never happens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention&lt;/td&gt;
&lt;td&gt;7 daily, 4 weekly, 12 monthly&lt;/td&gt;
&lt;td&gt;About a year of history&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The layout is one bucket with one repository per machine, each under its own prefix. Every machine gets its own restic passphrase and its own B2 application key limited to that prefix. A compromised server can't read or delete another machine's backups, because its key can't see them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The B2 bucket
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Visibility&lt;/td&gt;
&lt;td&gt;Private&lt;/td&gt;
&lt;td&gt;Obviously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default encryption&lt;/td&gt;
&lt;td&gt;Enabled (SSE-B2)&lt;/td&gt;
&lt;td&gt;A second layer on top of restic's, and it's a checkbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object Lock&lt;/td&gt;
&lt;td&gt;Disabled&lt;/td&gt;
&lt;td&gt;With retention set, pruning can't free locked files, and it &lt;a href="https://www.backblaze.com/docs/cloud-storage-object-lock" rel="noopener noreferrer"&gt;can't be turned off&lt;/a&gt; once enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Keep prior versions 30 days&lt;/td&gt;
&lt;td&gt;Deleted objects stay recoverable for a month, then stop costing money&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caps&lt;/td&gt;
&lt;td&gt;Storage $0.10/day, download $1/day&lt;/td&gt;
&lt;td&gt;About 440 GB stored before uploads block&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Object Lock looks like the responsible choice, and for some setups it is. But restic's &lt;code&gt;forget --prune&lt;/code&gt; works by deleting old pack files. With a retention period set, locked files can't be deleted until it expires, so pruning can't free them. Since the bucket setting is a one-way switch, I left it off.&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%2Fazjv19h9dklh4908t2q3.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%2Fazjv19h9dklh4908t2q3.webp" alt="The private B2 bucket with SSE-B2 encryption and the 30-day lifecycle rule" width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;restic talks to B2 through its S3-compatible API. The &lt;a href="https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html" rel="noopener noreferrer"&gt;restic docs&lt;/a&gt; recommend that over the native B2 backend because of error handling issues in the B2 library. So the repository URL is an S3 one, with the regional endpoint, the bucket, and the machine prefix:&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;RESTIC_REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"s3:https://s3.eu-central-003.backblazeb2.com/your-bucket/macbook"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Secrets live in the Keychain, not in files
&lt;/h2&gt;

&lt;p&gt;The quick way is an env file next to the script with the B2 keys and the repository password in it. I didn't want a plain-text file with credentials that can delete my backups sitting in a folder that gets backed up.&lt;/p&gt;

&lt;p&gt;macOS already has an encrypted secret store with a CLI. Four entries, added once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;security add-generic-password &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; restic-b2-key-id &lt;span class="nt"&gt;-w&lt;/span&gt;
security add-generic-password &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; restic-b2-key &lt;span class="nt"&gt;-w&lt;/span&gt;
security add-generic-password &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; restic-password &lt;span class="nt"&gt;-w&lt;/span&gt;
security add-generic-password &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; restic-healthcheck-url &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;-w&lt;/code&gt; at the end and no value, it prompts. The prompt says &lt;code&gt;password data for new item:&lt;/code&gt; and it wants the secret itself, not your Mac password. I got that wrong the first time.&lt;/p&gt;

&lt;p&gt;The scripts read them back at run time. restic has a &lt;code&gt;RESTIC_PASSWORD_COMMAND&lt;/code&gt; variable for exactly this, so the passphrase is never even exported as a plain variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;secret&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; security find-generic-password &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;secret restic-b2-key-id&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;secret restic-b2-key&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_PASSWORD_COMMAND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"security find-generic-password -s restic-password -w"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Keychain holds the working copies. Recovery copies live in a Bitwarden folder, and the restic passphrase is also on paper next to my Bitwarden recovery code. &lt;strong&gt;Lose the restic passphrase and the backup can never be decrypted, by anyone.&lt;/strong&gt; Backblaze can't help you. That's the whole point of client-side encryption.&lt;/p&gt;

&lt;p&gt;One more thing to remember. The Keychain isn't in the backup. On a new Mac, those four entries go back in by hand before any restore can happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets backed up, and what doesn't
&lt;/h2&gt;

&lt;p&gt;Two plain text files drive it. &lt;code&gt;includes.txt&lt;/code&gt; lists the folders, one path per line. Trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Code
~/Sites
~/side-projects
~/second-brain
~/archives

# Personal files
~/Documents
~/Desktop
~/Downloads
~/Library/Mobile Documents

# Settings, keys and app data
~/.ssh
~/.config
~/.claude
~/.zshrc
~/Library/Preferences
~/Library/Application Support
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The real file uses absolute paths. &lt;code&gt;Library/Mobile Documents&lt;/code&gt; is where iCloud Drive keeps its local files.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;excludes.txt&lt;/code&gt; is where the savings are. Anything that can be downloaded or rebuilt again stays out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Dependencies and build output
node_modules
vendor
dist
.next
.venv

# Laravel runtime files
/Users/you/**/storage/logs
/Users/you/**/storage/framework/cache
/Users/you/**/storage/framework/views

# App caches, at any depth
Cache
Code Cache
GPUCache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain names like &lt;code&gt;node_modules&lt;/code&gt; match at any depth. The dependency and build folders alone were about 16 GB. Claude's desktop app keeps a &lt;code&gt;vm_bundles&lt;/code&gt; folder of 10 GB that it re-downloads anyway. Apps whose data lives in their own cloud (Notion, Slack, Spotify, browsers) are skipped too.&lt;/p&gt;

&lt;p&gt;The first run read 26.3 GB across 158,561 files and stored about 16 GB after compression and dedup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The daily job
&lt;/h2&gt;

&lt;p&gt;launchd runs a script every day at 12:30. I picked launchd over cron for one reason. Apple's &lt;a href="https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html" rel="noopener noreferrer"&gt;scheduling docs&lt;/a&gt; say a &lt;code&gt;StartCalendarInterval&lt;/code&gt; job that was due while the Mac slept runs when it wakes up. Cron would just skip it. A laptop is asleep a lot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;12&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Stay out of the way of normal work. --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plist lives in &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; and runs &lt;code&gt;backup.sh&lt;/code&gt;. The core of that script is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; | ping_hc /start

&lt;span class="o"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d %H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; backup"&lt;/span&gt;
  restic backup &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--files-from&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes.txt"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--exclude-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIR&lt;/span&gt;&lt;span class="s2"&gt;/excludes.txt"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--exclude-caches&lt;/span&gt;
  &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;

  &lt;span class="c"&gt;# Sundays: drop old snapshots and verify the repository structure.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%u&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 7 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== prune"&lt;/span&gt;
    restic forget &lt;span class="nt"&gt;--prune&lt;/span&gt; &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 7 &lt;span class="nt"&gt;--keep-weekly&lt;/span&gt; 4 &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 12 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; restic check
    &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== exit &lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUN_LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUN_LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Exit 0 reports success. Anything else, including 3 (some files unreadable),&lt;/span&gt;
&lt;span class="c"&gt;# reports failure so the email arrives. The last lines explain why.&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 40 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUN_LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | ping_hc &lt;span class="s2"&gt;"/&lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;ping_hc&lt;/code&gt; is a one-line curl that never fails the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ping_hc&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 3 &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PING$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A run takes under a minute, and a day with no changes takes about 15 seconds, because restic only uploads what changed. On Sundays the same run also prunes old snapshots and runs &lt;code&gt;restic check&lt;/code&gt; against the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring with Healthchecks.io
&lt;/h2&gt;

&lt;p&gt;A laptop job fails quietly. The lid stays shut for a week, a plist breaks, a macOS update locks a folder. So the job reports to &lt;a href="https://healthchecks.io/" rel="noopener noreferrer"&gt;Healthchecks.io&lt;/a&gt;, which is free for up to 20 checks.&lt;/p&gt;

&lt;p&gt;The check is called "MacBook backup", with a period of one day and a grace of one day. If no successful ping arrives for about two days, I get an email. That covers the case where the job doesn't run at all, like a broken plist or a Mac that's been off for a week.&lt;/p&gt;

&lt;p&gt;The failure path is faster. Healthchecks has an &lt;a href="https://healthchecks.io/docs/http_api/" rel="noopener noreferrer"&gt;exit status endpoint&lt;/a&gt;. Ping &lt;code&gt;/0&lt;/code&gt; and it's a success. Ping any other number and it's a failure. The script sends restic's exit code straight through, with the last 40 lines of the log as the request body. Healthchecks stores up to 100 kB of each ping's body, so the alert email tells me why it failed without opening a terminal.&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%2Faq6ck3pueelmm0v756qs.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%2Faq6ck3pueelmm0v756qs.webp" alt="A green Healthchecks.io check for the daily MacBook backup" width="800" height="97"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/start&lt;/code&gt; ping at the top also gives me run durations for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas
&lt;/h2&gt;

&lt;p&gt;All of these cost me time.&lt;/p&gt;

&lt;h3&gt;
  
  
  B2 blocks uploads at 10 GB until you add a card
&lt;/h3&gt;

&lt;p&gt;B2's first 10 GB are free. What I didn't expect is that a new account without a payment method is hard-capped there. My first backup ran happily until it crossed 10 GB, then every upload failed with &lt;code&gt;storage cap exceeded&lt;/code&gt;. That's why the initial upload took two runs.&lt;/p&gt;

&lt;p&gt;Adding a card fixes it, but it also flips every cap to "No Cap". So the account that was safely limited a minute ago now has unlimited daily spend. Go straight to &lt;strong&gt;Caps &amp;amp; Alerts&lt;/strong&gt; and set them again by hand. Backblaze &lt;a href="https://www.backblaze.com/docs/cloud-storage-create-and-manage-caps-and-alerts" rel="noopener noreferrer"&gt;emails you&lt;/a&gt; at 75% and 100% of each cap, so a runaway script or a leaked key gets noticed within a day.&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%2Fomc2h8awopy69arq5gm6.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%2Fomc2h8awopy69arq5gm6.webp" alt="B2 Caps and Alerts with storage and download caps set by hand" width="799" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Exit code 3 is a failure, on purpose
&lt;/h3&gt;

&lt;p&gt;restic's &lt;a href="https://restic.readthedocs.io/en/stable/075_scripting.html" rel="noopener noreferrer"&gt;exit codes&lt;/a&gt; say code 3 means "backup could not read some source data". The snapshot still gets created. It's just missing whatever couldn't be read.&lt;/p&gt;

&lt;p&gt;The first few runs returned 3 constantly, thanks to the next gotcha. The tempting fix is to treat 3 as success so the alerts stop. I did the opposite. Every unreadable path either got fixed or got an explicit line in &lt;code&gt;excludes.txt&lt;/code&gt;, and 3 still reports as a failure. So when a macOS update protects some new folder, I get an email instead of a silent hole in my backups that I'd discover during a restore.&lt;/p&gt;

&lt;h3&gt;
  
  
  macOS privacy folders: exclude them, don't grant Full Disk Access
&lt;/h3&gt;

&lt;p&gt;macOS refuses to let a background job read certain folders. Contacts, Messages, FaceTime, call history, browser profiles. restic logs &lt;code&gt;operation not permitted&lt;/code&gt; for each one and exits with 3.&lt;/p&gt;

&lt;p&gt;The usual advice is to give Full Disk Access to whatever runs the backup. But launchd runs &lt;code&gt;/bin/bash&lt;/code&gt; here, and granting Full Disk Access to bash means granting it to every bash script on the machine. No thanks.&lt;/p&gt;

&lt;p&gt;Everything in those folders is already synced somewhere else, mostly iCloud and browser accounts. So I excluded them by path and left the privacy controls alone. iPhone backups also go to iCloud, so &lt;code&gt;MobileSync&lt;/code&gt; is out too. And one pleasant surprise. launchd can read Desktop, Documents and Downloads without Full Disk Access. I checked that on macOS 27 before trusting it.&lt;/p&gt;

&lt;h3&gt;
  
  
  restic ls only shows the top level
&lt;/h3&gt;

&lt;p&gt;My first sanity check was &lt;code&gt;restic ls latest ~/second-brain&lt;/code&gt;. It printed a handful of entries and I briefly thought the backup was nearly empty.&lt;/p&gt;

&lt;p&gt;It wasn't. The &lt;a href="https://restic.readthedocs.io/en/stable/045_working_with_repos.html" rel="noopener noreferrer"&gt;restic docs&lt;/a&gt; say it plainly. When filtering by directory, files in subdirectories aren't listed unless you pass &lt;code&gt;--recursive&lt;/code&gt;. Add the flag and the whole tree shows up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic &lt;span class="nb"&gt;ls &lt;/span&gt;latest ~/second-brain &lt;span class="nt"&gt;--recursive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A backup you haven't restored from is a guess
&lt;/h3&gt;

&lt;p&gt;Snapshots listed, check passed, Healthchecks green. None of that proves you can get your files back. So on day one I restored a real folder and compared it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restoring
&lt;/h2&gt;

&lt;p&gt;Everything manual goes through a small wrapper, &lt;code&gt;restic.sh&lt;/code&gt;, which loads the same Keychain secrets and passes its arguments to restic:&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;R&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.config/restic/restic.sh
&lt;span class="nv"&gt;$R&lt;/span&gt; snapshots                                   &lt;span class="c"&gt;# list backups with dates&lt;/span&gt;
&lt;span class="nv"&gt;$R&lt;/span&gt; find &lt;span class="s2"&gt;"contract.pdf"&lt;/span&gt;                         &lt;span class="c"&gt;# which snapshots contain a file&lt;/span&gt;
&lt;span class="nv"&gt;$R&lt;/span&gt; &lt;span class="nb"&gt;ls &lt;/span&gt;latest ~/Sites/personal &lt;span class="nt"&gt;--recursive&lt;/span&gt;      &lt;span class="c"&gt;# browse the latest backup&lt;/span&gt;
&lt;span class="nv"&gt;$R&lt;/span&gt; restore latest &lt;span class="nt"&gt;--include&lt;/span&gt; ~/Documents/contract.pdf &lt;span class="nt"&gt;--target&lt;/span&gt; ~/Desktop/restored
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always restore into a separate folder like &lt;code&gt;~/Desktop/restored&lt;/code&gt;, check it, then move files back by hand. A restore never touches current files unless the target is &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The test restore was &lt;code&gt;second-brain&lt;/code&gt;, my notes repo. It came back byte-identical, with its git history intact.&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%2Fncgbc1l9k32ualy9hm09.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%2Fncgbc1l9k32ualy9hm09.webp" alt="Restoring one folder from the latest snapshot into ~/Desktop/restored" width="799" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The new-Mac plan is written down too. &lt;code&gt;brew install restic&lt;/code&gt;, add the four Keychain entries, copy &lt;code&gt;~/.config/restic/&lt;/code&gt; back, then restore code and files first. Bring &lt;code&gt;Library&lt;/code&gt; settings back one app at a time and only where I actually miss something, because settings from an older macOS can clash with a newer one. Apps aren't in the backup. They get reinstalled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;B2 charges &lt;a href="https://www.backblaze.com/cloud-storage/pricing" rel="noopener noreferrer"&gt;$6.95 per TB per month&lt;/a&gt;, billed on the average amount stored, and the first 10 GB are always free.&lt;/p&gt;

&lt;p&gt;The Mac's repository is about 16 GB. Take off the free 10 GB and that's 6 GB billed, which works out to roughly four cents a month before VAT. The 30-day lifecycle rule keeps pruned data around for a month, so the real number wobbles a little after prune weeks. It's still a rounding error.&lt;/p&gt;

&lt;p&gt;Healthchecks.io is free at this scale. restic is free. The only real cost was an afternoon.&lt;/p&gt;

&lt;p&gt;Compare that with $9 a month for Backblaze Personal. Personal is simpler and does its job well. But for one laptop plus three servers, one restic setup is both cheaper and the same everywhere.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is restic with Backblaze B2 cheaper than Backblaze Personal Backup?
&lt;/h3&gt;

&lt;p&gt;For a single laptop with a small backup, by a wide margin. B2 bills by storage at $6.95 per TB per month with 10 GB free, while Personal is a flat $9 a month. Personal is unlimited though, so if you have several terabytes on one Mac, run the numbers for your own size.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does restic need Full Disk Access on macOS?
&lt;/h3&gt;

&lt;p&gt;Not for the usual folders. A launchd job could read Desktop, Documents and Downloads without it on macOS 27. It can't read privacy-protected folders like Messages or Contacts. You can grant Full Disk Access to the binary launchd runs, or exclude those folders if they're synced elsewhere, which is what I did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Backblaze read my restic backups?
&lt;/h3&gt;

&lt;p&gt;No. restic encrypts everything on the Mac before upload, with a passphrase that never leaves it. Backblaze stores ciphertext. That also means nobody can recover the data if you lose the passphrase, so keep an offline copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I enable Object Lock on a restic bucket?
&lt;/h3&gt;

&lt;p&gt;Only if you've planned for it. Once a retention period is set, Object Lock stops deletions until it expires, which also stops restic's &lt;code&gt;forget --prune&lt;/code&gt; from freeing old data. And B2 doesn't let you disable Object Lock on a bucket once it's on. A no-delete application key for machines that shouldn't prune gets you most of the protection with less friction.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know the backup is still running?
&lt;/h3&gt;

&lt;p&gt;Ping a monitoring service at the end of every run with the exit code. Healthchecks.io emails you when it gets a failure or when a success doesn't arrive within the period plus grace. Without that, a backup can stop for months before anyone notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;The servers are next. Production SQLite databases and uploads on three boxes have no backup yet, which is uncomfortable to write down after &lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;migrating a live SaaS&lt;/a&gt; between them. The plan uses the same bucket with one repository and one key per server. The difference is that server keys won't have delete permission. Pruning needs delete, so it'll run from the Mac, and a compromised server can't wipe its own history. Each server will also run &lt;code&gt;sqlite3 .backup&lt;/code&gt; before restic, because copying a live database file can capture it mid-write.&lt;/p&gt;

&lt;p&gt;After that, Time Machine on a separate drive for fast local restores. And a restore test every few months, because the day-one test only proves day one.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>macos</category>
      <category>backups</category>
      <category>restic</category>
    </item>
    <item>
      <title>Squashing Laravel Migrations: 5 Ways schema:dump --prune Bites a Real Team</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 30 Sep 2026 04:15:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/squashing-laravel-migrations-5-ways-schemadump-prune-bites-a-real-team-ea5</link>
      <guid>https://dev.to/hafiz619/squashing-laravel-migrations-5-ways-schemadump-prune-bites-a-real-team-ea5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-squash-migrations-schema-dump-prune" 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 was titled "250 migrations in 3 years. What's yours at?" The standard answer is 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;php artisan schema:dump &lt;span class="nt"&gt;--prune&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It dumps your current schema into &lt;code&gt;database/schema/mysql-schema.sql&lt;/code&gt;, deletes your migration files, and from then on a fresh database loads the dump instead of replaying years of history. The docs cover it in five paragraphs. The posts about it mostly date from Laravel 8, when the command shipped, and repeat those paragraphs.&lt;/p&gt;

&lt;p&gt;What they don't show is what happens on a team. So I built a small Laravel 13.33 app on MySQL with the kind of history a real app has (a data migration, a foreign key, a column added later), squashed it, and then broke it the ways a team would. Five things went wrong. Most of them fail silently, with a green success message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What migrate actually does once a dump exists
&lt;/h2&gt;

&lt;p&gt;Every trap below makes sense once you know the rule, so here it is from &lt;code&gt;MigrateCommand&lt;/code&gt;. When you run &lt;code&gt;migrate&lt;/code&gt;, Laravel checks whether the &lt;code&gt;migrations&lt;/code&gt; table has any rows. If it does, the dump is ignored completely and only pending migration files run. If it's empty, Laravel looks for &lt;code&gt;database/schema/{connection}-schema.sql&lt;/code&gt;, loads it through your database's command-line client, then runs any migration files the dump doesn't already list as run.&lt;/p&gt;

&lt;p&gt;And if that file doesn't exist? It carries on without a word.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-squash-migrations-schema-dump-prune" 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 file is picked by connection name, not by driver. And the dump holds your schema plus the rows of the &lt;code&gt;migrations&lt;/code&gt; table, nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: --prune deletes migrations you never ran
&lt;/h2&gt;

&lt;p&gt;This is the one that loses work. The &lt;code&gt;--prune&lt;/code&gt; flag doesn't compare anything. Here's the implementation in &lt;code&gt;DumpCommand&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Filesystem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;deleteDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;database_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'migrations'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;preserve&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It deletes the whole &lt;code&gt;database/migrations&lt;/code&gt; directory. The dump, meanwhile, reflects whatever your local database looks like. So if you pulled a teammate's migration this morning and didn't run it yet, the file is gone and it's not in the dump either.&lt;/p&gt;

&lt;p&gt;I reproduced it with one pending migration:&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 migrate:status
  2026_09_26_092550_add_timezone_to_users_table .......... Pending

$ php artisan schema:dump --prune
   INFO  Database schema dumped and pruned successfully.

$ ls database/migrations
ls: database/migrations: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timezone column isn't in &lt;code&gt;mysql-schema.sql&lt;/code&gt; and the file doesn't exist anymore. Git still has it, so you can recover. But only if someone notices, and nothing tells you.&lt;/p&gt;

&lt;p&gt;The fix is procedural. Squash on a clean &lt;code&gt;main&lt;/code&gt;, run &lt;code&gt;php artisan migrate:status&lt;/code&gt; first and make sure nothing is pending, then dump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: teammates who are behind main
&lt;/h2&gt;

&lt;p&gt;Squashing rewrites history that other people are standing on. Two things break.&lt;/p&gt;

&lt;p&gt;The first is a teammate whose local database is a migration behind. Say they hadn't run &lt;code&gt;add_archived_at_to_projects_table&lt;/code&gt; when you squashed. They pull, run &lt;code&gt;migrate&lt;/code&gt;, and get this:&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 migrate
   INFO  Running migrations.
  2026_09_26_100000_ensure_default_plans_exist ........... DONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(That migration is the fix from trap 4 below.) No error. Their &lt;code&gt;migrations&lt;/code&gt; table has rows, so the dump is skipped, and the file they needed is gone. &lt;code&gt;migrate:status&lt;/code&gt; doesn't even list it anymore. The &lt;code&gt;archived_at&lt;/code&gt; column simply isn't in their database, and they find out when a query fails.&lt;/p&gt;

&lt;p&gt;The second is an open branch that edits a migration you pruned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git merge feature/archive-reason
CONFLICT (modify/delete): database/migrations/2026_01_15_110000_add_archived_at_to_projects_table.php
deleted in HEAD and modified in feature/archive-reason.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one at least fails loudly. The fix is to move the change into a new migration, never to restore the old file.&lt;/p&gt;

&lt;p&gt;The fix for both is coordination, and it's worth being strict about. Announce the squash, get every open branch merged or rebased first, and have everyone run &lt;code&gt;migrate&lt;/code&gt; on &lt;code&gt;main&lt;/code&gt; before the squash lands. On a solo project none of this matters. On a team of five it's the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: your tests suddenly have no tables
&lt;/h2&gt;

&lt;p&gt;A stock Laravel 13 app runs its tests on SQLite in memory. That's in &lt;code&gt;phpunit.xml&lt;/code&gt; out of the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;env&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"DB_CONNECTION"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"sqlite"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;env&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"DB_DATABASE"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;":memory:"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After squashing on MySQL, my test with &lt;code&gt;RefreshDatabase&lt;/code&gt; failed on its first query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQLSTATE[HY000]: General error: 1 no such table: users
(Connection: sqlite, Database: :memory:, ...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow the rule from earlier. The test connection is named &lt;code&gt;sqlite&lt;/code&gt;, so &lt;code&gt;migrate&lt;/code&gt; looks for &lt;code&gt;sqlite-schema.sql&lt;/code&gt;. It doesn't exist, so Laravel skips it without a word, and there are no migration files left to run. The database stays empty.&lt;/p&gt;

&lt;p&gt;You can't point SQLite at the MySQL file either. It's MySQL syntax, backticks and &lt;code&gt;ENGINE=InnoDB&lt;/code&gt; included.&lt;/p&gt;

&lt;p&gt;The docs do cover this, in one paragraph. If your tests use a different connection, dump a schema for that connection too. Their example uses a connection called &lt;code&gt;testing&lt;/code&gt;. With the stock setup it's &lt;code&gt;sqlite&lt;/code&gt;, and the dump has to come from a real, migrated SQLite file, which means doing it &lt;strong&gt;before&lt;/strong&gt; you prune. This is what worked for me, run from the last commit before the squash:&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;touch &lt;/span&gt;database/dump.sqlite

&lt;span class="nv"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sqlite &lt;span class="nv"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;/database/dump.sqlite &lt;span class="se"&gt;\&lt;/span&gt;
    php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;span class="nv"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sqlite &lt;span class="nv"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;/database/dump.sqlite &lt;span class="se"&gt;\&lt;/span&gt;
    php artisan schema:dump &lt;span class="nt"&gt;--database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sqlite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Override &lt;code&gt;DB_DATABASE&lt;/code&gt; inline like that. The default &lt;code&gt;sqlite&lt;/code&gt; connection reads &lt;code&gt;DB_DATABASE&lt;/code&gt; too, so with your MySQL database name in &lt;code&gt;.env&lt;/code&gt; it would otherwise point at a SQLite file called &lt;code&gt;squash_demo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;sqlite-schema.sql&lt;/code&gt; committed next to &lt;code&gt;mysql-schema.sql&lt;/code&gt;, the stock test suite passed again. For &lt;code&gt;:memory:&lt;/code&gt; databases Laravel loads the file straight through PDO, so you don't need the &lt;code&gt;sqlite3&lt;/code&gt; binary for this.&lt;/p&gt;

&lt;p&gt;The cost is that you now have two dumps to regenerate at every future squash. If that bothers you, the other fix is to run your tests on the same engine you run in production. I'd take that trade anyway. SQLite tests passing says little about MySQL behaviour, as my post on &lt;a href="https://hafiz.dev/blog/eloquent-patterns-silently-break-mysql-index" rel="noopener noreferrer"&gt;Eloquent patterns that silently break MySQL indexes&lt;/a&gt; shows. There's more on setting up the test database in the &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest 4 guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: data migrations disappear
&lt;/h2&gt;

&lt;p&gt;Plenty of apps insert reference data from a migration. Plans, roles, countries. Mine had 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="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;'plans'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'starter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2900&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 dump contains &lt;code&gt;CREATE TABLE plans&lt;/code&gt; but none of the rows. What it does contain is this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="nv"&gt;`migrations`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;`id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`migration`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`batch`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'2025_03_10_090100_seed_default_plans'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So on a fresh database, Laravel loads the empty &lt;code&gt;plans&lt;/code&gt; table, reads that the seed migration already ran, and never runs it again. My test on a fresh MySQL test database failed with &lt;code&gt;Attempt to read property "id" on null&lt;/code&gt;, because there was no starter plan.&lt;/p&gt;

&lt;p&gt;This bites everywhere a database is created fresh. That means CI, a new developer's laptop, a staging rebuild, and every new tenant database if you use &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;database-per-tenant multi-tenancy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The fix is a new migration, dated after the dump, that restores the rows and is safe to run on databases that already have them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Migration&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;up&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="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;'plans'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insertOrIgnore&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'starter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2900&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;On a fresh database it runs after the dump loads and inserts both rows. On an existing database it runs once and the unique &lt;code&gt;slug&lt;/code&gt; index makes it a no-op. I tested both.&lt;/p&gt;

&lt;p&gt;My first attempt used &lt;code&gt;upsert()&lt;/code&gt; with &lt;code&gt;update: []&lt;/code&gt;, meaning "insert, don't touch existing rows". It blew up on the existing database with a duplicate-key error. Look at &lt;code&gt;Builder::upsert()&lt;/code&gt; and you'll see why. An empty update array doesn't mean "update nothing". It falls back to a plain &lt;code&gt;insert()&lt;/code&gt;. Use &lt;code&gt;insertOrIgnore()&lt;/code&gt;, which needs a unique index on the column you match on.&lt;/p&gt;

&lt;p&gt;Longer term, reference data belongs in a seeder that tests and new environments call explicitly. But the migration above is what keeps existing deploys and fresh databases in agreement on the day you squash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 5: CI needs the database client, not just the database
&lt;/h2&gt;

&lt;p&gt;Both halves of this feature shell out. &lt;code&gt;schema:dump&lt;/code&gt; runs &lt;code&gt;mysqldump&lt;/code&gt; (or &lt;code&gt;pg_dump&lt;/code&gt;, or &lt;code&gt;sqlite3&lt;/code&gt;), and loading a MySQL dump runs the &lt;code&gt;mysql&lt;/code&gt; client with the file piped in. Your CI job can reach the database fine through PDO and still fail the moment &lt;code&gt;migrate&lt;/code&gt; hits a dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database/schema/mysql-schema.sql ...................... FAIL
The command "mysql --user=... --database=... &amp;lt; ..." failed.
Exit Code: 127(Command not found)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where this bites depends on the image. GitHub's hosted &lt;code&gt;ubuntu-24.04&lt;/code&gt; runner ships MySQL 8.0, PostgreSQL 16 and sqlite3 clients, according to its &lt;a href="https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md" rel="noopener noreferrer"&gt;image manifest&lt;/a&gt;, so the &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;GitHub Actions setup I use&lt;/a&gt; is fine as it is. The official &lt;code&gt;php:8.4-cli&lt;/code&gt; Docker image ships none of them. I checked. If your CI, or your production container that runs &lt;code&gt;migrate&lt;/code&gt; on deploy, is built from a slim PHP image, add the client package before you squash, not after the pipeline goes red.&lt;/p&gt;

&lt;p&gt;There's also a version edge. The dump is produced by whatever &lt;code&gt;mysqldump&lt;/code&gt; is on the machine that ran it. Mine was a MySQL 9.2 client. Loading it with an older client or server is usually fine for plain DDL, but I didn't test that across versions, so dump with a client that matches production if you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The squash, in the order that avoids all five
&lt;/h2&gt;

&lt;p&gt;This is the procedure I'd follow on a team:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Announce it. Get open branches that touch migrations merged first.&lt;/li&gt;
&lt;li&gt;On an up-to-date &lt;code&gt;main&lt;/code&gt;, run &lt;code&gt;php artisan migrate&lt;/code&gt; and check &lt;code&gt;migrate:status&lt;/code&gt; shows nothing pending.&lt;/li&gt;
&lt;li&gt;If tests run on another connection, generate that dump now, while the files still exist.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;php artisan schema:dump --prune&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Find every migration that inserted data and add one new &lt;code&gt;insertOrIgnore&lt;/code&gt; migration that restores it.&lt;/li&gt;
&lt;li&gt;Run the full test suite, then &lt;code&gt;migrate:fresh&lt;/code&gt; on a scratch database with the same engine as production.&lt;/li&gt;
&lt;li&gt;Make sure CI and your deploy image have the database client.&lt;/li&gt;
&lt;li&gt;Commit the dump files and the deletions in one commit. Tell everyone to pull and run &lt;code&gt;migrate&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h3&gt;
  
  
  Does schema:dump --prune delete migrations that haven't run?
&lt;/h3&gt;

&lt;p&gt;Yes. It deletes the entire &lt;code&gt;database/migrations&lt;/code&gt; directory regardless of what your database has run, and the dump only contains what your database has. Check &lt;code&gt;migrate:status&lt;/code&gt; shows nothing pending before you prune.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are my tests failing with "no such table" after squashing?
&lt;/h3&gt;

&lt;p&gt;Your tests probably use a different connection, like the stock SQLite &lt;code&gt;:memory:&lt;/code&gt; setup. Laravel looks for &lt;code&gt;database/schema/{connection}-schema.sql&lt;/code&gt;, silently skips it when it's missing, and there are no migration files left to run. Dump a schema for the test connection before pruning, or run tests on the production engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the schema dump include seed data?
&lt;/h3&gt;

&lt;p&gt;No. It includes the schema and the rows of the &lt;code&gt;migrations&lt;/code&gt; table. Data inserted by migrations is lost on fresh databases, and those migrations are recorded as already run, so they never replay. Restore the rows with a new idempotent migration or move them to a seeder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I roll back past a squashed migration?
&lt;/h3&gt;

&lt;p&gt;No. The pruned migrations and their &lt;code&gt;down()&lt;/code&gt; methods are gone, so there's nothing for &lt;code&gt;migrate:rollback&lt;/code&gt; to run. Only migrations created after the squash can be rolled back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which databases support migration squashing?
&lt;/h3&gt;

&lt;p&gt;MariaDB, MySQL, PostgreSQL and SQLite, according to the &lt;a href="https://laravel.com/docs/13.x/migrations#squashing-migrations" rel="noopener noreferrer"&gt;Laravel docs&lt;/a&gt;. Each uses its command-line client to dump and load. SQL Server isn't supported, and &lt;code&gt;migrate&lt;/code&gt; skips schema loading on it entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you squash at all?
&lt;/h2&gt;

&lt;p&gt;Squashing buys you a fresh-database path that doesn't replay years of history, and a shorter &lt;code&gt;database/migrations&lt;/code&gt; folder. It costs you history in the working tree (git keeps it), rollbacks past the squash point, and the coordination above.&lt;/p&gt;

&lt;p&gt;If you're solo or on a small team with no tenant databases and your fresh migrate takes a few seconds, I wouldn't bother. The folder being long isn't a problem that needs solving. If fresh databases get created constantly, in CI, per tenant or per preview environment, and replaying history has become slow or fragile, squash. Do it at a quiet moment, like right after the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade&lt;/a&gt;, and follow the eight steps above in order.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>migrations</category>
      <category>mysql</category>
      <category>testing</category>
    </item>
    <item>
      <title>Laravel Mercure Broadcasting: Real-Time Without a WebSocket Server (and 3 Setup Traps)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 28 Sep 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-mercure-broadcasting-real-time-without-a-websocket-server-and-3-setup-traps-5b9j</link>
      <guid>https://dev.to/hafiz619/laravel-mercure-broadcasting-real-time-without-a-websocket-server-and-3-setup-traps-5b9j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-mercure-broadcasting-frankenphp" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel 13.32 shipped on September 15 with a Mercure broadcast driver, written by Kévin Dunglas (who also wrote Mercure and FrankenPHP). The pitch is simple. Real-time broadcasting over Server-Sent Events, no WebSocket server to run, and with FrankenPHP you don't even run a separate hub. Your existing &lt;code&gt;ShouldBroadcast&lt;/code&gt; events and Echo listeners keep working.&lt;/p&gt;

&lt;p&gt;I wanted to see how much of that holds up on a fresh app today. So I built a small ops board with one public channel, one presence channel and one end-to-end encrypted private channel, served by FrankenPHP 1.12.7 with its built-in hub, on Laravel 13.33.&lt;/p&gt;

&lt;p&gt;It works. All three channel types, two users, real payloads. But getting there took three fixes that aren't in the docs yet, and one of them breaks every fresh install. This post is the setup that actually worked, the traps in the order you'll hit them, and when Mercure beats Reverb.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Mercure changes about broadcasting
&lt;/h2&gt;

&lt;p&gt;With Reverb (or Pusher, or Soketi), the browser opens a WebSocket to a long-running server, and that server holds every connection open. For Reverb that's a PHP process you run and supervise alongside your app. I compared those three in &lt;a href="https://hafiz.dev/blog/laravel-reverb-vs-pusher-vs-soketi-websocket-comparison" rel="noopener noreferrer"&gt;Reverb vs Pusher vs Soketi&lt;/a&gt; if you want the WebSocket side in depth.&lt;/p&gt;

&lt;p&gt;Mercure flips the transport. The browser opens a plain HTTP request using the native &lt;code&gt;EventSource&lt;/code&gt; API and the server streams events down it. Laravel publishes an update with one HTTP POST to the hub (or an in-process function call on FrankenPHP), and the hub fans it out. The hub is written in Go and runs inside FrankenPHP's Caddy server, so the open connections live there, not in PHP workers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-mercure-broadcasting-frankenphp" 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;Your PHP code never holds a socket.&lt;/p&gt;

&lt;p&gt;The traffic is mostly one direction, server to browser. Echo's whispers still work over Mercure, but they're published by the client straight to the hub on their own topics and never touch Laravel. There's no general client-to-server channel like a WebSocket gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install: one command, one prompt worth reading
&lt;/h2&gt;

&lt;p&gt;The installer gained a &lt;code&gt;--mercure&lt;/code&gt; flag in 13.x (&lt;a href="https://github.com/laravel/framework/pull/61587" rel="noopener noreferrer"&gt;PR #61587&lt;/a&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;install&lt;/span&gt;:broadcasting &lt;span class="nt"&gt;--mercure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asks three questions. Which hub (FrankenPHP's built-in hub or a standalone one), a JWT secret (leave it empty and it generates a 64-character one), and whether to enable end-to-end encrypted channels. Say yes to that last one if you might ever send personal data through a private channel. It just writes a 32-byte key to your &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's what it changed in my app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;// composer.json
&lt;span class="gi"&gt;+ "symfony/mercure": "^0.8",
+ "web-token/jwt-library": "^4.1"
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;// package.json
&lt;span class="gi"&gt;+ "laravel-echo": "^2.5.0",
+ "pusher-js": "^8.6.0"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;BROADCAST_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mercure&lt;/span&gt;
&lt;span class="py"&gt;MERCURE_JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;652d4c76...&lt;/span&gt;
&lt;span class="py"&gt;MERCURE_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"base64:ZIT5FxQG..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;resources/js/echo.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Echo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;laravel-echo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Echo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Echo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;broadcaster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mercure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_MERCURE_HUB_URL&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;With the built-in hub there's no &lt;code&gt;MERCURE_URL&lt;/code&gt; at all. The driver checks for FrankenPHP's &lt;code&gt;mercure_publish()&lt;/code&gt; function and publishes in-process. The Echo connector defaults the hub to &lt;code&gt;/.well-known/mercure&lt;/code&gt; on the current origin, so the empty &lt;code&gt;VITE_MERCURE_HUB_URL&lt;/code&gt; is fine.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pusher-js&lt;/code&gt; dependency isn't needed for Mercure. I removed it and everything below still worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The events and channels are ordinary Laravel
&lt;/h2&gt;

&lt;p&gt;Nothing in the app code knows about Mercure. That's the best part of the driver. A public event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Events&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\Broadcasting\Channel&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\Contracts\Broadcasting\ShouldBroadcastNow&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\Foundation\Events\Dispatchable&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;DeployStatusUpdated&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldBroadcastNow&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;Dispatchable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&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;broadcastOn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Channel&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;Channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'deploys'&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;An encrypted private one just returns an &lt;code&gt;EncryptedPrivateChannel&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Broadcasting\EncryptedPrivateChannel&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;broadcastOn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;EncryptedPrivateChannel&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;EncryptedPrivateChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'incidents'&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;Channel authorization in &lt;code&gt;routes/channels.php&lt;/code&gt; is unchanged. Presence callbacks return the member payload like always:&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;Broadcast&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'incidents'&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;$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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nc"&gt;Broadcast&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ops-room'&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;$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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the client, the same Echo calls you'd write for Reverb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Echo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ops-room&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;here&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* seed the list */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joining&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* add */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;leaving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* remove */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;Echo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deploys&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DeployStatusUpdated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;Echo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encryptedPrivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;incidents&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IncidentOpened&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice I used &lt;code&gt;ShouldBroadcastNow&lt;/code&gt;. Keep that in mind for trap three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the Echo release doesn't have the connector yet
&lt;/h2&gt;

&lt;p&gt;I built the assets, logged in, and got this in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Broadcaster string mercure is not supported.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer pins &lt;code&gt;laravel-echo&lt;/code&gt; to &lt;code&gt;^2.5.0&lt;/code&gt;, and 2.5.0 is the latest release on npm. It was published on September 8. The Mercure connector (&lt;a href="https://github.com/laravel/echo/pull/549" rel="noopener noreferrer"&gt;laravel/echo#549&lt;/a&gt;) was merged into the 2.x branch on September 10. As I write this there's no Echo release that contains it.&lt;/p&gt;

&lt;p&gt;So a fresh &lt;code&gt;install:broadcasting --mercure&lt;/code&gt; today gives you a working backend and a frontend that can't connect. Dunglas's own &lt;a href="https://github.com/dunglas/laravel-mercure" rel="noopener noreferrer"&gt;demo app&lt;/a&gt; sidesteps this by building Echo from source, and that's what I did too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-b&lt;/span&gt; 2.x https://github.com/laravel/echo.git
&lt;span class="nb"&gt;cd echo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm &lt;span class="nb"&gt;install
cd &lt;/span&gt;packages/laravel-echo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm run build

&lt;span class="c"&gt;# back in your app&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; ../echo/packages/laravel-echo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;npm view laravel-echo version&lt;/code&gt; before you do this. Once a release after 2.5.0 lands, a plain &lt;code&gt;npm update laravel-echo&lt;/code&gt; is the fix and this whole section goes away.&lt;/p&gt;

&lt;p&gt;Once it loads, one &lt;code&gt;EventSource&lt;/code&gt; carries every channel you join. Joins and leaves are batched into a single re-auth. Authorization goes through the usual &lt;code&gt;/broadcasting/auth&lt;/code&gt; route, which answers with an httpOnly cookie the hub reads, and there's no client library to install beyond Echo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the Caddyfile in most examples no longer starts
&lt;/h2&gt;

&lt;p&gt;FrankenPHP's Mercure docs show the hub configured with &lt;code&gt;publisher_jwt&lt;/code&gt; and &lt;code&gt;subscriber_jwt&lt;/code&gt;. On FrankenPHP 1.12.7 that config refuses to boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the "publisher_jwt", "subscriber_jwt", "publisher_jwks_url" and
"subscriber_jwks_url" directives work only in compatibility mode,
which relaxes access-token validation: move them into an "issuer"
block for modern mode, or set "protocol_version_compatibility 8"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundled hub now speaks the Mercure 1.0 protocol, which switched to standard OAuth 2.0 access tokens with a required issuer (the &lt;a href="https://mercure.rocks/docs/UPGRADE" rel="noopener noreferrer"&gt;Mercure 1.0 upgrade guide&lt;/a&gt; covers the full change). Laravel's driver already signs tokens for 1.0, so don't reach for compatibility mode. Use an &lt;code&gt;issuer&lt;/code&gt; block instead.&lt;/p&gt;

&lt;p&gt;The issuer has to match the &lt;code&gt;iss&lt;/code&gt; claim Laravel puts in its tokens. Unless you set &lt;code&gt;MERCURE_JWT_ISSUER&lt;/code&gt;, the driver uses your &lt;code&gt;APP_URL&lt;/code&gt;. Here's the Caddyfile, with the &lt;code&gt;mercure&lt;/code&gt; block exactly as I tested it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    frankenphp
}

your-app.com {
    root public/
    encode zstd br gzip

    mercure {
        issuer {$APP_URL} {
            publisher {
                jwt {$MERCURE_JWT_SECRET}
            }
            subscriber {
                jwt {$MERCURE_JWT_SECRET}
            }
        }
        anonymous
        subscriptions
    }

    php_server
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Locally I served it on &lt;code&gt;localhost:8443&lt;/code&gt;, because ports 80 and 443 were already taken on my machine. That needed three extra global options, &lt;code&gt;http_port 8080&lt;/code&gt;, &lt;code&gt;auto_https disable_redirects&lt;/code&gt; and &lt;code&gt;skip_install_trust&lt;/code&gt;. On a server with a real domain you don't need any of them.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;anonymous&lt;/code&gt; lets browsers subscribe to public channels without a token. &lt;code&gt;subscriptions&lt;/code&gt; turns on the hub's subscription events, and presence channels are built on those.&lt;/p&gt;

&lt;p&gt;And one thing cost me a restart. My first attempt used &lt;code&gt;{env.APP_URL}&lt;/code&gt;, which is how Caddy writes environment placeholders in a lot of places. Every subscribe then failed with a 401, and the hub log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid JWT: untrusted issuer "https://localhost:8443"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issuer and the token were identical. The problem is that &lt;code&gt;{env.APP_URL}&lt;/code&gt; is a runtime placeholder, and the issuer name never gets expanded. Running &lt;code&gt;frankenphp adapt&lt;/code&gt; showed the issuer stored as the literal string &lt;code&gt;{env.APP_URL}&lt;/code&gt;. The parse-time form &lt;code&gt;{$APP_URL}&lt;/code&gt; gets substituted when the Caddyfile loads. Export &lt;code&gt;APP_URL&lt;/code&gt; and &lt;code&gt;MERCURE_JWT_SECRET&lt;/code&gt; into the environment before starting FrankenPHP, since Caddy doesn't read your &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With both fixes in, this is two real browser sessions after one deploy event and one incident event:&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%2Ft2as0cpmc9wfqffypzjh.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%2Ft2as0cpmc9wfqffypzjh.webp" alt="Two logged-in sessions on the Mercure demo. Both see Alice and Bob in the presence list and the public deploy event. Only Alice, who is authorized for the incidents channel, sees the decrypted incident." width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Presence, public and encrypted private channels all working over one SSE connection per tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually goes over the wire
&lt;/h2&gt;

&lt;p&gt;A public event on the SSE stream looks like this (captured with &lt;code&gt;curl -N&lt;/code&gt; against the hub):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;id: urn:uuid:01a0dce5-f4aa-75db-a1dd-24a7f86f65d1
data: {"channels":["deploys"],"event":"App\\Events\\DeployStatusUpdated","payload":{"app":"billing-api","status":"deployed"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The encrypted incident, as Alice's browser received it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data: {"channels":["private-encrypted-incidents"],"data":"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..avbjUXGvrdWNg-wJ.jKSQWcU_YfBk..."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a compact JWE. The event name and the customer email are both inside the ciphertext. Per the &lt;a href="https://github.com/laravel/framework/pull/61474" rel="noopener noreferrer"&gt;driver PR&lt;/a&gt;, each channel gets its own AES-256-GCM key derived with HKDF from your &lt;code&gt;MERCURE_ENCRYPTION_KEY&lt;/code&gt;, the auth endpoint hands the key to authorized browsers, and the browser decrypts with WebCrypto. The hub only ever relays ciphertext. If you run a shared or third-party hub, that's a real guarantee.&lt;/p&gt;

&lt;p&gt;Topics are namespaced too. Every channel becomes a topic under &lt;code&gt;https://laravel.alt/echo/&lt;/code&gt;, a deliberately non-resolvable &lt;code&gt;.alt&lt;/code&gt; URL, so two apps on one hub don't collide. Change it with &lt;code&gt;topic_prefix&lt;/code&gt; if you share a hub between environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: queue workers can't use the built-in hub
&lt;/h2&gt;

&lt;p&gt;This one catches you twice.&lt;/p&gt;

&lt;p&gt;First, the moment &lt;code&gt;BROADCAST_CONNECTION=mercure&lt;/code&gt; points at the built-in hub, plain &lt;code&gt;php artisan&lt;/code&gt; commands fail to boot. Even &lt;code&gt;migrate&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;Failed to create broadcaster for connection "mercure" with error:
The Mercure broadcasting connection requires a "url" configuration
value, unless the application is served by FrankenPHP with its
built-in Mercure hub enabled.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loading &lt;code&gt;routes/channels.php&lt;/code&gt; resolves the broadcaster, and &lt;code&gt;mercure_publish()&lt;/code&gt; only exists inside the running FrankenPHP server. Not in the PHP CLI, and not in &lt;code&gt;frankenphp php-cli&lt;/code&gt; either (I checked).&lt;/p&gt;

&lt;p&gt;Second, and more important in production. A plain &lt;code&gt;ShouldBroadcast&lt;/code&gt; event is queued, and queue workers are CLI processes. So the zero-config built-in hub only covers &lt;code&gt;ShouldBroadcastNow&lt;/code&gt; events dispatched during a web request. Everything your workers broadcast would fail.&lt;/p&gt;

&lt;p&gt;The fix is to give the driver a URL. When &lt;code&gt;MERCURE_URL&lt;/code&gt; is set, it stops calling &lt;code&gt;mercure_publish()&lt;/code&gt; and publishes over HTTP with a signed JWT, which works from anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;MERCURE_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://your-app.com/.well-known/mercure&lt;/span&gt;
&lt;span class="py"&gt;MERCURE_PUBLIC_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://your-app.com/.well-known/mercure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested this from the CLI against the same FrankenPHP hub, with a &lt;code&gt;curl -N&lt;/code&gt; subscriber open. The event arrived just like the in-process one. The trade-off is that web requests now make an HTTP call too, instead of the in-process publish. For almost every app that cost is noise next to a working queue. If your broadcasts go through &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queued jobs at any volume&lt;/a&gt;, set the URL from day one.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I need FrankenPHP to use the Laravel Mercure driver?
&lt;/h3&gt;

&lt;p&gt;No. Set &lt;code&gt;MERCURE_URL&lt;/code&gt; and &lt;code&gt;MERCURE_PUBLIC_URL&lt;/code&gt; to any Mercure hub, including the standalone &lt;code&gt;dunglas/mercure&lt;/code&gt; binary or Docker image (it needs to speak the 1.0 protocol), and the driver publishes over HTTP. FrankenPHP just removes the separate hub and adds an in-process publish path for web requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel Echo support Mercure?
&lt;/h3&gt;

&lt;p&gt;The connector is merged into Echo's 2.x branch but, as of September 26, 2026, not in any npm release. The latest release, 2.5.0, rejects &lt;code&gt;broadcaster: 'mercure'&lt;/code&gt;. Build it from the 2.x branch until a newer version ships.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do presence channels work over Mercure?
&lt;/h3&gt;

&lt;p&gt;Yes. They rely on the hub's subscription events, so the hub needs the &lt;code&gt;subscriptions&lt;/code&gt; directive. Echo seeds the member list from the hub's subscription API, then updates it live as users join and leave.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use whispers and client events with Mercure?
&lt;/h3&gt;

&lt;p&gt;Yes, &lt;code&gt;client_events&lt;/code&gt; is on by default. Whispers are published by the browser directly to the hub on dedicated per-channel topics, and the grant never covers the channel's own topic, so a member can't forge a server event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do my artisan commands fail after switching to Mercure?
&lt;/h3&gt;

&lt;p&gt;With the built-in FrankenPHP hub and no &lt;code&gt;MERCURE_URL&lt;/code&gt;, the driver needs &lt;code&gt;mercure_publish()&lt;/code&gt;, which only exists inside the FrankenPHP server. Set &lt;code&gt;MERCURE_URL&lt;/code&gt; so the CLI and queue workers publish over HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mercure or Reverb?
&lt;/h2&gt;

&lt;p&gt;Both drivers sit behind the same Laravel API, so this is an infrastructure decision, not a code decision. Switching later means changing &lt;code&gt;BROADCAST_CONNECTION&lt;/code&gt; and the Echo config. (If you're still building your first real-time feature, &lt;a href="https://hafiz.dev/blog/implementing-real-time-notifications-with-laravel-a-complete-guide" rel="noopener noreferrer"&gt;my notifications walkthrough&lt;/a&gt; covers the event side, and it applies unchanged to either driver.)&lt;/p&gt;

&lt;p&gt;Pick Mercure when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You already serve the app with FrankenPHP (including &lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;Octane on FrankenPHP&lt;/a&gt;). The hub is already in the binary. There's no extra process to supervise, and no extra port to open.&lt;/li&gt;
&lt;li&gt;Your real-time traffic is mostly server to browser. Notifications, dashboards, status updates, "someone else is editing this".&lt;/li&gt;
&lt;li&gt;You want end-to-end encryption for private channels that a hub operator can't read.&lt;/li&gt;
&lt;li&gt;Your network or proxy is unfriendly to WebSockets. SSE is plain HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stay with Reverb when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need heavy client-to-server messaging. Multiplayer state, collaborative cursors, chat with high message rates.&lt;/li&gt;
&lt;li&gt;You run PHP-FPM behind Nginx and have no plans to move. Then Mercure means running a standalone hub anyway, and Reverb is the better-trodden path in Laravel. It has more tutorials, more production mileage and a released Echo connector.&lt;/li&gt;
&lt;li&gt;You need it working today without building a JavaScript package from source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My take? For a new app on FrankenPHP, Mercure is the better default once Echo cuts a release. One binary serves PHP, TLS and real-time, the connections never touch your PHP workers, and encrypted channels come almost for free. For an existing Reverb setup there's no reason to migrate. It's the same &lt;code&gt;ShouldBroadcast&lt;/code&gt; code either way, so move when your infrastructure moves, not before. And if you do pick Mercure, set &lt;code&gt;MERCURE_URL&lt;/code&gt; before your first queued broadcast, not after the first failed job.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mercure</category>
      <category>frankenphp</category>
      <category>realtime</category>
    </item>
    <item>
      <title>Migrate Laravel Uploads From Local Disk to S3 With Zero Downtime: Read-Through Disks and moveToDisk</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 24 Sep 2026 04:15:07 +0000</pubDate>
      <link>https://dev.to/hafiz619/migrate-laravel-uploads-from-local-disk-to-s3-with-zero-downtime-read-through-disks-and-movetodisk-loc</link>
      <guid>https://dev.to/hafiz619/migrate-laravel-uploads-from-local-disk-to-s3-with-zero-downtime-read-through-disks-and-movetodisk-loc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-read-through-disk-migrate-uploads-to-s3" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every storage migration has the same shape. New uploads need to start landing in the new bucket now. The old files need to move over at some point. And for the whole time in between, a request for &lt;code&gt;avatars/42.jpg&lt;/code&gt; has to work whether that file has moved yet or not.&lt;/p&gt;

&lt;p&gt;Until this summer, Laravel gave you no help with the middle part. You either took a maintenance window, copied everything, flipped the config and hoped, or you wrote a two-disk lookup into every controller that touched a file. Both work. Both are worse than what 13.26 gives you.&lt;/p&gt;

&lt;p&gt;Laravel 13.26 shipped a &lt;code&gt;read-through&lt;/code&gt; filesystem driver that handles both disks behind one disk name. Laravel 13.32 added &lt;code&gt;copyToDisk()&lt;/code&gt; and &lt;code&gt;moveToDisk()&lt;/code&gt;, which are the primitives you want for the sweep at the end. Together they make the migration a config change plus one Artisan command.&lt;/p&gt;

&lt;p&gt;This is the runbook I would follow to move a Laravel app's uploads from the &lt;code&gt;local&lt;/code&gt; disk to S3, tested on 13.32 with 2,000 files, plus the six behaviours I verified against the framework source because the early write-ups disagreed on one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a read-through disk actually does
&lt;/h2&gt;

&lt;p&gt;You define one disk that sits on top of two ordinary disks:&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/filesystems.php&lt;/span&gt;
&lt;span class="s1"&gt;'disks'&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;'local-legacy'&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;'local'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'root'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app/private'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'throw'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="s1"&gt;'s3'&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;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AWS_ACCESS_KEY_ID'&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;'AWS_SECRET_ACCESS_KEY'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'region'&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;'AWS_DEFAULT_REGION'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'bucket'&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;'AWS_BUCKET'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'throw'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="s1"&gt;'uploads'&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;'read-through'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'primary'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'fallback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'local-legacy'&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;primary&lt;/code&gt; is where you are going. &lt;code&gt;fallback&lt;/code&gt; is where you are. Both can be disk names or inline config arrays. Then every operation your app performs on &lt;code&gt;uploads&lt;/code&gt; gets routed on purpose. I ran each of these against 13.32 with two local disks standing in for old and new, so this is what the code does, not what the docs promise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call on the read-through disk&lt;/th&gt;
&lt;th&gt;Which disk&lt;/th&gt;
&lt;th&gt;Copies the file to primary?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;get()&lt;/code&gt;, &lt;code&gt;readStream()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Primary, then fallback on a miss&lt;/td&gt;
&lt;td&gt;Yes, once, on the fallback hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;exists()&lt;/code&gt;, &lt;code&gt;size()&lt;/code&gt;, &lt;code&gt;mimeType()&lt;/code&gt;, &lt;code&gt;lastModified()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Primary, then fallback&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;put()&lt;/code&gt;, &lt;code&gt;putFile()&lt;/code&gt;, &lt;code&gt;writeStream()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Primary only&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;files()&lt;/code&gt;, &lt;code&gt;allFiles()&lt;/code&gt;, &lt;code&gt;directories()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Primary only&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;delete()&lt;/code&gt;, &lt;code&gt;deleteDirectory()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fallback first, then primary&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;url()&lt;/code&gt;, &lt;code&gt;temporaryUrl()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Whichever disk holds the file&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The copy on first read is called promotion. It is a copy, not a move. After my first &lt;code&gt;get()&lt;/code&gt; the file existed on both disks. It is also once: the adapter checks primary again after reading fallback, and if another request promoted the file in the meantime it discards what it read and uses the primary copy.&lt;/p&gt;

&lt;p&gt;The row that surprised me is &lt;code&gt;delete()&lt;/code&gt;. The Laravel News write-up from the 13.26 release says deletes only touch the primary, so a &lt;code&gt;delete()&lt;/code&gt; followed by &lt;code&gt;exists()&lt;/code&gt; could return &lt;code&gt;true&lt;/code&gt;. On 13.32 that is not what happens. &lt;code&gt;ReadThroughFilesystemAdapter::delete()&lt;/code&gt; removes the fallback copy first, then the primary one, and my test confirmed the file was gone from both. Aaron Francis's &lt;a href="https://laravel.com/blog/object-storage-migrations-with-laravels-read-through-filesystem" rel="noopener noreferrer"&gt;engineering post on laravel.com&lt;/a&gt; describes the same both-disks behaviour. Trust the source over the summary, and check your own version.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-read-through-disk-migrate-uploads-to-s3" 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;Two config options matter. &lt;code&gt;'copy' =&amp;gt; false&lt;/code&gt; turns promotion off, so the disk becomes a pure two-disk reader. That is useful for a staging environment pointed at production's old bucket, where you want reads to work but must not write anything. And &lt;code&gt;'throw_on_promotion_failure' =&amp;gt; true&lt;/code&gt; makes a failed copy fail the read as &lt;code&gt;UnableToReadFile&lt;/code&gt;, instead of the default where the read succeeds and the promotion quietly retries next time. Default is right for production, strict is right for tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runbook
&lt;/h2&gt;

&lt;p&gt;Seven steps. The first three take an afternoon, the fourth runs in the background for as long as it needs, and the last three are a deploy each.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Create the destination disk and prove it works
&lt;/h3&gt;

&lt;p&gt;Add the &lt;code&gt;s3&lt;/code&gt; disk (or R2, or anything S3-compatible via &lt;code&gt;AWS_ENDPOINT&lt;/code&gt;) and write one file to it from Tinker on production. Not staging. Credentials, bucket policy and region mistakes all surface here, where they cost nothing.&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;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&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;'migration-check.txt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&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;'migration-check.txt'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&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="s1"&gt;'migration-check.txt'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the same paths on both disks. The read-through driver looks up the identical path on fallback, so if your old disk has &lt;code&gt;avatars/42.jpg&lt;/code&gt; the new one must too. If you also want to reorganise paths, do it as a second migration with scoped disks later, not now.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Rename, insert, deploy
&lt;/h3&gt;

&lt;p&gt;This is the zero-downtime trick. Your application code refers to a disk by name, usually &lt;code&gt;local&lt;/code&gt; through &lt;code&gt;FILESYSTEM_DISK&lt;/code&gt; or an explicit &lt;code&gt;Storage::disk('uploads')&lt;/code&gt;. Do not change the code. Rename the old disk config to &lt;code&gt;local-legacy&lt;/code&gt;, and give the read-through disk the name your code already uses.&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;'uploads'&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;'read-through'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'primary'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'fallback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'local-legacy'&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;Deploy. From this request onwards new uploads land in S3, old files are still served from local, and nothing in &lt;code&gt;app/&lt;/code&gt; changed. If you use the &lt;code&gt;public&lt;/code&gt; disk with &lt;code&gt;php artisan storage:link&lt;/code&gt;, the same rename applies to &lt;code&gt;public&lt;/code&gt;, with one caveat about URLs I will get to below.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Let traffic promote the hot set
&lt;/h3&gt;

&lt;p&gt;Do nothing for a day or a week. Every file a user actually opens gets copied to S3 on its first read and served from S3 after that. In my test I seeded 2,000 files on the old disk and simulated traffic by reading 300 of them through the read-through disk. Afterwards S3 (well, my stand-in disk) held exactly those 300 and the old disk still held all 2,000.&lt;/p&gt;

&lt;p&gt;Watch two things during this phase. Memory, because &lt;code&gt;get()&lt;/code&gt; loads the whole file into a string before promoting it. For anything large, your code should already be using &lt;code&gt;readStream()&lt;/code&gt;, which promotes through a &lt;code&gt;php://temp&lt;/code&gt; buffer that spills to disk above 2 MiB. And latency, because the first read of a cold file now includes a download from old and an upload to new. For a 5 MB PDF that is noticeable. For avatars nobody will see it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Sweep the cold tail
&lt;/h3&gt;

&lt;p&gt;Traffic never touches everything. The files nobody has opened in a year are still on the old disk, and they need moving before you can retire it. This is where 13.32's &lt;code&gt;moveToDisk()&lt;/code&gt; earns its place:&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\Console\Commands&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\Console\Command&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\Storage&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\Number&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;League\Flysystem\StorageAttributes&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;SweepStorage&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'storage:sweep {from} {to} {--prefix=} {--dry-run}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Move every file left on the old disk onto the new one, skipping anything already there'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'from'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'to'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$moved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$skipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$bytes&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="c1"&gt;// listContents() is lazy: S3 pages through the bucket instead of&lt;/span&gt;
        &lt;span class="c1"&gt;// loading every key into one array the way allFiles() does.&lt;/span&gt;
        &lt;span class="nv"&gt;$listing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDriver&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;listContents&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;'prefix'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deep&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;StorageAttributes&lt;/span&gt; &lt;span class="nv"&gt;$item&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;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isFile&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$listing&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;path&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;$to&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$skipped&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// promoted already, or uploaded after cutover&lt;/span&gt;

                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dry-run'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$moved&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="nv"&gt;$bytes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;fileSize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;moveToDisk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$moved&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="nv"&gt;$bytes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;fileSize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$failed&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="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"failed: &lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;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="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'%s %d files (%s), skipped %d already on %s, %d failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dry-run'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'Would move'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Moved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$moved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;fileSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;$skipped&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;argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'to'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FAILURE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions in there are worth defending.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;listContents()&lt;/code&gt; on the Flysystem driver instead of &lt;code&gt;allFiles()&lt;/code&gt;. &lt;code&gt;allFiles()&lt;/code&gt; returns an array, and on a bucket with a million keys that is a million strings in PHP memory before you move anything. &lt;code&gt;listContents(deep: true)&lt;/code&gt; returns a &lt;code&gt;DirectoryListing&lt;/code&gt; that iterates lazily, so S3 pages through the bucket 1,000 keys at a time.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;exists()&lt;/code&gt; check on the destination before every move. It costs one HEAD request per file, and it is the line that makes the sweep safe to run while the app is live. A file already on primary is either one that traffic promoted or one that was uploaded after cutover. Either way the primary copy is the truth, and the sweep must not overwrite it with a stale fallback version.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;moveToDisk()&lt;/code&gt; rather than &lt;code&gt;copyToDisk()&lt;/code&gt;. Under the hood it is &lt;code&gt;copyToDisk()&lt;/code&gt; followed by &lt;code&gt;delete()&lt;/code&gt; on the source, streamed, so the old disk empties as the sweep progresses and you can watch the numbers converge. The method also accepts a disk instance instead of a name since 13.32, which is why &lt;code&gt;$to&lt;/code&gt; can be passed straight in.&lt;/p&gt;

&lt;p&gt;Here is the command against my 2,000-file test, after traffic had promoted 300:&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 storage:sweep local-legacy s3 &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;avatars &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;span class="go"&gt;Would move 1700 files (3 MB), skipped 300 already on s3, 0 failed

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;php artisan storage:sweep local-legacy s3 &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;avatars
&lt;span class="go"&gt;Moved 1700 files (3 MB), skipped 300 already on s3, 0 failed

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;php artisan storage:sweep local-legacy s3 &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;avatars
&lt;span class="go"&gt;Moved 0 files (0 B), skipped 300 already on s3, 0 failed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dry run first, always. Then the real run. Then the run that proves nothing is left to move. The second zero is the number you want before step 6.&lt;/p&gt;

&lt;p&gt;Against real S3 the sweep is bounded by network, not PHP, so run it under &lt;code&gt;nohup&lt;/code&gt; or as a queued job per prefix, with a generous timeout. The &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue setup from the 10,000-jobs post&lt;/a&gt; applies unchanged: one job per top-level directory, low concurrency, and retries that are safe because the &lt;code&gt;exists()&lt;/code&gt; check makes every move idempotent.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Verify
&lt;/h3&gt;

&lt;p&gt;Counts are the weak signal. &lt;code&gt;allFiles()&lt;/code&gt; on both disks and compare lengths, or better, a lazy listing of the old disk that checks &lt;code&gt;size()&lt;/code&gt; on the new one for every path. For anything where corruption would matter, compare checksums on a sample. The laravel.com post is honest that object counts alone are not proof, and I agree.&lt;/p&gt;

&lt;p&gt;Then wait. A week of the read-through disk with an empty old disk behind it costs you nothing and tells you whether any code path was still writing to &lt;code&gt;local-legacy&lt;/code&gt; directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Cut over
&lt;/h3&gt;

&lt;p&gt;Point the disk name straight at S3 and drop the read-through config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'uploads'&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;'s3'&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;Deploy. Same trick as step 2: the application code never changed, only what the name resolves to.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Retire the old disk
&lt;/h3&gt;

&lt;p&gt;Not before step 6, and not the moment after. The old disk is your rollback for as long as it exists. When you do retire it, delete it wholesale rather than file by file, and here is why.&lt;/p&gt;

&lt;p&gt;After my sweep the old disk was not empty. It still held 300 files. Those were the ones traffic had promoted in step 3, because promotion copies and never deletes, and the sweep skipped them because they already existed on the new disk. That is the correct behaviour. It means the old disk is a complete, untouched fallback right up until you decide it is not. It also means "the sweep reported 0 moved" and "the old disk is empty" are different statements. Check the one you mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas I verified
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;URLs follow the file.&lt;/strong&gt; &lt;code&gt;Storage::disk('uploads')-&amp;gt;url($path)&lt;/code&gt; asks whichever disk currently holds the file, so during the migration a page can render one avatar as &lt;code&gt;/storage/avatars/1.jpg&lt;/code&gt; and the next as &lt;code&gt;https://bucket.s3.amazonaws.com/avatars/2.jpg&lt;/code&gt;. For private files behind a controller that is invisible. For the &lt;code&gt;public&lt;/code&gt; disk it is not, and it gets worse: files served straight from &lt;code&gt;/storage/&lt;/code&gt; through the symlink never pass through PHP, so they never promote. If your public uploads are served by nginx, run the sweep for that prefix in step 3, not step 4, so the URLs flip once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writes to the old disk after promotion are invisible.&lt;/strong&gt; I promoted a file, then wrote a new version straight to the old disk. The read-through disk kept returning the old content, because primary had it and primary wins. Nothing should write to the fallback after cutover. If a cron job or a second app still does, find it before step 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overwrites race.&lt;/strong&gt; If your app overwrites paths in place (a &lt;code&gt;profile.jpg&lt;/code&gt; that gets replaced), a &lt;code&gt;put()&lt;/code&gt; can land between the promotion's existence check and its write, and the promotion then overwrites the fresh upload with the stale fallback bytes. Immutable, versioned filenames avoid it entirely. If you cannot change the naming, pause overwrites during the migration or sweep those prefixes first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletes need delete permission on the old disk.&lt;/strong&gt; Because &lt;code&gt;delete()&lt;/code&gt; hits fallback first, a read-only credential on the old bucket fails the whole delete, and the primary copy survives. Either give the migration credential delete rights, or defer deletions until the fallback is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata does not travel.&lt;/strong&gt; Promotion goes through Flysystem's generic write, so cache headers, content disposition and custom S3 metadata come from the new disk's defaults, not from the old object. If you serve files directly from S3 with tuned headers, audit them after the sweep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Egress costs money once.&lt;/strong&gt; Moving from local to S3 costs you S3 PUT requests and nothing else. Moving from S3 to R2 costs AWS transfer-out per gigabyte. The laravel.com post has the &lt;a href="https://laravel.com/blog/object-storage-migrations-with-laravels-read-through-filesystem" rel="noopener noreferrer"&gt;current rate table&lt;/a&gt; and it is the right place to look, because it will be updated and this post will not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats the alternatives
&lt;/h2&gt;

&lt;p&gt;The old way, a maintenance page plus &lt;code&gt;aws s3 sync&lt;/code&gt;, still works and is simpler to reason about. It costs you downtime proportional to your data, and every minute of that window is a minute you cannot test whether the new disk actually works under real traffic. When I &lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;moved a live SaaS to a new server&lt;/a&gt;, the storage delta rode inside the same two-minute window as the database snapshot, because there was no way to move files with the site up. This is that way.&lt;/p&gt;

&lt;p&gt;Cloudflare's Sippy does the same on-demand promotion at the R2 layer and catches requests that bypass Laravel entirely. If you are moving to R2 and serve files directly from the bucket, it is the better tool for step 3. The read-through disk wins when your files go through PHP anyway, when the source is a local disk that no external service can read, and when you want the migration visible in your own logs and tests rather than in a Cloudflare dashboard.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Which Laravel version do I need for read-through disks?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;read-through&lt;/code&gt; driver landed in Laravel 13.26.0 (18 August 2026, framework PR #61140), with the &lt;code&gt;copy =&amp;gt; false&lt;/code&gt; option in the same release. Fallback-aware &lt;code&gt;move()&lt;/code&gt; and &lt;code&gt;copy()&lt;/code&gt; came in 13.27.0, visibility handling for fallback-only files in 13.30.0, and &lt;code&gt;copyToDisk()&lt;/code&gt; / &lt;code&gt;moveToDisk()&lt;/code&gt; in 13.32.0 (15 September 2026). Run 13.32 or later for everything in this post.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a read-through disk slow down every request?
&lt;/h3&gt;

&lt;p&gt;Reads that hit primary cost one extra &lt;code&gt;exists()&lt;/code&gt; check compared to a plain disk. Reads that miss cost an existence check on both disks, a read from fallback, a second primary check and a write. That happens once per file. Writes, listings and deletes on primary are unchanged. Metadata calls like &lt;code&gt;exists()&lt;/code&gt; and &lt;code&gt;size()&lt;/code&gt; never trigger a copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use it to move between two S3-compatible buckets, or between prefixes?
&lt;/h3&gt;

&lt;p&gt;Yes. Both &lt;code&gt;primary&lt;/code&gt; and &lt;code&gt;fallback&lt;/code&gt; can be any configured disk, including two S3 disks with different endpoints, or two &lt;code&gt;scoped&lt;/code&gt; disks with different prefixes on the same bucket. The one requirement is that both adapters support existence checks and reads, and primary supports writes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does moveToDisk stream, or load the file into memory?
&lt;/h3&gt;

&lt;p&gt;It streams. &lt;code&gt;copyToDisk()&lt;/code&gt; opens a &lt;code&gt;readStream()&lt;/code&gt; on the source and passes it to &lt;code&gt;writeStream()&lt;/code&gt; on the destination, and &lt;code&gt;moveToDisk()&lt;/code&gt; is that followed by &lt;code&gt;delete()&lt;/code&gt; on the source. Memory use stays flat regardless of file size, which is what you want for a sweep over gigabytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the copy to S3 fails during a promotion?
&lt;/h3&gt;

&lt;p&gt;By default the read still returns the file from the fallback, the file stays where it was, and the next read tries again. Nothing is logged, so a persistently failing promotion is silent. Set &lt;code&gt;throw_on_promotion_failure =&amp;gt; true&lt;/code&gt; (together with the disk's &lt;code&gt;throw =&amp;gt; true&lt;/code&gt;) if you would rather see the failure as an &lt;code&gt;UnableToReadFile&lt;/code&gt; exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;p&gt;Two disks, one name, and a sweep that is safe to rerun. The read-through driver moves the files people actually use, &lt;code&gt;moveToDisk()&lt;/code&gt; moves the rest, and the &lt;code&gt;exists()&lt;/code&gt; check in between is the whole reason you can do it with the site up. Keep the old disk until the second sweep reports zero and a week has passed. Then delete it in one go, promoted copies and all.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filestorage</category>
      <category>awss3</category>
      <category>devops</category>
    </item>
    <item>
      <title>Jev Gives Your Laravel App a Probability. Here Is What to Do When It Says 0.69</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 21 Sep 2026 04:15:08 +0000</pubDate>
      <link>https://dev.to/hafiz619/jev-gives-your-laravel-app-a-probability-here-is-what-to-do-when-it-says-069-3odl</link>
      <guid>https://dev.to/hafiz619/jev-gives-your-laravel-app-a-probability-here-is-what-to-do-when-it-says-069-3odl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-jev-classification-confidence-review-queue" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;TypeSafe released Jev on 15 September 2026. Two days later the Laravel AI SDK grew a &lt;code&gt;Classification&lt;/code&gt; API on its &lt;code&gt;1.x&lt;/code&gt; branch, with TypeSafe as the first provider. Freek already used it to &lt;a href="https://freek.dev/3194-detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk" rel="noopener noreferrer"&gt;kill a hand-maintained spam list&lt;/a&gt;, and the PR's own example routes support tickets. Both stop at the same line: &lt;code&gt;$response['department']-&amp;gt;choice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the boring number. The interesting one sits next to it. A customer asks "can we pay by bank transfer instead of card before we sign up for the annual plan". Jev says &lt;code&gt;billing&lt;/code&gt; with a confidence of &lt;code&gt;0.69&lt;/code&gt;, and &lt;code&gt;sales&lt;/code&gt; is the runner-up at &lt;code&gt;0.23&lt;/code&gt;. What does your code do now?&lt;/p&gt;

&lt;p&gt;The obvious code does nothing. It reads &lt;code&gt;-&amp;gt;choice&lt;/code&gt;, routes the ticket, and the 0.69 evaporates. Which means you paid for a model that can say "I'm not sure" and then built a system that cannot hear it.&lt;/p&gt;

&lt;p&gt;This post builds the missing half. A threshold policy that treats confidence as a per-action decision. A review queue in Filament for the calls the model should not make alone. A write-back so the truth lands next to the guess. And an Artisan command that turns "calibrated" from a marketing word into a table you can read. All of it against 60 real-looking support tickets I wrote and labelled by hand, so the numbers at the end are measured, not imagined.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Classification API actually returns
&lt;/h2&gt;

&lt;p&gt;First the ground rules, because they shape everything after. Classification is on the &lt;code&gt;1.x&lt;/code&gt; branch only, so you install a dev version:&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 laravel/ai:1.x-dev
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ai-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stable is &lt;code&gt;v0.11.2&lt;/code&gt; at the time of writing, and &lt;code&gt;1.x&lt;/code&gt; has an upgrade guide branch open, so a &lt;code&gt;1.0&lt;/code&gt; tag is close. The docs on laravel.com do not mention Classification yet. The PR author also said the API may be flagged experimental after release. Every code block here ran against commit &lt;code&gt;ca8d9bf&lt;/code&gt; of &lt;code&gt;1.x&lt;/code&gt; on Laravel 13.32. If a method is renamed before you read this, the source under &lt;code&gt;vendor/laravel/ai/src/Classification&lt;/code&gt; is short enough to diff in a minute.&lt;/p&gt;

&lt;p&gt;Config is one key. &lt;code&gt;config/ai.php&lt;/code&gt; already carries &lt;code&gt;'default_for_classification' =&amp;gt; 'typesafe'&lt;/code&gt; and a &lt;code&gt;typesafe&lt;/code&gt; provider reading &lt;code&gt;TYPESAFE_API_KEY&lt;/code&gt;. The default model is &lt;code&gt;jev-latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are three question types, and they return three different answer objects:&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\Classification&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\Classification\Boolean&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\Classification\Choice&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\Classification\Score&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Classification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'subject'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'department'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Which team should handle this ticket?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'billing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Invoices, failed payments, plan changes, VAT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'technical'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bugs, errors, integrations, API problems'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'sales'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Pricing questions, quotes, trials, upgrades before buying'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'refund'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The customer is asking for money back'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="s1"&gt;'urgent'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Does the customer need a response today?'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'frustration'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How frustrated is the customer?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'Calm, stating facts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Annoyed but polite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Angry or threatening to leave'&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Real answers for the bank transfer ticket from the intro&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'department'&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;choice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// 'billing'&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'department'&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;confidence&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// 0.69&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'department'&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;probabilities&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// ['billing' =&amp;gt; 0.77, 'sales' =&amp;gt; 0.23, 'technical' =&amp;gt; 0, 'refund' =&amp;gt; 0]&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'urgent'&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;probability&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 0.34&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'urgent'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'frustration'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;level&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// 0&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'frustration'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// 'Calm, stating facts'&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;// 'jev-1.13.0'&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// 478&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter for the rest of the post.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Boolean&lt;/code&gt; answers carry a probability and nothing else. There is no &lt;code&gt;confidence&lt;/code&gt; property on &lt;code&gt;BooleanAnswer&lt;/code&gt;, because for a yes/no question the probability already is the certainty. 0.5 means the model has no idea, 0.99 or 0.01 means it does.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Choice&lt;/code&gt; and &lt;code&gt;Score&lt;/code&gt; answers carry both. The &lt;code&gt;probabilities&lt;/code&gt; array is the real output, and &lt;code&gt;confidence&lt;/code&gt; is a single number TypeSafe derives from its shape. Their &lt;a href="https://docs.typesafe.ai/confidence" rel="noopener noreferrer"&gt;confidence docs&lt;/a&gt; put it plainly: a distribution concentrated on one option is a confident answer, a flat one is not. It is not the same as the top probability. One ticket in my run came back &lt;code&gt;billing&lt;/code&gt; at 0.84 with a confidence of 0.78, because the remaining 0.16 sat on a single rival option rather than being spread thin. And the SDK types &lt;code&gt;confidence&lt;/code&gt; as &lt;code&gt;?float&lt;/code&gt;, so a future provider that cannot measure it returns &lt;code&gt;null&lt;/code&gt;. Your policy has to handle that case from day one.&lt;/p&gt;

&lt;p&gt;The other line worth pinning on the wall comes from the &lt;a href="https://docs.typesafe.ai/concepts/system-one" rel="noopener noreferrer"&gt;System One page&lt;/a&gt;. Calibration is measured across groups of predictions, and in their words "it does not guarantee that an individual answer is correct." So a 0.69 does not mean this ticket is 69% billing. It means that across many answers where Jev said 0.69, roughly 69% should turn out right. That is a statement about your whole queue, and you can only check it if you keep records. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  A threshold is a policy, not a number
&lt;/h2&gt;

&lt;p&gt;The naive version is &lt;code&gt;if ($answer-&amp;gt;confidence &amp;gt;= 0.8)&lt;/code&gt;. It fails the first time a refund request auto-routes to billing and sits there for two days. Misrouting a pricing question costs a forwarded email. Misrouting a refund costs a chargeback. The same confidence should not be allowed to do both.&lt;/p&gt;

&lt;p&gt;TypeSafe's own docs suggest three ranges (act, proceed with caution, do not act) and note that the boundaries move with the stakes. Here is what that looks like as a Laravel 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;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Support&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Responses\Data\ChoiceAnswer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfidencePolicy&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * @param  float  $floor  Below this the model is guessing. Never act.
     * @param  array&amp;lt;string, float&amp;gt;  $actAt  Per-option threshold to act without a human.
     */&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;private&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$actAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'billing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'technical'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'sales'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'refund'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// money leaves the building, so the bar is higher&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;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ChoiceAnswer&lt;/span&gt; &lt;span class="nv"&gt;$answer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Decision&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;$answer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$answer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;floor&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;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$threshold&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;actAt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$answer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mf"&gt;1.0&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;$answer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;confidence&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="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Act&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Review&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;Decision&lt;/code&gt; is a two-case enum, &lt;code&gt;Act&lt;/code&gt; and &lt;code&gt;Review&lt;/code&gt;. Three things are deliberate here. A &lt;code&gt;null&lt;/code&gt; confidence goes to review, not to a default threshold. An option missing from &lt;code&gt;$actAt&lt;/code&gt; gets a threshold of &lt;code&gt;1.0&lt;/code&gt;, so adding a fifth department to the &lt;code&gt;Choice&lt;/code&gt; without adding a threshold fails safe. And the numbers are constructor arguments, so a test can pass a strict policy and a lenient one without touching config.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;0.69&lt;/code&gt; from the intro hits the floor check, passes it, then fails &lt;code&gt;billing&lt;/code&gt;'s 0.75. Review. Which is the right answer for a sales question wearing billing vocabulary. The customer has not bought anything yet. (Running the same ticket again a minute later gave 0.70. Jev is not perfectly deterministic, so a policy that flips on the second decimal is a policy with a bad threshold.)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-jev-classification-confidence-review-queue" 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 action that ties it together is short. It classifies, decides, records, and only touches the ticket when the policy says so:&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\Actions&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;RouteTicket&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;private&lt;/span&gt; &lt;span class="kt"&gt;ConfidencePolicy&lt;/span&gt; &lt;span class="nv"&gt;$policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Ticket&lt;/span&gt; &lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ClassificationDecision&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hrtime&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Classification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$department&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'department'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nv"&gt;$decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'question'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'department'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'suggested'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'confidence'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'probabilities'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'decision'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$decision&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'input_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'latency_ms'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;hrtime&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="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Act&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$ticket&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;'department'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'routed_by'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'model'&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;$record&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;Every call writes a &lt;code&gt;classification_decisions&lt;/code&gt; row, whether the model acted or not. That table is the audit log, and it is the input to everything below. If you already run &lt;a href="https://hafiz.dev/blog/laravel-activity-log-v5-audit-trail-guide" rel="noopener noreferrer"&gt;Spatie's activity log&lt;/a&gt;, you could log there instead, but a dedicated table with typed columns for &lt;code&gt;confidence&lt;/code&gt; and &lt;code&gt;suggested&lt;/code&gt; makes the calibration query at the end a plain &lt;code&gt;where&lt;/code&gt;, not a JSON path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review queue is the other half of automation
&lt;/h2&gt;

&lt;p&gt;A decision of &lt;code&gt;Review&lt;/code&gt; has to land somewhere a human will actually look. In my apps that is Filament, so the queue is a resource over the same &lt;code&gt;classification_decisions&lt;/code&gt; table, filtered to what the model was unsure about and nobody has resolved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getEloquentQuery&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Builder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getEloquentQuery&lt;/span&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;'decision'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'review'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actual'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ticket'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'confidence'&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getNavigationBadge&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="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="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getEloquentQuery&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lowest confidence first, because those are the ones where the runner-up is most likely right. The badge on the sidebar is the whole "someone must work this queue" problem made visible. Two row actions do 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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;recordActions&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nc"&gt;Action&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;'accept'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Heroicon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Check&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ClassificationDecision&lt;/span&gt; &lt;span class="nv"&gt;$record&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;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;suggested&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nc"&gt;Action&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;'override'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Heroicon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;ArrowUturnLeft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gray'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Radio&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;'department'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ClassificationDecision&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$p&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;$option&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;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s (%.2f)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$option&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ClassificationDecision&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&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;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'department'&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 override form shows the model's own probabilities next to each option. The reviewer sees that &lt;code&gt;sales&lt;/code&gt; was at 0.23, which is often enough context to decide in two seconds. And &lt;code&gt;resolve()&lt;/code&gt; writes the same two columns in both cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="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;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ClassificationDecision&lt;/span&gt; &lt;span class="nv"&gt;$record&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;$department&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;$record&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;'actual'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'actual_source'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'review'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'reviewed_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ticket&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;'department'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'routed_by'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'human'&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;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%2Fnzuqszbyuaynmpmjo5ad.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%2Fnzuqszbyuaynmpmjo5ad.webp" alt="The Filament review queue after classifying 60 tickets. Two rows made it in: seats not updating after payment (billing at 0.39, technical runner-up) and the bank transfer question (billing at 0.69, sales runner-up)" width="799" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the same shape as the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-human-in-the-loop-tool-approval" rel="noopener noreferrer"&gt;tool approval flow&lt;/a&gt; the SDK ships for agents, applied to a classifier. The agent version pauses a tool call until a human approves it. This version pauses a routing decision. Same principle, and the same rule about who gets to press the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the truth back, including for the calls you got right
&lt;/h2&gt;

&lt;p&gt;The review queue gives you ground truth for the decisions the model doubted. That is a biased sample. If you only ever learn the outcome of low-confidence calls, you can never find out whether your 0.9s are actually 90% right, and that is the question that decides whether your threshold is too high or too low.&lt;/p&gt;

&lt;p&gt;So the auto-routed decisions need an outcome too. In a real helpdesk the natural moment is ticket closure, when an agent has handled it and the department it was closed in is known. That is one listener:&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;RecordRoutingOutcome&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;TicketClosed&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&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;ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'question'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'department'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actual'&lt;/span&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;'actual'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'actual_source'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'closed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;whereNull('actual')&lt;/code&gt; matters. A reviewed decision already has its truth from a human and must not be overwritten by whatever department the ticket eventually drifted to.&lt;/p&gt;

&lt;p&gt;For the demo I do not have a helpdesk, so a small command writes each ticket's labelled department onto its auto-routed decision, standing in for the closure event. Same two columns, same &lt;code&gt;whereNull&lt;/code&gt; guard.&lt;/p&gt;

&lt;p&gt;If you also want a global record of every classification for cost tracking, the SDK fires &lt;code&gt;Laravel\Ai\Events\Classified&lt;/code&gt; with the invocation id, provider, model, prompt and response after every call. It is the right hook for a usage ledger. It is the wrong hook for outcome tracking, because the invocation id never reaches your calling code, so you cannot join it back to the ticket later. Log decisions from the action, log usage from the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now calibration is a number you own
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;suggested&lt;/code&gt;, &lt;code&gt;confidence&lt;/code&gt; and &lt;code&gt;actual&lt;/code&gt; on the same row, the report is a single query and two tables:&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;$decisions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ClassificationDecision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'question'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'department'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actual'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first table buckets by confidence and asks how often the model was right in each bucket. The second replays every threshold from 0.5 to 0.9 and asks what would have happened: how many tickets would have auto-routed, how many would have gone to a human, and how many of the automatic ones would have been wrong.&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;collect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&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;float&lt;/span&gt; &lt;span class="nv"&gt;$threshold&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;$decisions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$auto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$decisions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$d&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;$d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;confidence&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="nv"&gt;$wrong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$auto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reject&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;wasCorrect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nb"&gt;number_format&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%d (%d%%)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$auto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$auto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$decisions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="nv"&gt;$decisions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$auto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nv"&gt;$auto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%d%%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$auto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$wrong&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$auto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="nv"&gt;$wrong&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 ran the 60 tickets through &lt;code&gt;jev-latest&lt;/code&gt;, which resolved to &lt;code&gt;jev-1.13.0&lt;/code&gt;. 18 billing, 18 technical, 14 sales, 10 refund, with 10 written to sit between two departments (a "charge I don't recognise" that is billing, and its twin with "reverse it please" that is a refund). The policy above auto-routed 58 and sent 2 to review. I resolved those two in Filament, overriding both, then wrote the labels back onto the rest to stand in for ticket closure. Here is the report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;60 decisions with a known outcome, model jev-1.13.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Accuracy by confidence bucket:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Confidence&lt;/th&gt;
&lt;th&gt;Decisions&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.0 to 0.5&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5 to 0.6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;(none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.6 to 0.7&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.7 to 0.8&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.8 to 0.9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.9 to 1.0&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the act threshold had been:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Auto-routed&lt;/th&gt;
&lt;th&gt;Sent to review&lt;/th&gt;
&lt;th&gt;Auto accuracy&lt;/th&gt;
&lt;th&gt;Wrong auto-routes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;59 (98%)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;59 (98%)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.70&lt;/td&gt;
&lt;td&gt;58 (97%)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;55 (92%)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;54 (90%)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the first table top to bottom. Below 0.7 Jev was wrong both times, which is what "I'm not sure" should look like. From 0.8 up it was right 55 times out of 55. The bucket that matters is 0.7 to 0.8, where one answer in three was right, and my 0.75 bar sits in the middle of it. Both wrong auto-routes landed there, at 0.76 and 0.78, and both were tickets a human could argue either way ("Invoice PDF will not download" went to technical, my label said billing, and honestly the model has a case).&lt;/p&gt;

&lt;p&gt;Now read the second table as a menu. At 0.75 I got 2 wrong routes and 2 reviews. Moving the bar to 0.80 costs three more tickets in the queue and removes every wrong route. That is not a judgement call any more. It is a row in a table, and the next run of the command tells you whether it held.&lt;/p&gt;

&lt;p&gt;Sixty tickets is a small sample and I wrote them, so treat the percentages as a demonstration of the method, not a benchmark of Jev. The point is that the table exists. You can run it every Monday on last week's decisions, and when it says your 0.75 bucket is only 70% right, you raise the threshold and the review queue grows by a known amount. Threshold tuning stops being a feeling.&lt;/p&gt;

&lt;p&gt;Cost, for the record. The 60 tickets used 28,479 input tokens, an average of 475 per ticket with three questions each. TypeSafe lists Jev at $42 per billion input tokens on &lt;a href="https://typesafe.ai/" rel="noopener noreferrer"&gt;typesafe.ai&lt;/a&gt;, which is $0.042 per million, so the whole run cost about a tenth of a cent. Latency from a VPS in Vienna was 589ms on average and 583ms median, with the slowest call at 704ms. Freek measured 639ms for a similar three-question call, so that is roughly what to budget for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without spending a cent
&lt;/h2&gt;

&lt;p&gt;The SDK ships a fake for classification, which is where the policy gets its real test. You hand it the exact answer you want and assert what your code did with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Classification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Responses\Data\ChoiceAnswer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sends a 0.69 department call to the review queue instead of acting'&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;Classification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'department'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ChoiceAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'billing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.77&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sales'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'technical'&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="s1"&gt;'refund'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.69&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RouteTicket&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decision&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'review'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Classification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertClassified&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'department'&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;Any question you do not override gets a random but shape-valid answer, so the &lt;code&gt;urgent&lt;/code&gt; and &lt;code&gt;frustration&lt;/code&gt; questions in the action do not need stubbing. &lt;code&gt;Classification::fake()&lt;/code&gt; also accepts a closure that receives the &lt;code&gt;ClassificationPrompt&lt;/code&gt;, so you can return 1.0 when &lt;code&gt;$prompt-&amp;gt;contains('ASAP')&lt;/code&gt; and 0.0 otherwise, and &lt;code&gt;preventStrayClassifications()&lt;/code&gt; makes an unfaked call throw. The second test in my suite feeds a refund at 0.85 and then at 0.93 and asserts that only the second one acts, which is the per-option threshold doing its job.&lt;/p&gt;

&lt;p&gt;If you queue the routing (and you should, since a classification is an HTTP call to a third party), the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" rel="noopener noreferrer"&gt;Horizon setup for AI SDK jobs&lt;/a&gt; applies unchanged. Short timeout, its own queue, a retry that does not double-write the decision row.&lt;/p&gt;

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

&lt;p&gt;You are building on a dev branch. The class names are stable enough that I would ship this behind a &lt;code&gt;composer.lock&lt;/code&gt; pin, but not on &lt;code&gt;1.x-dev&lt;/code&gt; without one. When &lt;code&gt;1.0&lt;/code&gt; tags, &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-goes-stable-what-changed-what-to-check" rel="noopener noreferrer"&gt;check the upgrade guide&lt;/a&gt; the same way you did at 0.x.&lt;/p&gt;

&lt;p&gt;One provider. Today &lt;code&gt;Classification&lt;/code&gt; has TypeSafe and nothing else. The abstraction is there (&lt;code&gt;ClassificationProvider&lt;/code&gt;, &lt;code&gt;ClassificationGateway&lt;/code&gt;), so a second provider is a PR away, but right now a TypeSafe outage is a classification outage. The SDK's failover loop in &lt;code&gt;PendingClassification::classify()&lt;/code&gt; already iterates providers, it just has one to iterate.&lt;/p&gt;

&lt;p&gt;Boolean has no confidence, by design. If you gate on a &lt;code&gt;Boolean&lt;/code&gt;, gate on distance from 0.5. A probability of 0.55 for "urgent" is a shrug, not a yes.&lt;/p&gt;

&lt;p&gt;A review queue only works if someone works it. The badge count is not decoration. If it climbs past what your team clears in a day, the fix is not to lower the thresholds, it is to look at what the model keeps doubting. In my 60 tickets, three of the five lowest-confidence answers were tickets from the batch of ten I had deliberately written to sit between two departments. That is what you want a classifier to do.&lt;/p&gt;

&lt;p&gt;And the calibration report is only as honest as your write-back. Skip the closure listener and you will tune thresholds on the biased half of the data. That shortcut is where the wrong lessons come from.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does the Laravel AI SDK Classification API work on the stable release?
&lt;/h3&gt;

&lt;p&gt;Not yet. Classification landed on the &lt;code&gt;1.x&lt;/code&gt; branch on 17 September 2026 (laravel/ai PR #1010). The latest stable tag is &lt;code&gt;v0.11.2&lt;/code&gt;. Install &lt;code&gt;laravel/ai:1.x-dev&lt;/code&gt; to use it now, and expect a &lt;code&gt;1.0&lt;/code&gt; release to follow, since the branch already has an upgrade guide in progress.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between probability and confidence in a Jev answer?
&lt;/h3&gt;

&lt;p&gt;For a &lt;code&gt;Choice&lt;/code&gt; or &lt;code&gt;Score&lt;/code&gt;, &lt;code&gt;probabilities&lt;/code&gt; is the full distribution across your options or levels, and &lt;code&gt;confidence&lt;/code&gt; is one number TypeSafe derives from how concentrated that distribution is. For a &lt;code&gt;Boolean&lt;/code&gt;, there is only &lt;code&gt;probability&lt;/code&gt;, because a yes/no answer's certainty is the probability itself. Confidence can be &lt;code&gt;null&lt;/code&gt; if a provider cannot measure it, so always handle that case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use a different threshold for each option?
&lt;/h3&gt;

&lt;p&gt;Yes, and you should. The policy class in this post keeps a floor below which nothing acts, then a per-option threshold above it. A refund route needs a higher bar than a sales route because the cost of being wrong is higher. Options without an explicit threshold default to 1.0, which sends them to review.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test classification code without calling TypeSafe?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Classification::fake()&lt;/code&gt; accepts an array of per-question answers or a closure. Pass a &lt;code&gt;ChoiceAnswer&lt;/code&gt; with the exact probabilities and confidence you want to exercise, run your code, and assert on the outcome. &lt;code&gt;Classification::assertClassified()&lt;/code&gt; and &lt;code&gt;assertNothingClassified()&lt;/code&gt; check that the call happened, and &lt;code&gt;preventStrayClassifications()&lt;/code&gt; makes any unfaked call throw.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Jev cheaper than using an LLM for the same classification?
&lt;/h3&gt;

&lt;p&gt;TypeSafe publishes $42 per billion input tokens with no charge for output. Whether that beats your current LLM setup depends on your prompt length and volume, and I have not benchmarked the two side by side here. What I can say from this run is the token count and latency per ticket above. A fair comparison against a small LLM with structured output is a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;p&gt;The Classification API is ten lines of code and everyone will write those ten lines. The decisions table, the per-option policy, the review queue and the write-back are maybe 200 more, and they are what let you raise a threshold on a Monday morning because a report told you to, instead of because a customer complained.&lt;/p&gt;

&lt;p&gt;Jev's whole pitch is that it tells you when it is unsure. Build the part that listens.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelaisdk</category>
      <category>aiagents</category>
      <category>filament</category>
    </item>
    <item>
      <title>Your Queue Worker Gets a SIGTERM on Every Deploy. Here Is What Laravel 13.31 Lets You Do About It</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 17 Sep 2026 04:15:11 +0000</pubDate>
      <link>https://dev.to/hafiz619/your-queue-worker-gets-a-sigterm-on-every-deploy-here-is-what-laravel-1331-lets-you-do-about-it-135o</link>
      <guid>https://dev.to/hafiz619/your-queue-worker-gets-a-sigterm-on-every-deploy-here-is-what-laravel-1331-lets-you-do-about-it-135o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-queue-worker-sigterm-interruptible-jobs" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every time you deploy, something sends your queue workers a SIGTERM. Supervisor does it on restart. Docker does it on &lt;code&gt;docker stop&lt;/code&gt;. Kubernetes does it before it evicts a pod. And somewhere in the middle of that, a job is halfway through importing 40,000 rows.&lt;/p&gt;

&lt;p&gt;What happens to that job?&lt;/p&gt;

&lt;p&gt;The comfortable answer is that Laravel handles it. The worker finishes the current job before exiting, so nothing is lost. That is true right up until the job takes longer than your process manager is willing to wait, at which point SIGKILL arrives and the job dies wherever it happens to be.&lt;/p&gt;

&lt;p&gt;Laravel 13.31 shipped a &lt;code&gt;JobInterrupted&lt;/code&gt; event, and 13.7 shipped the &lt;code&gt;Interruptible&lt;/code&gt; contract it depends on. Together they let a job notice the signal and stop cleanly at a safe point. I spent a morning testing both against a real 13.31 app, and two things turned out differently from how the release notes describe them. One of them silently disables the whole feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem is the gap between SIGTERM and SIGKILL
&lt;/h2&gt;

&lt;p&gt;When your process manager wants a worker gone, it does not kill it outright. It sends SIGTERM, waits, and then escalates to SIGKILL if the process is still alive.&lt;/p&gt;

&lt;p&gt;Laravel's worker catches that SIGTERM and sets an internal &lt;code&gt;shouldQuit&lt;/code&gt; flag. It does not abandon the job it is running. It lets the current job finish, then exits before reserving another one. So for a job that takes two seconds, this is a non-issue.&lt;/p&gt;

&lt;p&gt;For a job that takes nine minutes, it is the whole issue. Supervisor's &lt;code&gt;stopwaitsecs&lt;/code&gt; defaults to 10 seconds, and the Laravel docs recommend raising it past the length of your longest job, with an explicit warning: "You should ensure that the value of &lt;code&gt;stopwaitsecs&lt;/code&gt; is greater than the number of seconds consumed by your longest running job. Otherwise, Supervisor may kill the job before it is finished processing."&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://laravel.com/docs/13.x/queues#supervisor-configuration" rel="noopener noreferrer"&gt;sample Supervisor config in the docs&lt;/a&gt; sets it to 3600. An hour. That is the honest cost of the "worker finishes its current job" guarantee, and it means a deploy can hang for an hour waiting on one import.&lt;/p&gt;

&lt;p&gt;Interruptible jobs are the other way out. Instead of making the deploy wait for the job, you let the job hear the signal and wind itself down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens, in order
&lt;/h2&gt;

&lt;p&gt;Here is the part where testing beat reading. I wrote a job that logs each step, implemented &lt;code&gt;Interruptible&lt;/code&gt;, registered listeners on both events, and sent a real SIGTERM to a running worker mid-job.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-queue-worker-sigterm-interruptible-jobs" 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 observed log, in the order it was written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SlowReport: step 1
SlowReport: step 2
SlowReport: step 3
SlowReport: step 4
EVENT WorkerInterrupted signal=15 queue=default
SlowReport::interrupted() called with signal 15
EVENT JobInterrupted signal=15 conn=database
SlowReport: stopping cleanly at step 5
Worker STOPPED Interrupted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Signal 15 is SIGTERM. The ordering is not what you would guess from the changelog.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;interrupted()&lt;/code&gt; method on the job runs &lt;em&gt;before&lt;/em&gt; the &lt;code&gt;JobInterrupted&lt;/code&gt; event fires, not after. If you are writing a listener that assumes the job has already reacted to the signal by the time your listener runs, that assumption holds. If you assumed the reverse, that your listener runs first and can influence what the job does, it does not.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;Illuminate\Queue\Worker&lt;/code&gt;, the signal handler sets &lt;code&gt;shouldQuit&lt;/code&gt;, dispatches &lt;code&gt;WorkerInterrupted&lt;/code&gt;, then calls &lt;code&gt;notifyJobOfSignal()&lt;/code&gt;, which calls &lt;code&gt;$job-&amp;gt;interrupted($signal)&lt;/code&gt; and only then dispatches &lt;code&gt;JobInterrupted&lt;/code&gt;. Four steps, one after the other, inside the signal handler itself.&lt;/p&gt;

&lt;p&gt;And notice the last line. &lt;code&gt;Worker STOPPED Interrupted&lt;/code&gt; comes from the stop-reason output added in 13.30, which tells you why a worker exited instead of leaving you guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag that turns all of this off
&lt;/h2&gt;

&lt;p&gt;This is the one that will actually bite people.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;php artisan queue:work --once&lt;/code&gt; never installs the signal handlers at all.&lt;/p&gt;

&lt;p&gt;Not "handles them differently". Does not install them. In &lt;code&gt;Worker&lt;/code&gt;, the call to &lt;code&gt;listenForSignals()&lt;/code&gt; lives inside the &lt;code&gt;daemon()&lt;/code&gt; method. The &lt;code&gt;--once&lt;/code&gt; flag routes through &lt;code&gt;runNextJob()&lt;/code&gt; instead, which never touches signal handling. You can see the branch in &lt;code&gt;WorkCommand&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="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;'once'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'runNextJob'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'daemon'&lt;/span&gt;&lt;span class="p"&gt;}(&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran the same SIGTERM test with &lt;code&gt;--once&lt;/code&gt; and the log stopped dead at &lt;code&gt;step 4&lt;/code&gt;. No &lt;code&gt;WorkerInterrupted&lt;/code&gt;. No &lt;code&gt;interrupted()&lt;/code&gt; call. No &lt;code&gt;JobInterrupted&lt;/code&gt;. The process died mid-loop with the job still reserved in the table, waiting for its timeout to expire before anything retries it.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds, because &lt;code&gt;--once&lt;/code&gt; is everywhere. It is the standard pattern for running workers under cron. It shows up in Docker setups that prefer one job per container. It is what a lot of people reach for in Kubernetes when they want a job runner rather than a long-lived process. Every one of those setups gets SIGTERM on shutdown, and none of them will run your cleanup code.&lt;/p&gt;

&lt;p&gt;If you rely on interruptible jobs, run the daemon. That is the requirement, and nothing in the release notes says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing a job that stops cleanly
&lt;/h2&gt;

&lt;p&gt;The contract is one method:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Interruptible&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;interrupted&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;$signal&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing to understand is that &lt;code&gt;interrupted()&lt;/code&gt; is cooperative, not preemptive. Laravel calls it, and that is the entire extent of Laravel's involvement. It does not unwind your stack, throw an exception, or stop your loop. If your &lt;code&gt;handle()&lt;/code&gt; method never checks anything, the job keeps running exactly as before and your &lt;code&gt;interrupted()&lt;/code&gt; method accomplished nothing.&lt;/p&gt;

&lt;p&gt;So the pattern is always two halves. A flag set by the signal, and a check inside the work loop:&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&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\Contracts\Queue\Interruptible&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\Contracts\Queue\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;Illuminate\Foundation\Queue\Queueable&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;SlowReport&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="nc"&gt;Interruptible&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;protected&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$stopping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stopping&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;()&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;"SlowReport: stopping cleanly at step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

            &lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;()&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;"SlowReport: step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;()&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="s1"&gt;'SlowReport: finished all steps'&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;interrupted&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;$signal&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;stopping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;()&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;"SlowReport::interrupted() called with signal &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$signal&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where you put the check decides how quickly the job gives up the process. A check at the top of a loop that iterates once per second means you stop within a second. A check outside a loop that runs for four minutes means you stop in four minutes, which defeats the point.&lt;/p&gt;

&lt;p&gt;Three practical notes on the real version of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Save progress before returning.&lt;/strong&gt; Stopping cleanly is only useful if the next run can pick up where this one left off. A checkpoint column, a cursor, a last-processed ID. Returning without recording anything just means you redo the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return, do not throw.&lt;/strong&gt; Throwing marks the job failed and sends it down the retry path, which is not what happened. A clean return lets you re-dispatch on your own terms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the check where the work is.&lt;/strong&gt; Inside the chunk loop, not around it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your jobs process large batches, the checkpointing side of this pairs with the batching approach I wrote about in &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing 10,000 tasks without breaking&lt;/a&gt;, where the same idea keeps a failed run from restarting at zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two events do different jobs
&lt;/h2&gt;

&lt;p&gt;Both events exist and they are not interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;JobInterrupted&lt;/code&gt; carries &lt;code&gt;connectionName&lt;/code&gt;, &lt;code&gt;job&lt;/code&gt; and &lt;code&gt;signal&lt;/code&gt;. It fires only for jobs that implement &lt;code&gt;Interruptible&lt;/code&gt;. The check in &lt;code&gt;notifyJobOfSignal()&lt;/code&gt; returns early unless there is a current job, its resolved handler is a &lt;code&gt;CallQueuedHandler&lt;/code&gt;, and the underlying command implements the contract. Miss any of those and the event never fires.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WorkerInterrupted&lt;/code&gt; carries &lt;code&gt;signal&lt;/code&gt;, &lt;code&gt;connectionName&lt;/code&gt;, &lt;code&gt;queue&lt;/code&gt; and the &lt;code&gt;WorkerOptions&lt;/code&gt;. It fires whenever a worker catches a termination signal, whatever job happens to be running and whether or not that job implements anything.&lt;/p&gt;

&lt;p&gt;That difference makes &lt;code&gt;WorkerInterrupted&lt;/code&gt; the better observability hook. It tells you a worker on a given queue got a signal, and it tells you for every worker, not just the ones running jobs you have retrofitted. If you want a count of how often deploys are interrupting in-flight work, listen to that one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Events\WorkerInterrupted&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\Event&lt;/span&gt;&lt;span class="p"&gt;;&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;WorkerInterrupted&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="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Worker interrupted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'signal'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;connectionName&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;Use &lt;code&gt;JobInterrupted&lt;/code&gt; when you need the job instance, for per-job cleanup or for recording which specific job was cut short.&lt;/p&gt;

&lt;p&gt;Neither event appears in the queue documentation as of this writing. The &lt;code&gt;Interruptible&lt;/code&gt; contract is documented under &lt;a href="https://laravel.com/docs/13.x/queues#reacting-to-worker-signals" rel="noopener noreferrer"&gt;Reacting to Worker Signals&lt;/a&gt;, but the events are release-note material only, which is part of why the ordering surprise above is easy to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  totalSize() and the number you were probably alerting on
&lt;/h2&gt;

&lt;p&gt;The other half of 13.31 is queue measurement, and it fixes a real footgun.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;size($queue)&lt;/code&gt; counts one queue. If you call &lt;code&gt;size()&lt;/code&gt; with no argument, you get the default queue and nothing else. Plenty of monitoring code calls that, names the metric something like "queue depth", and quietly ignores every other queue in the system.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;totalSize()&lt;/code&gt; counts all of them. I dispatched three jobs onto three different queues and checked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;size("default"): 1
size("emails"):  1
totalSize():     3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three companion methods that break the number down, and they are the ones worth graphing:&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;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'database'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;totalPendingSize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// waiting, available now&lt;/span&gt;
&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;totalDelayedSize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// waiting, scheduled for later&lt;/span&gt;
&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;totalReservedSize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// picked up by a worker, still running&lt;/span&gt;
&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;totalSize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// all of the above&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean run with two immediate jobs and one delayed job returned 2, 1, 0 and 3 respectively.&lt;/p&gt;

&lt;p&gt;The split matters because the three numbers mean different things when something is wrong. Pending climbing means you are not consuming fast enough. Delayed climbing is usually just scheduled work and is often fine. Reserved climbing while pending stays flat means jobs are being picked up and not finishing, which is the shape of a hung worker or a job stuck on an external call.&lt;/p&gt;

&lt;p&gt;Two caveats worth knowing before you wire this into a dashboard.&lt;/p&gt;

&lt;p&gt;These methods are implemented per driver rather than on the base &lt;code&gt;Queue&lt;/code&gt; class, and that matters more than it sounds. &lt;code&gt;DatabaseQueue&lt;/code&gt; and &lt;code&gt;RedisQueue&lt;/code&gt; return real numbers. &lt;code&gt;SqsQueue&lt;/code&gt;, &lt;code&gt;SyncQueue&lt;/code&gt; and &lt;code&gt;BeanstalkdQueue&lt;/code&gt; implement the same methods and return a hardcoded &lt;code&gt;0&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="c1"&gt;// SqsQueue.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;totalSize&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="mi"&gt;0&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 on SQS the call succeeds, returns zero, and never errors. Wire that to a dashboard and you get a flat line that looks like a healthy queue. Check what your driver actually returns rather than whether the method exists.&lt;/p&gt;

&lt;p&gt;And the implementations are not equivalent in cost. &lt;code&gt;DatabaseQueue::totalSize()&lt;/code&gt; is a single unfiltered &lt;code&gt;count()&lt;/code&gt; against the jobs table. &lt;code&gt;RedisQueue::totalSize()&lt;/code&gt; enumerates every known queue name and sums &lt;code&gt;size()&lt;/code&gt; across them, so the work scales with how many queues you have. On Redis with a lot of queues, that is not a call to make every second from a hot path.&lt;/p&gt;

&lt;p&gt;For a fuller picture of what to watch beyond raw depth, the trade-offs in &lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;managed queues versus Horizon&lt;/a&gt; cover where each approach puts the monitoring burden.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would actually change
&lt;/h2&gt;

&lt;p&gt;If you run long jobs, do these three things in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether you are running &lt;code&gt;--once&lt;/code&gt;.&lt;/strong&gt; Everything else is moot if signal handlers were never installed. Look at your Supervisor config, your Dockerfile CMD, your Kubernetes manifests and your crontab. If &lt;code&gt;--once&lt;/code&gt; is there and you have jobs that run for minutes, you are losing work on every deploy and it is not being logged anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add &lt;code&gt;Interruptible&lt;/code&gt; to your longest job, not all of them.&lt;/strong&gt; Find the job with the worst p99 duration and start there. Most jobs finish in under a second and gain nothing from this. The import that runs for six minutes is the one holding up your deploys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listen to &lt;code&gt;WorkerInterrupted&lt;/code&gt; before you do anything else.&lt;/strong&gt; It costs one listener and it tells you whether this is a real problem in your app. If it fires twice a month, drop it and move on. If it fires forty times during a deploy window, you have found something.&lt;/p&gt;

&lt;p&gt;Then, once you know a job can be interrupted safely, you can lower &lt;code&gt;stopwaitsecs&lt;/code&gt; from the hour the docs suggest to something that matches how fast your jobs actually wind down. That is the payoff. Not just cleaner shutdowns, but deploys that do not have to choose between waiting an hour and killing work in progress.&lt;/p&gt;

&lt;p&gt;The multi-tenant version of this gets messier, because a job stopping cleanly still has to stop cleanly in the right tenant context. I wrote about &lt;a href="https://hafiz.dev/blog/multi-tenancy-queues-three-bugs-laravel-saas" rel="noopener noreferrer"&gt;three bugs that only show up in multi-tenant queues&lt;/a&gt; if you are running that setup, and the &lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;two-minute server migration&lt;/a&gt; covers the deploy side when workers are part of the cutover.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does implementing Interruptible mean my job gets killed when a deploy happens?
&lt;/h3&gt;

&lt;p&gt;No. Laravel calls your &lt;code&gt;interrupted()&lt;/code&gt; method and nothing else. The job keeps running until your own code decides to stop it, which is why the flag-and-check pattern matters. A job that implements the contract but never checks the flag behaves exactly as it did before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did nothing happen when I sent SIGTERM to my worker?
&lt;/h3&gt;

&lt;p&gt;The most likely cause is &lt;code&gt;--once&lt;/code&gt;. That flag routes through &lt;code&gt;runNextJob()&lt;/code&gt; instead of &lt;code&gt;daemon()&lt;/code&gt;, and signal handlers are only installed in &lt;code&gt;daemon()&lt;/code&gt;. The other possibility is that the &lt;code&gt;pcntl&lt;/code&gt; extension is not loaded, since Laravel checks for it before installing handlers and silently skips them if it is missing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between JobInterrupted and WorkerInterrupted?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;WorkerInterrupted&lt;/code&gt; fires whenever a worker receives a termination signal, regardless of what it is running. &lt;code&gt;JobInterrupted&lt;/code&gt; fires only when the job currently being processed implements &lt;code&gt;Interruptible&lt;/code&gt;. Use the worker event for observability across everything, and the job event when you need the job instance itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does totalSize() work on every queue driver?
&lt;/h3&gt;

&lt;p&gt;Every driver implements it, but only the database and Redis drivers return real numbers. &lt;code&gt;SqsQueue&lt;/code&gt;, &lt;code&gt;SyncQueue&lt;/code&gt; and &lt;code&gt;BeanstalkdQueue&lt;/code&gt; return a hardcoded &lt;code&gt;0&lt;/code&gt;, so the call works and tells you nothing. On SQS, queue depth has to come from CloudWatch instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I still set stopwaitsecs high in Supervisor?
&lt;/h3&gt;

&lt;p&gt;Until your long jobs handle interruption, yes, because the alternative is SIGKILL partway through. Once a job stops cleanly within a known window, you can bring the value down to match that window instead of padding it to cover the full job duration.&lt;/p&gt;

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

&lt;p&gt;The feature here is small. One interface, two events, four counting methods. What makes it worth an afternoon is that the failure it prevents is invisible: jobs dying partway through on deploy, leaving half-written state, and nothing in your logs saying that is what happened.&lt;/p&gt;

&lt;p&gt;Before you write a single listener, go and grep your infrastructure config for &lt;code&gt;--once&lt;/code&gt;. If it is there alongside jobs that take minutes, that one flag is quietly discarding every cleanup path Laravel offers, and it will keep doing it however many contracts you implement.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>queues</category>
      <category>devops</category>
      <category>php</category>
    </item>
    <item>
      <title>Laravel Starter Kits Now Ship Vite+. Here Is What vp migrate Does to an App You Already Have</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 14 Sep 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-starter-kits-now-ship-vite-here-is-what-vp-migrate-does-to-an-app-you-already-have-13ni</link>
      <guid>https://dev.to/hafiz619/laravel-starter-kits-now-ship-vite-here-is-what-vp-migrate-does-to-an-app-you-already-have-13ni</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-starter-kits-vite-plus-migration" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every Laravel starter kit now ships with Vite+. That landed in &lt;a href="https://github.com/laravel/maestro/pull/60" rel="noopener noreferrer"&gt;laravel/maestro#60&lt;/a&gt; on 28 August, covering all 21 kit variants, and it deletes &lt;code&gt;eslint.config.js&lt;/code&gt;, &lt;code&gt;.prettierrc&lt;/code&gt; and &lt;code&gt;.prettierignore&lt;/code&gt; in favour of one binary called &lt;code&gt;vp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you are starting a new app, you get this for free and it works. The interesting question is the other one. What happens when you point &lt;code&gt;vp migrate&lt;/code&gt; at an app you already have?&lt;/p&gt;

&lt;p&gt;I tried it on a stock Laravel React starter kit. The first attempt refused to run at all, the second reported success while quietly failing halfway through, and the config file came out at 1,293 lines. None of that is a reason to avoid Vite+, but all of it is worth knowing before you run the command on something you care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vite+ actually is
&lt;/h2&gt;

&lt;p&gt;One binary that swallows your whole frontend toolchain. Vite for the dev server and build, Rolldown for bundling, Vitest for tests, Oxlint in place of ESLint, Oxfmt in place of Prettier, tsdown for library packaging, plus its own Node and package-manager management.&lt;/p&gt;

&lt;p&gt;So instead of &lt;code&gt;npm run lint&lt;/code&gt; and &lt;code&gt;npm run format:check&lt;/code&gt; and &lt;code&gt;npm run types:check&lt;/code&gt;, you run &lt;code&gt;vp check&lt;/code&gt; and it does all three in one pass. The test half is Vitest, which is the JavaScript side of your suite rather than a replacement for &lt;a href="https://hafiz.dev/blog/pest-5-tia-run-only-tests-your-change-touched" rel="noopener noreferrer"&gt;Pest on the PHP side&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is beta, currently 0.3.1. It is also MIT licensed, which is worth saying clearly because VoidZero originally announced it as a paid product for startups and enterprises. They reversed that and open sourced the whole thing. If you were holding off because you expected a licence bill later, that concern is gone.&lt;/p&gt;

&lt;p&gt;Installation does not go through npm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://vite.plus | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;npx vp&lt;/code&gt;. If you try it, the CLI stops you and points at the installer. On macOS it drops into &lt;code&gt;~/.local/share/vite-plus&lt;/code&gt;, asks for no sudo, and adds a line to your shell config. &lt;code&gt;vp implode --yes&lt;/code&gt; removes it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt one: it refuses
&lt;/h2&gt;

&lt;p&gt;A freshly created Laravel React starter kit today ships Vite 6. Run &lt;code&gt;vp migrate&lt;/code&gt; against it and you get this.&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%2Fx5ev0n83t6gw6h5slkk0.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%2Fx5ev0n83t6gw6h5slkk0.webp" alt="vp migrate refusing to run against a starter kit on Vite 6.1.1" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Worth noting that the message and the docs disagree. The CLI asks for Vite 7 or newer. The &lt;a href="https://viteplus.dev/guide/migrate" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; says to upgrade to Vite 8 and Vitest 4.1 first. Follow the docs, since Vite+ 0.3 is built on Vite 8.&lt;/p&gt;

&lt;p&gt;The good news is that the refusal is clean. It checked &lt;code&gt;package.json&lt;/code&gt;, decided it could not proceed, and changed nothing. No half-migrated state to unpick.&lt;/p&gt;

&lt;p&gt;So the first real step of any migration is not &lt;code&gt;vp migrate&lt;/code&gt; at all. It is upgrading Vite, which for most existing Laravel apps is its own piece of work with its own breaking changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt two: the report says success, the middle says otherwise
&lt;/h2&gt;

&lt;p&gt;I upgraded Vite to 8.3.0 and ran it again.&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%2Fq4smj73zvjw045g212n2.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%2Fq4smj73zvjw045g212n2.webp" alt="vp migrate reporting a successful migration directly below a warning that dependency installation failed" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the summary and the warning together, because they contradict each other. &lt;code&gt;✓ Dependencies installed in 21s&lt;/code&gt; sits four lines above &lt;code&gt;Dependency installation failed (exit code 1)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What happened underneath is a peer dependency conflict, the JavaScript cousin of &lt;a href="https://hafiz.dev/blog/4-composer-conflicts-blocking-laravel-13-upgrade" rel="noopener noreferrer"&gt;the Composer conflicts that block a Laravel upgrade&lt;/a&gt;. The kit ships &lt;code&gt;@tailwindcss/vite@4.0.8&lt;/code&gt;, which declares a peer range of Vite 5 or 6. Once Vite is on 8, npm refuses to resolve the tree and bails out. The migration kept going regardless, rewrote every config file, and only mentioned the failure in a warning at the bottom.&lt;/p&gt;

&lt;p&gt;The fix is the one the warning names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vp &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it works, for a reason that is easy to miss. The migration rewrote &lt;code&gt;package.json&lt;/code&gt; so that &lt;code&gt;vite&lt;/code&gt; is no longer the real Vite:&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;"devDependencies"&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;"vite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:@voidzero-dev/vite-plus-core@0.3.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;"vite-plus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.3.1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"overrides"&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;"vite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:@voidzero-dev/vite-plus-core@0.3.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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vite&lt;/code&gt; is now an alias to VoidZero's own package, pinned with an &lt;code&gt;overrides&lt;/code&gt; entry so nothing in the tree can ask for anything else. Tailwind's peer range is satisfied by fiat. That is how the conflict disappears on the second attempt, and it is the part of Vite+ that deserves a moment of thought before you adopt it, because every package that peers on Vite is now resolving against a VoidZero build rather than upstream Vite.&lt;/p&gt;

&lt;p&gt;If you deploy from CI with &lt;code&gt;npm ci&lt;/code&gt; and a lockfile, test that path specifically. The alias plus override combination is exactly the sort of thing that behaves differently under a clean install.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diff
&lt;/h2&gt;

&lt;p&gt;Six files, and it deletes two of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; .prettierrc       |   18 -
 eslint.config.js  |   44 -
 package-lock.json | 7574 +-
 package.json      |   35 +-
 tsconfig.json     |    1 -
 vite.config.js    | 1286 ++++
 6 files changed, 4463 insertions(+), 4495 deletions(-)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine devDependencies collapse into two. Out go &lt;code&gt;eslint&lt;/code&gt;, &lt;code&gt;@eslint/js&lt;/code&gt;, &lt;code&gt;eslint-config-prettier&lt;/code&gt;, &lt;code&gt;eslint-plugin-react&lt;/code&gt;, &lt;code&gt;eslint-plugin-react-hooks&lt;/code&gt;, &lt;code&gt;typescript-eslint&lt;/code&gt;, &lt;code&gt;prettier&lt;/code&gt;, &lt;code&gt;prettier-plugin-organize-imports&lt;/code&gt; and &lt;code&gt;prettier-plugin-tailwindcss&lt;/code&gt;. In come &lt;code&gt;vite-plus&lt;/code&gt; and the aliased &lt;code&gt;vite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The scripts get rewritten to match, so &lt;code&gt;vite build&lt;/code&gt; becomes &lt;code&gt;vp build&lt;/code&gt;, &lt;code&gt;prettier --write&lt;/code&gt; becomes &lt;code&gt;vp fmt&lt;/code&gt;, &lt;code&gt;eslint . --fix&lt;/code&gt; becomes &lt;code&gt;vp lint . --fix&lt;/code&gt;, and a &lt;code&gt;prepare&lt;/code&gt; script is added to wire up git hooks.&lt;/p&gt;

&lt;p&gt;One detail the summary glosses over. It claims Prettier was migrated to Oxfmt, and it deleted &lt;code&gt;.prettierrc&lt;/code&gt;, but it left &lt;code&gt;.prettierignore&lt;/code&gt; on disk with a warning suggesting you move those patterns into the config. So you finish the migration with a stray dotfile for a tool that is no longer installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why vite.config.js is now 1,293 lines
&lt;/h2&gt;

&lt;p&gt;This is the number that surprised me. The starter kit's config was 21 lines. After migration it is 1,293.&lt;/p&gt;

&lt;p&gt;It is not gratuitous. The ESLint config, the Prettier config and the lint environment all got folded into one file, and most of the bulk is a single block: 1,127 browser globals written out one per line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;staged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vp check --fix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;oxc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;typescript&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unicorn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;correctness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;builtin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;globals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;readonly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;readonly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// ...1,125 more&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;Underneath that sits the rules block, then the formatter settings, then your actual plugins.&lt;/p&gt;

&lt;p&gt;Credit where it is due on the translation. It carried the kit's real Prettier settings across rather than resetting to defaults, so &lt;code&gt;printWidth: 150&lt;/code&gt;, &lt;code&gt;tabWidth: 4&lt;/code&gt;, single quotes, the Tailwind class sorting with its &lt;code&gt;clsx&lt;/code&gt; and &lt;code&gt;cn&lt;/code&gt; functions, and the ignore pattern for the generated UI components all survived intact.&lt;/p&gt;

&lt;p&gt;Your Vite plugins also get wrapped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;lazyPlugins&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;laravel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/css/app.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/js/app.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;ssr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/js/ssr.jsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;tailwindcss&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;lazyPlugins&lt;/code&gt; keeps the plugin array from being constructed when you are only running lint or format, which is how &lt;code&gt;vp check&lt;/code&gt; stays fast without booting your whole build pipeline.&lt;/p&gt;

&lt;p&gt;The practical cost is that &lt;code&gt;vite.config.js&lt;/code&gt; is no longer a file you read. It is generated output that happens to live in your repo, and every future diff on it will be noise. If that bothers you, the globals block is the part to consider trimming by hand once you know which environments you actually target.&lt;/p&gt;

&lt;h2&gt;
  
  
  It builds, and then vp check fails
&lt;/h2&gt;

&lt;p&gt;The build works, and quickly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ built in 467ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vp dev&lt;/code&gt; starts a normal dev server on port 5173 and picks up the Laravel plugin correctly. Two deprecation warnings come from &lt;code&gt;vite:react-babel&lt;/code&gt; about &lt;code&gt;esbuild&lt;/code&gt; and &lt;code&gt;optimizeDeps.esbuildOptions&lt;/code&gt; now being Rolldown's job, which is upstream plugin lag rather than anything your app did.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;vp check&lt;/code&gt; fails on a starter kit nobody has touched.&lt;/p&gt;

&lt;p&gt;Two separate things are going on, and it is worth keeping them apart.&lt;/p&gt;

&lt;p&gt;The formatting failures are a scope change. The old scripts were &lt;code&gt;prettier --write resources/&lt;/code&gt;, so formatting only ever looked at the &lt;code&gt;resources/&lt;/code&gt; directory. &lt;code&gt;vp check&lt;/code&gt; looks at the repository. That means it reformats files Prettier was never pointed at, and on this kit it rewrote both GitHub Actions workflow files, 76 lines each. Nothing broke, but a migration that quietly restyles your CI config is a surprise, and it explains the reviewer note on the Laravel PR about Inertia kits needing workflow updates. If your &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;GitHub Actions pipeline&lt;/a&gt; asserts on formatting, that reformat is the kind of thing that turns a green build red for reasons unrelated to your code.&lt;/p&gt;

&lt;p&gt;The four lint errors are a different story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;welcome.tsx:221:46        TS2322  '"plus-darker"' is not assignable to MixBlendMode
login.tsx:25:72           TS2344  LoginForm does not satisfy FormDataType
register.tsx:20:72        TS2344  RegisterForm does not satisfy FormDataType
reset-password.tsx:24:72  TS2344  ResetPasswordForm does not satisfy FormDataType
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vite+ did not cause these. I checked out the pre-migration commit and ran &lt;code&gt;npx tsc --noEmit&lt;/code&gt; against it, and the same four errors are already there. The starter kit had no &lt;code&gt;types:check&lt;/code&gt; script, so nothing in the normal workflow ever ran the type checker. Vite+ turns on &lt;code&gt;typeAware&lt;/code&gt; and &lt;code&gt;typeCheck&lt;/code&gt; by default, so the moment you migrate, four pre-existing type errors become visible.&lt;/p&gt;

&lt;p&gt;That is an argument for Vite+, not against it. But it does mean your first &lt;code&gt;vp check&lt;/code&gt; on a real codebase will probably fail, and the failures will look like the migration's fault when they are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you migrate an existing app?
&lt;/h2&gt;

&lt;p&gt;For a new project, take it. The kits ship with it, it works, and one command in place of four npm scripts is a real improvement to the daily loop. That holds whichever kit you picked, though the Livewire kits changed least of all of them, since they have no TypeScript to lint, which is one more small point in the &lt;a href="https://hafiz.dev/blog/livewire-4-vs-inertia-3-laravel-frontend-2026" rel="noopener noreferrer"&gt;Livewire versus Inertia&lt;/a&gt; ledger.&lt;/p&gt;

&lt;p&gt;For an app you already run, the honest sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to Vite 8 first.&lt;/strong&gt; This is the actual project. &lt;code&gt;vp migrate&lt;/code&gt; will not even start until you do, and the Vite 8 upgrade has its own breaking changes that have nothing to do with Vite+.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the migration on a clean branch&lt;/strong&gt; and read the whole diff, particularly &lt;code&gt;vite.config.js&lt;/code&gt; and anything under &lt;code&gt;.github/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;vp install&lt;/code&gt; immediately&lt;/strong&gt;, whatever the summary claims about dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect the first &lt;code&gt;vp check&lt;/code&gt; to fail&lt;/strong&gt;, and triage it before assuming the migration broke something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test your CI install path&lt;/strong&gt;, since the aliased &lt;code&gt;vite&lt;/code&gt; and the &lt;code&gt;overrides&lt;/code&gt; entry are the parts most likely to behave differently under &lt;code&gt;npm ci&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The thing I would weigh hardest is the alias. Vite+ is MIT and the reversal on pricing was the right call, but adopting it means your &lt;code&gt;vite&lt;/code&gt; dependency resolves to a VoidZero package and every peer range in your tree is satisfied against that instead of upstream Vite. For most apps that is a fine trade for the tooling consolidation. It is still a decision, not a formality, and it is not one the migration output asks you to make out loud.&lt;/p&gt;

&lt;p&gt;Worth knowing too that none of this is in the Laravel documentation yet. The &lt;a href="https://laravel.com/docs/13.x/vite" rel="noopener noreferrer"&gt;asset bundling docs&lt;/a&gt; do not mention Vite+, &lt;code&gt;vp&lt;/code&gt;, Oxlint or Rolldown anywhere, even though the starter kits now ship all four.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I have to migrate if I am on an older Laravel app?
&lt;/h3&gt;

&lt;p&gt;No. Vite+ ships in new starter kits, and nothing in Laravel requires it. The existing &lt;code&gt;vite&lt;/code&gt; plus &lt;code&gt;laravel-vite-plugin&lt;/code&gt; setup keeps working, and the Laravel documentation still describes that as the standard path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did vp migrate refuse to run on my project?
&lt;/h3&gt;

&lt;p&gt;Most likely your Vite version. The CLI stops if &lt;code&gt;package.json&lt;/code&gt; has anything below Vite 7, and the migration guide asks for Vite 8 and Vitest 4.1. The refusal is clean, so nothing is modified when it happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Vite+ free, or does it become paid later?
&lt;/h3&gt;

&lt;p&gt;MIT licensed and free. VoidZero originally announced tiered pricing with a flat fee for startups and custom enterprise pricing, then reversed course and open sourced it. The projects it builds on, Vite, Vitest, Rolldown and Oxc, are all MIT as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my vite.config.js suddenly over a thousand lines?
&lt;/h3&gt;

&lt;p&gt;The lint environment gets inlined, including roughly 1,100 browser globals, along with the rules block and the formatter settings that used to live in &lt;code&gt;eslint.config.js&lt;/code&gt; and &lt;code&gt;.prettierrc&lt;/code&gt;. It is generated configuration rather than something you are expected to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will vp check pass on my codebase after migrating?
&lt;/h3&gt;

&lt;p&gt;Probably not on the first run, and often for reasons that predate the migration. Vite+ enables type-aware checking by default, so any type errors your old lint script never surfaced will appear immediately. Check them against &lt;code&gt;tsc&lt;/code&gt; on your pre-migration commit before treating them as regressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this comes down to
&lt;/h2&gt;

&lt;p&gt;Vite+ in the starter kits is a straightforward win for new apps. For existing ones, &lt;code&gt;vp migrate&lt;/code&gt; is not really the step that matters. Upgrading to Vite 8 is, and the migration is the short part at the end.&lt;/p&gt;

&lt;p&gt;If you do run it, read the summary sceptically. A migration that prints "Dependencies installed" and "Dependency installation failed" in the same output has earned a careful look at the diff before you commit it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>vite</category>
      <category>tooling</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
