<?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>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>
    <item>
      <title>Spatie Permission vs Bouncer: One Question Decides Which One You Need</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 10 Sep 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/spatie-permission-vs-bouncer-one-question-decides-which-one-you-need-4d17</link>
      <guid>https://dev.to/hafiz619/spatie-permission-vs-bouncer-one-question-decides-which-one-you-need-4d17</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/spatie-permission-vs-bouncer-laravel-roles" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




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

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

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

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

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

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

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

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

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

&lt;p&gt;Spatie's model is roles and permissions as strings, stored in tables, editable at runtime. You add a trait and you're running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Permission\Traits\HasRoles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasRoles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you create the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Permission\Models\Permission&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Permission\Models\Role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editor'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$editor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;givePermissionTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Permission&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'edit articles'&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assignRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'editor'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit articles'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is most of the package. There's a query scope for finding users by permission, &lt;code&gt;syncPermissions&lt;/code&gt; and &lt;code&gt;syncRoles&lt;/code&gt; for bulk changes, and &lt;code&gt;getAllPermissions&lt;/code&gt; for showing someone what they hold.&lt;/p&gt;

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

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

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

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

&lt;p&gt;Bouncer calls them abilities, and an ability can be granted against a class or against one model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// any post&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// this post&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Ownership is built in, which removes a policy method most apps write by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ownedVia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created_by'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there's a capability Spatie has no answer for. Bouncer can forbid, which beats any allow that would otherwise apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;everything&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forbid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toManage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forbid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'banned'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;everything&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'banned'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;Put the package check inside the policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit articles'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'locked'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your controller then calls &lt;code&gt;Gate::authorize('update', $post)&lt;/code&gt; and knows nothing about which package you chose. Swap Spatie for Bouncer later and the controllers don't change. Spatie's own documentation recommends exactly this, describing policies as the place where your application logic combines with your permission rules.&lt;/p&gt;

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

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

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

&lt;p&gt;Spatie has a teams mode you turn on in config before running migrations, then set the active team per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/permission.php&lt;/span&gt;
&lt;span class="s1"&gt;'teams'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;setPermissionsTeamId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details cause problems. That middleware has to run before &lt;code&gt;SubstituteBindings&lt;/code&gt; or you'll get 404 responses instead of 403s, which is a confusing afternoon. Spatie's documentation still shows this being set in &lt;code&gt;app/Http/Kernel.php&lt;/code&gt;, and that file hasn't existed since Laravel 11. On Laravel 13 the priority goes in &lt;code&gt;bootstrap/app.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Middleware&lt;/span&gt; &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prependToPriorityList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;\Illuminate\Routing\Middleware\SubstituteBindings&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;prepend&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;\App\Http\Middleware\TeamsPermission&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you switch teams inside a single request you must clear the loaded relations, or you'll read the previous team's answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;setPermissionsTeamId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$newTeamId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsetRelation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roles'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsetRelation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bouncer scopes everything through one call instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;Spatie caches the role and permission registry for 24 hours by default. The helper methods reset it for you, but editing rows directly in the database does not, and neither does a deploy. When permissions look stale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan permission:cache-reset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bouncer caches per request by default, which is safe. Turn on cross-request caching for speed and you take on invalidation yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// faster&lt;/span&gt;
&lt;span class="nc"&gt;Bouncer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;refreshFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// now your job, after every change&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SalesCoach&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Summarise this transcript.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

&lt;p&gt;&lt;strong&gt;Temperature&lt;/strong&gt; controls how adventurous the pick is. When the model has ten plausible next tokens, low temperature makes it take the most likely one nearly every time, and high temperature lets it wander. In the SDK it's an attribute on the agent class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Attributes\Temperature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Temperature(0.2)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoiceClassifier&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Classification, extraction, anything you're going to parse: keep it low. Naming things and writing copy: raise it.&lt;/p&gt;

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

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

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

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

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

&lt;p&gt;A &lt;strong&gt;tool&lt;/strong&gt; is a function you write, described in a way the model can understand, that the model may choose to call. The model never runs your code. It emits a request to run it, your framework runs it, and the result goes back into the conversation. In the SDK a tool is a class with three methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\JsonSchema\JsonSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Tools\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Stringable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LookupOrder&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Look up an order and its current status by order ID.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'order_id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toJson&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;An &lt;strong&gt;agent&lt;/strong&gt; bundles instructions and tools together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasTools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You help customers with order and billing questions.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LookupOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IssueRefund&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;agent loop&lt;/strong&gt; is what happens when you prompt that class. The model reads the conversation, decides whether to answer or call a tool, and if it calls one, your code runs and the result is appended. Then it starts again. It keeps going until it produces a final answer or hits the limit you set with &lt;code&gt;#[MaxSteps(10)]&lt;/code&gt;.&lt;/p&gt;

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

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

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

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

&lt;p&gt;&lt;strong&gt;Multi-agent&lt;/strong&gt; systems put agents inside other agents. In the SDK, an agent becomes callable as a tool by implementing &lt;code&gt;CanActAsTool&lt;/code&gt; and giving itself a name and description:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundsAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CanActAsTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'refunds_specialist'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Decide whether an order qualifies for a refund.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a parent agent lists &lt;code&gt;new RefundsAgent&lt;/code&gt; among its tools, and delegation is just a tool call. It's elegant. It's also the fastest way to spend money I know of, because every sub-agent carries its own context and its own steps. My honest advice is to reach for it only when one agent's tool list has grown incoherent, not because the architecture diagram looks better. I wrote up the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;sub-agent patterns in detail&lt;/a&gt; if you want the longer version.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Memory&lt;/strong&gt;, then, means deciding what to resend. The SDK gives you conversation persistence out of the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Concerns\RemembersConversations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Conversational&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Conversational&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RemembersConversations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Where is my order?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'And the one before that?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;p&gt;Laravel does all of this natively now, which surprised me when I first went looking. Generating embeddings is one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Napa Valley has great wine.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toEmbeddings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storing them is a column type, and an HNSW index keeps similarity search fast as the table grows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ensureVectorExtensionExists&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'documents'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'embedding'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;vector database&lt;/strong&gt; is a database built to store those numeric arrays and find the nearest ones quickly. Pinecone and Qdrant are the well-known standalone ones. But you very likely don't need either, because PostgreSQL with pgvector, MariaDB 11.7 or later, and MongoDB all do this from inside your existing database, and Laravel ships the query method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$documents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereVectorSimilarTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'embedding'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'best wineries in Napa Valley'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minSimilarity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Reranking&lt;/strong&gt; is a cheap trick, and it has done more for my results than anything else in this layer. Retrieve a wide set fast, then have a model reorder the top candidates by actual relevance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Article&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereFullText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;p&gt;An MCP &lt;strong&gt;server&lt;/strong&gt; exposes tools, resources and prompts. An MCP &lt;strong&gt;client&lt;/strong&gt; consumes them. Your Laravel app can be either, and there's a first-party package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Facades\Mcp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/support'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SupportServer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:mcp'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;p&gt;In Laravel this is middleware, and it works on both directions in one class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Closure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Prompts\AgentPrompt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Responses\AgentResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScreenContent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AgentPrompt&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort_if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;looksLikeInjection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AgentResponse&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'agent.responded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Human in the loop&lt;/strong&gt; means the agent stops and asks before doing something it can't undo. The SDK makes approval a property of the tool, so the dangerous ones pause and the harmless ones don't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;needsApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Approval&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'amount'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Approval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Refunds above 5,000 need a human.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent then returns with &lt;code&gt;hasPendingApprovals()&lt;/code&gt; true, you show a human the pending call and its arguments, and you resume with &lt;code&gt;Decision::approve()&lt;/code&gt; or &lt;code&gt;Decision::reject()&lt;/code&gt;. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-human-in-the-loop-tool-approval" rel="noopener noreferrer"&gt;full workflow is here&lt;/a&gt;, including what happens when generation fails mid-approval.&lt;/p&gt;

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

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

&lt;p&gt;Then &lt;strong&gt;cost&lt;/strong&gt;. Two attributes cover most of it, since not every task needs your best model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[UseCheapestModel]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TagExtractor&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

&lt;p&gt;Let me show you rather than describe it. A fresh Laravel 13.29 app, SQLite, the default &lt;code&gt;users&lt;/code&gt; table with its unique index on &lt;code&gt;email&lt;/code&gt;. Create a user, then try to create the same one again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mario Rossi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mario.rossi@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'secret-password'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mario Rossi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mario.rossi@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'secret-password'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second call throws a &lt;code&gt;QueryException&lt;/code&gt; (a &lt;code&gt;UniqueConstraintViolationException&lt;/code&gt;, to be exact). This is its &lt;code&gt;getMessage()&lt;/code&gt;, straight from the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SQLSTATE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;23000&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;Integrity&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;www&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Mario&lt;/span&gt; &lt;span class="n"&gt;Rossi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rossi&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;hR9&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;29&lt;/span&gt; &lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;57&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;29&lt;/span&gt; &lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;57&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;&lt;a href="https://github.com/laravel/framework/releases/tag/v13.27.0" rel="noopener noreferrer"&gt;Laravel 13.27&lt;/a&gt; adds a per-connection config key, contributed by Lau Josefsen in PR #61326:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'mask_bindings_in_exception_messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_MASK_BINDINGS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it on, the same failure produces this message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SQLSTATE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;23000&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;Integrity&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;www&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;The fix is to add the line to each connection you use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'mysql'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'mask_bindings_in_exception_messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_MASK_BINDINGS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set &lt;code&gt;DB_MASK_BINDINGS=true&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, clear the config cache, and it works. I verified this by checking &lt;code&gt;config()&lt;/code&gt; before and after. If you skip the config edit, the env var is silently ignored, which is the worst kind of security setting: one that looks on and isn't.&lt;/p&gt;

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

&lt;p&gt;You don't lose the data, though. The &lt;code&gt;QueryException&lt;/code&gt; object still carries everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;QueryException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSql&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// the query with ? placeholders&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBindings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// the real values, as an array&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRawSql&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// the query with values filled in, properly quoted&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;p&gt;Everything day to day goes through three small bash scripts installed to &lt;code&gt;/usr/local/bin&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;p                      &lt;span class="c"&gt;# list projects, staging URLs, which sessions are running&lt;/span&gt;
p prompt-optimizer         &lt;span class="c"&gt;# jump into that project's tmux session, already cd'd&lt;/span&gt;

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;v4 ships a generator that creates both halves of a screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan native:make WatchList
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you &lt;code&gt;app/NativeComponents/WatchList.php&lt;/code&gt; and &lt;code&gt;resources/views/native/watch-list.blade.php&lt;/code&gt;, plus the route line to paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\NativeComponents\WatchList&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;native&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;WatchList&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Route::native()&lt;/code&gt; is the mobile sibling of a Livewire route. Parameters work like web routes, so &lt;code&gt;Route::native('/video/{id}', VideoDetail::class)&lt;/code&gt; matches a path segment and the screen reads it with &lt;code&gt;$this-&amp;gt;param('id')&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;A full PHP runtime with SQLite runs on the device, so the model and migration are exactly what you would write in any Laravel app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'videos'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'channel'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsignedSmallInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minutes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'watched'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One on-device quirk worth knowing: there is no &lt;code&gt;db:seed&lt;/code&gt; on the phone. Migrations run once on app start, so starter data goes into the migration's &lt;code&gt;up()&lt;/code&gt; method as plain inserts. It feels wrong for about a minute and then makes complete sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  The component
&lt;/h3&gt;



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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\View\View&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Native\Mobile\Edge\NativeComponent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WatchList&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;NativeComponent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toggleWatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Video&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;watched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;watched&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Video&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'watched'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'native.watch-list'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'videos'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$videos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queuedMinutes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$videos&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'watched'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minutes'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eloquent, on a phone, feeding SwiftUI. That sentence still feels strange to type.&lt;/p&gt;

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

&lt;p&gt;The view uses EDGE elements, Blade tags under the &lt;code&gt;native:&lt;/code&gt; namespace that map one-to-one onto native UI. Styling is Tailwind utility classes, parsed by the engine into native modifiers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;native:top-bar title="Watch Later" /&amp;gt;

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;This is the part of v4 that changes who can use it. Compiling an iOS app still requires a Mac with Xcode. Running one during development no longer does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan native:jump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

&lt;p&gt;The sleeper feature of v4 is the testing harness. &lt;code&gt;php artisan native:make-test WatchList&lt;/code&gt; scaffolds a Pest test, and the API reads like Livewire's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\NativeComponents\WatchList&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Native\Mobile\Testing\Native&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'toggles a video watched when its row is tapped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Video&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Laracon US 2026 Keynote'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstOrFail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Native&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WatchList&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"video-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'66 minutes queued'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$video&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;watched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

&lt;p&gt;&lt;code&gt;Cache::lock()&lt;/code&gt; is the built-in answer, and for a lot of cases it is the right one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stock_allocation_SKU-1180'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;h2&gt;
  
  
  What the package adds
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;ZaberDev\Lock\Facades\Lock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipment_dispatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$shipment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$carrier&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shipment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Lock::for('shipment_dispatch', $shipment)&lt;/code&gt; builds the key from the name plus the model, so the same target always produces the same key. You cannot typo your way into a second lock on the same row.&lt;/p&gt;

&lt;p&gt;There is a &lt;code&gt;block()&lt;/code&gt; form that handles acquire and release for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipment_dispatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$shipment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shipment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$carrier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$carrier&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shipment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Models can carry their own locks with a trait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;ZaberDev\Lock\HasLocks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Shipment&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasLocks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;And routes can be guarded with middleware, which is the piece I have hand-rolled more than once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/warehouse/reconcile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReconcileController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lock:warehouse_reconcile,300'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/shipments/{shipment}/dispatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ShipmentController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'dispatch'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lock:shipment_dispatch:{shipment},60'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one binds the lock to the route parameter, so two requests for different shipments do not block each other while two requests for the same one do. When the lock is already held, the middleware does not queue the request. It rejects it with a 429 before your controller runs.&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require zaber-dev/laravel-lock
php artisan vendor:publish &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ZaberDev&lt;/span&gt;&lt;span class="se"&gt;\L&lt;/span&gt;&lt;span class="s2"&gt;ock&lt;/span&gt;&lt;span class="se"&gt;\L&lt;/span&gt;&lt;span class="s2"&gt;ockServiceProvider"&lt;/span&gt;
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>laravel</category>
      <category>concurrency</category>
      <category>packages</category>
      <category>php</category>
    </item>
  </channel>
</rss>
