<?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>Forem: Hafiz</title>
    <description>The latest articles on Forem by Hafiz (@hafiz619).</description>
    <link>https://forem.com/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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>Forem: Hafiz</title>
      <link>https://forem.com/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>Livewire 4 Single-File Components: Build a Live Search in One File</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 08 Apr 2026 05:29:47 +0000</pubDate>
      <link>https://forem.com/hafiz619/livewire-4-single-file-components-build-a-live-search-in-one-file-5274</link>
      <guid>https://forem.com/hafiz619/livewire-4-single-file-components-build-a-live-search-in-one-file-5274</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/livewire-4-single-file-components-tutorial" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you ran &lt;code&gt;php artisan make:livewire&lt;/code&gt; after upgrading to Livewire v4 and noticed the file landed in &lt;code&gt;resources/views/components/&lt;/code&gt; instead of &lt;code&gt;app/Livewire/&lt;/code&gt;, that wasn't a mistake. That's the new default. Livewire 4 shipped single-file components as the standard format in January 2026, and most Livewire developers are still building with the old two-file pattern out of habit.&lt;/p&gt;

&lt;p&gt;This tutorial covers how single-file components actually work, when to use them versus the multi-file format, what's changed about scoped CSS and JavaScript in v4, and how to build something real with it: a live search component with filtering, scoped styles, and an Island for expensive data. No shortcuts , just the format you'll be using from now on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With the Old Two-File Pattern
&lt;/h2&gt;

&lt;p&gt;In Livewire v3, creating a component meant two files minimum. The PHP class lived in &lt;code&gt;app/Livewire/SearchPosts.php&lt;/code&gt;, and the Blade view lived in &lt;code&gt;resources/views/livewire/search-posts.blade.php&lt;/code&gt;. If you wanted component-specific JavaScript, you'd reach for &lt;code&gt;@script&lt;/code&gt; or &lt;code&gt;@push('scripts')&lt;/code&gt;. CSS was either inline or pushed to a stack.&lt;/p&gt;

&lt;p&gt;It worked. But every time you built a component, you mentally held two files together. When you searched for a component in your editor, you got two results. When you read a diff, the logic and the template were on separate lines of the PR. The connection between them existed only in your head and in &lt;code&gt;render()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Livewire 4 puts everything in one file. PHP class, Blade template, scoped CSS, component JavaScript. One file, one search result, one diff chunk. That's the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Single-File Component Looks Like
&lt;/h2&gt;

&lt;p&gt;Create one with:&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 make:livewire search-posts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a file at &lt;code&gt;resources/views/components/⚡search-posts.blade.php&lt;/code&gt;. The structure is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Computed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&lt;/span&gt;&lt;span class="p"&gt;;&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;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&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="na"&gt;#[Computed]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;results&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="nb"&gt;strlen&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;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;collect&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;Post&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;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&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;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;span class="p"&gt;}&lt;/span&gt;

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

&lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;

&amp;gt; **[View the interactive component on hafiz.dev](https://hafiz.dev/blog/livewire-4-single-file-components-tutorial)**
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; blocks are served as native &lt;code&gt;.css&lt;/code&gt; and &lt;code&gt;.js&lt;/code&gt; files with browser caching. Livewire handles the bundling. You don't touch Vite config or &lt;code&gt;webpack.mix.js&lt;/code&gt; to make this work.&lt;/p&gt;

&lt;p&gt;One thing to flag: the bare &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag works in single-file and multi-file components. If you're still on class-based components where the Blade view is separate from the PHP class, you need &lt;code&gt;@script&lt;/code&gt; instead. That's the v3 pattern and it still works, but it's no longer the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding an Island for Expensive Data
&lt;/h2&gt;

&lt;p&gt;Our search component runs a database query on every keystroke (debounced to 300ms). That's fine for a simple search. But what if the component also needs to show some stats , total posts, most searched terms , that are expensive to compute and don't change with every search?&lt;/p&gt;

&lt;p&gt;That's where Islands come in. They let you mark a region of your component to update independently from the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Computed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&lt;/span&gt;&lt;span class="p"&gt;;&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;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&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="na"&gt;#[Computed]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;results&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="nb"&gt;strlen&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;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;collect&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;Post&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;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&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;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;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Computed]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;stats&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;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'published'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'published'&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;span class="p"&gt;}&lt;/span&gt;

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

&lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt;
        &lt;span class="na"&gt;wire:model.live.debounce.300ms=&lt;/span&gt;&lt;span class="s"&gt;"query"&lt;/span&gt;
        &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;
        &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Search posts..."&lt;/span&gt;
        &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"search-input"&lt;/span&gt;
    &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    @if($this-&amp;gt;results-&amp;gt;isNotEmpty())
        &lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"results-list"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            @foreach($this-&amp;gt;results as $post)
                &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ route('posts.show', $post) }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                        {{ $post-&amp;gt;title }}
                    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
            @endforeach
        &lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
    @endif

    @island(name: 'stats', lazy: true)
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"stats"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;{{ $this-&amp;gt;stats['total'] }} total posts&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;{{ $this-&amp;gt;stats['published'] }} published&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    @endisland
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the user types, only the search results re-render. The &lt;code&gt;stats&lt;/code&gt; island loads lazily and stays cached until you explicitly tell it to refresh. The database queries for &lt;code&gt;stats()&lt;/code&gt; don't run on every keystroke. That's the key performance win , you're isolating the expensive parts of your component rather than paying for them on every interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering the Component
&lt;/h2&gt;

&lt;p&gt;Include it in any Blade template the same way as any other Livewire component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;livewire:search-posts /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component name is derived from the filename. The &lt;code&gt;⚡&lt;/code&gt; prefix and directory structure are stripped automatically. So &lt;code&gt;⚡search-posts.blade.php&lt;/code&gt; becomes &lt;code&gt;search-posts&lt;/code&gt;. You can switch between single-file and multi-file formats without changing this reference.&lt;/p&gt;

&lt;p&gt;For full-page components (search results as a standalone page, for example), use the &lt;code&gt;pages::&lt;/code&gt; namespace:&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 make:livewire pages::search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And register the route with &lt;code&gt;Route::livewire()&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="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;livewire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/search'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pages::search'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When to Use Single-File vs Multi-File
&lt;/h2&gt;

&lt;p&gt;Single-file is the right default for most components. It works well up to a few hundred lines, covers the vast majority of real-world use cases, and keeps everything co-located.&lt;/p&gt;

&lt;p&gt;Multi-file makes sense when a component gets complex enough that one file becomes hard to navigate, or when you want a dedicated test file alongside the component. Create one with:&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 make:livewire post.editor &lt;span class="nt"&gt;--mfc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resources/views/components/post/⚡editor/
├── editor.php
├── editor.blade.php
├── editor.js
└── editor.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The directory structure doesn't change how you reference the component. &lt;code&gt;&amp;lt;livewire:post.editor /&amp;gt;&lt;/code&gt; still works. And you can convert between formats at any time:&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="c"&gt;# Single-file → Multi-file&lt;/span&gt;
php artisan livewire:convert search-posts

&lt;span class="c"&gt;# Multi-file → Single-file&lt;/span&gt;
php artisan livewire:convert post.editor &lt;span class="nt"&gt;--single&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a multi-file component has a test file, Livewire will warn you before converting to single-file since test files can't be preserved in that format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating a v3 Component
&lt;/h2&gt;

&lt;p&gt;If you have existing v3 class-based components, nothing breaks. They keep working. But if you want to move a specific component to the new format, here's the before and after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (v3 class-based):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/Livewire/SearchPosts.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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Livewire&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&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;SearchPosts&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&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="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="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;'livewire.search-posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'results'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;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;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&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;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;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;resources/views/livewire/search-posts.blade.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;wire:model.live=&lt;/span&gt;&lt;span class="s"&gt;"query"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    @foreach($results as $post)
        &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&lt;/span&gt;{{ $post-&amp;gt;title }}&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
    @endforeach
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (v4 single-file):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resources/views/components/⚡search-posts.blade.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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Computed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&lt;/span&gt;&lt;span class="p"&gt;;&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;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&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="na"&gt;#[Computed]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;results&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;Post&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;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&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;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;span class="p"&gt;}&lt;/span&gt;

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

&lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;wire:model.live=&lt;/span&gt;&lt;span class="s"&gt;"query"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    @foreach($this-&amp;gt;results as $post)
        &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&lt;/span&gt;{{ $post-&amp;gt;title }}&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
    @endforeach
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main differences: no separate class file, no &lt;code&gt;render()&lt;/code&gt; method, results accessed via &lt;code&gt;$this-&amp;gt;results&lt;/code&gt; with the &lt;code&gt;#[Computed]&lt;/code&gt; attribute instead of being passed as view data, and the anonymous class definition instead of a named class in the &lt;code&gt;App\Livewire&lt;/code&gt; namespace.&lt;/p&gt;

&lt;p&gt;One v4 change worth knowing if you're migrating: &lt;code&gt;wire:model&lt;/code&gt; no longer listens to events that bubble up from child elements. In v3, &lt;code&gt;wire:model&lt;/code&gt; on a container element would catch input events from nested inputs inside it. That's gone in v4 , &lt;code&gt;wire:model&lt;/code&gt; only responds to events directly on the element it's attached to. If you need the old behavior, add the &lt;code&gt;.deep&lt;/code&gt; modifier: &lt;code&gt;wire:model.deep&lt;/code&gt;. This catches most developers off guard the first time they hit it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I have to rewrite my existing v3 components?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Class-based components still work exactly as before. Single-file is the new default for components you create going forward, but nothing forces you to migrate old ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Filament with Livewire 4 single-file components?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Filament v5 runs on Livewire v4. Your Filament resources and custom pages are separate from your own Livewire components , they coexist without conflict. If you're building &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;a Filament admin panel&lt;/a&gt;, you don't need to change anything about how Filament works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the &lt;code&gt;#[Computed]&lt;/code&gt; attribute required for properties accessed in the template?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. You can still pass data to the template through a &lt;code&gt;render()&lt;/code&gt; method if you want. &lt;code&gt;#[Computed]&lt;/code&gt; is a convenience attribute that caches the result for the lifetime of the request and makes the property accessible as &lt;code&gt;$this-&amp;gt;results&lt;/code&gt; directly in the template. It's the cleaner pattern for v4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does wire:model.live work the same as in v3?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The debouncing behavior is the same, but the event bubbling behavior changed. In v4, &lt;code&gt;wire:model&lt;/code&gt; only listens to events that originate directly on the element , not events that bubble up from children. For forms with standard inputs (text, select, textarea), you'll notice no difference. The change only affects non-standard uses like &lt;code&gt;wire:model&lt;/code&gt; on a container element.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I still use Alpine.js inside single-file components?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, fully. Alpine directives work in the template exactly as before. The &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block gives you access to &lt;code&gt;$wire&lt;/code&gt; for crossing the PHP-JavaScript boundary when you need it. Alpine handles client-side state, &lt;code&gt;$wire&lt;/code&gt; handles server state.&lt;/p&gt;




&lt;p&gt;The single-file format doesn't unlock anything that was impossible in v3 , it just removes the overhead of managing two files for every component. For small to medium components that's a genuine improvement, and for anything with scoped CSS or component-specific JavaScript it's significantly cleaner. If you're building a &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS with Livewire and Filament&lt;/a&gt;, starting new components in the v4 format now means less context-switching and fewer files to track as the app grows.&lt;/p&gt;

&lt;p&gt;Check the &lt;a href="https://livewire.laravel.com/docs/4.x/components" rel="noopener noreferrer"&gt;official Livewire v4 docs&lt;/a&gt; for the full component reference, including namespaces, slots, and attribute forwarding.&lt;/p&gt;

&lt;p&gt;If you're planning a build and unsure whether Livewire or Inertia is the right call for your specific project, the &lt;a href="https://hafiz.dev/blog/livewire-4-vs-inertia-3-laravel-frontend-2026" rel="noopener noreferrer"&gt;Livewire 4 vs Inertia.js 3 comparison&lt;/a&gt; covers the decision in detail.&lt;/p&gt;

&lt;p&gt;If you're building something with Livewire and want another dev to look it over, &lt;a href="https://hafiz.dev/#contact" rel="noopener noreferrer"&gt;reach out&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Scotty vs Laravel Envoy: Spatie's New Deploy Tool Is Worth the Switch</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 06 Apr 2026 05:38:54 +0000</pubDate>
      <link>https://forem.com/hafiz619/scotty-vs-laravel-envoy-spaties-new-deploy-tool-is-worth-the-switch-mfc</link>
      <guid>https://forem.com/hafiz619/scotty-vs-laravel-envoy-spaties-new-deploy-tool-is-worth-the-switch-mfc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Spatie released Scotty on March 30th. It's a new SSH task runner that does what Laravel Envoy does: run deploy scripts on remote servers. But it uses plain bash syntax instead of Blade templates, and gives you significantly better terminal output while tasks run.&lt;/p&gt;

&lt;p&gt;Freek Van der Herten wrote about it on his blog: "Even though services like Laravel Cloud make it possible to never think about servers again, I still prefer deploying to my own servers for some projects." That's exactly the scenario Scotty targets. If you're on a DigitalOcean droplet, a Hetzner box, or anything you manage yourself, and you're either still SSH-ing in manually or running Envoy, Scotty is worth a look.&lt;/p&gt;

&lt;p&gt;Let's break down what it actually does differently, whether it's a meaningful upgrade, and how to migrate or set it up from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Laravel Envoy
&lt;/h2&gt;

&lt;p&gt;Envoy works. I'm not going to pretend it's broken. But there are two friction points that come up every time you actually use it.&lt;/p&gt;

&lt;p&gt;The first is the Blade file format. Your deploy script is an &lt;code&gt;Envoy.blade.php&lt;/code&gt; file full of &lt;code&gt;@task&lt;/code&gt;, &lt;code&gt;@servers&lt;/code&gt;, &lt;code&gt;@story&lt;/code&gt; directives and &lt;code&gt;{{ $variable }}&lt;/code&gt; syntax. It looks like PHP, but it's not quite PHP. Your editor treats it differently depending on how your Blade support is configured. Shell linting won't touch it. Autocompletion for bash commands doesn't work inside the Blade blocks. It's a hybrid format that's slightly awkward for what is fundamentally a shell scripting task.&lt;/p&gt;

&lt;p&gt;The second is the output. When Envoy runs, you see the commands executing one after another in a plain stream. There's no step counter, no elapsed time per task, no summary at the end. When something takes 40 seconds you're just watching text scroll by hoping nothing's wrong.&lt;/p&gt;

&lt;p&gt;Scotty addresses both directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Scotty Does Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Plain bash with annotation comments.&lt;/strong&gt; Your script is a &lt;code&gt;Scotty.sh&lt;/code&gt; file with a &lt;code&gt;#!/usr/bin/env scotty&lt;/code&gt; shebang. Tasks are regular bash functions. Server targets and macros are annotation comments. It looks like this:&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="c"&gt;#!/usr/bin/env scotty&lt;/span&gt;

&lt;span class="c"&gt;# @servers remote=deployer@your-server.com&lt;/span&gt;
&lt;span class="c"&gt;# @macro deploy pullCode runComposer runMigrations clearCaches restartWorkers&lt;/span&gt;

&lt;span class="nv"&gt;APP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/www/my-app"&lt;/span&gt;
&lt;span class="nv"&gt;BRANCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BRANCH&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote confirm="Deploy to production?"&lt;/span&gt;
pullCode&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;
    git pull origin &lt;span class="nv"&gt;$BRANCH&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
runComposer&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;
    composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt; &lt;span class="nt"&gt;--prefer-dist&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
runMigrations&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;
    php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
clearCaches&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
restartWorkers&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;
    php artisan horizon:terminate
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a complete deploy script. Notice that &lt;code&gt;BRANCH="${BRANCH:-main}"&lt;/code&gt; is just bash. It defaults to &lt;code&gt;main&lt;/code&gt; and accepts an override from the command line. No Blade interpolation needed. Your editor highlights it correctly. &lt;code&gt;shellcheck&lt;/code&gt; can lint it. Bash autocomplete works inside the functions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live output with a summary table.&lt;/strong&gt; While tasks run, Scotty shows each one with its name, a step counter, elapsed time, and the current command executing. When everything finishes, you get a summary table showing how long each step took. It's a small thing but it makes a real difference when a deploy takes two minutes and you need to know if the three-second Composer install is suspicious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pause and resume.&lt;/strong&gt; If you need to interrupt a deploy mid-flight, press &lt;code&gt;p&lt;/code&gt; and Scotty waits for the current task to finish, then pauses. Hit &lt;code&gt;Enter&lt;/code&gt; to resume. This matters more than it sounds when you're deploying a hot fix at 11pm and something looks off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;scotty doctor&lt;/code&gt; command.&lt;/strong&gt; Run &lt;code&gt;scotty doctor&lt;/code&gt; before your first deploy and it validates your Scotty.sh file, tests SSH connectivity to each server, and checks that PHP, Composer, and Git are installed on the remote machine. A pre-flight check that catches most setup issues before a deploy even starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--pretend&lt;/code&gt; mode.&lt;/strong&gt; Before running a deploy on a new server for the first time, add the &lt;code&gt;--pretend&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scotty run deploy &lt;span class="nt"&gt;--pretend&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scotty prints every SSH command it would execute without actually connecting to anything. &lt;code&gt;scotty doctor&lt;/code&gt; checks your setup. &lt;code&gt;--pretend&lt;/code&gt; checks your script logic. Run both before you touch production for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Scotty
&lt;/h2&gt;

&lt;p&gt;Install it as a global Composer package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer global require spatie/scotty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure Composer's global bin directory is in your &lt;code&gt;$PATH&lt;/code&gt;. If you're not sure where it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer global config bin-dir &lt;span class="nt"&gt;--absolute&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once installed, verify it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scotty list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To create a new Scotty file in your project, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scotty init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asks for your server SSH connection string and generates a starter &lt;code&gt;Scotty.sh&lt;/code&gt; file. Or just create the file manually. The format is simple enough that you don't really need a generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating From Envoy
&lt;/h2&gt;

&lt;p&gt;If you already have an &lt;code&gt;Envoy.blade.php&lt;/code&gt;, you don't have to rewrite it immediately. Scotty reads Envoy files out of the box. Just run &lt;code&gt;scotty run deploy&lt;/code&gt; against your existing Envoy file and it works.&lt;/p&gt;

&lt;p&gt;When you're ready to migrate to the native format, the mental model is clear:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Envoy&lt;/th&gt;
&lt;th&gt;Scotty.sh&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@servers(['web' =&amp;gt; 'user@host'])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;# @servers remote=user@host&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@story('deploy') ... @endstory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;# @macro deploy task1 task2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@task('pullCode', ['on' =&amp;gt; 'web'])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;# @task on:remote&lt;/code&gt; followed by &lt;code&gt;pullCode() { }&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ $branch }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;$BRANCH&lt;/code&gt; (plain bash variable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@setup $branch = 'main'; @endsetup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BRANCH="${BRANCH:-main}"&lt;/code&gt; at the top of the file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The actual shell commands inside tasks don't change at all. You're just rewriting the wrappers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-Downtime Deployments
&lt;/h2&gt;

&lt;p&gt;This is where Scotty shines for production apps. The Scotty docs include a complete zero-downtime deploy script, and it's the same pattern Spatie uses for all their own applications.&lt;/p&gt;

&lt;p&gt;The idea: instead of updating files in place (which means there's always a window where your code is half-updated), you clone each release into a new timestamped directory and flip a symlink when everything's ready. Here's what the directory structure looks like on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/www/my-app/
├── current -&amp;gt; /var/www/my-app/releases/20260406-140000
├── persistent/
│   └── storage/
├── releases/
│   ├── 20260406-130000/
│   └── 20260406-140000/
└── .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your Nginx document root points to &lt;code&gt;/var/www/my-app/current/public&lt;/code&gt;. The &lt;code&gt;current&lt;/code&gt; symlink gets updated atomically at the end of a successful deploy. If Composer fails or a migration breaks, &lt;code&gt;current&lt;/code&gt; still points to the last working release and your users see nothing wrong.&lt;/p&gt;

&lt;p&gt;Here's the complete zero-downtime script:&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="c"&gt;#!/usr/bin/env scotty&lt;/span&gt;

&lt;span class="c"&gt;# @servers local=127.0.0.1 remote=deployer@your-server.com&lt;/span&gt;
&lt;span class="c"&gt;# @macro deploy startDeployment cloneRepository runComposer buildAssets updateSymlinks migrateDatabase blessNewRelease cleanOldReleases&lt;/span&gt;

&lt;span class="nv"&gt;BASE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/www/my-app"&lt;/span&gt;
&lt;span class="nv"&gt;RELEASES_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/releases"&lt;/span&gt;
&lt;span class="nv"&gt;PERSISTENT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/persistent"&lt;/span&gt;
&lt;span class="nv"&gt;CURRENT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/current"&lt;/span&gt;
&lt;span class="nv"&gt;NEW_RELEASE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;NEW_RELEASE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASES_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$NEW_RELEASE_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-org/your-repo"&lt;/span&gt;
&lt;span class="nv"&gt;BRANCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BRANCH&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# @task on:local&lt;/span&gt;
startDeployment&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    git checkout &lt;span class="nv"&gt;$BRANCH&lt;/span&gt;
    git pull origin &lt;span class="nv"&gt;$BRANCH&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
cloneRepository&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;$RELEASES_DIR&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$RELEASES_DIR&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;$PERSISTENT_DIR&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$PERSISTENT_DIR&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;$PERSISTENT_DIR&lt;/span&gt;/storage &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$PERSISTENT_DIR&lt;/span&gt;/storage
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$RELEASES_DIR&lt;/span&gt;
    git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 &lt;span class="nt"&gt;--branch&lt;/span&gt; &lt;span class="nv"&gt;$BRANCH&lt;/span&gt; git@github.com:&lt;span class="nv"&gt;$REPOSITORY&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_NAME&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
runComposer&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_DIR&lt;/span&gt;
    &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-nfs&lt;/span&gt; &lt;span class="nv"&gt;$BASE_DIR&lt;/span&gt;/.env .env
    composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--prefer-dist&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
buildAssets&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_DIR&lt;/span&gt;
    npm ci
    npm run build
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; node_modules
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
updateSymlinks&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_DIR&lt;/span&gt;/storage
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_DIR&lt;/span&gt;
    &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-nfs&lt;/span&gt; &lt;span class="nv"&gt;$PERSISTENT_DIR&lt;/span&gt;/storage storage
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
migrateDatabase&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_DIR&lt;/span&gt;
    php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
blessNewRelease&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-nfs&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_DIR&lt;/span&gt; &lt;span class="nv"&gt;$CURRENT_DIR&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$NEW_RELEASE_DIR&lt;/span&gt;
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
    php artisan cache:clear
    php artisan horizon:terminate
    &lt;span class="nb"&gt;sudo &lt;/span&gt;service php8.4-fpm restart
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# @task on:remote&lt;/span&gt;
cleanOldReleases&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$RELEASES_DIR&lt;/span&gt;
    &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-dt&lt;/span&gt; &lt;span class="nv"&gt;$RELEASES_DIR&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +4 | xargs &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scotty run deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or deploy a specific branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scotty run deploy &lt;span class="nt"&gt;--branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;develop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting about this script. The &lt;code&gt;startDeployment&lt;/code&gt; task runs locally: it checks out and pulls the branch on your machine first, so you catch any git conflicts before touching the server. The &lt;code&gt;blessNewRelease&lt;/code&gt; task is where the symlink actually flips, so everything before that step is safe to fail. And &lt;code&gt;cleanOldReleases&lt;/code&gt; keeps the three most recent releases on disk in case you ever need to inspect one.&lt;/p&gt;

&lt;p&gt;If you're running queue workers with Horizon, &lt;code&gt;php artisan horizon:terminate&lt;/code&gt; tells Supervisor to restart it with the new code once the current jobs finish. If you have a &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue setup&lt;/a&gt;, this is the step that picks up your latest job definitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  So Is It Worth Switching?
&lt;/h2&gt;

&lt;p&gt;If you're starting a new project: yes, use Scotty from the beginning. The bash format is strictly better than Blade for shell scripting, and there's no migration cost.&lt;/p&gt;

&lt;p&gt;If you're on Envoy and it's working: the migration is low-effort since Scotty reads your existing file as-is. The question is whether the output improvements and &lt;code&gt;scotty doctor&lt;/code&gt; are worth 20 minutes of your time. For most projects, they are.&lt;/p&gt;

&lt;p&gt;If you're on Laravel Forge's built-in deployment: Scotty isn't for you. Forge handles this well and gives you a UI for it. Scotty is for developers who prefer terminal-native control and version-controlled deploy scripts that live inside the repo.&lt;/p&gt;

&lt;p&gt;If you're on Laravel Cloud: also not for you. The whole point of Cloud is that you don't manage servers. Scotty is specifically for self-hosted apps where you control the environment, whether that's a plain VPS or a &lt;a href="https://hafiz.dev/blog/effortlessly-dockerize-your-laravel-vue-application-a-step-by-step-guide" rel="noopener noreferrer"&gt;Dockerized Laravel setup&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The honest verdict: Scotty is a clean, well-considered tool. It doesn't reinvent deployment, it just makes the script format sane and the output readable. For anyone self-hosting Laravel apps and already using Envoy, it's the obvious upgrade. For anyone who's never set up deploy automation at all, the docs give you a complete production-ready script to start from.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does Scotty work with multiple servers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. You can define multiple servers in the &lt;code&gt;# @servers&lt;/code&gt; line and specify &lt;code&gt;on:web&lt;/code&gt;, &lt;code&gt;on:workers&lt;/code&gt;, etc. in individual tasks. You can also run tasks on multiple servers in parallel by adding the &lt;code&gt;parallel&lt;/code&gt; option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between a task and a macro?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A task is a single function that runs shell commands on a target, either local or remote. A macro is a named sequence of tasks. It's what you actually run with &lt;code&gt;scotty run deploy&lt;/code&gt;. Think of macros as your deploy pipeline definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run Scotty in CI/CD?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Since it's a global Composer package, you install it in your CI environment the same way you would locally. It works anywhere you have SSH access to your server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if a task fails mid-deploy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scotty stops immediately at the failing task and shows you the error output. If you're using the zero-downtime script, the &lt;code&gt;current&lt;/code&gt; symlink hasn't been updated yet, so your live application is untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to commit the Scotty.sh file to my repo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, that's the recommended approach. The script lives in version control alongside your code, so your whole team has access to the same deploy process and changes to it go through normal code review.&lt;/p&gt;




&lt;p&gt;Scotty's documentation is at &lt;a href="https://spatie.be/docs/scotty/v1/introduction" rel="noopener noreferrer"&gt;spatie.be/docs/scotty&lt;/a&gt; and the source is on &lt;a href="https://github.com/spatie/scotty" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you're building out your server setup and want to harden it before adding deploy automation, the &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; covers SSH keys, Cloudflare, and Tailscale on a fresh DigitalOcean droplet.&lt;/p&gt;

&lt;p&gt;If automated deployments aren't on your radar yet because you're still in the build phase, &lt;a href="https://hafiz.dev/#contact" rel="noopener noreferrer"&gt;reach out&lt;/a&gt;. Getting the deploy pipeline right early saves a lot of pain later.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>deployment</category>
      <category>spatie</category>
      <category>devops</category>
    </item>
    <item>
      <title>Livewire 4 vs Inertia.js 3: Which Laravel Frontend Stack Should You Use in 2026?</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Fri, 03 Apr 2026 05:47:17 +0000</pubDate>
      <link>https://forem.com/hafiz619/livewire-4-vs-inertiajs-3-which-laravel-frontend-stack-should-you-use-in-2026-47p4</link>
      <guid>https://forem.com/hafiz619/livewire-4-vs-inertiajs-3-which-laravel-frontend-stack-should-you-use-in-2026-47p4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/livewire-4-vs-inertia-3-laravel-frontend-2026" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you asked a room of Laravel developers "Livewire or Inertia?" two years ago, the answer split cleanly down one fault line: do you want to write JavaScript? Livewire for PHP purists. Inertia for anyone with Vue or React muscle memory.&lt;/p&gt;

&lt;p&gt;That framing still holds as a starting point. But it doesn't tell the whole story anymore. In January 2026, Livewire shipped version 4. In March 2026, Inertia.js shipped version 3. Both are major releases, and both of them solved problems that used to tip the scale one way or the other. The comparison has shifted.&lt;/p&gt;

&lt;p&gt;So let's answer the question properly, with both tools at their current state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Livewire 4 Actually Changed
&lt;/h2&gt;

&lt;p&gt;Livewire 4 shipped in January 2026 with substantially more than a few incremental improvements. The most visible change is how you write components. Instead of two files (a PHP class and a Blade view), you now put everything in a single file with a &lt;code&gt;⚡&lt;/code&gt; prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// resources/views/components/⚡counter.blade.php&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;Livewire\Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$count&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;increment&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;count&lt;/span&gt;&lt;span class="o"&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="cp"&gt;?&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;wire:click=&lt;/span&gt;&lt;span class="s"&gt;"increment"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;+&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;{{ $count }}&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is now the default when you run &lt;code&gt;php artisan make:livewire&lt;/code&gt;. Your existing class-based components still work. The new format is opt-in, and you can convert between formats anytime with &lt;code&gt;php artisan livewire:convert&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;More interesting is the &lt;strong&gt;Islands&lt;/strong&gt; feature. It lets you define isolated regions inside a component that update independently from the rest of the page, without creating separate child components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@island(name: 'stats', lazy: true)
    &amp;lt;div&amp;gt;{{ $this-&amp;gt;expensiveStats }}&amp;lt;/div&amp;gt;
@endisland
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters a lot if you've ever hit a wall with Livewire's re-render behavior on complex dashboards. Islands let you pin expensive sections to their own update cycle, which means better performance without restructuring your entire component tree.&lt;/p&gt;

&lt;p&gt;Two other v4 changes worth knowing before you upgrade. Requests now run in parallel, so &lt;code&gt;wire:model.live&lt;/code&gt; on multiple fields no longer blocks each other. And &lt;code&gt;wire:model&lt;/code&gt; changed its event bubbling behavior: it now only listens to events originating directly from the element itself, not from child elements. That last one is a silent breaking change for anyone using &lt;code&gt;wire:model&lt;/code&gt; on container elements like modals. It won't throw an error. It'll just stop working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Inertia.js 3 Actually Changed
&lt;/h2&gt;

&lt;p&gt;Inertia.js 3 shipped stable in late March 2026 after a beta period. The headline change is architectural: Axios is gone. Inertia now ships its own built-in XHR client, removing roughly 15KB gzipped from your bundle by default. If you rely on Axios interceptors, you can still plug Axios back in as an optional adapter.&lt;/p&gt;

&lt;p&gt;The setup story is also dramatically simpler. In v2, every project required a &lt;code&gt;resolve&lt;/code&gt; callback, a &lt;code&gt;setup&lt;/code&gt; callback, and a separate SSR entry point. In v3, you add the new Vite plugin and call &lt;code&gt;createInertiaApp()&lt;/code&gt; with no arguments:&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="c1"&gt;// resources/js/app.js&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;createInertiaApp&lt;/span&gt; &lt;span class="p"&gt;}&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;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nf"&gt;createInertiaApp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The plugin resolves pages from your &lt;code&gt;./Pages&lt;/code&gt; directory, handles lazy-loading and code splitting, and wires up SSR automatically during &lt;code&gt;npm run dev&lt;/code&gt;. No separate Node process. No build step just to preview server-side rendering. This was a real friction point in v2, and it's gone.&lt;/p&gt;

&lt;p&gt;Two new features deserve mention. &lt;code&gt;useHttp&lt;/code&gt; is a new hook for making plain HTTP requests (to a search endpoint or autocomplete API, for example) without triggering a page navigation. It mirrors the API of &lt;code&gt;useForm&lt;/code&gt;, so there's no new pattern to learn. And optimistic updates are now first-class. You can apply data changes instantly before the server responds, with automatic rollback on failure:&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;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optimistic&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;props&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="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;likes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/like`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upgrade from v2 has a handful of breaking changes worth reading before you update. I covered those in detail in the &lt;a href="https://hafiz.dev/blog/inertia-v3-upgrade-guide-laravel" rel="noopener noreferrer"&gt;Inertia.js v3 upgrade guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fundamental Difference Is Still the Same
&lt;/h2&gt;

&lt;p&gt;Here's the thing: all of the above are improvements &lt;em&gt;within&lt;/em&gt; each tool. They didn't change what each tool is fundamentally for.&lt;/p&gt;

&lt;p&gt;Livewire is still a server-side component framework. When a user clicks a button or types in an input, a network request goes to your Laravel server, the component re-renders in PHP, and the diff gets applied to the DOM. The browser never runs your component logic. JavaScript is minimal and optional.&lt;/p&gt;

&lt;p&gt;Inertia is still a protocol layer. Your Laravel controllers return JavaScript page components instead of Blade views. The frontend is fully Vue, React, or Svelte, with access to the entire npm ecosystem. The backend handles routing and data, but the rendering happens in JavaScript.&lt;/p&gt;

&lt;p&gt;Neither is a replacement for the other. They're built on different philosophies, and v4 and v3 didn't change that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Decision in 2026
&lt;/h2&gt;

&lt;p&gt;Here's a decision flowchart, then some concrete scenarios:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/livewire-4-vs-inertia-3-laravel-frontend-2026" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Pick Livewire 4 if&lt;/strong&gt; you're building an admin panel, SaaS dashboard, or anything where the UI is form-heavy and data-driven. Livewire is faster to ship for this kind of work because you're not managing a separate frontend build or serializing everything through props. If Filament is in the stack (and for a lot of &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;Laravel SaaS projects&lt;/a&gt;, it should be). Filament v5 already runs on Livewire v4, so the ecosystem stays consistent. You also get strong SEO defaults since content renders server-side first.&lt;/p&gt;

&lt;p&gt;The Islands feature in v4 specifically removes one of Livewire's older weaknesses: the performance ceiling you'd hit with complex dashboards. That's not a complete answer to "can Livewire handle this complex UI?" but it moves the ceiling noticeably higher.&lt;/p&gt;

&lt;p&gt;If your team is primarily PHP developers, Livewire also keeps context-switching minimal. You stay in Laravel and Blade. No shift to TypeScript types or JSX syntax mid-afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Inertia.js 3 if&lt;/strong&gt; your team is already productive with Vue or React. Full stop. If the developers on your project think in components, reach for &lt;code&gt;useState&lt;/code&gt;, or have strong TypeScript instincts, giving them Inertia is a productivity multiplier. Don't make React developers write Blade components.&lt;/p&gt;

&lt;p&gt;You should also pick Inertia when your UI needs the npm ecosystem. Complex drag-and-drop, advanced charting libraries, animation tools like Framer Motion, component libraries like shadcn. These integrate naturally into an Inertia project. Livewire can interface with Alpine.js for a lot of this, but there's a point where you're fighting the grain of the tool.&lt;/p&gt;

&lt;p&gt;The type safety story is also better with Inertia. Tools like &lt;a href="https://hafiz.dev/blog/laravel-wayfinder-type-safe-routes-and-forms-with-inertia" rel="noopener noreferrer"&gt;Laravel Wayfinder&lt;/a&gt; give you end-to-end TypeScript coverage from your Laravel routes down to your Vue or React components, which matters as a codebase grows. If you want to see what that looks like in practice with Vue, the &lt;a href="https://hafiz.dev/blog/laravel-vue-3-composition-api-build-modern-full-stack-spas" rel="noopener noreferrer"&gt;Laravel + Vue 3 Composition API&lt;/a&gt; post is a good reference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gray area.&lt;/strong&gt; Most SaaS products fit either tool. If you're starting fresh as a solo developer or small team, and no one has a strong JS framework preference, I'd default to Livewire. You'll ship faster in the early stages, Filament handles your admin needs, and you can always reach for Alpine.js for the handful of things that need client-side state.&lt;/p&gt;

&lt;p&gt;The mistake I see most often: picking Inertia because it feels more "modern" without actually needing the React ecosystem. That just adds complexity for no gain. The flip side is picking Livewire for a public-facing app with &lt;a href="https://hafiz.dev/blog/implementing-real-time-notifications-with-laravel-a-complete-guide" rel="noopener noreferrer"&gt;real-time features&lt;/a&gt; when your entire team thinks in React. That's the wrong tool for the wrong people.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Take
&lt;/h2&gt;

&lt;p&gt;I use both depending on the project. Livewire and Filament for anything admin-heavy or SaaS-internal. Inertia and Vue for public-facing products where the team has frontend experience and the UI benefits from the full Vue ecosystem.&lt;/p&gt;

&lt;p&gt;What I don't do is agonize over the choice. Both are well-maintained, both have strong ecosystems, and both just shipped major versions that made them better.&lt;/p&gt;

&lt;p&gt;The real risk isn't picking the "wrong" one. It's spending three weeks researching and not shipping anything.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I use Livewire and Inertia in the same Laravel project?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Technically yes, but it's not a great idea unless you have a very clear architectural separation. The more common pattern is Livewire for internal admin sections and a different approach for the public-facing frontend. Running both adds mental overhead without a compelling reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Livewire 4 require Filament v5?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Filament v5 requires Livewire v4, but Livewire v4 works fine without Filament. You can upgrade Livewire independently. If you're on Filament, just verify your Filament version supports Livewire v4 before updating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Inertia.js still the right pick if my frontend is mostly CRUD?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Probably not, if you're the only developer on the project. CRUD-heavy UIs are exactly where Livewire shines: you get reactive forms, real-time validation, and table interactions without touching JavaScript. Inertia makes more sense when the UI complexity justifies bringing in the JS ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which performs better?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends heavily on what you're building. For server-rendered content, Livewire has the edge because there's no JavaScript hydration cost. Inertia v3's Instant Visits feature narrows that gap for navigation, and SSR is now much easier to set up. For complex client-side interactions, Inertia's JavaScript-native approach typically performs better because state stays in the browser.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>inertiajs</category>
      <category>php</category>
    </item>
    <item>
      <title>Inertia.js v3 Is Out: The Upgrade Guide Every Laravel Developer Actually Needs</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 01 Apr 2026 05:07:52 +0000</pubDate>
      <link>https://forem.com/hafiz619/inertiajs-v3-is-out-the-upgrade-guide-every-laravel-developer-actually-needs-419b</link>
      <guid>https://forem.com/hafiz619/inertiajs-v3-is-out-the-upgrade-guide-every-laravel-developer-actually-needs-419b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/inertia-v3-upgrade-guide-laravel" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Inertia.js v3 went stable on March 25. If you missed the announcement, it's a real major release, not one of those "major" bumps that changes nothing. Axios is gone, ESM is the only output format, React 18 and Svelte 4 are both dropped, and a handful of event names and APIs have changed. There's also a config file restructure that'll catch you off guard if you don't read the upgrade guide first.&lt;/p&gt;

&lt;p&gt;The good news: v3 is genuinely better. The bundle is smaller, SSR works in dev without a separate Node.js process, and two new APIs (the &lt;code&gt;useHttp&lt;/code&gt; hook and optimistic updates) solve problems that previously needed awkward workarounds. If you've been putting off upgrading to take stock of the situation first, this post is for you.&lt;/p&gt;

&lt;p&gt;We also just went through a similar exercise with 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;, which had zero breaking changes. Inertia v3 is different. Worth actually reading before you touch anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Actually New in v3
&lt;/h2&gt;

&lt;p&gt;Before we get into what breaks, let's talk about why you'd want to upgrade at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Vite plugin.&lt;/strong&gt; This is the biggest quality-of-life change. Previously, setting up an Inertia app meant writing a resolve callback, a setup callback, and a separate SSR entry point with its own config. Now you just install &lt;code&gt;@inertiajs/vite&lt;/code&gt; and your entry point can be a single &lt;code&gt;createInertiaApp()&lt;/code&gt; call with no arguments. Page resolution, code splitting, and SSR config all happen automatically. It removes a surprising amount of boilerplate that you'd copy-paste from the docs and forget about for years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSR in dev mode.&lt;/strong&gt; Before v3, if you were running SSR, you had to start a separate Node.js server to see it during development. Now the Vite plugin handles it automatically as part of &lt;code&gt;npm run dev&lt;/code&gt;. No extra process, better error messages (it logs the component name and URL when SSR fails), and a flash-of-unstyled-content fix is included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;useHttp&lt;/code&gt; hook.&lt;/strong&gt; This one fills a genuine gap. In v2, if you needed to make an HTTP request that didn't trigger a page visit, like hitting a search endpoint or submitting to an API route, you'd reach for Axios or raw fetch and lose the reactive state you get from &lt;code&gt;useForm&lt;/code&gt;. The new &lt;code&gt;useHttp&lt;/code&gt; hook gives you the same developer experience as &lt;code&gt;useForm&lt;/code&gt; (reactive &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;errors&lt;/code&gt;, &lt;code&gt;progress&lt;/code&gt;, &lt;code&gt;isDirty&lt;/code&gt; state) but for plain JSON requests. No page navigation, no full Inertia lifecycle.&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="c1"&gt;// Vue example&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useHttp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/search&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;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've been mixing Axios calls inside Inertia components because there was no clean alternative, this replaces that pattern entirely. Pairs nicely with the &lt;a href="https://hafiz.dev/blog/laravel-api-development-restful-best-practices" rel="noopener noreferrer"&gt;REST API patterns covered here&lt;/a&gt; if you're calling your own endpoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic updates.&lt;/strong&gt; Inertia now has first-class support for applying a UI change immediately before the server confirms it, then rolling it back automatically if the request fails. It works on the router, &lt;code&gt;useForm&lt;/code&gt;, and &lt;code&gt;useHttp&lt;/code&gt;. Concurrent optimistic requests are handled too, each with its own rollback snapshot.&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;router&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optimistic&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;props&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="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;likes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/like`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before v3, building this pattern meant managing local state manually, writing your own rollback logic, and being careful about race conditions. Now it's one chained method call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layout props.&lt;/strong&gt; You can now pass typed data from a page component into its persistent layout without needing an event bus or &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt;. Pages declare layout props alongside the layout component, and the layout receives them as regular component props. Much cleaner than the workarounds people were using.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instant visits.&lt;/strong&gt; When navigating, Inertia can now swap to the target page component immediately using shared props, then merge in the page-specific props once the server responds. The navigation feels instant even though a full server request still happens. Opt in per-link with &lt;code&gt;:instant&lt;/code&gt; or globally via config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can You Upgrade Right Now?
&lt;/h2&gt;

&lt;p&gt;Before reading any further, this diagram gives you the quick answer based on your setup.&lt;/p&gt;

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

&lt;p&gt;If the diagram lands you at "Upgrade now", the rest of this guide is your step-by-step path. If you're in one of the "migrate first" branches, come back once that's done. This post isn't going anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before You Run composer update: Check Your Requirements
&lt;/h2&gt;

&lt;p&gt;This is the part that'll bite you if you skip it. Inertia v3 has hard version requirements that you need to meet before installing anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PHP 8.2 and Laravel 11&lt;/strong&gt; are the minimum. If you're on Laravel 10 or PHP 8.1, you need to upgrade those first. Laravel 13 is fully compatible and has zero issues with Inertia v3. The Laravel adapter was tested against both L12 and L13 and there are no compatibility problems to worry about on the PHP side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React 19&lt;/strong&gt; is required if you're using the React adapter. React 18 is no longer supported. This is the requirement most likely to cause a ripple effect through your dependency tree, since React 19 also requires updating things like &lt;code&gt;react-dom&lt;/code&gt;, form libraries, and animation packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Svelte 5&lt;/strong&gt; is required for the Svelte adapter. Svelte 4 is dropped entirely. All Svelte code needs to be updated to Svelte 5's runes syntax: &lt;code&gt;$props()&lt;/code&gt;, &lt;code&gt;$state()&lt;/code&gt;, &lt;code&gt;$effect()&lt;/code&gt;, and so on. This isn't a small change. If you're on Svelte 4, factor in real migration time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vite 7+&lt;/strong&gt; is required. Vite 6 is no longer supported. If you're on Vite 8 already, you're fine. If you're somewhere behind, check your &lt;code&gt;package.json&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;Vue 3 users? You don't have any of these extra adapter concerns. The Vue adapter upgrade is the cleanest path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Upgrade Steps
&lt;/h2&gt;

&lt;p&gt;With requirements confirmed, here's the actual upgrade sequence. Don't skip the last two commands, they're not optional.&lt;/p&gt;

&lt;p&gt;One thing to check before you start: if you're using third-party Inertia packages like Inertia Modal, Inertia Table, or any community adapters, verify they have v3 support before upgrading. Some packages ship separate major versions for Inertia v3 compatibility (Inertia Table v3 for example targets Tailwind v4). Check each package's GitHub releases page. There's no point upgrading Inertia core if a critical package in your app isn't ready yet.&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="c"&gt;# Install the client adapter (pick your framework)&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @inertiajs/vue3@^3.0
&lt;span class="c"&gt;# or: npm install @inertiajs/react@^3.0&lt;/span&gt;
&lt;span class="c"&gt;# or: npm install @inertiajs/svelte@^3.0&lt;/span&gt;

&lt;span class="c"&gt;# optional but recommended: install the Vite plugin&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @inertiajs/vite@^3.0

&lt;span class="c"&gt;# upgrade the Laravel adapter&lt;/span&gt;
composer require inertiajs/inertia-laravel:^3.0

&lt;span class="c"&gt;# republish the config file (it has been restructured in v3)&lt;/span&gt;
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;"Inertia&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;ServiceProvider"&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;span class="c"&gt;# clear cached views (@inertia directive output has changed)&lt;/span&gt;
php artisan view:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the package installs, work through this checklist before you test anything. It covers every breaking change you'll need to handle.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Breaking Changes to Fix
&lt;/h2&gt;

&lt;p&gt;Here's the full breakdown of what needs changing, with before/after code for each.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event renames
&lt;/h3&gt;

&lt;p&gt;Two global router events have been renamed. If you've got &lt;code&gt;router.on()&lt;/code&gt; listeners anywhere in your app, search for both of these.&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="c1"&gt;// Before (v2)&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&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="nx"&gt;event&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exception&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="nx"&gt;event&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3)&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;httpException&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="nx"&gt;event&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkError&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="nx"&gt;event&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also handle these per-visit now using &lt;code&gt;onHttpException&lt;/code&gt; and &lt;code&gt;onNetworkError&lt;/code&gt; callbacks, which didn't exist in v2.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axios, qs, and lodash-es are gone
&lt;/h3&gt;

&lt;p&gt;Inertia no longer bundles any of these. For most apps this means nothing changes, because you weren't importing them directly from Inertia's internals. But if any of your code does &lt;code&gt;import axios from 'axios'&lt;/code&gt;, &lt;code&gt;import qs from 'qs'&lt;/code&gt;, or &lt;code&gt;import _ from 'lodash-es'&lt;/code&gt; and those packages were implicit transitive dependencies, you'll need to install them directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;axios   &lt;span class="c"&gt;# if you still need it&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;qs      &lt;span class="c"&gt;# if your code imports it directly&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;lodash-es  &lt;span class="c"&gt;# if your code imports it directly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Axios is still usable, just not required. The built-in XHR client supports interceptors natively, so if you were using Axios interceptors you can migrate them directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;inertia&lt;/code&gt; head attribute became &lt;code&gt;data-inertia&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Open your root Blade template and look in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; section. Any element with the &lt;code&gt;inertia&lt;/code&gt; attribute needs it renamed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Before (v2) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;title&lt;/span&gt; &lt;span class="na"&gt;inertia&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;My App&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- After (v3) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;title&lt;/span&gt; &lt;span class="na"&gt;data-inertia&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;My App&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small change, easy to miss. Affects any head element you're managing with Inertia's &lt;code&gt;&amp;lt;Head&amp;gt;&lt;/code&gt; component.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;router.cancel()&lt;/code&gt; became &lt;code&gt;router.cancelAll()&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v2): only cancelled synchronous requests&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3): cancels all request types by default&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// To match v2 behavior exactly (sync only)&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelAll&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;async&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="na"&gt;prefetch&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;h3&gt;
  
  
  &lt;code&gt;Inertia::lazy()&lt;/code&gt; is removed
&lt;/h3&gt;

&lt;p&gt;If you were using &lt;code&gt;Inertia::lazy()&lt;/code&gt; on any backend response, switch it to &lt;code&gt;Inertia::optional()&lt;/code&gt;. Same behaviour, different name. The &lt;code&gt;LazyProp&lt;/code&gt; class is also removed.&lt;br&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;// Before (v2)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Users/Index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Users/Index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Progress indicator exports
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v2)&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;hideProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;revealProgress&lt;/span&gt; &lt;span class="p"&gt;}&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;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nf"&gt;hideProgress&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;revealProgress&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3)&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;progress&lt;/span&gt; &lt;span class="p"&gt;}&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;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reveal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The &lt;code&gt;future&lt;/code&gt; config block is gone
&lt;/h3&gt;

&lt;p&gt;If you were using v2's &lt;code&gt;future&lt;/code&gt; options in &lt;code&gt;createInertiaApp&lt;/code&gt;, just delete the entire &lt;code&gt;future&lt;/code&gt; block. All four options are now always enabled and can't be toggled.&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="c1"&gt;// Before (v2)&lt;/span&gt;
&lt;span class="nf"&gt;createInertiaApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;preserveEqualProps&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="na"&gt;useDataInertiaHeadAttribute&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="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3): just remove it&lt;/span&gt;
&lt;span class="nf"&gt;createInertiaApp&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;h3&gt;
  
  
  Config file restructuring
&lt;/h3&gt;

&lt;p&gt;After running &lt;code&gt;vendor:publish --force&lt;/code&gt;, open the new &lt;code&gt;config/inertia.php&lt;/code&gt; and compare it side by side with your old one. Page-related settings have moved under a &lt;code&gt;pages&lt;/code&gt; key, and the &lt;code&gt;testing&lt;/code&gt; section is simplified. Don't just overwrite and hope. Review the diff. The &lt;a href="https://hafiz.dev/tools/diff-checker" rel="noopener noreferrer"&gt;Diff Checker tool&lt;/a&gt; is handy for this if you saved a copy of your old config.&lt;/p&gt;

&lt;h3&gt;
  
  
  ESM-only output
&lt;/h3&gt;

&lt;p&gt;All Inertia packages now ship as ES Modules only. CommonJS &lt;code&gt;require()&lt;/code&gt; imports no longer work.&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="c1"&gt;// Before (v2): worked in some setups&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3): ESM only&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;router&lt;/span&gt; &lt;span class="p"&gt;}&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;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your build setup was relying on CommonJS in any way, this is the one to audit carefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Things Worth Using Right Away
&lt;/h2&gt;

&lt;p&gt;Once the upgrade is done, two features are worth reaching for immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;useHttp&lt;/code&gt; for non-navigation requests.&lt;/strong&gt; If you're building a &lt;a href="https://hafiz.dev/blog/laravel-vue-3-composition-api-build-modern-full-stack-spas" rel="noopener noreferrer"&gt;Laravel + Vue SPA&lt;/a&gt; and have any search boxes, autocomplete fields, or background data fetches that aren't page visits, replace them with &lt;code&gt;useHttp&lt;/code&gt;. You get reactive state, proper error handling, and upload progress for free. No more mixing Axios calls into Inertia components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic updates for interactive actions.&lt;/strong&gt; Like buttons, follow buttons, toggles, anything where the user takes an action and you're confident it'll succeed. Chain &lt;code&gt;.optimistic()&lt;/code&gt; before the request, define the expected state change, and let Inertia handle the rollback if something goes wrong. It's the kind of UX improvement that takes days to implement correctly from scratch and ten minutes with v3.&lt;/p&gt;

&lt;p&gt;If you're also using Wayfinder with Inertia for type-safe routing, the &lt;a href="https://hafiz.dev/blog/laravel-wayfinder-type-safe-routes-and-forms-with-inertia" rel="noopener noreferrer"&gt;Laravel Wayfinder guide here&lt;/a&gt; is worth revisiting since Inertia v3's typed form generics pair well with Wayfinder's typed route parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Upgrade This Weekend?
&lt;/h2&gt;

&lt;p&gt;Depends on your stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upgrade now&lt;/strong&gt; if you're on Vue 3, Laravel 11+, PHP 8.2+, and Vite 7+. The breaking changes are real but mechanical. Search and replace, rename two or three methods, republish the config, clear views. Most of the checklist items above take less than five minutes each. You'll be done in an afternoon. The Vite plugin alone makes it worth it, and the removed Axios dependency is a free bundle size reduction you'd otherwise have to work for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait&lt;/strong&gt; if you're on the React adapter and haven't upgraded to React 19. That's a separate, larger upgrade and you shouldn't do both at once. Get your React 19 migration done first, make sure everything still works, then layer in Inertia v3. Same story for Svelte 4 apps. The Svelte 5 runes migration is a real rewrite, not a find-and-replace. Don't combine it with an Inertia upgrade in the same PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't rush&lt;/strong&gt; if you're running a production app with SSR and you haven't got a proper staging environment to test on. The SSR improvements in v3 are genuinely good, but SSR changes are also the most likely to surface behaviour differences between environments. Test on staging, watch it for a day, then deploy.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing: Laravel Boost ships with an &lt;code&gt;UpgradeInertiaV3&lt;/code&gt; prompt if you're using it. It walks through the upgrade automatically. Worth checking before you do it manually.&lt;/p&gt;

&lt;p&gt;v2 isn't going anywhere immediately, so there's no pressure. But v3 is the better version, and if your requirements are already met, there's no reason to sit on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I have to use the new Vite plugin?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The &lt;code&gt;@inertiajs/vite&lt;/code&gt; plugin is optional. Your existing setup with &lt;code&gt;resolve&lt;/code&gt; and &lt;code&gt;setup&lt;/code&gt; callbacks still works in v3. The plugin just simplifies things if you want it to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I still use Axios with Inertia v3?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Axios is no longer bundled or required, but it's still available as an optional peer dependency. Install it manually and use the Axios adapter if you prefer to keep your existing interceptor setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I'm on Laravel 10?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Laravel adapter v3 requires Laravel 11 at minimum. You'd need to upgrade Laravel first. Laravel 10 reaches end of life in August 2026, so the upgrade is worth doing regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Inertia v3 work with Filament?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Filament uses Livewire under the hood, not Inertia. The two don't interact. If you're building an admin panel with Filament alongside an Inertia-powered frontend, the Inertia upgrade doesn't affect Filament at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the &lt;code&gt;useHttp&lt;/code&gt; hook available in all three adapters?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Vue, React, and Svelte all have it. The API is the same across adapters: reactive &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;errors&lt;/code&gt;, &lt;code&gt;progress&lt;/code&gt;, and &lt;code&gt;isDirty&lt;/code&gt; state, plus &lt;code&gt;get()&lt;/code&gt;, &lt;code&gt;post()&lt;/code&gt;, &lt;code&gt;put()&lt;/code&gt;, &lt;code&gt;patch()&lt;/code&gt;, and &lt;code&gt;delete()&lt;/code&gt; methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The upgrade itself isn't the hard part. The actual work is auditing your codebase for the three or four breaking changes that affect you specifically. Run through the checklist above item by item, fix what needs fixing, and test on a feature branch before touching main.&lt;/p&gt;

&lt;p&gt;If you're building a new Laravel app from scratch and debating whether to use Inertia at all, v3 is the most compelling version yet. Smaller bundle, less configuration, and a feature set that competes seriously with decoupled SPA setups for most use cases.&lt;/p&gt;

&lt;p&gt;Got a specific upgrade question or a weird edge case you ran into? &lt;a href="https://hafiz.dev/#contact" rel="noopener noreferrer"&gt;Reach out&lt;/a&gt;, happy to dig into it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>inertiajs</category>
      <category>javascript</category>
      <category>vue</category>
    </item>
    <item>
      <title>Cache::funnel() in Laravel: Concurrency Limiting Without Redis</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 30 Mar 2026 06:04:56 +0000</pubDate>
      <link>https://forem.com/hafiz619/cachefunnel-in-laravel-concurrency-limiting-without-redis-2dkb</link>
      <guid>https://forem.com/hafiz619/cachefunnel-in-laravel-concurrency-limiting-without-redis-2dkb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cache-funnel-concurrency-limiting" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Picture a queue of jobs that each call an external AI provider. Your API key allows 5 concurrent requests. Any more and you start getting 429s. Any fewer and you're leaving throughput on the table.&lt;/p&gt;

&lt;p&gt;The classic Laravel solution was &lt;code&gt;Redis::funnel()&lt;/code&gt;. Which meant you needed Redis. Not great when your project runs on the file or database cache driver. And genuinely painful in tests, where you either had to mock the Redis facade (fragile), spin up a real Redis instance in CI (annoying), or skip testing the concurrency logic altogether (common, but not ideal).&lt;/p&gt;

&lt;p&gt;Laravel 12.53.0 shipped &lt;code&gt;Cache::funnel()&lt;/code&gt;. Same concept, same API shape, but backed by any lock-capable cache driver. File, database, Redis, or the array driver in tests. Your concurrency logic stops being coupled to your infrastructure choice.&lt;/p&gt;

&lt;p&gt;Here's what it does, how to use it, and when it actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quick Recap of What Redis::funnel() Was Doing
&lt;/h2&gt;

&lt;p&gt;Before the new API makes sense, it's worth understanding the old one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Redis::funnel()&lt;/code&gt; used Redis Lua scripts to manage a pool of execution slots atomically. You'd define a key for the resource you wanted to protect, set a limit on concurrent executions, and the Lua script handled the semaphore logic on the Redis side.&lt;br&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;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-calls'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;30&lt;/span&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// at most 5 of these running at once&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked well. Still works well. But you couldn't use it without Redis, and you couldn't test it without Redis either. That's the friction &lt;code&gt;Cache::funnel()&lt;/code&gt; resolves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Cache::funnel()&lt;/code&gt; lives on the &lt;code&gt;Cache&lt;/code&gt; facade and uses the cache layer's lock primitives rather than Redis-specific scripts. Any driver implementing &lt;code&gt;LockProvider&lt;/code&gt; works: database, file, Redis, and the array driver you're probably already using in tests.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-calls'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;30&lt;/span&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// slot acquired&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// couldn't get a slot within block time&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've used &lt;code&gt;Redis::funnel()&lt;/code&gt; before, this reads identically. That's intentional. Let's break down each method so nothing is ambiguous.&lt;/p&gt;

&lt;h3&gt;
  
  
  limit()
&lt;/h3&gt;

&lt;p&gt;Sets the maximum number of concurrent executions that can hold a slot. &lt;code&gt;-&amp;gt;limit(5)&lt;/code&gt; means at most 5 closures are running simultaneously. The 6th caller blocks or fails, depending on your &lt;code&gt;block()&lt;/code&gt; setting.&lt;/p&gt;

&lt;h3&gt;
  
  
  releaseAfter()
&lt;/h3&gt;

&lt;p&gt;The safety TTL, in seconds. If a process acquires a slot and then crashes or gets killed before finishing, the slot auto-expires after this many seconds. It's not a timeout for how long your work should take. Think of it as a dead-man's switch so slots don't stay locked forever after a crash.&lt;/p&gt;

&lt;p&gt;Set this realistically. If your job can legitimately take four minutes, a &lt;code&gt;releaseAfter(60)&lt;/code&gt; means crashed processes release slots after one minute and new executions grab them before previous ones are done. When in doubt, overestimate.&lt;/p&gt;

&lt;h3&gt;
  
  
  block()
&lt;/h3&gt;

&lt;p&gt;How long a caller should wait for a slot to become available before giving up. &lt;code&gt;-&amp;gt;block(30)&lt;/code&gt; means wait up to 30 seconds. &lt;code&gt;-&amp;gt;block(0)&lt;/code&gt; means don't wait at all: try once, and if no slot is available right now, immediately run the failure callback.&lt;/p&gt;

&lt;h3&gt;
  
  
  then()
&lt;/h3&gt;

&lt;p&gt;Two callables. The first runs when a slot is acquired. The second runs when the block time expires without getting one. The slot releases automatically when the first callable returns, so the next waiting caller can grab it.&lt;/p&gt;

&lt;p&gt;If you'd rather handle failure via exceptions than a callback, leave the second argument out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Cache\Limiters\LimiterTimeoutException&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="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-calls'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;30&lt;/span&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="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// runs with a slot&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;LimiterTimeoutException&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="c1"&gt;// block time expired, no slot acquired&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Could not acquire concurrency slot'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ai-api-calls'&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 if you need a specific store rather than the default:&lt;br&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;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;store&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-calls'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;30&lt;/span&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// locked to the database store explicitly&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to flag upfront: Memcached doesn't implement &lt;code&gt;LockProvider&lt;/code&gt;. Calling &lt;code&gt;Cache::funnel()&lt;/code&gt; on a Memcached-backed store throws &lt;code&gt;BadMethodCallException&lt;/code&gt;. If Memcached is your default driver, call &lt;code&gt;Cache::store('database')-&amp;gt;funnel()&lt;/code&gt; or &lt;code&gt;Cache::store('redis')-&amp;gt;funnel()&lt;/code&gt; explicitly instead.&lt;/p&gt;

&lt;p&gt;Here's how the full slot acquisition flow works when you call &lt;code&gt;Cache::funnel()&lt;/code&gt;:&lt;/p&gt;

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

&lt;p&gt;The key thing to notice: the slot releases automatically when the success closure returns. You don't call anything manually. And if a process crashes before returning, &lt;code&gt;releaseAfter&lt;/code&gt; handles the cleanup on a timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Case 1: Throttling External API Calls in Queue Jobs
&lt;/h2&gt;

&lt;p&gt;This is the most common reason to reach for concurrency limiting. You have a pool of jobs calling an external service and you need to stay within its concurrency cap.&lt;/p&gt;

&lt;p&gt;If you're not on Laravel 12.53.0 yet, check the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;upgrade guide&lt;/a&gt; first, since &lt;code&gt;Cache::funnel()&lt;/code&gt; isn't available in earlier versions.&lt;/p&gt;

&lt;p&gt;Here's a queue job that limits itself to 5 concurrent calls against an external AI API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessAiRequest&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dispatchable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SerializesModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&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;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$prompt&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-concurrency'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;30&lt;/span&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="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;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.ai.key'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="o"&gt;-&amp;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;'https://api.example.com/v1/generate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                            &lt;span class="s1"&gt;'prompt'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="p"&gt;]);&lt;/span&gt;

                    &lt;span class="c1"&gt;// process $response&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="c1"&gt;// no slot within 30 seconds, re-queue&lt;/span&gt;
                    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="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 slot releases the moment the closure returns, so the next waiting job can grab it. If the job crashes mid-execution, the slot releases after 120 seconds. Workers just keep pulling from the queue and the funnel manages the cap invisibly.&lt;/p&gt;

&lt;p&gt;The funnel key &lt;code&gt;'ai-api-concurrency'&lt;/code&gt; is global here, meaning the limit applies across all workers and all processes combined. That's usually exactly what you want when limiting against a shared external resource with a fixed API key.&lt;/p&gt;

&lt;p&gt;If you're structuring more complex pipelines with different agent types, the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-multi-agent-patterns-production" rel="noopener noreferrer"&gt;multi-agent queue patterns post&lt;/a&gt; covers how to approach those. Per-agent-type limits fit naturally into that kind of setup, where you'd just make the key more specific: &lt;code&gt;"ai-concurrency:{$agentType}"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Case 2: Per-User Report Generation
&lt;/h2&gt;

&lt;p&gt;Classic SaaS problem. Users can kick off report generation, and you want at most 2 running per user at once. More than that and the server starts to feel it. Less than that is fine: just tell the user their report is queued.&lt;br&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;generateReport&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;Report&lt;/span&gt; &lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"report-generation:&lt;/span&gt;&lt;span class="si"&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="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;limit&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;markAsQueued&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nc"&gt;UserNotification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Your report is queued and will start shortly.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-&amp;gt;block(0)&lt;/code&gt; is intentional. You don't want to hold up the current request waiting for a slot. If both slots are taken, fall into the failure callback immediately and handle it gracefully.&lt;/p&gt;

&lt;p&gt;The funnel key includes the user ID, so each user gets their own independent slot pool. One user hammering the generate button a dozen times doesn't affect anyone else's quota. That's the real value: fine-grained per-entity control with very little code.&lt;/p&gt;

&lt;p&gt;This kind of control complements the queue topology patterns covered in the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue jobs post&lt;/a&gt;. You can configure your workers to run a large number of concurrent jobs at the queue level, then use &lt;code&gt;Cache::funnel()&lt;/code&gt; inside jobs to apply more targeted limits based on the resource being accessed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Case 3: Testing Without Infrastructure
&lt;/h2&gt;

&lt;p&gt;This is the improvement I'm most excited about, and the one the PHP community noticed quickest when the PR landed.&lt;/p&gt;

&lt;p&gt;Before &lt;code&gt;Cache::funnel()&lt;/code&gt;, testing concurrency logic meant wrestling with infrastructure. Your choices with &lt;code&gt;Redis::funnel()&lt;/code&gt; were: mock the Redis facade (you're testing your mock, not your logic), spin up Redis in CI (more setup, more cost), or skip testing it at all (the most honest option, and the most common).&lt;/p&gt;

&lt;p&gt;With the array driver, all of that goes away. Your concurrency logic tests run in pure in-memory isolation with zero infrastructure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Cache&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;'blocks executions beyond the concurrency limit'&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;$acquired&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="nv"&gt;$blocked&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&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="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'test-resource'&lt;/span&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;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$acquired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$acquired&lt;/span&gt;&lt;span class="o"&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;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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$blocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$blocked&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$acquired&lt;/span&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="mi"&gt;2&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;$blocked&lt;/span&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Redis connection, no Docker service, no special test helpers. The test proves your concurrency logic works correctly and runs in milliseconds.&lt;/p&gt;

&lt;p&gt;This testing story alone is a compelling reason to migrate away from &lt;code&gt;Redis::funnel()&lt;/code&gt; in any application where you actually want to test this kind of behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using It in Job Middleware
&lt;/h2&gt;

&lt;p&gt;So far all the examples put the funnel logic inside &lt;code&gt;handle()&lt;/code&gt;. That works, but there's a cleaner pattern for queue jobs: defining it in the job's &lt;code&gt;middleware()&lt;/code&gt; 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;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessAiRequest&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dispatchable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SerializesModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-concurrency'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="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;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="c1"&gt;// handle() only runs if the funnel granted a slot&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;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.ai.key'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.example.com/v1/generate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'prompt'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prompt&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 advantage here is separation of concerns. The concurrency limit is declared alongside the job's other infrastructure concerns (retries, backoff, timeout) rather than buried inside the business logic. The &lt;code&gt;handle()&lt;/code&gt; method stays focused on what the job actually does.&lt;/p&gt;

&lt;p&gt;This pattern is especially useful when multiple job types need the same concurrency limit. You can extract the middleware closure into a shared class and reference it from each job rather than duplicating the funnel configuration everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache::funnel() vs Cache::withoutOverlapping()
&lt;/h2&gt;

&lt;p&gt;Both live in the concurrency limiting section of the Laravel docs and it's easy to confuse them. They solve different problems.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Cache::withoutOverlapping()&lt;/code&gt; is for single-instance control: only one execution at a time, globally. Use this for scheduled commands where two copies running simultaneously would be a bug.&lt;br&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;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withoutOverlapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightly-data-sync'&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;DataSync&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;runAll&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;Cache::funnel()&lt;/code&gt; is for controlled parallelism: up to N concurrent executions, but not unlimited. Use this when some concurrency is fine, you just need a ceiling on it.&lt;/p&gt;

&lt;p&gt;The classic CS framing: &lt;code&gt;withoutOverlapping&lt;/code&gt; is a mutex (one at a time), &lt;code&gt;funnel&lt;/code&gt; is a semaphore (N at a time). Reach for &lt;code&gt;withoutOverlapping&lt;/code&gt; when concurrency is always wrong for the operation. Reach for &lt;code&gt;funnel&lt;/code&gt; when it's acceptable but needs a cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Migrate Away From Redis::funnel()?
&lt;/h2&gt;

&lt;p&gt;If you're already using &lt;code&gt;Redis::funnel()&lt;/code&gt; and it works, nothing is broken and nothing is deprecated. There's no urgent reason to change.&lt;/p&gt;

&lt;p&gt;The migration itself is a straight swap at each call site:&lt;br&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;// Before&lt;/span&gt;
&lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-calls'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;30&lt;/span&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;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;callApi&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// After&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;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai-api-calls'&lt;/span&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&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="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="mi"&gt;30&lt;/span&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;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;callApi&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just the facade changes. Everything else is identical. If you're on Redis in production, the behaviour is equivalent since &lt;code&gt;Cache::funnel()&lt;/code&gt; delegates to the same lock primitives on the Redis driver.&lt;/p&gt;

&lt;p&gt;That said, there are three concrete reasons to prefer &lt;code&gt;Cache::funnel()&lt;/code&gt; going forward.&lt;/p&gt;

&lt;p&gt;First, no hard Redis dependency. If your cache driver ever changes, your concurrency logic moves with it. No code changes, no surprise errors in a staging environment that uses a different driver than production.&lt;/p&gt;

&lt;p&gt;Second, it's testable without infrastructure. Array driver in tests, real driver in production. You write tests that actually exercise the concurrency logic rather than mocking around it.&lt;/p&gt;

&lt;p&gt;Third, it's one fewer abstraction to maintain. &lt;code&gt;Redis::funnel()&lt;/code&gt; lives in the Redis documentation. &lt;code&gt;Cache::funnel()&lt;/code&gt; lives in the Cache documentation. One facade, one section of the docs, one mental model for your team.&lt;/p&gt;

&lt;p&gt;The underlying mechanism differs: &lt;code&gt;Redis::funnel()&lt;/code&gt; uses Lua scripts, &lt;code&gt;Cache::funnel()&lt;/code&gt; uses the lock primitives the cache driver exposes. But for application-level concurrency control, the behaviour is equivalent and the difference is invisible to your application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to Watch Out For
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Memcached is unsupported.&lt;/strong&gt; If your default cache driver is Memcached, &lt;code&gt;Cache::funnel()&lt;/code&gt; throws &lt;code&gt;BadMethodCallException&lt;/code&gt;. Either switch to a supported driver or call a specific store like &lt;code&gt;Cache::store('database')-&amp;gt;funnel()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;releaseAfter&lt;/code&gt; conservatively.&lt;/strong&gt; If the work could legitimately take five minutes, a 60-second TTL means crashed processes release slots before work finishes and new ones grab them. You end up with more concurrent executions than your &lt;code&gt;limit()&lt;/code&gt; intended. Overestimate rather than underestimate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;block(0)&lt;/code&gt; needs a failure handler you actually wrote.&lt;/strong&gt; With zero wait time, any call that doesn't immediately get a slot hits the failure callback. Returning nothing silently from that callback is almost always wrong. Re-queue the job, notify the user, log a warning, or do something intentional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is application-level, not queue-level.&lt;/strong&gt; &lt;code&gt;Cache::funnel()&lt;/code&gt; doesn't configure Horizon or tell your queue supervisor to run fewer concurrent workers. If you need to control queue-level concurrency, that's a separate configuration. These are complementary tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key is global across all workers.&lt;/strong&gt; The funnel key &lt;code&gt;'api-calls'&lt;/code&gt; applies across every worker process and every server. That's what you want when the limit comes from a shared external resource. If you need per-server limits, scope the key: &lt;code&gt;"api-calls:{$serverId}"&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Which Laravel version introduced Cache::funnel()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It was merged February 21, 2026 and shipped in Laravel 12.53.0. It's also in Laravel 13 from the initial release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which cache drivers support it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Redis, database, file, and array drivers all implement &lt;code&gt;LockProvider&lt;/code&gt; and work with &lt;code&gt;Cache::funnel()&lt;/code&gt;. Memcached does not, and calling &lt;code&gt;funnel()&lt;/code&gt; on it throws &lt;code&gt;BadMethodCallException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if a process crashes mid-execution?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The slot auto-releases after the &lt;code&gt;releaseAfter&lt;/code&gt; timeout. Set it long enough to cover legitimate execution time, otherwise crashed processes release slots early and new executions start before previous ones are done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I skip the failure callback?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Omit the second argument to &lt;code&gt;-&amp;gt;then()&lt;/code&gt; and &lt;code&gt;LimiterTimeoutException&lt;/code&gt; is thrown when block time expires without getting a slot. Useful if you'd rather handle failure in a &lt;code&gt;catch&lt;/code&gt; block than a closure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this the same as rate limiting middleware?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The &lt;code&gt;throttle&lt;/code&gt; middleware controls request frequency: how many requests per minute from a given client. &lt;code&gt;Cache::funnel()&lt;/code&gt; controls concurrent executions: how many can run at the same time. Different problems, different tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this in a scheduled command?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but &lt;code&gt;Cache::withoutOverlapping()&lt;/code&gt; is usually the better fit for commands where you want zero overlap. Use &lt;code&gt;Cache::funnel()&lt;/code&gt; when some parallelism is fine but you need a specific cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Cache::funnel()&lt;/code&gt; is one of those changes that quietly fixes a real friction point. Concurrency logic in Laravel shouldn't require Redis. Tests for that logic shouldn't require infrastructure.&lt;/p&gt;

&lt;p&gt;If you've been relying on &lt;code&gt;Redis::funnel()&lt;/code&gt;, the migration is straightforward: same method chain, different facade call. If you've been avoiding concurrency limiting because the Redis dependency was awkward or the testing story was painful, those excuses are gone now.&lt;/p&gt;

&lt;p&gt;The pattern is genuinely useful once you start seeing where it applies. Per-user limits, per-resource limits, per-API-key throttling. Most queue-heavy features have at least one spot where &lt;code&gt;Cache::funnel()&lt;/code&gt; simplifies the code and removes a dependency you didn't need.&lt;/p&gt;

&lt;p&gt;Questions or edge cases I didn't cover? &lt;a href="https://hafiz.dev/#contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; and we can dig into it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>caching</category>
      <category>queuejobs</category>
      <category>performance</category>
    </item>
    <item>
      <title>Laravel AI SDK: 3 Multi-Agent Patterns Worth Using in Production (and 2 to Skip)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Fri, 27 Mar 2026 06:56:08 +0000</pubDate>
      <link>https://forem.com/hafiz619/laravel-ai-sdk-3-multi-agent-patterns-worth-using-in-production-and-2-to-skip-4hh9</link>
      <guid>https://forem.com/hafiz619/laravel-ai-sdk-3-multi-agent-patterns-worth-using-in-production-and-2-to-skip-4hh9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-multi-agent-patterns-production" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most Laravel developers building AI features make the same mistake. They read about multi-agent patterns, get excited, and wire up an orchestrator-workers system on their very first feature. A week later they're debugging dynamic planning logic, API costs are unpredictable, and the feature still hasn't shipped.&lt;/p&gt;

&lt;p&gt;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; makes spinning up agents so easy that it lowers the barrier to overengineering. That's not a criticism of the SDK. It's just a trap worth naming before you fall into it.&lt;/p&gt;

&lt;p&gt;Anthropic's original research identified five multi-agent patterns. The &lt;a href="https://laravel.com/blog/building-multi-agent-workflows-with-the-laravel-ai-sdk" rel="noopener noreferrer"&gt;official Laravel blog covered all five on March 13&lt;/a&gt; with clean code examples. What it didn't say is which ones you should actually reach for first, and which ones will cost you more than they're worth in a typical SaaS context.&lt;/p&gt;

&lt;p&gt;This is that post.&lt;/p&gt;

&lt;p&gt;A quick note on approach: all the code examples below use proper Agent classes generated with &lt;code&gt;php artisan make:agent&lt;/code&gt;, not the &lt;code&gt;agent()&lt;/code&gt; helper shorthand you'll see in most tutorials. The helper is great for prototyping. But in production, you want Agent classes. They're testable with the SDK's built-in fakes, the instructions live in one place, and when a prompt breaks in production you know exactly where to find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Multi-Agent Patterns Actually Are
&lt;/h2&gt;

&lt;p&gt;A single agent is one AI call with a system prompt. You send a prompt, you get a response. Simple. Multi-agent patterns are structured ways to chain, route, or run multiple AI calls together so complex tasks get broken into focused steps.&lt;/p&gt;

&lt;p&gt;The five patterns Anthropic identified: prompt chaining, routing, parallelization, orchestrator-workers, and evaluator-optimizer. Each solves a different problem. Three of them are practical for most Laravel SaaS apps today. Two of them are expensive and difficult to debug unless you've got a very specific use case.&lt;/p&gt;

&lt;p&gt;Let's go through the three worth shipping first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: Prompt Chaining
&lt;/h2&gt;

&lt;p&gt;This is the assembly line. Agent A does step 1 and passes its output to Agent B, which does step 2 and passes to Agent C. Each agent has one job and does it well.&lt;/p&gt;

&lt;p&gt;Laravel's built-in &lt;code&gt;Pipeline&lt;/code&gt; handles this naturally. Each step wraps a dedicated Agent class and enriches the payload before passing it forward.&lt;/p&gt;

&lt;p&gt;Here's a real-world example: a lead enrichment pipeline for a CRM. A salesperson drops in a company name, and three agents run in sequence to produce a ready-to-send outreach email.&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 make:agent LeadResearcher
php artisan make:agent LeadScorer
php artisan make:agent OutreachDrafter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Agent class defines focused instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Agents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;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\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;LeadResearcher&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="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 are a B2B lead researcher. Given a company name, write a 3-sentence brief: what they do, their likely tech stack, and their growth stage. Be concise and factual.'&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 pipeline steps wrap each agent and pass the enriched payload forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Pipeline&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\Ai\Agents\LeadResearcher&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Closure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResearchStep&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;array&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$brief&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LeadResearcher&lt;/span&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="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'company'&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="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'research'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$brief&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;Wire it together in a controller or job:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'company'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Acme Corp'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;through&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nc"&gt;ResearchStep&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;ScoringStep&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;OutreachStep&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;thenReturn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Three agents, three focused jobs, one clean result. You can test each step independently, which is genuinely valuable when something breaks in production. The instructions live in their own class, so updating the researcher prompt doesn't touch the scorer or the drafter.&lt;/p&gt;

&lt;p&gt;Chaining also scales well with complexity. Need to add a "tone check" step between scoring and drafting? Add one pipeline class. Need to skip the scoring step for returning customers? Add a conditional in that step. The structure absorbs changes cleanly, which is more than you can say for a single 800-word system prompt trying to do five things at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use chaining when:&lt;/strong&gt; the task has a clear sequence where each step depends on the previous one. Draft, validate, refine. Extract, classify, format. Research, score, write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: Routing
&lt;/h2&gt;

&lt;p&gt;Routing means classifying the input first, then sending it to the right specialist. One agent reads the request and decides which agent should handle it. Different input types get different instructions. Different complexity levels can get different models and different costs.&lt;/p&gt;

&lt;p&gt;This is the pattern that makes the most economic sense for support bots and anything where inputs vary widely.&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 make:agent TicketClassifier
php artisan make:agent BillingAgent
php artisan make:agent TechnicalAgent
php artisan make:agent GeneralSupportAgent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classifier returns a single category word, nothing else:&lt;br&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;TicketClassifier&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="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;'Classify the support ticket into exactly one of: billing, technical, general. Respond with just the category word, nothing else.'&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 router reads that category and dispatches to the right specialist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\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;App\Ai\Agents\BillingAgent&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\Ai\Agents\GeneralSupportAgent&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\Ai\Agents\TechnicalAgent&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\Ai\Agents\TicketClassifier&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;SupportRouter&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;route&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;$ticket&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TicketClassifier&lt;/span&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="nv"&gt;$ticket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$category&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="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&lt;/span&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="nv"&gt;$ticket&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="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TechnicalAgent&lt;/span&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="nv"&gt;$ticket&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;default&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GeneralSupportAgent&lt;/span&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="nv"&gt;$ticket&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;You can take this further by specifying different providers per agent in the &lt;code&gt;prompt()&lt;/code&gt; call. Simple billing questions can go to a cheaper, faster model. Complex technical issues go to a more capable one. The classifier call pays for itself quickly once you're handling real volume.&lt;/p&gt;

&lt;p&gt;Here's what that looks like for the technical agent specifically:&lt;br&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\Enums\Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// In TechnicalAgent's caller, or via the prompt() override:&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TechnicalAgent&lt;/span&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="nv"&gt;$ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'claude-opus-4-5-20251101'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Billing uses the default cheaper model from config/ai.php&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&lt;/span&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="nv"&gt;$ticket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The total cost for a billing ticket drops significantly while technical tickets still get the full model. That's a cost profile that actually makes sense in production, especially at scale.&lt;/p&gt;

&lt;p&gt;Here's what the routing flow looks like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-multi-agent-patterns-production" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Use routing when:&lt;/strong&gt; inputs vary significantly in type or complexity, and a single prompt can't handle all cases cleanly without turning into a mess of conditional instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: Parallelization
&lt;/h2&gt;

&lt;p&gt;When multiple agents need to look at the same input independently, there's no reason to run them one by one. Laravel's &lt;code&gt;Concurrency::run()&lt;/code&gt; lets you kick all of them off simultaneously and collect results when they're done.&lt;/p&gt;

&lt;p&gt;The time difference is real. Three agents in parallel takes roughly the same wall-clock time as one. Three agents in sequence takes three times as long.&lt;/p&gt;

&lt;p&gt;Here's a document analysis example. You've got a contract and want legal, financial, and risk assessments all at once:&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 make:agent LegalReviewer
php artisan make:agent FinancialAnalyzer
php artisan make:agent RiskAssessor
php artisan make:agent ContractSummaryAgent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the three specialists in parallel, then feed their outputs to a summary agent:&lt;br&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\Ai\Agents\ContractSummaryAgent&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\Ai\Agents\FinancialAnalyzer&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\Ai\Agents\LegalReviewer&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\Ai\Agents\RiskAssessor&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\Concurrency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$legal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$financial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$risk&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Concurrency&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LegalReviewer&lt;/span&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="nv"&gt;$contract&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FinancialAnalyzer&lt;/span&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="nv"&gt;$contract&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RiskAssessor&lt;/span&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="nv"&gt;$contract&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContractSummaryAgent&lt;/span&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="s2"&gt;"Legal review:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$legal&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Financial analysis:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$financial&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Risk assessment:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$risk&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ContractSummaryAgent&lt;/code&gt; only runs after all three are done, so it has the full picture. This pattern also pairs naturally with queued jobs when you're processing documents asynchronously. If you're not already confident with Laravel queues at scale, &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;this breakdown on processing large job volumes&lt;/a&gt; covers the fundamentals worth knowing before you lean into async agent pipelines.&lt;/p&gt;

&lt;p&gt;Note: &lt;code&gt;Concurrency::run()&lt;/code&gt; was introduced in Laravel 11. If you're still on Laravel 10, you'll need to handle parallelism through process pools or queued jobs instead.&lt;/p&gt;

&lt;p&gt;One thing worth handling in production: if one of the parallel agents fails, &lt;code&gt;Concurrency::run()&lt;/code&gt; will throw. Wrap it in a try/catch and decide whether you want to fail the whole request or fall back to running the failed agent synchronously. For document analysis, falling back gracefully is usually better than failing the whole operation over one specialist's timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use parallelization when:&lt;/strong&gt; multiple independent specialists need to look at the same input, or when you need several separate analyses that don't depend on each other's results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Patterns to Skip (For Now)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Orchestrator-Workers
&lt;/h3&gt;

&lt;p&gt;This one sounds compelling. A manager agent receives a complex task, breaks it into subtasks dynamically, delegates to worker agents, and assembles the result.&lt;/p&gt;

&lt;p&gt;The problem in practice: the orchestrator runs an internal agentic loop. It needs to reason about what steps are required, call worker tools in an order it determines at runtime, and figure out when it's done. That means you can't predict token usage, you can't easily trace what happened when something breaks, and testing becomes genuinely hard.&lt;/p&gt;

&lt;p&gt;For most SaaS features, the required steps aren't actually unknown at runtime. You just think they are. Nine times out of ten, you can replace a dynamic orchestrator with a well-structured chaining pipeline and end up with something cheaper, faster, and debuggable. A three-step pipeline that you wrote is easier to maintain than a planning loop you're hoping the model executes correctly.&lt;/p&gt;

&lt;p&gt;The orchestrator pattern earns its place when the task genuinely varies in structure. A code generation agent that might need to create five files or fifteen depending on the feature request. A research agent that needs to decide how many sources to query. If your task has a predictable shape, use chaining. You'll know when orchestration is actually necessary because a fixed pipeline genuinely can't handle the variability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evaluator-Optimizer
&lt;/h3&gt;

&lt;p&gt;This pattern loops: generate output, score it, rewrite if it doesn't meet the bar, repeat up to N times. Sounds great for quality. It's brutal on cost.&lt;/p&gt;

&lt;p&gt;Think about what "3 refinement iterations" actually means in production. That's up to four API calls per user action: one write, one evaluate, one rewrite, one final evaluate. If you're generating content at any real volume, that multiplier compounds fast. A feature that processes 1,000 requests per day and costs $0.01 per single-agent call becomes $0.04 with a 3-iteration evaluator loop. Doesn't sound like much until it's running for a month.&lt;/p&gt;

&lt;p&gt;There's also the latency problem. Each iteration adds a full round-trip to an AI provider. If you're doing this synchronously in a web request, you're looking at 10-20 seconds of wait time by the third iteration. That's not a user experience you want to ship.&lt;/p&gt;

&lt;p&gt;The evaluator pattern genuinely earns its place when output quality is business-critical and a human would otherwise review every result. Legal document drafting. Medical content. Anything where a bad output has real consequences. For most SaaS AI features, a single well-prompted agent with a clear output schema and structured output validation does the job at a fraction of the cost.&lt;/p&gt;

&lt;p&gt;If you're building a RAG-powered support system and want better response quality without retry loops, the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-part-2-build-a-rag-powered-support-bot-with-tools-and-memory" rel="noopener noreferrer"&gt;Part 2 tutorial on tools and memory&lt;/a&gt; covers how to get more out of a single agent before reaching for the evaluator.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simple Decision Framework
&lt;/h2&gt;

&lt;p&gt;Before picking a pattern, ask two questions: does the task have a predictable structure, and do the steps depend on each other?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clear sequence, each step depends on the previous&lt;/td&gt;
&lt;td&gt;Chaining&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inputs vary in type or complexity&lt;/td&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple independent analyses of the same input&lt;/td&gt;
&lt;td&gt;Parallelization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steps are genuinely unknown until runtime&lt;/td&gt;
&lt;td&gt;Orchestrator-workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output quality needs iterative refinement&lt;/td&gt;
&lt;td&gt;Evaluator-optimizer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start with a single well-prompted Agent class. If one agent can't do the job cleanly, reach for chaining first. Then routing or parallelization if the shape fits. The orchestrator and evaluator are there when you actually need them, and you'll know when you do.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need to pick one pattern, or can I mix them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can mix them freely. A routing pattern that dispatches tickets to specialist agents could use chaining inside the technical agent for complex multi-step resolution. The patterns compose. Just start with the simplest thing that works and layer from there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the actual cost difference between chaining and parallelization?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same number of API calls, different wall-clock time. Chaining runs them in sequence so total time is the sum of all agent response times. Parallelization runs them simultaneously so total time is roughly the slowest single agent. Costs are identical. Pick based on whether the steps depend on each other, not on cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with Laravel 11, 12, and 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The Laravel AI SDK supports all three. &lt;code&gt;Concurrency::run()&lt;/code&gt; for parallelization requires Laravel 11 or later. For Laravel 10 you'll need a different approach to parallel execution. The Agent class patterns for chaining and routing work on any supported version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test multi-agent workflows?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The AI SDK includes built-in fakes. You can fake each Agent class in tests and assert it was called with the right prompt in the right order. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;30-minute SDK tutorial&lt;/a&gt; covers the testing utilities in detail before you start wiring up multi-step pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I use &lt;code&gt;php artisan make:agent&lt;/code&gt; vs. the &lt;code&gt;agent()&lt;/code&gt; helper?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;agent()&lt;/code&gt; helper is fine for one-off calls and prototyping. For production multi-agent workflows, use proper Agent classes. They're testable, reusable, and the instructions live in one place. When you need to update a system prompt, you know exactly where to look. The &lt;a href="https://hafiz.dev/blog/the-complete-laravel-claude-code-ecosystem-every-tool-plugin-and-config-you-actually-need" rel="noopener noreferrer"&gt;complete Claude Code and Laravel ecosystem guide&lt;/a&gt; covers how Agent classes fit into a larger agentic dev setup if you want the full picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Multi-agent patterns are a real leap in what you can build with AI in Laravel. But the best one to start with is almost always the simplest one that solves the actual problem. Get a single agent working first. Then use chaining to add structure, routing to handle variety, and parallelization to cut wait time. Save the orchestrator and evaluator for when the task genuinely demands them.&lt;/p&gt;

&lt;p&gt;The Laravel AI SDK makes all of this feel like writing normal Laravel code. The Pipeline is already there. Concurrency is already there. You're just pointing agents at it. And because each Agent class is a plain PHP class with a well-defined interface, your tests stay clean and your prompts stay maintainable as the feature evolves.&lt;/p&gt;

&lt;p&gt;The patterns you skip today aren't gone forever. Once you've shipped chaining in production and have a handle on your real API costs and latency profile, you'll have a much clearer sense of whether an orchestrator or evaluator is worth reaching for. Most of the time you'll find the simpler patterns got you further than you expected.&lt;/p&gt;

&lt;p&gt;If you're building multi-agent workflows for a client project or a SaaS and want a second set of eyes on the architecture, &lt;a href="https://hafiz.dev/#contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>php</category>
      <category>multiagent</category>
    </item>
    <item>
      <title>Laravel 13 Queue::route(): One Place to Control Your Entire Queue Topology</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 25 Mar 2026 06:08:00 +0000</pubDate>
      <link>https://forem.com/hafiz619/laravel-13-queueroute-one-place-to-control-your-entire-queue-topology-4mjg</link>
      <guid>https://forem.com/hafiz619/laravel-13-queueroute-one-place-to-control-your-entire-queue-topology-4mjg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-queue-route-centralize-queue-topology" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every Laravel app I've worked on ends up with the same queue mess eventually. You start clean: one default queue, all jobs go there, life is simple. Then a client complains that emails are slow, so you spin up a dedicated &lt;code&gt;emails&lt;/code&gt; queue with its own worker. Then Stripe webhooks start backing up, so &lt;code&gt;billing&lt;/code&gt; gets its own queue too. Then AI processing jobs show up and they're eating into everything else, so &lt;code&gt;heavy&lt;/code&gt; points at a beefier Redis connection with more memory.&lt;/p&gt;

&lt;p&gt;Six months later, your queue topology lives in three different places at once: &lt;code&gt;$queue&lt;/code&gt; properties on some job classes, &lt;code&gt;-&amp;gt;onQueue()&lt;/code&gt; chains scattered across controllers and scheduled commands, and a handful of jobs that never got configured and quietly fall into the default queue. Change one queue name and you're grep-ing the entire codebase. Add a new developer and they have no idea where to look. The answer to "where does &lt;code&gt;ProcessInvoice&lt;/code&gt; go?" is: everywhere, depending on who wrote the dispatch call.&lt;/p&gt;

&lt;p&gt;Laravel 13 ships &lt;code&gt;Queue::route()&lt;/code&gt;. Same philosophy as &lt;code&gt;RateLimiter::for()&lt;/code&gt; for rate limits, or &lt;code&gt;Route::model()&lt;/code&gt; for model binding. Infrastructure config belongs in one place, not spread across twenty job classes. This is the post I wished existed when I was cleaning up a SaaS codebase with eleven queues and zero consistency.&lt;/p&gt;

&lt;p&gt;Here's how it works, how to use interface-based routing to make it scale, and how to migrate an existing app without breaking anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Patterns You're Probably Mixing Right Now
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;Queue::route()&lt;/code&gt;, you had two real options when you needed a job on a specific queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: class properties.&lt;/strong&gt; Works, but it makes the job class aware of your infrastructure.&lt;br&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;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'billing'&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;$connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sqs'&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;ProcessInvoice&lt;/code&gt; shouldn't care that you're using SQS in production but &lt;code&gt;database&lt;/code&gt; in staging. That's an environment concern, not a business logic concern. And if you want to move this job to a different queue, you have to touch the class itself. That should only happen when the business logic changes, not because you're reorganizing infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: chaining at dispatch.&lt;/strong&gt; Pushes the infrastructure knowledge to the caller 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;ProcessInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onQueue&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sqs'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every controller, command, and listener that dispatches this job has to remember to chain the right queue and connection. Miss one call site and the job silently lands on the default queue. No errors. No warnings. Just slower billing processing and a confused dev watching the &lt;code&gt;billing&lt;/code&gt; Horizon worker idle while the default queue grows.&lt;/p&gt;

&lt;p&gt;In practice, most apps end up mixing both patterns. Some jobs use class properties. Some use dispatch chaining. A few have it in both places and one overrides the other in a way nobody can remember without checking. When you onboard someone new, there's no obvious place to look. You just have to grep and hope.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Queue::route()&lt;/code&gt; replaces both patterns with one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Queue::route() Works
&lt;/h2&gt;

&lt;p&gt;You register your queue topology once, in &lt;code&gt;AppServiceProvider::boot()&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\Support\Facades\Queue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProcessInvoice&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;connection&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'sqs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SendWelcomeEmail&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;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'emails'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GenerateReport&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;connection&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'heavy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProcessPodcast&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;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'media'&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;Done. Every dispatch of &lt;code&gt;ProcessInvoice&lt;/code&gt; now automatically lands on the &lt;code&gt;billing&lt;/code&gt; queue using the &lt;code&gt;sqs&lt;/code&gt; connection, regardless of where in your codebase you dispatch it. No chaining. No class properties required.&lt;br&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;// Goes to billing/sqs automatically&lt;/span&gt;
&lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// So does this, from anywhere in the app&lt;/span&gt;
&lt;span class="nf"&gt;dispatch&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;ProcessInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job class itself stays clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;Invoice&lt;/span&gt; &lt;span class="nv"&gt;$invoice&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;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="c1"&gt;// just business logic here&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;$queue&lt;/code&gt;. No &lt;code&gt;$connection&lt;/code&gt;. No infrastructure noise at the top of a class that's supposed to be about billing logic.&lt;/p&gt;

&lt;p&gt;Laravel also ships an array shorthand for batch registration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&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;'billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sqs'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;// queue + connection&lt;/span&gt;
    &lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'emails'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// queue only, default connection&lt;/span&gt;
    &lt;span class="nc"&gt;GenerateReport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&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;'heavy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both styles work. I prefer the per-line approach because it's easier to diff in code review, but the array syntax is useful when your service provider is getting long.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actually Clever Part: Interface and Trait Routing
&lt;/h2&gt;

&lt;p&gt;Routing individual job classes is useful. But routing by interface is where the feature gets genuinely powerful, especially as your app grows.&lt;/p&gt;

&lt;p&gt;Say you have a dozen jobs that all belong to your billing system: &lt;code&gt;ProcessInvoice&lt;/code&gt;, &lt;code&gt;RefundPayment&lt;/code&gt;, &lt;code&gt;ChargeSubscription&lt;/code&gt;, &lt;code&gt;GenerateReceipt&lt;/code&gt;, and so on. You could register each one individually. But you'd have to update &lt;code&gt;AppServiceProvider&lt;/code&gt; every time a new billing job is added. That's the same maintenance overhead you were trying to escape.&lt;/p&gt;

&lt;p&gt;Instead, create a marker interface:&lt;br&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\Contracts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;BillingJob&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Implement it on every billing job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessInvoice&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;BillingJob&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="c1"&gt;// ...&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;RefundPayment&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;BillingJob&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="c1"&gt;// ...&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;ChargeSubscription&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;BillingJob&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="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 register 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;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BillingJob&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;connection&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'sqs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any job that implements &lt;code&gt;BillingJob&lt;/code&gt; automatically routes to billing/SQS. Add a new billing job tomorrow, implement the interface, and it's routed correctly. No service provider changes needed.&lt;/p&gt;

&lt;p&gt;The same pattern works with parent classes if you prefer inheritance over interfaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillingJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BillingJob&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;RefundPayment&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BillingJob&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And with traits if you prefer composition:&lt;br&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;trait&lt;/span&gt; &lt;span class="nc"&gt;IsBillingJob&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IsBillingJob&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;connection&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'sqs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick whichever fits how you already organize your jobs. The routing resolution works the same way regardless of whether you pass a concrete class, an interface, a trait, or a parent class.&lt;/p&gt;

&lt;p&gt;One thing worth understanding about precedence: a direct class registration always wins over an interface match. So if you route &lt;code&gt;BillingJob&lt;/code&gt; to &lt;code&gt;billing/sqs&lt;/code&gt;, but then add a specific route for &lt;code&gt;ProcessInvoice&lt;/code&gt; pointing to &lt;code&gt;billing-priority/sqs&lt;/code&gt;, the more specific rule wins for that one class while everything else continues using the interface route. Specific beats general. That's the behaviour you'd expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Visual: How Dispatch Resolution Works
&lt;/h2&gt;

&lt;p&gt;Here's the full picture of what happens after you've set up &lt;code&gt;Queue::route()&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;flowchart LR
    A[ProcessInvoice::dispatch] --&amp;gt; R{Queue::route resolver}
    B[RefundPayment::dispatch] --&amp;gt; R
    C[SendWelcomeEmail::dispatch] --&amp;gt; S{Queue::route resolver}
    D[GenerateReport::dispatch] --&amp;gt; T{Queue::route resolver}
    R --&amp;gt; F[billing queue / SQS]
    S --&amp;gt; G[emails queue / default]
    T --&amp;gt; H[heavy queue / Redis]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every dispatch passes through the resolver. The resolver checks the job class against registered routes, then any interfaces, traits, and parent classes. First match wins. If nothing matches, the job falls through to the default queue for the connection, exactly as before.&lt;/p&gt;

&lt;p&gt;Your dispatch call sites stay clean regardless of where in the app they live.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Real Before and After
&lt;/h2&gt;

&lt;p&gt;Here's what a typical app with three queues looks like before this change. Four dispatches, four different patterns:&lt;br&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;// InvoiceController.php&lt;/span&gt;
&lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onQueue&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sqs'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// RefundController.php: someone forgot onConnection, wrong connection in production&lt;/span&gt;
&lt;span class="nc"&gt;RefundPayment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$refund&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onQueue&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="c1"&gt;// SendWelcomeEmail.php: hardcoded property on the job class&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendWelcomeEmail&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'emails'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ReportCommand.php: no routing at all, silently falls to default queue&lt;/span&gt;
&lt;span class="nc"&gt;GenerateReport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the refactor, &lt;code&gt;AppServiceProvider&lt;/code&gt; is the single source of truth:&lt;br&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;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BillingJob&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;connection&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'sqs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SendWelcomeEmail&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;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'emails'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GenerateReport&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;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'heavy'&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 dispatch site becomes the same clean 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="nc"&gt;ProcessInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;RefundPayment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$refund&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;GenerateReport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$report&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;RefundPayment&lt;/code&gt; connection bug is gone. The silent &lt;code&gt;GenerateReport&lt;/code&gt; routing issue is fixed. And anyone reading the codebase knows exactly where to look for queue config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating an Existing App
&lt;/h2&gt;

&lt;p&gt;If you're upgrading to Laravel 13, this refactor pairs well with the upgrade itself. My &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade guide&lt;/a&gt; covers the upgrade process; this refactor can slot in right after or during.&lt;/p&gt;

&lt;p&gt;Start by finding every &lt;code&gt;$queue&lt;/code&gt; and &lt;code&gt;$connection&lt;/code&gt; property across your job classes:&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;-rn&lt;/span&gt; &lt;span class="s1"&gt;'public string \$queue\|public string \$connection'&lt;/span&gt; app/Jobs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then find every dispatch call with explicit routing:&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;-rn&lt;/span&gt; &lt;span class="s1"&gt;'onQueue\|onConnection'&lt;/span&gt; app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each job you find, register a route in &lt;code&gt;AppServiceProvider&lt;/code&gt;, remove the property from the class, and clean up the dispatch call. Run your test suite after each one. Don't try to do them all at once.&lt;/p&gt;

&lt;p&gt;One important point: &lt;code&gt;Queue::route()&lt;/code&gt; takes precedence over class properties if both exist. So you can register the route first, confirm it works in staging, and then clean up the property in a second commit. You're never in a state where the class property silently overrides your new central config during the transition.&lt;/p&gt;

&lt;p&gt;One thing I'd also recommend doing during the migration: group your jobs by concern before writing the routes. Look for natural clusters: billing jobs, email jobs, media processing jobs, AI jobs. If you have a natural grouping, that's a sign you should probably use interface-based routing for the whole group rather than registering each class individually. Taking ten minutes to plan this before writing any code will save you a much longer conversation when the team wants to move a whole category of jobs to a new connection.&lt;/p&gt;

&lt;p&gt;For a deep dive into how workers actually pick up named queues, set priorities, and handle Supervisor config, the post on &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing 10,000 queue jobs without breaking&lt;/a&gt; covers all of that in detail. Worth reading alongside this refactor if your worker config is also a mess.&lt;/p&gt;

&lt;h2&gt;
  
  
  When -&amp;gt;onQueue() Still Makes Sense
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Queue::route()&lt;/code&gt; sets the &lt;em&gt;default&lt;/em&gt; for a job class. You can still override it at the dispatch site. The feature doesn't remove any flexibility. It just changes what happens when you don't specify.&lt;/p&gt;

&lt;p&gt;This matters for priority overrides. Say you route most invoices to the &lt;code&gt;billing&lt;/code&gt; queue, but premium customers need to jump ahead of the line:&lt;br&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;// Standard dispatch: goes to billing via Queue::route()&lt;/span&gt;
&lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Premium customer: override to fast-track queue&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;$customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPremium&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing-priority'&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 central default handles 99% of cases. Edge cases get explicit overrides at the dispatch site. Clean split between the rule and the exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs Worth Being Honest About
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It's only in Laravel 13+.&lt;/strong&gt; If you're on Laravel 12, you can't use this. That's also a reasonable push to upgrade. Not because 12 is insecure (it gets security fixes until February 2027), but because the quality-of-life improvements add up. PHP Attributes for models, jobs, and commands. &lt;code&gt;Cache::touch()&lt;/code&gt;. This. They're individually small. Together they make day-to-day work cleaner. The &lt;a href="https://hafiz.dev/blog/laravel-13-php-attributes-refactor-your-models-jobs-and-commands" rel="noopener noreferrer"&gt;PHP Attributes post&lt;/a&gt; covers that side of the release if you want the full picture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discoverability moves to the service provider.&lt;/strong&gt; A developer reading &lt;code&gt;ProcessInvoice.php&lt;/code&gt; won't see which queue it uses without also knowing to check &lt;code&gt;AppServiceProvider&lt;/code&gt;. If your team values job classes being fully self-documenting about their infrastructure setup, that's a real trade-off worth discussing. My take: infrastructure config shouldn't live on the class. The same argument came up when &lt;code&gt;RateLimiter::for()&lt;/code&gt; shipped, and nobody complains about that pattern now. Centralization is the right call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test assertions still work, but for different reasons.&lt;/strong&gt; If you have tests using &lt;code&gt;Queue::assertPushedOn('billing', ProcessInvoice::class)&lt;/code&gt;, those tests will still pass after the migration. But now they pass because of &lt;code&gt;Queue::route()&lt;/code&gt; rather than an explicit &lt;code&gt;-&amp;gt;onQueue()&lt;/code&gt; chain. That's actually more correct behavior. But it can be briefly confusing during migration if a previously-failing test suddenly passes. It's not a bug. It's the feature working as intended.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does Queue::route() work with Horizon?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Horizon reads queue names when picking up jobs. &lt;code&gt;Queue::route()&lt;/code&gt; resolves before the job hits the queue, so Horizon sees the job on the correct named queue. Your Horizon config still controls worker counts and priorities per queue. Nothing about that changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I dispatch a job with no registered route and no class property?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It falls back to the default queue for the connection, exactly as before. &lt;code&gt;Queue::route()&lt;/code&gt; is purely additive. Existing behavior doesn't break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with batched and chained jobs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;batched jobs&lt;/strong&gt;, yes. Each job in a batch resolves its route independently through &lt;code&gt;Queue::route()&lt;/code&gt;. For &lt;strong&gt;chained jobs&lt;/strong&gt;, it depends on how the chain is configured. If you dispatch a chain with an explicit &lt;code&gt;-&amp;gt;onQueue()&lt;/code&gt; or &lt;code&gt;-&amp;gt;onConnection()&lt;/code&gt; at the chain level, those values take precedence over registered routes for all jobs in the chain, since chain-level queue settings apply to all jobs unless individually overridden. If no queue is specified at the chain level, &lt;code&gt;Queue::route()&lt;/code&gt; resolves as normal for each job. Bottom line: test chain behavior in your specific setup before relying on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Queue::route() and still override at the dispatch site?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. &lt;code&gt;-&amp;gt;onQueue()&lt;/code&gt; and &lt;code&gt;-&amp;gt;onConnection()&lt;/code&gt; at the dispatch site always override a registered route. The route is just the default when nothing is specified explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with mailables and notifications that implement ShouldQueue?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. &lt;code&gt;Queue::route()&lt;/code&gt; applies to job classes specifically. Mailables and notifications use their own &lt;code&gt;onQueue()&lt;/code&gt; method and aren't affected by this feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if a job matches multiple interface routes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Direct class registrations win over interface registrations. Among interfaces, the first matching route wins. Design your interfaces so a job only ever matches one route. It keeps things predictable and avoids the kind of subtle ordering dependency that bites you six months later.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;Queue::route()&lt;/code&gt; isn't flashy. It won't make it into any conference keynote highlight reel. But it's the kind of feature you notice every single day once you're using it: one file, one place to look, no surprises about where a job ends up.&lt;/p&gt;

&lt;p&gt;Queue topology is infrastructure config. It belongs in &lt;code&gt;AppServiceProvider&lt;/code&gt; next to rate limiters and model bindings. Not embedded in job classes and not repeated at every dispatch call site. If you're on Laravel 13 and have more than two queues, this refactor is worth an hour of your time this week. It's exactly the kind of thing that prevents the "wait, which queue does this job actually go to?" conversation at 2am when something's backing up.&lt;/p&gt;

&lt;p&gt;The pattern also makes you think more clearly about your queue topology in general. When everything is in one file, you can see at a glance whether your infrastructure matches your mental model. Gaps become obvious. Misconfigurations stand out. It's a small change with a surprisingly large effect on how well the team understands the system.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravel13</category>
      <category>queues</category>
      <category>php</category>
    </item>
    <item>
      <title>Claude Code Channels: How to Control Your AI Agent from Your Phone</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 23 Mar 2026 09:03:50 +0000</pubDate>
      <link>https://forem.com/hafiz619/claude-code-channels-how-to-control-your-ai-agent-from-your-phone-403k</link>
      <guid>https://forem.com/hafiz619/claude-code-channels-how-to-control-your-ai-agent-from-your-phone-403k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/claude-code-channels-how-to-control-your-ai-agent-from-your-phone" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You kick off a big database migration on staging, get up to make coffee, and come back 45 minutes later. Claude hit a permission prompt 3 minutes in. Everything's been frozen since then. You had no idea.&lt;/p&gt;

&lt;p&gt;That's the specific problem Claude Code Channels fixes.&lt;/p&gt;

&lt;p&gt;Shipped on March 20, 2026 as a research preview, Channels is a feature that turns your running Claude Code session into something you can message from anywhere. Fire off a task before a meeting. Check in from your phone. Get a reply in Telegram when it's done. Your laptop stays open, your session stays alive, and you're not chained to the terminal waiting for output.&lt;/p&gt;

&lt;p&gt;This isn't a tutorial about what Channels is. If you want that, the official docs cover it fine. This is about what you actually do with it inside a real Laravel project. The setup takes 5 minutes. The workflows take longer to figure out on your own, so I'm going to save you the trial and error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Channels Actually Is (The Short Version)
&lt;/h2&gt;

&lt;p&gt;A Channel is an MCP server that runs locally alongside your Claude Code session. It acts as a bridge between an external messaging platform and your active terminal session.&lt;/p&gt;

&lt;p&gt;The key word is "local." Your code never leaves your machine. When you send a message from Telegram, the plugin (running on your laptop) receives it and injects it directly into your open Claude Code session as an event. Claude processes it with full access to your filesystem, your MCP servers, your git history, everything. Then it replies back through the same channel.&lt;/p&gt;

&lt;p&gt;Claude Code&lt;br&gt;
Channels Message Flow&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
Your&lt;br&gt;Phone&lt;/p&gt;

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

&lt;p&gt;DM&lt;/p&gt;



&lt;p&gt;&lt;br&gt;
Bot&lt;br&gt;API&lt;/p&gt;

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

&lt;p&gt;polls&lt;/p&gt;



&lt;p&gt;&lt;br&gt;
MCP&lt;br&gt;Plugin&lt;/p&gt;

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

&lt;p&gt;event&lt;/p&gt;



&lt;p&gt;&lt;br&gt;
Claude&lt;br&gt;Session&lt;/p&gt;

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

&lt;p&gt;reads&lt;/p&gt;



&lt;p&gt;&lt;br&gt;
Your&lt;br&gt;Codebase&lt;/p&gt;



&lt;p&gt;function alInit(){&lt;br&gt;
if(!document.getElementById('al-s0')){setTimeout(alInit,50);return;}&lt;br&gt;
var AMBER='#c9aa71',GREEN='#4ade80',STEP=520,CONN=420,HOLD=900,PAUSE=600;&lt;br&gt;
var msgs=['message traveling from phone \u2192','routing through Telegram Bot API \u2192','MCP plugin injecting event into session \u2192','Claude working in your local project \u2192','codebase access: full \u2014 composing reply...','reply delivered back through Telegram \u2713'];&lt;br&gt;
function nd(i){return document.getElementById('al-s'+i);}&lt;br&gt;
function ic(i){return document.getElementById('al-i'+i);}&lt;br&gt;
function lb(i){return document.getElementById('al-l'+i);}&lt;br&gt;
function cn(i){return document.getElementById('al-c'+i);}&lt;br&gt;
function aw(i){return document.getElementById('al-a'+i);}&lt;br&gt;
function ar(i){return document.getElementById('al-arr'+i);}&lt;br&gt;
var st=document.getElementById('al-status');&lt;br&gt;
function setNode(i,color){nd(i).style.borderColor=color;nd(i).style.background=color===AMBER?'rgba(201,170,113,0.12)':'rgba(74,222,128,0.12)';ic(i).style.color=color;lb(i).style.color=color===AMBER?'rgba(201,170,113,0.9)':'rgba(74,222,128,0.9)';}&lt;br&gt;
function fillConn(i,color,cb){&lt;br&gt;
var c=cn(i),a=aw(i),arrEl=ar(i);&lt;br&gt;
var arrowColor=color===AMBER?'rgba(201,170,113,0.7)':'rgba(74,222,128,0.7)';&lt;br&gt;
if(arrEl){arrEl.querySelector('polygon').setAttribute('fill',color);arrEl.style.opacity='0';}&lt;br&gt;
c.style.background=color;c.style.transition='none';c.style.width='0%';&lt;br&gt;
a.style.color=arrowColor;&lt;br&gt;
requestAnimationFrame(function(){requestAnimationFrame(function(){&lt;br&gt;
c.style.transition='width '+CONN+'ms ease';c.style.width='100%';&lt;br&gt;
setTimeout(function(){if(arrEl)arrEl.style.opacity='1';cb();},CONN+40);&lt;br&gt;
});});&lt;br&gt;
}&lt;br&gt;
function resetAll(){&lt;br&gt;
for(var i=0;i&amp;lt;5;i++){nd(i).style.borderColor='rgba(201,170,113,0.2)';nd(i).style.background='#1a1a26';ic(i).style.color='rgba(201,170,113,0.25)';lb(i).style.color='rgba(224,224,224,0.3)';}&lt;br&gt;
for(var j=0;j&amp;lt;4;j++){var c=cn(j);c.style.transition='none';c.style.width='0%';aw(j).style.color='rgba(201,170,113,0.0)';var arrEl=ar(j);if(arrEl)arrEl.style.opacity='0';}&lt;br&gt;
st.style.opacity='0';&lt;br&gt;
}&lt;br&gt;
function setStatus(msg){st.style.opacity='0';setTimeout(function(){st.textContent=msg;st.style.opacity='1';},150);}&lt;br&gt;
function loop(){&lt;br&gt;
resetAll();&lt;br&gt;
setTimeout(function(){&lt;br&gt;
setNode(0,AMBER);setStatus(msgs[0]);&lt;br&gt;
setTimeout(function(){fillConn(0,AMBER,function(){&lt;br&gt;
setNode(1,AMBER);setStatus(msgs[1]);&lt;br&gt;
setTimeout(function(){fillConn(1,AMBER,function(){&lt;br&gt;
setNode(2,AMBER);setStatus(msgs[2]);&lt;br&gt;
setTimeout(function(){fillConn(2,AMBER,function(){&lt;br&gt;
setNode(3,AMBER);setStatus(msgs[3]);&lt;br&gt;
setTimeout(function(){fillConn(3,AMBER,function(){&lt;br&gt;
setNode(4,AMBER);setStatus(msgs[4]);&lt;br&gt;
setTimeout(function(){&lt;br&gt;
setStatus(msgs[5]);&lt;br&gt;
for(var k=0;k&amp;lt;5;k++){setNode(k,GREEN);}&lt;br&gt;
setTimeout(function(){setTimeout(loop,PAUSE);},HOLD);&lt;br&gt;
},STEP);&lt;br&gt;
});},STEP);&lt;br&gt;
});},STEP);&lt;br&gt;
});},STEP);&lt;br&gt;
});},STEP);&lt;br&gt;
},400);&lt;br&gt;
}&lt;br&gt;
loop();&lt;br&gt;
}&lt;br&gt;
alInit();&lt;/p&gt;



&lt;p&gt;One thing the docs are clear about: events only arrive while your session is open. Close the terminal and messages sent during that time are gone. They won't queue up and deliver later. So for anything you want to monitor while you're away, you need to keep Claude Code running in a &lt;code&gt;tmux&lt;/code&gt; or &lt;code&gt;screen&lt;/code&gt; session. More on this in a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Set Up (The Minimum You Need to Know)
&lt;/h2&gt;

&lt;p&gt;Before you start: Channels requires Claude Code v2.1.80 or later, a &lt;code&gt;claude.ai&lt;/code&gt; login (API key auth doesn't work), and the Bun runtime. Not Node. Bun. This trips up a lot of people. If you install the plugin and nothing happens, Bun is almost certainly the reason.&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="c"&gt;# Check your Claude Code version first&lt;/span&gt;
claude &lt;span class="nt"&gt;--version&lt;/span&gt;

&lt;span class="c"&gt;# Install Bun if you don't have it&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://bun.sh/install | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you're past that, the Telegram setup is three commands inside an active Claude Code session:&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="c"&gt;# Install the official plugin&lt;/span&gt;
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;telegram@claude-plugins-official

&lt;span class="c"&gt;# Save your BotFather token&lt;/span&gt;
/telegram:configure YOUR_BOT_TOKEN_HERE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then exit and relaunch with the channels flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--channels&lt;/span&gt; plugin:telegram@claude-plugins-official
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DM your bot on Telegram. It replies with a 6-character pairing code. Go back to your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/telegram:access pair YOUR_PAIRING_CODE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. You're connected. Send a message from Telegram and Claude responds in the chat.&lt;/p&gt;

&lt;p&gt;One more thing to do immediately: lock down access.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/telegram:access policy allowlist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, anyone who messages your bot gets a pairing code reply. That's fine for testing, but you don't want strangers trying to pair with your session. Run the allowlist command before you forget.&lt;/p&gt;

&lt;p&gt;Now. Let's talk about what you actually use this for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 1: Long-Running Artisan Commands
&lt;/h2&gt;

&lt;p&gt;The migration scenario from the intro is the most common pain point. You're running something that takes time and needs a yes/no at some point in the middle. A data import. A multi-step migration. A batch reprocessing job on a large table.&lt;/p&gt;

&lt;p&gt;With Channels, the workflow looks like this. You kick off the command and tell Claude what to expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You: Run the import:customers artisan command against the staging database.
    It'll ask for confirmation before it starts writing. Wait for my approval
    before you proceed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you walk away. When Claude hits the confirmation step, it messages you in Telegram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude: The import is ready to begin. It found 14,832 customer records 
to process. The command is asking for confirmation before writing. 
Should I proceed? (yes/no)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You reply "yes" from your phone. Claude approves the prompt in the terminal and the import runs.&lt;/p&gt;

&lt;p&gt;This works for any scenario where you want a human checkpoint in the middle of a longer task. Migrations against production data. Seed scripts that drop and recreate tables. Any &lt;code&gt;--force&lt;/code&gt; command you want to consciously approve rather than just automate past.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 2: Queue Failures While You're Not at Your Desk
&lt;/h2&gt;

&lt;p&gt;If you're running a Laravel app with queued jobs, you already know the late-night failed job routine. Horizon shows something red. You get an email. You sigh and open the laptop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel Queue Jobs: Processing 10,000 Tasks Without Breaking&lt;/a&gt; covers the architecture side well. Channels adds the response layer.&lt;/p&gt;

&lt;p&gt;Keep Claude Code running in a &lt;code&gt;tmux&lt;/code&gt; session on your development machine with your project loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; claude-session
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; claude-session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'cd ~/clients/myapp &amp;amp;&amp;amp; claude --channels plugin:telegram@claude-plugins-official --dangerously-skip-permissions'&lt;/span&gt; Enter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now from your phone, whenever something looks off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You: Check the failed jobs in Horizon. What's failing and why?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude reads the failed job records, checks the exception messages, looks at the relevant code, and replies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude: 3 jobs failed in the last hour. All are ProcessInvoicePdf jobs.
The exception is: Unable to find class App\Services\PdfRenderer.
Looks like a namespace change that wasn't reflected in the job class.
The fix is a one-line update in ProcessInvoicePdf.php line 23.
Want me to fix it and restart the queue?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You reply "yes, fix it" from Telegram. Claude makes the change and tells you when it's done. No laptop needed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; flag is doing real work here. Without it, every file write would pause and wait for terminal confirmation, which defeats the purpose when you're trying to resolve something remotely. I'll be honest about the trade-off: you're giving up the permission safety net. Only use this on machines and projects you trust, in environments where the blast radius of a bad action is acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 3: Client Bug Reports When You're Away from the Desk
&lt;/h2&gt;

&lt;p&gt;This one happens constantly in client work. Someone messages you about a bug at 7pm. You're not at your computer. Before Channels, your options were: ignore it until morning, or open the laptop.&lt;/p&gt;

&lt;p&gt;One thing to understand clearly before you rely on this scenario: three conditions must all be true at the same time. Your machine has to be on. Claude Code has to be running with &lt;code&gt;--channels&lt;/code&gt; in a tmux session. And the right client project has to be loaded in that session. If you have two active clients, you need two named tmux sessions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux new-session &lt;span class="nt"&gt;-s&lt;/span&gt; client-a   &lt;span class="c"&gt;# client A project loaded here&lt;/span&gt;
tmux new-session &lt;span class="nt"&gt;-s&lt;/span&gt; client-b   &lt;span class="c"&gt;# client B project loaded here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each session runs Claude Code pointed at the right directory. When a bug comes in for client 1, you're messaging into the session that already has their codebase loaded. This is the setup that makes Scenario 3 actually work in practice.&lt;/p&gt;

&lt;p&gt;With that in place, when a client reports a bug you forward it to Claude in Telegram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You: Client just reported that the invoice filter on the dashboard isn't
    working. When they select "overdue" from the dropdown, it shows all
    invoices instead of filtering. Look at InvoiceController and the
    related Vue component and tell me what's wrong.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude reads both files, traces the issue, and comes back to you in the chat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude: Found it. In InvoiceController@index, the 'overdue' case in the
switch statement is missing a break, so it falls through to the default
'all' query. The fix is adding `break;` on line 67. The Vue component
is fine. Want me to write the fix and a test for it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You reply "yes, write the fix and test, but don't commit yet." Claude writes both. In the morning you sit down, review what it wrote, and either commit or adjust. The diagnosis work is already done. Half the problem is solved before you've touched the keyboard.&lt;/p&gt;

&lt;p&gt;This is where Channels starts to feel less like a tool and more like having a developer you can text. You're not directing every step. You're checking in, approving things, and staying informed without being present.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 4: Kicking Off Feature Work Before You Step Away
&lt;/h2&gt;

&lt;p&gt;This one pairs well with the &lt;a href="https://hafiz.dev/blog/the-ralph-wiggum-technique-let-claude-code-work-through-your-task-list" rel="noopener noreferrer"&gt;Ralph Wiggum Technique&lt;/a&gt; and &lt;a href="https://hafiz.dev/blog/git-worktrees-for-laravel-developers-run-multiple-claude-code-sessions-in-parallel" rel="noopener noreferrer"&gt;git worktrees&lt;/a&gt; approach.&lt;/p&gt;

&lt;p&gt;Start a well-scoped task before you leave for lunch or a meeting. Give Claude a clear spec via Telegram, with the project already loaded in the session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You: Build the notification preferences page. Users should be able to toggle
    email notifications on/off for: new invoice, payment received, invoice
    overdue. Save preferences to user_notification_preferences table (create
    the migration). Vue component in resources/js/Pages/Settings/Notifications.vue.
    Use the existing NotificationService. Don't build the actual email sending,
    just the preferences UI and storage.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude starts working. The Telegram typing indicator shows it's active. When it finishes (or hits something it needs to ask about), it messages you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude: Done. Here's what I built:
- Migration: create_user_notification_preferences_table
- Model: UserNotificationPreference with fillable and relationship on User
- Controller: NotificationPreferenceController (store/update)
- Route: PATCH /settings/notifications
- Vue component: Notifications.vue with three toggles, auto-saves on change
- I didn't find NotificationService in your app, so I left a TODO comment
  where it should be called. Can you point me to the right file?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You clarify. It continues. Back and forth over Telegram, no laptop open.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://hafiz.dev/blog/laravel-boost-and-mcp-servers-the-context-your-ai-agent-is-missing" rel="noopener noreferrer"&gt;MCP server setup&lt;/a&gt; matters a lot here. The more context Claude has about your project conventions and codebase, the less you have to explain in each Telegram message. Get your MCP configuration right first and these remote sessions become much more productive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Session-Open Requirement and tmux
&lt;/h2&gt;

&lt;p&gt;One thing to understand clearly: Channels is not a cloud service. Claude Code isn't running somewhere persistent on Anthropic's servers waiting for your messages. It's running on your machine. Close the terminal, and it's gone.&lt;/p&gt;

&lt;p&gt;For workflows where you want to kick something off and check in later, you need to keep the session alive. &lt;code&gt;tmux&lt;/code&gt; is the standard solution:&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="c"&gt;# Start a persistent session&lt;/span&gt;
tmux new-session &lt;span class="nt"&gt;-s&lt;/span&gt; claude

&lt;span class="c"&gt;# Later, reattach from any terminal&lt;/span&gt;
tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; claude

&lt;span class="c"&gt;# Or check what's running&lt;/span&gt;
tmux &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start Claude Code inside the tmux session with the channels flag, and it'll keep running even if you close your terminal window. The session persists until your machine restarts or you explicitly kill it.&lt;/p&gt;

&lt;p&gt;One thing that still requires the terminal: permission prompts. If you're not using &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; and Claude hits a prompt it needs answered, the session pauses. You can't approve it from Telegram. You have to get back to the terminal. This is a known limitation during the research preview, and based on the docs it sounds like remote permission approval is on the roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Channels Isn't
&lt;/h2&gt;

&lt;p&gt;Worth being honest about the current state. This is a research preview. A few things that don't work yet or have rough edges:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No message history.&lt;/strong&gt; The Telegram Bot API doesn't expose message history. If Claude replies while your phone has no signal, the message is gone. You won't see it when you reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only Anthropic-whitelisted plugins for now.&lt;/strong&gt; During the preview, &lt;code&gt;--channels&lt;/code&gt; only accepts plugins from &lt;code&gt;claude-plugins-official&lt;/code&gt;. If you want to build a custom Slack channel or a webhook bridge for something like Forge deploy notifications, you need the &lt;code&gt;--dangerously-load-development-channels&lt;/code&gt; flag, which disables the allowlist entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Availability is still rolling out.&lt;/strong&gt; Some accounts don't have it yet even after updating. If the &lt;code&gt;--channels&lt;/code&gt; flag doesn't register anything on startup, you may just not have access yet.&lt;/p&gt;

&lt;p&gt;If you were using &lt;a href="https://hafiz.dev/blog/openclaw-laravel-forge-deploy-your-ai-assistant-in-5-minutes" rel="noopener noreferrer"&gt;OpenClaw on Forge&lt;/a&gt; before, Channels covers most of the same ground with better first-party support. OpenClaw still has an edge on platform breadth (iMessage, WhatsApp, Slack), but for Telegram and Discord use cases, Channels is the cleaner option.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need a special Claude plan to use Channels?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You need a &lt;code&gt;claude.ai&lt;/code&gt; login. It works on Pro, Max, Team, and Enterprise plans. Console and API key authentication aren't supported. Team and Enterprise orgs also need an admin to enable it in managed settings before individual setup works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Discord instead of Telegram?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The setup is slightly more involved (you need to create a bot in the Discord Developer Portal and enable Message Content Intent under Privileged Gateway Intents), but the workflow is the same. Run &lt;code&gt;/plugin install discord@claude-plugins-official&lt;/code&gt; and &lt;code&gt;/discord:configure YOUR_TOKEN&lt;/code&gt; in your Claude Code session, then relaunch with &lt;code&gt;--channels plugin:discord@claude-plugins-official&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to use --dangerously-skip-permissions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends entirely on your environment. On a local dev machine with a project you fully control, it's fine. On a machine with production database credentials and live API keys, think carefully. The flag removes all permission checkpoints. If Claude misinterprets an instruction, it'll execute without asking. Use it where the blast radius of a mistake is acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to messages I send when Claude Code isn't running?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They're lost. The channel plugin runs locally and only polls while Claude Code is active. If the session is closed, no one's listening. This is why tmux matters for always-on setups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run multiple channel plugins at the same time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Space-separate them in the &lt;code&gt;--channels&lt;/code&gt; flag: &lt;code&gt;claude --channels plugin:telegram@claude-plugins-official plugin:discord@claude-plugins-official&lt;/code&gt;. Both channels will be active in the same session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shift That Actually Matters
&lt;/h2&gt;

&lt;p&gt;The practical change here is smaller than the hype suggests, but it's real. You go from "I have to be at my terminal to do anything with Claude" to "I can start something, step away, and stay in the loop from wherever I am."&lt;/p&gt;

&lt;p&gt;For client work especially, that's meaningful. A bug comes in at 7pm. You don't have to choose between ignoring it and sitting back down at your desk. You fire off the investigation from your phone and handle it on your own schedule.&lt;/p&gt;

&lt;p&gt;Set up tmux, get the Bun requirement sorted first, and lock down your allowlist before you share your bot token anywhere. The rest is just workflow.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>claudecode</category>
      <category>aidevelopment</category>
      <category>developertools</category>
    </item>
    <item>
      <title>How to Build a Laravel MCP Server with Filament</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Sat, 21 Mar 2026 09:06:25 +0000</pubDate>
      <link>https://forem.com/hafiz619/how-to-build-a-laravel-mcp-server-with-filament-28hg</link>
      <guid>https://forem.com/hafiz619/how-to-build-a-laravel-mcp-server-with-filament-28hg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/how-to-build-a-laravel-mcp-server-with-filament" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you've been using Claude Code or Cursor on a Filament project, you already know the frustration. You ask the agent to add a filter to your &lt;code&gt;OrderResource&lt;/code&gt;, and it invents a column that doesn't exist. You ask it to fix a relationship display issue, and it writes code for the wrong Filament version. The agent isn't bad. It just has no idea what's actually inside your app.&lt;/p&gt;

&lt;p&gt;That's the exact problem Laravel MCP solves. Instead of an agent guessing at your schema from vague documentation references or grep results, it queries a server you control. You define exactly what it can see. You decide what gets exposed, what stays private, and how the data is shaped before the agent ever touches it.&lt;/p&gt;

&lt;p&gt;In this tutorial I'm going to walk through building a practical Laravel MCP server specifically for a Filament admin. The examples use an e-commerce order management setup because it's concrete and easy to follow, but the patterns apply directly to any Filament project regardless of domain. We'll create tools that expose your Eloquent data with filters, a resource that gives agents a map of your admin panel structure, and wire everything up for both local development and remote HTTP access.&lt;/p&gt;

&lt;p&gt;If you're new to MCP in Laravel, read &lt;a href="https://hafiz.dev/blog/laravel-boost-and-mcp-servers-the-context-your-ai-agent-is-missing" rel="noopener noreferrer"&gt;my earlier post on Laravel Boost and MCP servers&lt;/a&gt; first. This tutorial assumes you know what MCP is and why it matters. We're going straight to the implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Filament Projects Specifically Need This
&lt;/h2&gt;

&lt;p&gt;Most MCP tutorials stop at exposing raw database tables. That's useful, but it misses a big chunk of what Filament apps actually need. When you're working in a Filament project, your domain logic is split between Eloquent models and Filament Resources. The resources define how data is filtered, how forms are structured, which columns are displayed, and how permissions work. None of that lives in the database schema.&lt;/p&gt;

&lt;p&gt;An AI agent that only has database access can tell you what columns exist. It can't tell you which &lt;code&gt;OrderResource&lt;/code&gt; pages are registered, what the create form looks like, or how your custom widgets pull data. That's the gap a Filament-aware MCP server fills. You're giving the agent structured access to both layers, the data and the admin layer on top of it.&lt;/p&gt;

&lt;p&gt;The second reason is practical: Filament apps tend to be complex. Multi-panel setups, custom pages, resource relations, polymorphic relationships. The more complex your app, the more the agent needs structured context rather than broad guessing. A one-hour investment in MCP setup pays back consistently across every dev session after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Laravel 12 or 13 (the &lt;code&gt;laravel/mcp&lt;/code&gt; package doesn't support earlier versions)&lt;/li&gt;
&lt;li&gt;A running Filament v3+ installation&lt;/li&gt;
&lt;li&gt;PHP 8.2 or higher&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're starting fresh with Filament, check out &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;this guide on building admin dashboards with Filament&lt;/a&gt; before continuing. It covers panel setup and resource structure in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Laravel MCP
&lt;/h2&gt;

&lt;p&gt;Start with a single Composer command:&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/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then publish the routes file:&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 vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ai-routes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;routes/ai.php&lt;/code&gt;. Think of it as &lt;code&gt;routes/api.php&lt;/code&gt; but for AI clients. You'll register your MCP servers here, control which middleware runs on each one, and decide which servers are local (stdio-based) vs. web (HTTP-based). You can mix both in the same file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the Filament Server
&lt;/h2&gt;

&lt;p&gt;Run the generator:&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 make:mcp-server FilamentAdminServer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll find the new class at &lt;code&gt;app/Mcp/Servers/FilamentAdminServer.php&lt;/code&gt;. The PHP attributes on the class matter more than most people realise. The &lt;code&gt;#[Instructions]&lt;/code&gt; attribute is literally the first thing the AI model reads about this server. It uses it to decide whether to query this server at all, so vague instructions produce vague agent behavior.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Servers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Instructions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Version&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Name('Filament Admin Server')]&lt;/span&gt;
&lt;span class="na"&gt;#[Version('1.0.0')]&lt;/span&gt;
&lt;span class="na"&gt;#[Instructions('Provides AI agents with structured access to this application\'s Filament admin resources, Eloquent data, and panel navigation. Use this server when you need to inspect or query application data, understand the resource structure, or generate code that interacts with Filament resources.')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FilamentAdminServer&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Server&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;array&lt;/span&gt; &lt;span class="nv"&gt;$tools&lt;/span&gt; &lt;span class="o"&gt;=&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;array&lt;/span&gt; &lt;span class="nv"&gt;$resources&lt;/span&gt; &lt;span class="o"&gt;=&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;array&lt;/span&gt; &lt;span class="nv"&gt;$prompts&lt;/span&gt; &lt;span class="o"&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;"Provides access to data" tells the agent nothing. "Provides structured access to Filament admin resources, Eloquent data, and panel navigation" tells it exactly when to reach for this server. The more precise you are here, the fewer times you'll have to manually prompt the agent to use the right tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Your First Tool: List Records
&lt;/h2&gt;

&lt;p&gt;The most immediately useful tool for a Filament app is one that lets the agent query actual data with business-logic filters, not raw SQL. Let's build &lt;code&gt;ListOrdersTool&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:mcp-tool ListOrdersTool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the full implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Tools&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\Order&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\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\Mcp\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tools\Annotations\IsReadOnly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Description('Lists orders from the database. Supports filtering by status and limiting results. Returns order ID, reference, status, total, and customer name.')]&lt;/span&gt;
&lt;span class="na"&gt;#[IsReadOnly]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListOrdersTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'nullable|string|in:pending,processing,completed,cancelled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'limit'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'nullable|integer|min:1|max:50'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'status.in'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Status must be one of: pending, processing, completed, cancelled.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'limit.max'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Limit cannot exceed 50 records.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&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;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;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&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;$q&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;$q&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'limit'&lt;/span&gt;&lt;span class="p"&gt;]&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;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="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;structured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$orders&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="nv"&gt;$order&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="s1"&gt;'id'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&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;'reference'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'status'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&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="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'total'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'customer_name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&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="s1"&gt;'created_at'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toDateTimeString&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;toArray&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;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;'status'&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;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Filter orders by status. Omit to return all statuses.'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

            &lt;span class="s1"&gt;'limit'&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Number of records to return. Defaults to 10, max 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="k"&gt;default&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="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 here worth explaining. First, &lt;code&gt;Response::structured()&lt;/code&gt; returns a proper JSON object the AI client can parse and reason about, not just a text blob. When you return plain text, the agent has to interpret natural language. When you return structured data, it can reliably extract the &lt;code&gt;id&lt;/code&gt; or &lt;code&gt;status&lt;/code&gt; and use them in the next step. Second, &lt;code&gt;#[IsReadOnly]&lt;/code&gt; tells the client this tool won't modify any state, so it can be called freely without requesting confirmation. Third, the validation error messages are written for the AI, not for a human form. They're specific and tell the agent exactly what to fix.&lt;/p&gt;

&lt;p&gt;Register it in your server's &lt;code&gt;$tools&lt;/code&gt; array before moving on. The pattern here, validate inputs clearly, query with explicit field selection, return structured output, is the same pattern you'll follow for every tool you add. Once you've built two or three, each new one takes about ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Deeper Tool: Get a Single Record
&lt;/h2&gt;

&lt;p&gt;A list tool is a good start, but agents often need to inspect one record in full detail before writing code that touches it. Without this, the agent calls your list tool, gets back a summary, and then has to guess what relationships and fields are available on the full record. That's where hallucinations creep back in.&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 make:mcp-tool GetOrderTool
&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Tools&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\Order&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\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\Mcp\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tools\Annotations\IsReadOnly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Description('Retrieves a single order with full details including line items, customer, and status history.')]&lt;/span&gt;
&lt;span class="na"&gt;#[IsReadOnly]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetOrderTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|integer|exists:orders,id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'id.required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'You must provide an order ID.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'id.exists'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'No order found with that ID. Use the list-orders tool first to find valid IDs.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&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;with&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'customer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'items.product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'statusHistory'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;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;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;structured&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;$order&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;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&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="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'total'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'customer'&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;'id'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&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;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&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="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&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="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;items&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="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="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'product_name'&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="n"&gt;product&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="s1"&gt;'quantity'&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="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'unit_price'&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="n"&gt;unit_price&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;toArray&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'created_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toDateTimeString&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;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;'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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The database ID of the order to retrieve.'&lt;/span&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;id.exists&lt;/code&gt; validation message tells the agent exactly what to do next. That's a pattern worth following across every tool you build. Good MCP validation errors guide the agent's next action. They don't just report failure and leave it stuck.&lt;/p&gt;

&lt;p&gt;Add both tools to your server:&lt;br&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="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;ListOrdersTool&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;GetOrderTool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Adding a Resource: Expose Your Filament Navigation
&lt;/h2&gt;

&lt;p&gt;Tools handle queries. Resources handle context. There's a real difference. A tool runs on demand and returns fresh data from a specific request. A resource is something the agent reads once to orient itself, like a map of your application. It answers questions like: what resources exist, what models do they manage, and what are the correct admin URLs?&lt;/p&gt;

&lt;p&gt;This is the thing that prevents the agent from inventing route names or misidentifying which Resource class handles which model. Without it, the agent has to grep your codebase and hope it finds everything.&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 make:mcp-resource FilamentNavigationResource
&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Resources&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Facades\Filament&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\MimeType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Resource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Description('Lists all registered Filament resources, the Eloquent models they manage, and their admin panel index URLs.')]&lt;/span&gt;
&lt;span class="na"&gt;#[Uri('filament://resources/navigation')]&lt;/span&gt;
&lt;span class="na"&gt;#[MimeType('application/json')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FilamentNavigationResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Resource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Adjust 'admin' to match your panel ID if it's different&lt;/span&gt;
        &lt;span class="nv"&gt;$panel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getPanel&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="nv"&gt;$resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getResources&lt;/span&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="nv"&gt;$resourceClass&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="s1"&gt;'class'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$resourceClass&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;$resourceClass&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getModel&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="s1"&gt;'plural_label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$resourceClass&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getPluralModelLabel&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="s1"&gt;'index_url'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$resourceClass&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;structured&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'panel_id'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'resources'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$resources&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;Register it in your server's &lt;code&gt;$resources&lt;/code&gt; array. When Claude Code reads this resource at the start of a session, it knows the exact class name for every resource in your admin, which model each one manages, and the correct index URL. That's a concrete, useful context dump. Compare that to an agent starting cold with no information, and the difference in code quality is noticeable immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering the Server
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;routes/ai.php&lt;/code&gt;. You have two options.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;Claude Code or Cursor running locally&lt;/strong&gt;, use &lt;code&gt;local&lt;/code&gt;. The server runs as a subprocess your MCP client starts over stdio:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'filament-admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FilamentAdminServer&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;For &lt;strong&gt;remote HTTP clients&lt;/strong&gt; (a web-based AI assistant, an external agent, anything outside your local machine), use &lt;code&gt;web&lt;/code&gt; with authentication middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="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/filament'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FilamentAdminServer&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;Both can live in the same file simultaneously. Here's how the full flow looks once everything is connected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Claude Code / Cursor] --&amp;gt;|calls tool or reads resource| B[FilamentAdminServer]
    B --&amp;gt; C{Request type}
    C --&amp;gt;|ListOrdersTool| D[Eloquent query with filters]
    C --&amp;gt;|GetOrderTool| E[findOrFail with relations]
    C --&amp;gt;|FilamentNavigationResource| F[Filament panel + resources]
    D --&amp;gt; G[Response::structured]
    E --&amp;gt; G
    F --&amp;gt; G
    G --&amp;gt; A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing with the MCP Inspector
&lt;/h2&gt;

&lt;p&gt;Before connecting a real client, use the built-in inspector to verify everything works. For a local server:&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 mcp:start filament-admin &lt;span class="nt"&gt;--inspector&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inspector gives you a browser UI to call each tool manually and inspect the raw JSON responses. It's the quickest way to catch a missing eager load, a validation rule mismatch, or a Filament API method that doesn't exist in your version, before they cause confusion in a real agent session.&lt;/p&gt;

&lt;p&gt;When your tool returns data, paste it into a &lt;a href="https://hafiz.dev/tools/json-formatter" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; to inspect the structure clearly before you commit to it as the tool's output contract. Changing the response shape later forces you to update any agent workflows that already depend on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: What Not to Skip
&lt;/h2&gt;

&lt;p&gt;A few things to get right before any of this goes beyond local dev.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gate write tools with &lt;code&gt;shouldRegister&lt;/code&gt;.&lt;/strong&gt; Any tool that creates, updates, or deletes data should check permissions before it appears in the tool list at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;shouldRegister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasRole&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;??&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;When &lt;code&gt;shouldRegister&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, the tool doesn't appear in the available tools list. The agent can't call what it can't see. That's much better than returning an auth error after the fact, because it gives the agent an accurate picture of what it can actually do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Sanctum for web servers.&lt;/strong&gt; The &lt;code&gt;auth:sanctum&lt;/code&gt; middleware on your &lt;code&gt;Mcp::web()&lt;/code&gt; registration means clients need a valid token. Issue tokens with short expiry for AI integrations, same as you would for any external API client. Don't reuse tokens across different agents or sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never return raw Eloquent models.&lt;/strong&gt; Always explicitly select the fields you're exposing. The field mappings in the tools above are intentional, not lazy. You don't want the agent accidentally receiving password hashes, internal flags, or anything else sitting on the model that shouldn't leave the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Build a Custom Server vs. Just Using Boost
&lt;/h2&gt;

&lt;p&gt;If you're doing local development with Claude Code, &lt;a href="https://hafiz.dev/blog/laravel-boost-and-mcp-servers-the-context-your-ai-agent-is-missing" rel="noopener noreferrer"&gt;Laravel Boost&lt;/a&gt; already gives the agent schema inspection, log reading, and Tinker access out of the box. You don't need a custom MCP server for those things. Don't duplicate what Boost already does well.&lt;/p&gt;

&lt;p&gt;Build a custom server when you need business-logic-aware queries the agent can call on demand, not just raw schema access. When you're connecting a non-developer AI client. When you need fine-grained control over data visibility. Or specifically when you're working with Filament's resource and panel structure, which Boost knows nothing about.&lt;/p&gt;

&lt;p&gt;They're not mutually exclusive. A mature Filament project will often use both. And if you're building AI features into the app itself rather than just using AI for development, you'll want to look at 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; alongside this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this work with Filament v3 and v4?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The MCP server doesn't depend on a specific Filament version. &lt;code&gt;FilamentNavigationResource&lt;/code&gt; uses &lt;code&gt;Filament::getPanel()&lt;/code&gt; and &lt;code&gt;$panel-&amp;gt;getResources()&lt;/code&gt;, both available in v3 and v4. On Filament v5, verify the Facades API because some method signatures changed between major versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I expose Filament form schemas as a resource?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can, but it's rarely worth the effort. Filament form schemas are PHP class structures. Turning them into JSON via reflection is messy and the agent rarely needs that level of detail for code generation tasks. Model data and resource navigation cover 90% of what agents actually ask for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between &lt;code&gt;Mcp::local()&lt;/code&gt; and &lt;code&gt;Mcp::web()&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;local&lt;/code&gt; servers run as a subprocess started by your MCP client (like Claude Code) over stdio. &lt;code&gt;web&lt;/code&gt; servers run as HTTP endpoints, suitable for any MCP-compatible client that can make POST requests. For day-to-day development, &lt;code&gt;local&lt;/code&gt; is simpler. For production deployments where multiple external clients need access, &lt;code&gt;web&lt;/code&gt; is the right choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I cache tool responses?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For read-heavy tools, yes. Wrap your Eloquent query in &lt;code&gt;Cache::remember()&lt;/code&gt; with a short TTL. 30 to 60 seconds works well for most cases. Agents often call the same tool multiple times in one session, and caching stops you from hammering the database with identical queries. Don't cache anything that changes frequently or carries sensitive state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I conditionally register tools based on which Filament panel is active?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Inside &lt;code&gt;shouldRegister&lt;/code&gt;, check &lt;code&gt;Filament::getCurrentPanel()?-&amp;gt;getId()&lt;/code&gt; and return &lt;code&gt;false&lt;/code&gt; if the active panel doesn't match. This is useful in multi-panel setups where you want a clean separation of what each AI client can access based on which panel they've authenticated against.&lt;/p&gt;

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

&lt;p&gt;Laravel MCP turns your Filament admin from a codebase AI agents have to guess about into one they can actually query. A handful of well-designed tools give any MCP-compatible client real access to your data, your resource structure, and your business logic. No hallucinated columns. No invented route names. No code written for a Filament version you're not running.&lt;/p&gt;

&lt;p&gt;The setup takes less than an hour on an existing Filament project, and the improvement in agent output quality is immediate. If you're already &lt;a href="https://hafiz.dev/blog/how-to-make-your-laravel-app-ai-agent-friendly-the-complete-2026-guide" rel="noopener noreferrer"&gt;making your Laravel app AI-agent friendly&lt;/a&gt;, this is the natural next step specifically for Filament.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mcp</category>
      <category>filament</category>
      <category>aidevelopment</category>
    </item>
    <item>
      <title>Laravel 12 to 13 Upgrade Guide: Zero Breaking Changes Doesn't Mean Zero Work</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 19 Mar 2026 06:58:27 +0000</pubDate>
      <link>https://forem.com/hafiz619/laravel-12-to-13-upgrade-guide-zero-breaking-changes-doesnt-mean-zero-work-55if</link>
      <guid>https://forem.com/hafiz619/laravel-12-to-13-upgrade-guide-zero-breaking-changes-doesnt-mean-zero-work-55if</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel 13 dropped on March 17. The official messaging is "zero breaking changes," and for most apps that's pretty close to the truth. But close isn't the same as zero. There are a handful of defaults that changed quietly, one PHP requirement that will block your upgrade entirely if you ignore it, and a new first-party tool that makes the whole process significantly faster. This guide covers all of it.&lt;/p&gt;

&lt;p&gt;This isn't a feature tour. If you want before/after examples of PHP Attributes or Cache::touch(), &lt;a href="https://hafiz.dev/blog/laravel-13-php-attributes-refactor-your-models-jobs-and-commands" rel="noopener noreferrer"&gt;I've already covered those&lt;/a&gt;. This post is only about the upgrade process itself, in the order that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Zero Breaking Changes" Isn't the Full Story
&lt;/h2&gt;

&lt;p&gt;The phrase is technically accurate for application-level code. Your routes, controllers, Eloquent models, and business logic almost certainly don't need touching.&lt;/p&gt;

&lt;p&gt;But "breaking changes" in the Laravel changelog only covers the framework itself. It doesn't cover your infrastructure, your &lt;code&gt;.env&lt;/code&gt; defaults, or your third-party packages. That's where things can catch you off guard.&lt;/p&gt;

&lt;p&gt;Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your server runs PHP 8.2, your upgrade fails before it starts&lt;/li&gt;
&lt;li&gt;If you've never explicitly set &lt;code&gt;CACHE_PREFIX&lt;/code&gt; or &lt;code&gt;SESSION_COOKIE&lt;/code&gt; in your &lt;code&gt;.env&lt;/code&gt;, those values will silently change after the upgrade&lt;/li&gt;
&lt;li&gt;If you reference pagination view names as strings anywhere in your codebase, those names changed&lt;/li&gt;
&lt;li&gt;If you have custom cache store implementations, a new contract method is now required&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are application logic. All of them can quietly break a production app.&lt;/p&gt;

&lt;p&gt;So when Laravel says smooth upgrade, they're right. But smooth still requires knowing what to look at. Let's go through it in the order that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: PHP 8.3 First
&lt;/h2&gt;

&lt;p&gt;This is the only hard blocker. Do not change your &lt;code&gt;composer.json&lt;/code&gt; until this is sorted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on PHP 8.2 or earlier, you need to upgrade your server runtime before touching anything else. On Ubuntu/Debian with Ondrej's PPA:&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;sudo &lt;/span&gt;add-apt-repository ppa:ondrej/php
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;php8.3 php8.3-fpm php8.3-mbstring php8.3-xml php8.3-curl php8.3-mysql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't forget to update your Nginx or Apache config to point at the new FPM socket. And update &lt;code&gt;composer.json&lt;/code&gt;'s PHP constraint while you're there:&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;"php"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^8.3"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on Forge or Vapor, it's a one-click PHP version switch in the server panel. If you're on a shared host still defaulting to PHP 8.1, that's the actual problem to solve first. &lt;a href="https://hafiz.dev/blog/using-docker-to-solve-php-version-compatibility-issues-a-practical-guide" rel="noopener noreferrer"&gt;Docker is a clean escape hatch here&lt;/a&gt; if you need runtime flexibility without touching production infrastructure directly.&lt;/p&gt;

&lt;p&gt;Once you've confirmed &lt;code&gt;php -v&lt;/code&gt; returns 8.3+, move to the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Pin Your .env Before Touching Composer
&lt;/h2&gt;

&lt;p&gt;This is the step most upgrade guides skip. It's also the one most likely to cause silent, user-facing problems on deploy day.&lt;/p&gt;

&lt;p&gt;Laravel 13 changed two default naming patterns for cache and session:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache and Redis key prefixes&lt;/strong&gt; now use hyphenated suffixes. The generated default changed from &lt;code&gt;myapp_cache_&lt;/code&gt; to &lt;code&gt;myapp-cache-&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session cookie names&lt;/strong&gt; now use &lt;code&gt;Str::snake()&lt;/code&gt; instead of &lt;code&gt;Str::slug()&lt;/code&gt; with underscores.&lt;/p&gt;

&lt;p&gt;Here's the critical part: this only affects apps that have never explicitly configured these values. If your &lt;code&gt;config/cache.php&lt;/code&gt; and &lt;code&gt;config/session.php&lt;/code&gt; already reference &lt;code&gt;.env&lt;/code&gt; variables that you've set explicitly, nothing changes for you.&lt;/p&gt;

&lt;p&gt;But if you're relying on framework-generated fallback values (more common than you'd think, especially on apps that were scaffolded quickly), every active user session will be invalidated the moment you deploy. Their cookie won't match the new session cookie name. And your cached data becomes unreachable because the key prefix changed.&lt;/p&gt;

&lt;p&gt;Fix this before running the upgrade. Check what your app is currently generating:&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 tinker
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; config&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cache.prefix'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; config&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'session.cookie'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then pin those exact current values in your &lt;code&gt;.env&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;&lt;span class="nv"&gt;CACHE_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp_cache_
&lt;span class="nv"&gt;REDIS_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp_cache_
&lt;span class="nv"&gt;SESSION_COOKIE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp_session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once they're explicitly set, the change in framework defaults becomes irrelevant. Your app keeps using whatever you've defined, regardless of what the framework generates as a fallback.&lt;/p&gt;

&lt;p&gt;This two-minute step prevents a lot of pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The Composer Update
&lt;/h2&gt;

&lt;p&gt;With PHP sorted and &lt;code&gt;.env&lt;/code&gt; pinned, the actual upgrade is a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer update laravel/framework &lt;span class="nt"&gt;--with-all-dependencies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--with-all-dependencies&lt;/code&gt; flag matters. Without it, transitive dependencies can stay on older versions and cause conflicts that are annoying to debug. Use it.&lt;/p&gt;

&lt;p&gt;Not sure what's blocking you before you run it? Check first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer why-not laravel/framework:^13.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells you exactly which packages don't support Laravel 13 yet. For most major packages (Filament, Livewire, Inertia, Spatie) support landed on or shortly after March 17. But some smaller packages might need a day or two to catch up, and it's better to know that before you're halfway through an upgrade on a branch.&lt;/p&gt;

&lt;p&gt;If a package you rely on isn't compatible yet, you've got two options: wait for the maintainer to tag a new release, or hold the upgrade until then. Don't force it. A &lt;code&gt;"^12.0|^13.0"&lt;/code&gt; constraint in your own packages is the right pattern to use while the ecosystem catches up, but that's for packages you control.&lt;/p&gt;

&lt;p&gt;After the update runs successfully:&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 optimize:clear
php artisan config:clear
php artisan cache:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run your test suite. Don't skip this step even if you're confident. The tests will catch the things this guide doesn't know about your specific app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Breaking Changes That Can Actually Bite You
&lt;/h2&gt;

&lt;p&gt;The official upgrade guide documents a fair number of changes, but most of them only apply to edge cases. Here are the ones most likely to affect a real production application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache Serialization Hardening
&lt;/h3&gt;

&lt;p&gt;Laravel 13 adds a &lt;code&gt;serializable_classes&lt;/code&gt; option to &lt;code&gt;config/cache.php&lt;/code&gt;, defaulting to &lt;code&gt;false&lt;/code&gt;. This is a security change. It prevents PHP deserialization gadget chain attacks if your &lt;code&gt;APP_KEY&lt;/code&gt; ever leaks.&lt;/p&gt;

&lt;p&gt;For most apps this has zero impact because you're caching arrays and scalar values. But if you're caching actual PHP objects (Eloquent model instances, DTOs, anything like that), you need to explicitly allow 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="s1"&gt;'serializable_classes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;App\Data\CachedDashboardStats&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;App\Support\CachedPricingSnapshot&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're unsure whether your app caches objects, search for &lt;code&gt;Cache::put&lt;/code&gt; and &lt;code&gt;Cache::remember&lt;/code&gt; calls and check what's being stored. Primitives and arrays are fine. Objects need to be on the list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pagination View Names
&lt;/h3&gt;

&lt;p&gt;This one only bites you if you're passing pagination view names as strings anywhere directly. The names changed:&lt;br&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;// Laravel 12&lt;/span&gt;
&lt;span class="s1"&gt;'pagination::default'&lt;/span&gt;
&lt;span class="s1"&gt;'pagination::simple-default'&lt;/span&gt;

&lt;span class="c1"&gt;// Laravel 13&lt;/span&gt;
&lt;span class="s1"&gt;'pagination::bootstrap-3'&lt;/span&gt;
&lt;span class="s1"&gt;'pagination::simple-bootstrap-3'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use &lt;code&gt;$results-&amp;gt;links()&lt;/code&gt; with no arguments, nothing changes. But if you have anything like &lt;code&gt;$results-&amp;gt;links('pagination::default')&lt;/code&gt; hardcoded somewhere, update those strings.&lt;/p&gt;

&lt;h3&gt;
  
  
  MySQL DELETE with JOIN
&lt;/h3&gt;

&lt;p&gt;If your app has query builder calls that combine &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt;, and either &lt;code&gt;ORDER BY&lt;/code&gt; or &lt;code&gt;LIMIT&lt;/code&gt;, the generated SQL changed. In previous versions the &lt;code&gt;ORDER BY&lt;/code&gt; and &lt;code&gt;LIMIT&lt;/code&gt; clauses were silently ignored on joined deletes. Laravel 13 includes them, which can throw a &lt;code&gt;QueryException&lt;/code&gt; on MySQL/MariaDB setups that don't support this syntax combination.&lt;/p&gt;

&lt;p&gt;Run a quick search:&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;-r&lt;/span&gt; &lt;span class="s2"&gt;"-&amp;gt;join("&lt;/span&gt; app/ &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.php"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;".test."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check whether any of those query chains also call &lt;code&gt;-&amp;gt;delete()&lt;/code&gt;. It's probably rare in your codebase. But it's worth 30 seconds to verify.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom Contract Implementations
&lt;/h3&gt;

&lt;p&gt;If you've built a custom cache store driver, the &lt;code&gt;Illuminate\Contracts\Cache\Store&lt;/code&gt; contract now requires a &lt;code&gt;touch&lt;/code&gt; method. A few other contracts also gained new methods. If you don't maintain any custom framework interface implementations, skip this. If you do, check the official upgrade guide for the complete list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fast Path: Laravel Boost and /upgrade-laravel-v13
&lt;/h2&gt;

&lt;p&gt;If you already have &lt;a href="https://hafiz.dev/blog/laravel-boost-and-mcp-servers-the-context-your-ai-agent-is-missing" rel="noopener noreferrer"&gt;Laravel Boost configured with your AI editor&lt;/a&gt;, there's a dedicated slash command worth knowing about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/upgrade-laravel-v13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it in Claude Code, Cursor, OpenCode, or VS Code. Boost is a first-party Laravel MCP server, and this command gives your AI assistant a guided upgrade prompt that knows the official breaking change documentation. It scans your codebase for patterns that match the documented changes and flags them.&lt;/p&gt;

&lt;p&gt;It won't upgrade PHP for you. It won't fix the &lt;code&gt;.env&lt;/code&gt; defaults issue (that's environment-level, not code-level). But for the contract changes, event listener signature updates, and model boot method issues, it does the scan automatically instead of you manually grep-ing through a large codebase.&lt;/p&gt;

&lt;p&gt;Think of it as an automated first pass that catches the obscure stuff. You still do a final review, but it does the heavy lifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Adopt Now vs What to Skip
&lt;/h2&gt;

&lt;p&gt;Upgrading to Laravel 13 and adopting its new features are two separate decisions. Don't conflate them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the upgrade, just make sure everything still works.&lt;/strong&gt; That's the only goal for day one.&lt;/p&gt;

&lt;p&gt;Once that's confirmed, here's how I'd think about the new features:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache::touch() is a quick win.&lt;/strong&gt; If your app extends cache TTLs anywhere (rate limiting, user session renewal, anything like that), you're currently doing a get + put round trip. &lt;code&gt;Cache::touch()&lt;/code&gt; collapses that into a single &lt;code&gt;EXPIRE&lt;/code&gt; command on Redis. Low risk, tiny refactor, immediate improvement.&lt;br&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;// Before: two round trips, full value transferred&lt;/span&gt;
&lt;span class="nv"&gt;$value&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rate_limit_key'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rate_limit_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;addMinutes&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="c1"&gt;// After: single EXPIRE command, value never leaves the server&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rate_limit_key'&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;addMinutes&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-13-php-attributes-refactor-your-models-jobs-and-commands" rel="noopener noreferrer"&gt;PHP Attributes for models, jobs, and commands&lt;/a&gt; are optional.&lt;/strong&gt; The old property-based syntax still works and there's no deprecation timeline. For a new project, start with Attributes. For an existing codebase, migrate gradually or not at all. There's no urgency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Reverb database driver (no Redis required for WebSockets) is promising.&lt;/strong&gt; For smaller apps that don't want to provision Redis just for WebSocket scaling, this removes a real infrastructure dependency. I'd run benchmarks at your expected connection count before switching in production, but for a new real-time feature it's worth starting with the database driver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/passkeys-in-laravel-what-they-are-and-how-to-get-started" rel="noopener noreferrer"&gt;Passkeys via Fortify&lt;/a&gt; are now built-in for new Laravel 13 installations.&lt;/strong&gt; For existing apps it's still a migration project, not a one-liner. Good time to evaluate it for your next auth system refresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI SDK is stable.&lt;/strong&gt; But read the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-goes-stable-what-changed-what-to-check" rel="noopener noreferrer"&gt;stable release notes&lt;/a&gt; before dropping it into production. There were config changes between beta and stable that matter if you were running the beta.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need to upgrade now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Laravel 12 receives bug fixes until August 2026 and security fixes until February 2027. If you're starting a new project today, use 13. If you're maintaining an active production app, plan it properly and upgrade when you have test coverage to back it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will my packages break?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;composer why-not laravel/framework:^13.0&lt;/code&gt; before you start. This shows you exactly which packages are blocking the upgrade and what version they need to be at. Most major packages are already compatible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use Laravel Shift or do it manually?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Shift costs $29 per upgrade and handles around 80-90% of the mechanical changes. For large codebases the time savings justify the price. For smaller apps the manual approach in this guide covers everything. Your call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I'm on Laravel 10 or 11?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You need to upgrade version by version: 10 to 11, then 11 to 12, then 12 to 13. You can't skip versions. Each major release has its own migration path and skipping them compounds the breaking changes significantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does this actually take?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Depends on your PHP version situation and how many of the edge cases apply to you. If you're already on PHP 8.3 and don't have custom contract implementations, the upgrade itself can be done in under an hour. The PHP upgrade on a production server is the wildcard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go Upgrade
&lt;/h2&gt;

&lt;p&gt;Laravel 13 really is one of the smoothest major upgrades the framework has ever shipped. The "zero breaking changes" messaging isn't wrong. But smooth upgrades still require knowing which three or four things to check before you run composer update.&lt;/p&gt;

&lt;p&gt;Pin your &lt;code&gt;.env&lt;/code&gt; values first. Upgrade PHP before anything else. Run the Boost slash command if you have it. Then do the composer update and run your tests. That order matters.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravel13</category>
      <category>php</category>
      <category>upgradeguide</category>
    </item>
    <item>
      <title>How I Hardened My VPS in One Afternoon: SSH, Cloudflare, and Tailscale</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Tue, 17 Mar 2026 07:35:24 +0000</pubDate>
      <link>https://forem.com/hafiz619/how-i-hardened-my-vps-in-one-afternoon-ssh-cloudflare-and-tailscale-525j</link>
      <guid>https://forem.com/hafiz619/how-i-hardened-my-vps-in-one-afternoon-ssh-cloudflare-and-tailscale-525j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;A tweet from &lt;a href="https://x.com/levelsio" rel="noopener noreferrer"&gt;@levelsio&lt;/a&gt; went viral last week. The advice was simple: lock down your VPS before someone else does.&lt;/p&gt;

&lt;p&gt;I checked my own server settings immediately. &lt;code&gt;PermitRootLogin yes&lt;/code&gt;. &lt;code&gt;PasswordAuthentication&lt;/code&gt; defaulting to enabled. Port 22 open to the entire internet. No Cloudflare proxy. Nothing.&lt;/p&gt;

&lt;p&gt;That's fully exposed root SSH access on a production server running multiple live projects. Not great.&lt;/p&gt;

&lt;p&gt;So I fixed all of it. SSH hardening, Cloudflare DNS migration, Tailscale installed, port 22 locked. Here's exactly what I did, in the order I did it, with the real commands and the mistakes I made along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Stack Specifically
&lt;/h2&gt;

&lt;p&gt;There are a hundred ways to "secure" a server. Most guides tell you to do one thing. The levelsio setup is three independent layers working together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSH&lt;/strong&gt;: key-based auth only, no passwords, no brute-force possible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt;: your real server IP stays hidden, all web traffic proxied&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale&lt;/strong&gt;: port 22 becomes invisible to the public internet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each layer is useful on its own. Together they make your attack surface genuinely small. An attacker would need to break all three simultaneously to get anywhere.&lt;/p&gt;

&lt;p&gt;Here's what the setup looks like once it's done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Internet / Bots] --&amp;gt;|HTTPS only| B[Cloudflare]
    B --&amp;gt;|Proxied| C[Your VPS]
    D[Your Mac] --&amp;gt;|SSH via Tailscale| C
    A -.-&amp;gt;|Port 22 invisible| C
    A -.-&amp;gt;|Real IP hidden| B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashed lines are failed attacks. That's the goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: SSH Hardening
&lt;/h2&gt;

&lt;p&gt;Do this first. It's the most urgent fix and takes five minutes.&lt;/p&gt;

&lt;p&gt;SSH into your server and check what you're working with:&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;-E&lt;/span&gt; &lt;span class="s2"&gt;"PermitRootLogin|PasswordAuthentication"&lt;/span&gt; /etc/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;PermitRootLogin yes&lt;/code&gt; or nothing at all for &lt;code&gt;PasswordAuthentication&lt;/code&gt; (which means it defaults to yes), you need to change both. Open the config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano /etc/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change these two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;PermitRootLogin&lt;/span&gt; prohibit-password
&lt;span class="k"&gt;PasswordAuthentication&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save and restart SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prohibit-password&lt;/code&gt; means root can still log in, but only with an SSH key. Since you're already using SSH keys, nothing changes for you day-to-day. What changes is that password brute-force attacks are now completely useless. Bots can hammer the port all they want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing before you make this change:&lt;/strong&gt; confirm you know where your private key file is (&lt;code&gt;~/.ssh/id_rsa&lt;/code&gt; or &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt; on Mac). And make sure your SSH key passphrase is strong. If it isn't, a &lt;a href="https://hafiz.dev/tools/password-generator" rel="noopener noreferrer"&gt;password generator&lt;/a&gt; can help you create something that isn't guessable. If you ever do get locked out, DigitalOcean and Hetzner both have browser-based console access in their dashboards. That's your emergency backdoor and it bypasses SSH entirely.&lt;/p&gt;

&lt;p&gt;Test your key auth works, apply the changes, test again. Don't just assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Put Cloudflare In Front of Your Server
&lt;/h2&gt;

&lt;p&gt;If your domain's A record points directly at your server IP, that IP is public. Anyone can find it, target it, and bypass whatever you set up in Cloudflare later. The proxy only works if you actually use it.&lt;/p&gt;

&lt;p&gt;Cloudflare free tier gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your real server IP hidden behind Cloudflare's network&lt;/li&gt;
&lt;li&gt;DDoS protection at the application layer&lt;/li&gt;
&lt;li&gt;Free SSL certificate management (no more certbot renewals)&lt;/li&gt;
&lt;li&gt;CDN caching for static assets&lt;/li&gt;
&lt;li&gt;Analytics at the infrastructure level&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration causes zero downtime. Here's how it works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create your Cloudflare account and add your domain&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go to &lt;a href="https://cloudflare.com" rel="noopener noreferrer"&gt;cloudflare.com&lt;/a&gt;, sign up, then add your domain. Choose "Import DNS records automatically". Cloudflare scans your existing DNS and pulls everything in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Review the imported records carefully&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cloudflare's auto-import is good but not perfect. In my case it missed two records that were in Namecheap but didn't show up in the scan: a subdomain A record and a second DKIM record for ConvertKit (&lt;code&gt;cka2._domainkey&lt;/code&gt;). Always compare the Cloudflare list against your registrar's Advanced DNS settings manually before switching.&lt;/p&gt;

&lt;p&gt;One rule that matters: DKIM, DMARC, and SPF records must stay as &lt;strong&gt;DNS only&lt;/strong&gt; (grey cloud), not proxied (orange cloud). Proxying email-related records breaks email delivery. Your A records for the main domain and www should be proxied. Everything email-related should not be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Add missing records manually&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you find gaps, use "Add record" to fill them in before proceeding. Getting this right now saves you debugging email deliverability issues later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Change your nameservers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cloudflare gives you two nameservers (something like &lt;code&gt;anton.ns.cloudflare.com&lt;/code&gt; and &lt;code&gt;megan.ns.cloudflare.com&lt;/code&gt;). Go to your registrar, find the nameservers section, switch from their default to Custom DNS, and enter the two Cloudflare nameservers.&lt;/p&gt;

&lt;p&gt;DNS propagation takes anywhere from 5 minutes to a few hours. In my case it took under two minutes. Cloudflare emails you when the domain goes active.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the AI crawler setting:&lt;/strong&gt; during setup Cloudflare asks if you want to block AI training bots. If you run a content site and want visibility in AI-generated answers, choose "Do not block". If you have an llms.txt file or care about AI referencing your content, blocking crawlers contradicts that completely.&lt;/p&gt;

&lt;p&gt;Once Cloudflare is active, your server IP is no longer the first thing that resolves for your domain. Attackers hitting your domain hit Cloudflare first. That's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Tailscale
&lt;/h2&gt;

&lt;p&gt;This is the part most people skip. It sounds complicated. It's not. It took me 15 minutes start to finish.&lt;/p&gt;

&lt;p&gt;Tailscale creates a private mesh network between your devices. Once installed on your laptop and your VPS, both get private Tailscale IP addresses in the &lt;code&gt;100.x.x.x&lt;/code&gt; range. These IPs only exist within your Tailscale network. Nobody outside can see or reach them.&lt;/p&gt;

&lt;p&gt;The goal is to lock port 22 so it only accepts connections from the Tailscale network. The public internet can't even see the port exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install Tailscale on your Mac&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Download from the &lt;a href="https://tailscale.com/download/macos" rel="noopener noreferrer"&gt;Mac App Store&lt;/a&gt;, or via Homebrew:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Sign in with Google or GitHub. Your Mac gets added to your Tailscale network and gets a private IP like &lt;code&gt;100.79.x.x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install Tailscale on your VPS&lt;/strong&gt;&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://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second command outputs a URL like &lt;code&gt;https://login.tailscale.com/a/xxxxxx&lt;/code&gt;. Open it in your browser, authenticate with the same account you used on your Mac, and the VPS joins your network.&lt;/p&gt;

&lt;p&gt;You might see a warning during installation about the running kernel version not matching the expected version. This is Ubuntu telling you there's a pending kernel update that requires a reboot to apply. It doesn't affect Tailscale at all. Schedule a &lt;code&gt;sudo reboot&lt;/code&gt; when it's convenient and the server will come back up with everything running automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the Tailscale dashboard&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go to the Machines tab in your Tailscale account. You should see two devices, both showing "Connected", each with a &lt;code&gt;100.x.x.x&lt;/code&gt; IP address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test SSH through Tailscale before locking anything&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This step is not optional. Open a new terminal and SSH using the VPS Tailscale IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@100.x.x.x   &lt;span class="c"&gt;# use your VPS Tailscale IP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get in, you're ready to lock port 22. If you can't connect, stop and figure out why before going any further.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock port 22 to Tailscale only&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run these three commands on your server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on tailscale0 to any port 22
ufw deny 22
ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open a second terminal and SSH via the Tailscale IP again. If it connects, you're done. Port 22 is now invisible to the public internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update your SSH config on your Mac&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So you don't have to remember the Tailscale IP:&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="c"&gt;# ~/.ssh/config&lt;/span&gt;
Host your-server
    HostName 100.x.x.x    &lt;span class="c"&gt;# your VPS Tailscale IP&lt;/span&gt;
    User root
    IdentityFile ~/.ssh/id_rsa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From this point on, &lt;code&gt;ssh your-server&lt;/code&gt; works exactly as before. Tailscale runs as a background service on your Mac and starts automatically on login. You'll never notice it's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Daily Workflow?
&lt;/h2&gt;

&lt;p&gt;Nothing changes. You still SSH the same way, just to the Tailscale IP instead of the public one. Your Laravel app deploys the same way. Your deploy scripts, Nginx configs, cron jobs, Supervisor processes all keep running as normal. If you're running &lt;a href="https://hafiz.dev/blog/effortlessly-dockerize-your-laravel-vue-application-a-step-by-step-guide" rel="noopener noreferrer"&gt;Docker containers on the same server&lt;/a&gt;, those are untouched too. Tailscale only affects how you get into the machine, not what runs inside it.&lt;/p&gt;

&lt;p&gt;The only difference you'll notice is that if Tailscale somehow isn't running on your Mac, SSH to the Tailscale IP will time out. Your fallbacks in that case:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start Tailscale on your Mac (it's a menu bar app, one click to reconnect)&lt;/li&gt;
&lt;li&gt;Use DigitalOcean's browser console (always available, no SSH needed)&lt;/li&gt;
&lt;li&gt;Install Tailscale on your iPhone as a backup device&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tailscale's reliability has been solid in my experience. It starts automatically on your Mac at login and reconnects if your internet drops. But knowing your escape routes before you need them is basic practice. I keep the DO console bookmarked. It's saved me once already.&lt;/p&gt;

&lt;p&gt;One thing worth doing after you lock port 22: run &lt;code&gt;ufw status&lt;/code&gt; and check what's actually open. Your output should look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status: active

To                         Action      From
--                         ------      ----
Nginx Full                 ALLOW       Anywhere
22 on tailscale0           ALLOW       Anywhere
22                         DENY        Anywhere
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If port 22 shows DENY and you can still SSH in via the Tailscale IP, the setup is working exactly as intended.&lt;/p&gt;

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

&lt;p&gt;This setup isn't magic. Here's what you're actually signing up for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare free tier proxies HTTP/HTTPS only.&lt;/strong&gt; Non-standard ports, custom TCP protocols, game servers: none of these get proxied on the free plan. For a standard web app, you won't hit this limit. But if you're running something unusual, check the Cloudflare Spectrum pricing before assuming everything's covered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailscale adds a dependency for SSH access.&lt;/strong&gt; Any device you SSH from needs Tailscale installed. For a solo developer with one laptop, this is fine. If you ever need to SSH from a machine you don't control, you'd need to install Tailscale there first, or fall back to the DO console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare adds a tiny latency overhead for cache misses.&lt;/strong&gt; Requests that hit your origin server (dynamic Laravel responses) pass through Cloudflare's network. The overhead is negligible in practice, we're talking single-digit milliseconds. For static assets, Cloudflare is actually faster because files serve from a nearby edge node.&lt;/p&gt;

&lt;p&gt;For a solo developer running production projects on a $5-20/month VPS, these trade-offs are worth it. Both Tailscale and Cloudflare are free for this use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Doesn't Stop at the Server
&lt;/h2&gt;

&lt;p&gt;Once you've hardened your VPS, the next thing worth auditing is what's running on it. If you're deploying Laravel apps, your Composer dependencies are another real attack surface. Fake packages mimicking legitimate ones have been found in the wild. &lt;a href="https://hafiz.dev/blog/fake-laravel-packages-targeting-your-env-how-to-audit-composer-dependencies" rel="noopener noreferrer"&gt;This walkthrough on auditing your Composer dependencies&lt;/a&gt; covers how to check what's actually installed and what to look for.&lt;/p&gt;

&lt;p&gt;Infrastructure security and application security are different layers. You need both.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need Cloudflare if I'm not worried about DDoS attacks?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The DDoS protection is a bonus, not the main reason. The real value is hiding your server IP. If attackers can't find your server's IP address, they can't target it directly. The free tier is worth setting up for any public-facing production server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Tailscale on Windows instead of Mac?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Tailscale has native clients for Windows, macOS, Linux, iOS, and Android. The VPS setup is identical regardless of which device you connect from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if Tailscale goes down and I need to SSH in?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use your VPS provider's browser-based console. DigitalOcean calls it the Droplet Console, Hetzner calls it the Console button. It gives you terminal access directly through the browser without needing SSH at all. Bookmark it before you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work on Hetzner, AWS, or other VPS providers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The SSH hardening and Tailscale steps are identical on any Ubuntu/Debian server. For Cloudflare, you just need control of your domain's DNS. The server provider doesn't matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the order I should do this if my server is already live?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SSH hardening first (today, takes five minutes). Cloudflare second (takes 20 minutes, zero downtime). Tailscale third (takes 15 minutes, test before locking). Each step is independent, so you can space them out over a few days if needed. But the SSH hardening is the one to do immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Summary
&lt;/h2&gt;

&lt;p&gt;The whole setup took me one afternoon. SSH hardening took five minutes. Cloudflare took twenty. Tailscale took fifteen.&lt;/p&gt;

&lt;p&gt;Your server is running right now with bots probing it constantly. Most of the time nothing happens. But "most of the time" isn't a security strategy when you're running client projects on that machine.&lt;/p&gt;

&lt;p&gt;Do the SSH hardening today. Add Cloudflare this week. Set up Tailscale when you have 30 minutes. Each layer is independent and each one meaningfully reduces your exposure.&lt;/p&gt;

&lt;p&gt;If you're building something and want to talk through the setup for your specific stack, &lt;a href="https://hafiz.dev/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vps</category>
      <category>security</category>
      <category>devops</category>
      <category>ssh</category>
    </item>
    <item>
      <title>Laravel AI SDK Goes Stable on March 17: What Changed and What to Check Before You Ship</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Tue, 17 Mar 2026 06:02:04 +0000</pubDate>
      <link>https://forem.com/hafiz619/laravel-ai-sdk-goes-stable-on-march-17-what-changed-and-what-to-check-before-you-ship-57ll</link>
      <guid>https://forem.com/hafiz619/laravel-ai-sdk-goes-stable-on-march-17-what-changed-and-what-to-check-before-you-ship-57ll</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-goes-stable-what-changed-what-to-check" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The Laravel AI SDK went stable today, March 17, alongside Laravel 13. If you've been watching from the sidelines waiting for the official green light, this is it.&lt;/p&gt;

&lt;p&gt;But here's the thing: "stable" isn't just a label. It changes how you depend on the package, what you can expect from version updates, and whether it's safe to build production features on top of it. And if you already followed my &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;overview post&lt;/a&gt; or the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; and &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-part-2-build-a-rag-powered-support-bot-with-tools-and-memory" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; tutorials, you're probably wondering: does my code still work? What actually changed?&lt;/p&gt;

&lt;p&gt;That's what this post covers. No feature recaps you've read five times already. Just what changed, what "stable" means in practice, and a checklist to run through before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Stable" Actually Means Here
&lt;/h2&gt;

&lt;p&gt;When Laravel tags a package as stable, a few concrete things happen.&lt;/p&gt;

&lt;p&gt;First, semantic versioning kicks in properly. During the beta period, the v0.x releases could introduce breaking changes between minor versions, and they did. The API shifted. Namespace conventions got tidied up. Method signatures changed. Once the package hits 1.0, breaking changes are reserved for major version bumps only. You'll get bug fixes and new features without your code suddenly not compiling.&lt;/p&gt;

&lt;p&gt;Second, the package becomes safe to lock in production &lt;code&gt;composer.json&lt;/code&gt; files. During beta, the right call was &lt;code&gt;"laravel/ai": "dev-main"&lt;/code&gt; or a loose &lt;code&gt;^0.x&lt;/code&gt; constraint. After March 17, you'll want &lt;code&gt;^0.3&lt;/code&gt; or higher. That pins you to the stable release channel and means &lt;code&gt;composer update&lt;/code&gt; won't pull in something that breaks your agents.&lt;/p&gt;

&lt;p&gt;Third, first-party support is now official. Security vulnerabilities get patched. The team maintains backward compatibility. Other packages in the ecosystem (Filament, Prism, and others) can safely declare it as a dependency without worrying the ground will shift under them.&lt;/p&gt;

&lt;p&gt;So it's not that the features change dramatically on March 17. It's that the reliability contract is now formal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed During the Beta Period
&lt;/h2&gt;

&lt;p&gt;The SDK launched publicly in early February 2026 as a bold first release. The core idea was solid from day one: a unified, Laravel-native API for working with AI providers, built around the &lt;code&gt;Agent&lt;/code&gt; class pattern.&lt;/p&gt;

&lt;p&gt;But the beta period was genuinely used to polish things. Here's what shifted between the initial release and v0.3.0:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider tool configuration became cleaner.&lt;/strong&gt; The &lt;code&gt;WebSearch&lt;/code&gt;, &lt;code&gt;WebFetch&lt;/code&gt;, and &lt;code&gt;FileSearch&lt;/code&gt; provider tools are now properly namespaced under &lt;code&gt;Laravel\Ai\Providers\Tools&lt;/code&gt;. Early beta code that referenced these directly needed updating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configurable timeouts for embedding requests landed in v0.3.0.&lt;/strong&gt; If you're running large embedding jobs and hitting timeout issues, this is the fix. You can now set timeout values per provider in &lt;code&gt;config/ai.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;RemembersConversations&lt;/code&gt; trait stabilised.&lt;/strong&gt; Early implementations had some rough edges around the &lt;code&gt;agent_conversations&lt;/code&gt; and &lt;code&gt;agent_conversation_messages&lt;/code&gt; tables. The migration schema is now final.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured output via &lt;code&gt;JsonSchema&lt;/code&gt; cleaned up its fluent API.&lt;/strong&gt; Some method chains that worked in early beta were deprecated in favour of a more consistent interface. If you wrote structured output agents before February 2026, double-check your schema definitions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing fakes became comprehensive.&lt;/strong&gt; The ability to fake agents, images, audio, transcriptions, embeddings, reranking, and file stores is now a first-class feature. This wasn't complete in the initial launch.&lt;/p&gt;

&lt;p&gt;The v0.3.0 release on March 12 (which Taylor tweeted about) also added a handful of fixes around tool request handling. Nothing that breaks existing code, but it's worth pulling the latest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Feature Set, Now Locked In
&lt;/h2&gt;

&lt;p&gt;Since the stable release freezes the API, it's worth knowing exactly what you're working with. Here's everything that's now officially supported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    A["Your Laravel app&amp;lt;br/&amp;gt;agents · controllers · jobs"]
    A --&amp;gt; B["Laravel AI SDK unified interface"]
    B --&amp;gt; C[OpenAI]
    B --&amp;gt; D[Anthropic]
    B --&amp;gt; E[Gemini]
    B --&amp;gt; F["+ 8 more"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Agents&lt;/strong&gt; are the core primitive. You create them via &lt;code&gt;php artisan make:agent&lt;/code&gt; and configure instructions, tools, memory, and output schema inside the class. Or you use the &lt;code&gt;agent()&lt;/code&gt; helper for quick inline agents. Think of each agent as a focused specialist: it has one job, one set of instructions, and one output contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-provider support&lt;/strong&gt; covers OpenAI, Anthropic, Gemini, ElevenLabs, Groq, Cohere, DeepSeek, xAI, and OpenRouter. You swap providers by changing a single string. No client library changes, no interface refactoring. This is the part that makes the SDK genuinely different from just wrapping the OpenAI PHP client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider failover&lt;/strong&gt; is built in. Pass an array of providers and the SDK automatically tries the next one if it hits a rate limit or outage:&lt;br&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;'Analyse this transcript...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Your Laravel app] --&amp;gt; B[SDK tries OpenAI]
    B -- success --&amp;gt; D[Response returned]
    B -- rate limit / outage --&amp;gt; C[SDK retries Anthropic]
    C --&amp;gt; D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Structured output&lt;/strong&gt; uses a &lt;code&gt;JsonSchema&lt;/code&gt; builder that generates the schema definition automatically and casts the response to your specified types. No more manually parsing JSON strings from AI responses. The agent returns a typed array you can work with directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image, audio, and embedding generation&lt;/strong&gt; all use the same fluent interface pattern. Images can be queued and stored to any filesystem disk. Audio supports both synthesis and transcription. Embeddings work with any vector store you configure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vector stores&lt;/strong&gt; for RAG use cases are fully supported, with &lt;code&gt;SimilaritySearch&lt;/code&gt; available for semantic document retrieval. If you built the support bot from my Part 2 tutorial, this is the feature you used most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting per user&lt;/strong&gt; is configurable in &lt;code&gt;config/ai.php&lt;/code&gt;. Important for any app where end users trigger AI requests directly. Without this, a single user can burn through your API quota in minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Middleware for agents&lt;/strong&gt; lets you intercept and modify requests and responses. Useful for logging every prompt and response, filtering sensitive content before it reaches the model, or injecting user context automatically so you don't repeat it in every system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Readiness Checklist
&lt;/h2&gt;

&lt;p&gt;Before you ship anything using the stable SDK, run through this list. Some of these will seem obvious. But I've seen each one catch someone out on a real project.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Update your composer constraint
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/ai:^0.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't stay on &lt;code&gt;dev-main&lt;/code&gt; or &lt;code&gt;^0.x&lt;/code&gt; in production. The stable tag is exactly when you tighten this.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Publish and review the config file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;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;"Laravel&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;i&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;iServiceProvider"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you published this during beta, diff the new config against yours. Timeout settings, rate limit configuration, and the provider list may have new options you're not using yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Run the migrations fresh
&lt;/h3&gt;



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

&lt;/div&gt;



&lt;p&gt;If you installed the SDK during beta, confirm your &lt;code&gt;agent_conversations&lt;/code&gt; and &lt;code&gt;agent_conversation_messages&lt;/code&gt; tables match the final migration schema. The conversation storage schema stabilised in v0.3.0 and it's worth verifying your existing tables haven't drifted.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Set up provider failover for anything user-facing
&lt;/h3&gt;

&lt;p&gt;Single-provider setups are fine for internal tools. For anything a real user waits on, configure a fallback:&lt;br&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/ai.php&lt;/span&gt;
&lt;span class="s1"&gt;'default_providers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&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;Or set it per agent class using the &lt;code&gt;provider&lt;/code&gt; argument. Rate limits and brief outages are normal. Your users shouldn't see them.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Move heavy AI operations to queued jobs
&lt;/h3&gt;

&lt;p&gt;Image generation, embedding indexing, audio transcription: none of these should run synchronously in a request cycle. The SDK supports queuing natively:&lt;br&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;Image&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;'A product banner for...'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products/banner.webp'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap that in a queued job and your HTTP response times stay fast regardless of what the AI provider is doing. I covered &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue patterns in depth here&lt;/a&gt; if you want the full setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Write tests using fakes, not real API calls
&lt;/h3&gt;

&lt;p&gt;This is where the stable SDK really delivers. Every AI operation has a fake:&lt;br&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\Testing\AgentFake&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;AgentFake&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="nc"&gt;SupportAgent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Here is the answer to your question...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Your test now runs without hitting OpenAI&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How do I reset my password?'&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;assertStringContainsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'answer'&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;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests that hit real AI APIs are slow, expensive, and flaky. Use the fakes. They're comprehensive and they cover images, audio, embeddings, and file stores too.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Validate structured output schemas
&lt;/h3&gt;

&lt;p&gt;If you're using &lt;code&gt;JsonSchema&lt;/code&gt; for structured agent responses, run a quick sanity check against the final stable API. Some fluent methods changed between early beta and v0.3.0. This is especially important if you wrote agents before mid-February. When debugging structured outputs, a &lt;a href="https://hafiz.dev/tools/json-formatter" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; saves time when you're staring at raw API response bodies trying to figure out where the schema mismatch is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Your Existing Tutorial Code Still Work?
&lt;/h2&gt;

&lt;p&gt;If you built the document analyser from &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; or the RAG support bot from &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-part-2-build-a-rag-powered-support-bot-with-tools-and-memory" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt;, the answer is mostly yes, with one thing to check.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;agent()&lt;/code&gt; helper function, agent class structure, &lt;code&gt;Promptable&lt;/code&gt; trait, tool interface, and &lt;code&gt;SimilaritySearch&lt;/code&gt; usage are all unchanged. Your business logic is fine.&lt;/p&gt;

&lt;p&gt;The one thing to verify: if you referenced provider tools like &lt;code&gt;WebSearch&lt;/code&gt; or &lt;code&gt;WebFetch&lt;/code&gt; directly in your agents, confirm the namespace is &lt;code&gt;Laravel\Ai\Providers\Tools\WebSearch&lt;/code&gt;. That moved during beta.&lt;/p&gt;

&lt;p&gt;Also bump your composer constraint to &lt;code&gt;^0.3&lt;/code&gt; or higher and run &lt;code&gt;composer update&lt;/code&gt; to get any fixes from the v0.3.0 release before the stable tag lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Shouldn't Rush to Use It
&lt;/h2&gt;

&lt;p&gt;The stable launch doesn't mean AI is the right call for every new feature. I want to be direct about this.&lt;/p&gt;

&lt;p&gt;The SDK is great when you have a specific, bounded use case with clear inputs and outputs. Structured output agents for classification, document analysis, support routing, content generation with known schemas: these all work well and are genuinely production-ready.&lt;/p&gt;

&lt;p&gt;It's harder when you need deterministic behaviour. AI responses aren't consistent the way database queries are. If your feature requires exact, reproducible outputs, you're still better off with conventional code.&lt;/p&gt;

&lt;p&gt;And cost matters. Every AI API call has a price. For user-facing features that get hit frequently, run the numbers before shipping. The SDK's rate limiting helps control this, but it doesn't make the problem disappear.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://hafiz.dev/blog/laravel-13-php-attributes-refactor-your-models-jobs-and-commands" rel="noopener noreferrer"&gt;PHP Attributes post from March 4&lt;/a&gt; is actually a good example of what goes stable alongside this SDK. Laravel 13 as a whole is taking a measured, practical approach to new capabilities. The AI SDK fits that same philosophy. It's ready for production use cases. It's not a reason to retrofit AI into things that don't need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does upgrading to the stable SDK require any database migration changes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're already using the SDK in beta, run &lt;code&gt;php artisan migrate&lt;/code&gt; and check that your &lt;code&gt;agent_conversations&lt;/code&gt; table matches the final schema. For new installs, publish the migrations fresh after upgrading to &lt;code&gt;^0.3&lt;/code&gt; or higher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the stable SDK on Laravel 12 or do I need to upgrade to Laravel 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The SDK currently lives in the Laravel 12.x docs and has been available since February 2026 on Laravel 12. The March 17 stable tag coincides with Laravel 13's release, but you don't need to upgrade Laravel to use the stable SDK. It works on Laravel 12.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between using the &lt;code&gt;agent()&lt;/code&gt; helper and creating a dedicated Agent class?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;agent()&lt;/code&gt; helper is great for quick, inline agents: one-off operations where you don't need reusability. Dedicated Agent classes are for agents you'll prompt from multiple places in your app, or agents with complex tool configurations and middleware. Start with the helper, extract to a class when it grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle AI API rate limits in production without the failover feature?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're locked to a single provider, queue your AI operations and add retry logic via Laravel's built-in job retry mechanisms. Set &lt;code&gt;public $tries = 3&lt;/code&gt; and &lt;code&gt;public $backoff = [30, 60, 120]&lt;/code&gt; on your job class. The failover feature is better, but queued retries work as a fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the Laravel AI SDK a replacement for Prism or should they coexist?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The official SDK covers the most common use cases and is now the first-party choice for new Laravel projects. Prism has a larger provider surface and some features the official SDK doesn't have yet. For new projects, start with the official SDK. If you have existing Prism code that works, there's no urgent reason to migrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Do Right Now
&lt;/h2&gt;

&lt;p&gt;Install the stable SDK today, update your composer constraint to &lt;code&gt;^0.3&lt;/code&gt; or higher, and run through the checklist above. If you've been holding off on building AI features because the SDK was in beta, the wait is over.&lt;/p&gt;

&lt;p&gt;This is genuinely the cleanest path to adding AI to a Laravel app right now. Not because it's the only option, but because it follows every Laravel convention you already know: service providers, facades, artisan commands, queue jobs, and config files. There's no mental context switch. You're writing Laravel code that happens to call an AI provider.&lt;/p&gt;

&lt;p&gt;The full &lt;a href="https://laravel.com/docs/12.x/ai-sdk" rel="noopener noreferrer"&gt;Laravel AI SDK docs&lt;/a&gt; are already live and cover everything with code examples. They're the authoritative reference now that the stable tag has landed.&lt;/p&gt;

&lt;p&gt;Building an AI-powered product and want to ship it fast? I help founders and teams go from idea to working Laravel MVP. &lt;a href="https://hafiz.dev/#contact" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>laravel13</category>
      <category>php</category>
    </item>
  </channel>
</rss>
