<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hafiz</title>
    <description>The latest articles on DEV Community by Hafiz (@hafiz619).</description>
    <link>https://dev.to/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>DEV Community: Hafiz</title>
      <link>https://dev.to/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>How Laravel Events, Listeners, and Observers Actually Work (And When to Use Each)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 20 Apr 2026 05:51:50 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-laravel-events-listeners-and-observers-actually-work-and-when-to-use-each-53c7</link>
      <guid>https://dev.to/hafiz619/how-laravel-events-listeners-and-observers-actually-work-and-when-to-use-each-53c7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/how-laravel-events-listeners-observers-actually-work" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;A new user registers on your SaaS. You need to send a welcome email, provision their free trial, log the signup event, and notify your Slack channel. Where does that code go?&lt;/p&gt;

&lt;p&gt;If the answer is "the controller", your controller is doing too much. If the answer is "a listener that calls a listener that calls another listener", your event system is doing too much. Laravel gives you three distinct tools to get there: Events, Listeners, and Observers. Each one has a clear job. The distinction is worth understanding because reaching for the wrong one creates the kind of coupling you were trying to avoid in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Problem Are We Actually Solving?
&lt;/h2&gt;

&lt;p&gt;When a user registers, the naive approach stuffs everything in the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RegisterRequest&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;RedirectResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&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;WelcomeEmail&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;Trial&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;provision&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;Slack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'New signup: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'User registered'&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'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works. It's also a problem. The controller now knows about mail, trials, Slack, and logging. Add a new onboarding step and you touch the controller. Change how trials work and you touch the controller. Write a test for the controller and you mock four different things.&lt;/p&gt;

&lt;p&gt;Events, Listeners, and Observers flip this around. The controller fires a signal ("something happened") and the rest of the application reacts. The controller doesn't know or care what reacts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Events: The Signal
&lt;/h2&gt;

&lt;p&gt;An event is a plain PHP class that represents something that happened. That's it. It carries data about the occurrence and 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="cp"&gt;&amp;lt;?php&lt;/span&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Events\Dispatchable&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\Queue\SerializesModels&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;UserRegistered&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;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;__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;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create one with Artisan:&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:event UserRegistered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fire it anywhere in your application:&lt;br&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;UserRegistered&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="c1"&gt;// or&lt;/span&gt;
&lt;span class="nf"&gt;event&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;UserRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An event class should be small. It shouldn't contain methods that do things. It's a data container, not a service. The name should describe what happened in past tense: &lt;code&gt;UserRegistered&lt;/code&gt;, &lt;code&gt;OrderShipped&lt;/code&gt;, &lt;code&gt;PaymentFailed&lt;/code&gt;. If you find yourself writing logic inside an event class, that logic belongs in a listener.&lt;/p&gt;

&lt;h2&gt;
  
  
  Listeners: What Reacts
&lt;/h2&gt;

&lt;p&gt;A listener receives an event and does something with it. One event can have many listeners. Listeners don't know about each other.&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:listener SendWelcomeEmail &lt;span class="nt"&gt;--event&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;UserRegistered
&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\Listeners&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\Events\UserRegistered&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\Mail\WelcomeEmail&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\Mail&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;SendWelcomeEmail&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;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&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;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&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;Each listener does one job. &lt;code&gt;SendWelcomeEmail&lt;/code&gt; sends a welcome email. &lt;code&gt;ProvisionFreeTrial&lt;/code&gt; provisions a trial. &lt;code&gt;NotifySlack&lt;/code&gt; posts to Slack. Adding a new step means adding a new listener. You don't touch the existing ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auto-Discovery in Laravel 11+
&lt;/h3&gt;

&lt;p&gt;Before Laravel 11, you had to manually register every event and listener in &lt;code&gt;EventServiceProvider::$listen&lt;/code&gt;. Laravel 11 removed &lt;code&gt;EventServiceProvider&lt;/code&gt; from the default application structure and turned on auto-discovery by default.&lt;/p&gt;

&lt;p&gt;Auto-discovery works by scanning &lt;code&gt;app/Listeners/&lt;/code&gt; and looking for &lt;code&gt;handle()&lt;/code&gt; methods. If a &lt;code&gt;handle()&lt;/code&gt; method type-hints an event class, Laravel automatically wires that listener to the event. No registration 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;// This listener is auto-discovered. No registration needed.&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendWelcomeEmail&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;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&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;This matters because the old approach had a subtle maintenance problem: the &lt;code&gt;$listen&lt;/code&gt; array in &lt;code&gt;EventServiceProvider&lt;/code&gt; was a second source of truth. You could create a listener, forget to register it, and your code would run without errors. The listener just silently never fired. Auto-discovery eliminates that category of bug entirely.&lt;/p&gt;

&lt;p&gt;One listener class can also handle multiple events by defining multiple methods, each type-hinting a different event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserActivityListener&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;handleLogin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Login&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs on login&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;handleLogout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Logout&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs on logout&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;Laravel's scanner picks up both &lt;code&gt;handleLogin&lt;/code&gt; and &lt;code&gt;handleLogout&lt;/code&gt; automatically because they start with &lt;code&gt;handle&lt;/code&gt; and type-hint an event class.&lt;/p&gt;

&lt;p&gt;In production, cache the discovered listener manifest so Laravel doesn't scan on every request:&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 event:cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clear it during deployment 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 event:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on an older Laravel version or want explicit control, you can still register listeners 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\Event&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;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;UserRegistered&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;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both approaches work. Auto-discovery is cleaner for new applications. Explicit registration is useful when you need listeners from third-party packages or conditional registration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Queued Listeners
&lt;/h3&gt;

&lt;p&gt;Sending emails, making HTTP calls, generating reports: these don't need to block the HTTP response. Implement &lt;code&gt;ShouldQueue&lt;/code&gt; and Laravel automatically dispatches the listener as a &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;background queue job&lt;/a&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\Contracts\Queue\ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\InteractsWithQueue&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;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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithQueue&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// seconds&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;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&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;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Handle failure: log it, alert, retry logic, etc.&lt;/span&gt;
        &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome email failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'user'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&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;failed()&lt;/code&gt; method is important. Queued listeners can fail. Define this method to handle failures gracefully rather than silently losing the email send.&lt;/p&gt;

&lt;h3&gt;
  
  
  After Database Commit
&lt;/h3&gt;

&lt;p&gt;One common gotcha: a listener fires before the database transaction commits. Your listener reads a user ID, queries the database, and finds nothing because the record doesn't exist yet.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;ShouldHandleEventsAfterCommit&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\Contracts\Events\ShouldHandleEventsAfterCommit&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;ProvisionFreeTrial&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;ShouldHandleEventsAfterCommit&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;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Guaranteed to run only after the database transaction commits&lt;/span&gt;
        &lt;span class="nc"&gt;Trial&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this whenever your listener reads from the database and you're dispatching the event inside a transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observers: Model Lifecycle Hooks
&lt;/h2&gt;

&lt;p&gt;Observers are a different tool for a different job. While events and listeners handle application-level signals, observers handle Eloquent model lifecycle events: &lt;code&gt;creating&lt;/code&gt;, &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;updating&lt;/code&gt;, &lt;code&gt;updated&lt;/code&gt;, &lt;code&gt;deleting&lt;/code&gt;, &lt;code&gt;deleted&lt;/code&gt;, and more.&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:observer UserObserver &lt;span class="nt"&gt;--model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;User
&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\Observers&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\User&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;UserObserver&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;created&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs every time any User is created anywhere in the codebase&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;updated&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs every time any User is updated&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;deleted&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs every time any User is deleted&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 the observer on the model using the &lt;code&gt;#[ObservedBy]&lt;/code&gt; PHP attribute, introduced in Laravel 10.44 and fully supported in Laravel 11, 12, and 13:&lt;br&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\Models&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Observers\UserObserver&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Attributes\ObservedBy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Auth\User&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ObservedBy([UserObserver::class])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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;Before this attribute existed, you'd register observers in a service provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppServiceProvider::boot()&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;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserObserver&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;Both still work. The &lt;code&gt;#[ObservedBy]&lt;/code&gt; attribute is cleaner because the registration lives on the model itself. You can see at a glance that &lt;code&gt;UserObserver&lt;/code&gt; is active without hunting through providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full List of Observer Methods
&lt;/h3&gt;

&lt;p&gt;An observer class can define methods for any of the Eloquent lifecycle events:&lt;br&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;UserObserver&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;retrieved&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;   &lt;span class="c1"&gt;// After fetching from DB&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;creating&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// Before insert&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;created&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;     &lt;span class="c1"&gt;// After insert&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;updating&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// Before update&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;updated&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;     &lt;span class="c1"&gt;// After update&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;saving&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;      &lt;span class="c1"&gt;// Before create or update&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;saved&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;       &lt;span class="c1"&gt;// After create or update&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;deleting&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// Before delete&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;deleted&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;     &lt;span class="c1"&gt;// After delete&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;restoring&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;   &lt;span class="c1"&gt;// Before soft-restore&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;restored&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;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// After soft-restore&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't need to define all of them. Define only the lifecycle hooks your use case actually needs. An observer with one method is perfectly fine.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;creating&lt;/code&gt; and &lt;code&gt;updating&lt;/code&gt; hooks (before the operation) are useful for validation, transformations, or cancelling the operation by returning &lt;code&gt;false&lt;/code&gt;. The &lt;code&gt;created&lt;/code&gt; and &lt;code&gt;updated&lt;/code&gt; hooks (after the operation) are better for side effects like sending notifications or clearing caches, since you know the database state is settled.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Goes in an Observer vs a Listener
&lt;/h3&gt;

&lt;p&gt;This is where most developers get confused. The distinction is simpler than it looks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use an observer when&lt;/strong&gt; the behavior should trigger on every instance of a model event, everywhere in the codebase. Audit logging is the clearest example: every time any user is created, updated, or deleted, you want a log entry. An observer is the right place for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use an event and listener when&lt;/strong&gt; the behavior is specific to a particular business flow. A user registering via the web form should get a welcome email. A user created programmatically by a data import job probably shouldn't. Events give you control over when to fire the signal. Observers fire automatically no matter what.&lt;/p&gt;

&lt;p&gt;Here's a practical SaaS breakdown:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Send welcome email on registration&lt;/td&gt;
&lt;td&gt;Event + Listener&lt;/td&gt;
&lt;td&gt;Only fires when you explicitly dispatch the event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write to audit log on every User update&lt;/td&gt;
&lt;td&gt;Observer&lt;/td&gt;
&lt;td&gt;Should always fire, regardless of where the update originates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provision free trial after signup&lt;/td&gt;
&lt;td&gt;Event + Listener (queued)&lt;/td&gt;
&lt;td&gt;Business flow specific, benefits from queueing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear cache when Post is deleted&lt;/td&gt;
&lt;td&gt;Observer&lt;/td&gt;
&lt;td&gt;Should always happen when any Post is deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notify Slack on first payment&lt;/td&gt;
&lt;td&gt;Event + Listener&lt;/td&gt;
&lt;td&gt;Specific business milestone, not every payment creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update &lt;code&gt;last_updated_at&lt;/code&gt; on every Order save&lt;/td&gt;
&lt;td&gt;Observer&lt;/td&gt;
&lt;td&gt;Always should happen, tightly coupled to model lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're still unsure which to reach for, this decision flow covers most cases:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/how-laravel-events-listeners-observers-actually-work" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A Real-World Pattern: User Registration
&lt;/h2&gt;

&lt;p&gt;Here's how all three tools work together in a user registration flow. The controller fires one event. Two listeners react to that event asynchronously. The observer independently handles model-level concerns for every user creation, no matter where it originates.&lt;br&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;// Controller stays clean&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RegisterController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&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;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RegisterRequest&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;RedirectResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="nc"&gt;UserRegistered&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Listener: sends welcome email (queued)&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="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;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&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;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&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;// Listener: provisions trial (queued, after commit)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProvisionFreeTrial&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;ShouldHandleEventsAfterCommit&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;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Trial&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Observer: handles model-level concerns for ALL user creation&lt;/span&gt;
&lt;span class="na"&gt;#[ObservedBy([UserObserver::class])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;UserObserver&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;created&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AuditLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;updated&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AuditLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.updated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDirty&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 controller fires one event. Two listeners react to that event in the background. The observer independently logs every user creation, including the one triggered by the controller. Each piece of code does one job and doesn't know about the others.&lt;/p&gt;

&lt;p&gt;This pattern scales well in &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;multi-tenant SaaS applications&lt;/a&gt;, where the same model events fire across tenants and the observer ensures audit logging is consistent regardless of which flow created the record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Events and Listeners
&lt;/h2&gt;

&lt;p&gt;Laravel's &lt;code&gt;Event::fake()&lt;/code&gt; replaces the event dispatcher with a fake that captures dispatched events without actually running listeners. This is what you want for most feature tests. You want to assert that an event was dispatched, not that a listener ran.&lt;br&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\Events\UserRegistered&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user registration dispatches UserRegistered event'&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;Event&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="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/register'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Hafiz Riaz'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'hafiz@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'password_confirmation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertDispatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'hafiz@example.com'&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;Test the listener separately by instantiating it directly:&lt;br&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\Events\UserRegistered&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\Listeners\SendWelcomeEmail&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\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Mail&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\Mail\WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SendWelcomeEmail listener sends welcome email'&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;Mail&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="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mail&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;$mail&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&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;For observers, &lt;code&gt;Event::fake()&lt;/code&gt; silences them by default. Model events don't fire when the dispatcher is faked. If you need observers to run inside a fake context, use &lt;code&gt;Event::fakeFor()&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'observer logs user creation'&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;// Events are faked, so observers don't run here&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;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fakeFor&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// Event::assertDispatched() checks happen inside fakeFor&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// After fakeFor, events and observers run normally&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="nf"&gt;update&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;'shipped'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="c1"&gt;// Observer fires here&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 fake only specific events, leaving others to run normally:&lt;br&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;Event&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;UserRegistered&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// Only UserRegistered is faked; all other events fire as usual&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Artisan Commands Reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create&lt;/span&gt;
php artisan make:event UserRegistered
php artisan make:listener SendWelcomeEmail &lt;span class="nt"&gt;--event&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;UserRegistered
php artisan make:observer UserObserver &lt;span class="nt"&gt;--model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;User

&lt;span class="c"&gt;# List all registered events and listeners&lt;/span&gt;
php artisan event:list

&lt;span class="c"&gt;# Cache discovered events (run on deployment)&lt;/span&gt;
php artisan event:cache

&lt;span class="c"&gt;# Clear the event cache&lt;/span&gt;
php artisan event:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can find the full list of available Artisan commands in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Putting business logic in events.&lt;/strong&gt; Events are data containers. If you have methods in your event class that query the database or send emails, move that logic to a listener. Keeping events lean also makes them serializable, which matters for queued listeners. Laravel needs to serialize the event to pass it to the queue worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using observers for flow-specific behavior.&lt;/strong&gt; Observers fire on every model event everywhere. If you want an email sent only when a user registers via the web form, use an event that you dispatch explicitly, not an observer that fires every time a &lt;code&gt;User&lt;/code&gt; record is created (including imports, seeds, and tests). Observers are for behavior that should always fire regardless of the origin of the change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting &lt;code&gt;event:cache&lt;/code&gt; in production.&lt;/strong&gt; Auto-discovery scans the filesystem on every request unless you cache the manifest. Always run &lt;code&gt;php artisan event:cache&lt;/code&gt; during deployment. If you're using Laravel Forge, add it to your deployment script. If you're using a CI/CD pipeline, add it after the &lt;code&gt;composer install&lt;/code&gt; step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not defining &lt;code&gt;failed()&lt;/code&gt; on queued listeners.&lt;/strong&gt; Queued listeners can fail silently. Define the &lt;code&gt;failed()&lt;/code&gt; method to handle errors: log them, send alerts, or retry with different parameters. A queued listener that throws an exception will be retried based on your queue configuration, but without a &lt;code&gt;failed()&lt;/code&gt; handler you have no visibility into what failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dispatching events inside database transactions without &lt;code&gt;ShouldHandleEventsAfterCommit&lt;/code&gt;.&lt;/strong&gt; If your listener reads data that the transaction hasn't committed yet, it will fail in subtle ways. Always add &lt;code&gt;ShouldHandleEventsAfterCommit&lt;/code&gt; when your listener queries data that the same transaction creates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing with Event::fake() and expecting observers to run.&lt;/strong&gt; When you call &lt;code&gt;Event::fake()&lt;/code&gt;, model observers are also silenced because they rely on the event system internally. If your test needs observer behavior, either use &lt;code&gt;Event::fakeFor()&lt;/code&gt; for the specific section that shouldn't fire observers, or don't fake events for the part of the test where observer behavior matters.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Should I use Events or Jobs for background tasks?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both can run code in the background, but the semantics differ. Events signal that something happened and multiple listeners can react. Jobs represent a specific unit of work with one purpose. Use events when you have multiple things that need to react to an occurrence. Use jobs when you have one specific task to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can one listener handle multiple events?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Type-hint multiple event classes in separate &lt;code&gt;handle*&lt;/code&gt; methods, or use union types in a single method. With auto-discovery, Laravel registers each &lt;code&gt;handle*&lt;/code&gt; method as a listener for the event it type-hints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I fire an event vs call a method directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fire an event when you want to decouple the caller from what happens next, especially when multiple things need to react, or when the reactions might change in the future. Call a method directly when it's a single, always-present behavior that's tightly coupled to the action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do observer methods run inside database transactions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By default, observer methods run inside the same transaction as the Eloquent operation. If you need the observer to run only after the transaction commits, implement &lt;code&gt;ShouldHandleEventsAfterCommit&lt;/code&gt; on the observer class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to queued listeners if the application crashes before they run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They stay in the queue. As long as you're using a persistent queue driver like Redis or database, queued jobs survive application restarts. This is one of the advantages of queuing over synchronous execution.&lt;/p&gt;

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

&lt;p&gt;Events signal that something happened. Listeners react to those signals. Observers hook into the Eloquent model lifecycle automatically.&lt;/p&gt;

&lt;p&gt;The practical rule: if you want behavior to fire only when you choose to fire it, use events. If you want behavior to fire every time a model changes no matter what, use observers. When in doubt, events with explicit dispatch give you more control.&lt;/p&gt;

&lt;p&gt;Think about it from the perspective of a new developer joining your codebase. If they see a &lt;code&gt;UserRegistered::dispatch($user)&lt;/code&gt; in the controller, they know exactly where to look for what happens next: the &lt;code&gt;app/Listeners/&lt;/code&gt; directory, or a quick &lt;code&gt;php artisan event:list&lt;/code&gt;. If they see an observer on the &lt;code&gt;User&lt;/code&gt; model, they know that code runs on every user lifecycle event regardless of where it originates. Both are discoverable. Both have clear intent. That's the point.&lt;/p&gt;

&lt;p&gt;The modern Laravel 11+ setup makes this easier. Auto-discovery removes the registration boilerplate. &lt;code&gt;#[ObservedBy]&lt;/code&gt; keeps observer registration on the model where it belongs. &lt;code&gt;ShouldHandleEventsAfterCommit&lt;/code&gt; handles the transaction timing edge cases that have caught developers off guard for years. And &lt;code&gt;Event::fake()&lt;/code&gt; makes the whole system testable without running real side effects.&lt;/p&gt;

&lt;p&gt;Start with events and listeners for business flows. Add observers for model lifecycle concerns that should always fire. Keep each piece small and focused. The system scales from a single controller action to a multi-tenant SaaS without the architecture needing to change.&lt;/p&gt;

&lt;p&gt;Building something that needs this architecture across a complex codebase? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>How I Built a macOS Menu Bar App with NativePHP, Laravel 12 and Livewire 4</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 16 Apr 2026 08:25:31 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-i-built-a-macos-menu-bar-app-with-nativephp-laravel-12-and-livewire-4-g21</link>
      <guid>https://dev.to/hafiz619/how-i-built-a-macos-menu-bar-app-with-nativephp-laravel-12-and-livewire-4-g21</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/how-i-built-macos-menu-bar-app-nativephp-laravel-livewire" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every developer has ignored a break reminder. The notification pops up, you dismiss it in 0.2 seconds without thinking, and two hours later your back hurts and your eyes ache. Notifications don't work. You need something you can't mindlessly dismiss.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/hzeeshan/forcedbreak/releases" rel="noopener noreferrer"&gt;ForcedBreak&lt;/a&gt;. A macOS menu bar app that shows a full-screen overlay after a configurable interval (25, 45, 60 minutes, whatever you prefer) and makes you complete a physical challenge before you can get back to work. Push-ups. A glass of water. Box breathing. It covers all your monitors. You have to consciously deal with it: complete the challenge, skip it (with a 5-minute penalty), or close it and feel bad about yourself.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the concept. It's that I built it entirely with PHP: &lt;a href="https://nativephp.com" rel="noopener noreferrer"&gt;NativePHP&lt;/a&gt;, Laravel 12, Livewire 4, and Tailwind. No Swift. No Objective-C. No raw Electron boilerplate. The app is &lt;a href="https://github.com/hzeeshan/forcedbreak" rel="noopener noreferrer"&gt;open source on GitHub&lt;/a&gt; and the &lt;code&gt;.dmg&lt;/code&gt; for Apple Silicon is available on the &lt;a href="https://github.com/hzeeshan/forcedbreak/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frkf1dnr86aabujxo838l.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frkf1dnr86aabujxo838l.webp" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post covers the architecture and the four problems that gave me real trouble. If you're building anything with NativePHP, you'll hit at least two of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why NativePHP and Why Laravel 12, Not 13
&lt;/h2&gt;

&lt;p&gt;NativePHP wraps your Laravel app in Electron, giving you access to native macOS APIs through Laravel facades: menu bar, system notifications, window management, screen info. You write PHP and Blade. NativePHP handles the Electron layer.&lt;/p&gt;

&lt;p&gt;If you want a proper NativePHP introduction first, I covered the setup basics in &lt;a href="https://hafiz.dev/blog/build-your-first-mobile-app-with-laravel-and-nativephp-v3-free-step-by-step" rel="noopener noreferrer"&gt;Build Your First App with Laravel and NativePHP&lt;/a&gt;. This post assumes you have a working NativePHP setup and focuses on the harder parts.&lt;/p&gt;

&lt;p&gt;The stack for ForcedBreak:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Desktop framework&lt;/td&gt;
&lt;td&gt;NativePHP 1.3&lt;/td&gt;
&lt;td&gt;Laravel-native desktop apps, no Swift needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Laravel 12&lt;/td&gt;
&lt;td&gt;Familiar cache, ORM, everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Livewire 4&lt;/td&gt;
&lt;td&gt;Reactive components without writing JavaScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Styling&lt;/td&gt;
&lt;td&gt;Tailwind CSS v4&lt;/td&gt;
&lt;td&gt;Dark theme, fast iteration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;Fully offline, ships with the app&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh1apc7ny668r0381ei1q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh1apc7ny668r0381ei1q.png" width="152" height="62"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One critical note on the version: NativePHP 1.x requires &lt;code&gt;illuminate/contracts ^10.0|^11.0|^12.0&lt;/code&gt;. Laravel 13 internalized &lt;code&gt;illuminate/contracts&lt;/code&gt; as part of the framework itself, which breaks this Composer constraint. You can't use Laravel 13 with NativePHP 1.x. Pinning to Laravel 12 is the right call until NativePHP officially ships support.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Architecture: Two Timers, Not One
&lt;/h2&gt;

&lt;p&gt;This is the most important thing to understand before building any NativePHP app that needs background behavior.&lt;/p&gt;

&lt;p&gt;The obvious approach is Livewire's &lt;code&gt;wire:poll&lt;/code&gt;. Set it to tick every second, decrement a counter, show the result in the menu bar. Simple enough.&lt;/p&gt;

&lt;p&gt;It doesn't work. Livewire polling only runs when a browser window is open. A menu bar app lives in the menu bar. The popover window is closed 99% of the time. When the user isn't looking at the popover, &lt;code&gt;wire:poll&lt;/code&gt; isn't running. The timer would only tick when the user clicked to open it.&lt;/p&gt;

&lt;p&gt;The solution is two separate systems:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/how-i-built-macos-menu-bar-app-nativephp-laravel-livewire" 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;Layer 1: The background ticker (authoritative).&lt;/strong&gt; A dedicated Artisan command runs in an infinite loop as a persistent &lt;code&gt;ChildProcess&lt;/code&gt;. It ticks every second, decrements the cache, updates the menu bar label, and triggers the overlay when the timer hits zero. This runs whether the popover is open or not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Livewire polling (UI only).&lt;/strong&gt; When the user opens the popover, &lt;code&gt;wire:poll.1000ms&lt;/code&gt; reads from the same cache keys and displays the countdown. It never writes to cache. It's a pure reader.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Console/Commands/TickMenuBarLabel.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;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="nv"&gt;$lastLabel&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;while&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="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'on_break'&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="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$secondsLeft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'break_seconds_left'&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;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'break_seconds_left'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secondsLeft&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;addHours&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="c1"&gt;// Only call MenuBar::label() when the value actually changes&lt;/span&gt;
            &lt;span class="nv"&gt;$label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%02d:%02d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;intdiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secondsLeft&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="nv"&gt;$secondsLeft&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$lastLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;MenuBar&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$lastLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secondsLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;openOverlayOnAllScreens&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sleepUntilNextSecond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$start&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sleepUntilNextSecond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$start&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;$elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$elapsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;usleep&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$remaining&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is registered in &lt;code&gt;NativeAppServiceProvider&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;ChildProcess&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;artisan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app:tick-menubar-label'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ticker'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;persistent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sleepUntilNextSecond()&lt;/code&gt; method deserves a mention because the naive version of this loop (just calling &lt;code&gt;sleep(1)&lt;/code&gt;) causes 100% CPU usage. &lt;code&gt;sleep(1)&lt;/code&gt; blocks for exactly one second but ignores the time the tick itself took. Over time the loop drifts, and under some system conditions it spins. The fix is to measure how long the tick took with &lt;code&gt;microtime(true)&lt;/code&gt; and sleep only the remaining microseconds to the next second boundary. It also skips calling &lt;code&gt;MenuBar::label()&lt;/code&gt; when the value hasn't changed, which avoids unnecessary HTTP calls to Electron's bridge on every tick.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;persistent: true&lt;/code&gt; flag tells NativePHP to restart the process if it crashes. Without it, the ticker dies and the menu bar freezes.&lt;/p&gt;

&lt;p&gt;If you've worked with &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue workers running as background daemons&lt;/a&gt;, this pattern will feel familiar. The mental model is the same: one authoritative process manages state, and the UI reads from it. The difference here is that the "worker" is an infinite loop command rather than a Horizon worker, because NativePHP's scheduler runs every minute by default and a one-minute resolution isn't good enough for a visible countdown timer.&lt;/p&gt;

&lt;p&gt;The reason &lt;code&gt;persistent: true&lt;/code&gt; matters is that an infinite loop command can crash. SQLite can throw an exception, a cache operation can fail, or the process can be killed by macOS under memory pressure. Without &lt;code&gt;persistent: true&lt;/code&gt;, your menu bar label freezes at whatever time it was showing when the crash happened, and nothing ever triggers the overlay. The user just sits there wondering why the app stopped working. With &lt;code&gt;persistent: true&lt;/code&gt;, NativePHP restarts the child process automatically within a few seconds and the timer continues.&lt;/p&gt;

&lt;p&gt;This two-layer pattern applies to anything that needs to run when no window is open: timers, background polling, file watchers, scheduled sync tasks. Once you have it working for one thing, you understand the whole model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: The Dual-Database Trap
&lt;/h2&gt;

&lt;p&gt;This one cost me two hours. After I renamed the app, the timer stopped working entirely. No countdown. No overlay. No errors in the logs. Everything appeared fine when I opened the popover.&lt;/p&gt;

&lt;p&gt;Here's what was happening.&lt;/p&gt;

&lt;p&gt;NativePHP stores its database at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/Library/Application Support/{NATIVEPHP_APP_ID}-dev/database/database.sqlite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I changed &lt;code&gt;NATIVEPHP_APP_ID&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, NativePHP created a new storage directory with a fresh database file. The old migrations stayed in the old directory. The new database existed at 0 bytes. All &lt;code&gt;cache()-&amp;gt;get()&lt;/code&gt; and &lt;code&gt;cache()-&amp;gt;put()&lt;/code&gt; calls silently returned null. The ticker decremented nothing. Nothing happened.&lt;/p&gt;

&lt;p&gt;There's a second layer to this: NativePHP doesn't auto-run migrations in dev mode. The file exists but has no tables.&lt;/p&gt;

&lt;p&gt;The fix: auto-migrate on every boot in &lt;code&gt;NativeAppServiceProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Auto-migrate ensures the NativePHP database always has tables.&lt;/span&gt;
    &lt;span class="c1"&gt;// Without this, a fresh storage directory silently breaks everything.&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'migrate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--force'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'db:seed'&lt;/span&gt;&lt;span class="p"&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="s1"&gt;'ChallengesSeeder'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'--force'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The seeder uses &lt;code&gt;firstOrCreate&lt;/code&gt;, so re-running it on every boot is safe.&lt;/p&gt;

&lt;p&gt;The broader rule: never change &lt;code&gt;NATIVEPHP_APP_ID&lt;/code&gt; without understanding what it does. Your display name is controlled by &lt;code&gt;NATIVEPHP_APP_NAME&lt;/code&gt; and can change freely. The app ID determines the storage directory path. Change it and you lose all settings and user data.&lt;/p&gt;

&lt;p&gt;To debug this yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 ~/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support/&lt;span class="o"&gt;{&lt;/span&gt;your-app-id&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;-dev&lt;/span&gt;/database/database.sqlite &lt;span class="s2"&gt;".tables"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get no output, the database is empty. Copy your local one across and restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Not All NativePHP Facades Work From Child Processes
&lt;/h2&gt;

&lt;p&gt;When I added pre-break warning notifications, the code ran without errors. No notification ever appeared.&lt;/p&gt;

&lt;p&gt;NativePHP facades communicate with Electron's main process via an HTTP bridge. Some of them work fine from child processes. Others silently fail.&lt;/p&gt;

&lt;p&gt;Here's what I found through testing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Facade&lt;/th&gt;
&lt;th&gt;Works from ChildProcess?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MenuBar::label()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Window::open()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (with a URL caveat, see next section)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Screen::displays()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification::show()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No, silently fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;Notification&lt;/code&gt; facade doesn't work from child processes. I tried routing it through a web endpoint as a workaround, where the child process calls an internal HTTP endpoint and that endpoint sends the notification. That also didn't work reliably in my testing.&lt;/p&gt;

&lt;p&gt;The pattern I'd suggest before building anything complex with a NativePHP facade: write a quick web route that calls it directly and test in the browser first. If it works there, it will probably work from a child process. If it doesn't work in the browser context, you have a different problem. And if it works in the browser but not in a child process, you've hit this limitation and you'll need to find an alternative approach.&lt;/p&gt;

&lt;p&gt;Pre-break notifications are on the v2 list. Once I have more time to investigate the bridge behavior for &lt;code&gt;Notification&lt;/code&gt;, I'll add it back in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: URL Resolution in Artisan Context
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Window::open()&lt;/code&gt; works from child processes, but you have to build the URL yourself.&lt;/p&gt;

&lt;p&gt;The default &lt;code&gt;APP_URL&lt;/code&gt; in Laravel's &lt;code&gt;.env&lt;/code&gt; is &lt;code&gt;http://localhost&lt;/code&gt;. NativePHP's PHP server runs on &lt;code&gt;http://127.0.0.1:{dynamic_port}&lt;/code&gt;. When the ticker calls &lt;code&gt;Window::open()-&amp;gt;route('break.overlay')&lt;/code&gt;, it generates &lt;code&gt;http://localhost/break-overlay&lt;/code&gt;. Electron tries to load it and gets "Not Found."&lt;/p&gt;

&lt;p&gt;The fix: write the actual server URL to a file during boot, then read it in child processes.&lt;br&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;// NativeAppServiceProvider::boot()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mo"&gt;0755&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="nv"&gt;$port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'SERVER_PORT'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;8100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app/server_url.txt'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s2"&gt;"http://127.0.0.1:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$port&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;Then in the ticker command:&lt;br&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;$baseUrl&lt;/span&gt; &lt;span class="o"&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;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app/server_url.txt'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nc"&gt;Window&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'break-overlay'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$baseUrl&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/break-overlay'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not the most elegant solution, but it works reliably. Since &lt;code&gt;NativeAppServiceProvider::boot()&lt;/code&gt; runs before any child process starts, the file is always there when the ticker needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Multi-Screen Overlay
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;Window::open()&lt;/code&gt; call opens one window on your primary display. If you have a second monitor, the user can just look over there and keep working.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Screen::displays()&lt;/code&gt; returns all connected displays with their bounds. Open one window per display:&lt;br&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;$displays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Screen&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;displays&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$displays&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$display&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$bounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$display&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'bounds'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$windowId&lt;/span&gt; &lt;span class="o"&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="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'break-overlay'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"break-overlay-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$i&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="nc"&gt;Window&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$windowId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$baseUrl&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/break-overlay'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;alwaysOnTop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;titleBarHidden&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;position&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'width'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'height'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important gotcha: don't use &lt;code&gt;-&amp;gt;closable(false)&lt;/code&gt; on overlay windows. It looks like the right way to prevent accidental dismissal, but it makes &lt;code&gt;Window::close()&lt;/code&gt; a no-op. NativePHP calls &lt;code&gt;window.close()&lt;/code&gt; under the hood, and when &lt;code&gt;closable&lt;/code&gt; is false, Electron ignores it. You'd never be able to close the overlay programmatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;alwaysOnTop()&lt;/code&gt; with &lt;code&gt;titleBarHidden()&lt;/code&gt; is enough. The macOS close button is still technically visible, but the user has to make a deliberate choice to click it. That's a different thing from mindlessly swiping away a notification. When the user clicks "I Did It!", the component iterates the same window IDs and closes each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distributing the App
&lt;/h2&gt;

&lt;p&gt;Distributing a NativePHP app outside the Mac App Store is simpler than it sounds. No developer certificate required for direct distribution.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;That produces a &lt;code&gt;.dmg&lt;/code&gt; in the &lt;code&gt;dist/&lt;/code&gt; folder. I wrapped this in a &lt;code&gt;build.sh&lt;/code&gt; script that handles switching &lt;code&gt;.env&lt;/code&gt; to production settings, clearing caches, building assets with npm, running the NativePHP build command, and restoring the dev environment afterward. The whole process takes about 3 minutes on an M2 Mac.&lt;/p&gt;

&lt;p&gt;The only thing users have to do after dragging the app to &lt;code&gt;/Applications&lt;/code&gt; is run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xattr &lt;span class="nt"&gt;-cr&lt;/span&gt; /Applications/ForcedBreak.app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes the macOS quarantine flag that blocks unsigned apps. It's a standard step for indie Mac apps distributed outside the App Store, safe to run, and you only need to do it once.&lt;/p&gt;

&lt;p&gt;Desktop deployment is a different mental model from web apps. There's no server, no zero-downtime concern, no rollback mechanism. You build the &lt;code&gt;.dmg&lt;/code&gt;, upload it to GitHub Releases, and users download the new version manually. Much simpler than the &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;deploy pipeline I covered with Scotty and Laravel Envoy&lt;/a&gt;, at least for v1.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Shipped vs What I Cut
&lt;/h2&gt;

&lt;p&gt;Six features were planned and cut before v1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cut feature&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pre-break notifications&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Notification&lt;/code&gt; facade fails from child processes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-updater&lt;/td&gt;
&lt;td&gt;Adds complexity, manual download is fine for now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Onboarding flow&lt;/td&gt;
&lt;td&gt;App is self-explanatory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stats and history charts&lt;/td&gt;
&lt;td&gt;Nice to have, not essential for the core concept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Break snooze&lt;/td&gt;
&lt;td&gt;Undermines the "forced" nature of the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iCloud sync&lt;/td&gt;
&lt;td&gt;Contradicts the fully offline goal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every cut made the app ship faster and the core experience sharper. ForcedBreak does one thing: it covers your screens and makes you do a push-up. Everything else is noise until the core concept is proven.&lt;/p&gt;

&lt;p&gt;This is the same thing I try to apply when building MVPs for clients. You can read more about &lt;a href="https://hafiz.dev/blog/how-to-validate-your-idea-before-spending-eur5000-on-development" rel="noopener noreferrer"&gt;how to validate an idea before spending thousands on development&lt;/a&gt;, but the short version is: ship the smallest version that tests the assumption. Features can always be added once someone is using it.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl1im30v4t71eh3r0jqmu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl1im30v4t71eh3r0jqmu.webp" width="747" height="968"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ForcedBreak v1.0.0 is live now. If you're on an Apple Silicon Mac and want to try it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/hzeeshan/forcedbreak/releases" rel="noopener noreferrer"&gt;Download ForcedBreak v1.0.0 for Apple Silicon →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/hzeeshan/forcedbreak" rel="noopener noreferrer"&gt;source code is on GitHub&lt;/a&gt; under MIT. If you find it useful, a star helps others discover it.&lt;/p&gt;

&lt;p&gt;For v2, the list is short: get &lt;code&gt;Notification&lt;/code&gt; working before breaks (still investigating the child process bridge), add a streak history chart, and build an Intel (x64) version for older Macs.&lt;/p&gt;

&lt;p&gt;If you're debugging a stuck NativePHP app, this is the workflow that solved most of my problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the NativePHP database has tables: &lt;code&gt;sqlite3 ~/Library/Application\ Support/{app-id}-dev/database/database.sqlite ".tables"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Test any NativePHP facade from a web route before using it in a child process&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;curl http://127.0.0.1:8100/dev/force-break&lt;/code&gt; to trigger events in the live app, never &lt;code&gt;php artisan tinker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;storage/app/server_url.txt&lt;/code&gt; exists and has the correct port&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;&lt;strong&gt;Does ForcedBreak work on Intel Macs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not yet. The current &lt;code&gt;.dmg&lt;/code&gt; is arm64 only (Apple Silicon). An x64 build is on the v2 roadmap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can NativePHP build Windows apps?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;NativePHP 1.x supports macOS and Linux via Electron. Windows support exists but is less tested in the community. The facades and APIs work the same way in theory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just build this in Swift?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you already know Swift, that's valid. NativePHP's advantage is zero context switching. You stay in Laravel, use Eloquent, use the cache, write Blade. For a PHP developer who wants to ship a real desktop app without learning a new language and toolchain, it's the right call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How large is the final .dmg?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;136 MB. That's almost entirely Electron. The actual Laravel app is small, around 500 lines of PHP across models, commands, and Livewire components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if a user disables all challenges?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The app falls back to the full built-in challenge list. You can disable individual challenges, but the overlay always has something to show. No empty state.&lt;/p&gt;

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

&lt;p&gt;NativePHP is production-ready for focused desktop apps. You don't need Swift. You don't need Objective-C. If you know Laravel, you can ship something real.&lt;/p&gt;

&lt;p&gt;The hard part isn't the UI. It's understanding the web context versus the child process context, and what that means for which APIs are available to you. Once that distinction is clear, everything else is just Laravel.&lt;/p&gt;

&lt;p&gt;ForcedBreak is &lt;a href="https://github.com/hzeeshan/forcedbreak/releases" rel="noopener noreferrer"&gt;available to download on GitHub&lt;/a&gt;. If you're building something with NativePHP and hit a wall, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>nativephp</category>
      <category>livewire</category>
      <category>macos</category>
    </item>
    <item>
      <title>Laravel Policies vs Gates: The Complete Authorization Guide</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 15 Apr 2026 05:26:28 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-policies-vs-gates-the-complete-authorization-guide-3bj9</link>
      <guid>https://dev.to/hafiz619/laravel-policies-vs-gates-the-complete-authorization-guide-3bj9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-policies-vs-gates-authorization-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Authentication tells Laravel who you are. Authorization tells Laravel what you're allowed to do. Most developers get authentication right from day one. Laravel's starter kits handle it. Authorization is the part that quietly goes wrong. Rules end up scattered across controllers, Blade files, and middleware, duplicated in three places, and inconsistently applied. One controller checks a Gate. Another skips the check entirely. A third checks directly against a column value inline. After a year of that, nobody knows for certain whether a given action is actually protected.&lt;/p&gt;

&lt;p&gt;Laravel solves this with two tools: Gates and Policies. They look similar, they work differently, and knowing which to reach for saves you from that maintenance mess. This guide covers both: what they do, when to use each, how to wire them up correctly, and the specific mistakes worth avoiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gates vs Policies: The One-Sentence Version
&lt;/h2&gt;

&lt;p&gt;Gates are closures for authorization checks that don't belong to a model. Policies are classes that organize authorization logic around a specific model. That's the whole distinction. The rest is just details.&lt;/p&gt;

&lt;p&gt;The Laravel docs use a good analogy: Gates are to routes as Policies are to controllers. A Gate is a quick closure you define in &lt;code&gt;AppServiceProvider&lt;/code&gt;. A Policy is a dedicated class with methods for every action a user might take against a resource. When your app is small, Gates feel faster. When your app grows, Policies scale much better because the logic is organized, testable in isolation, and easy to find when something needs to change.&lt;/p&gt;

&lt;p&gt;You don't have to pick one. Most real applications use both. Gates for cross-cutting concerns like "can this user access the admin panel", Policies for resource-specific logic like "can this user edit this post". The decision tree is simple: if the check involves an Eloquent model, use a Policy. If it doesn't, a Gate is probably fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gates: Quick Authorization Without a Model
&lt;/h2&gt;

&lt;p&gt;Define Gates in the &lt;code&gt;boot()&lt;/code&gt; method of &lt;code&gt;App\Providers\AppServiceProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Gate&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;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view-admin-dashboard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;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;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manage-settings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;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;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'super-admin'&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;Laravel automatically injects the authenticated user as the first argument. You never pass it manually. Then anywhere in your application you check it with &lt;code&gt;Gate::allows()&lt;/code&gt; or &lt;code&gt;Gate::denies()&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\Gate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// In a controller&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;denies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view-admin-dashboard'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Throw automatically if denied&lt;/span&gt;
&lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manage-settings'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Blade templates, &lt;code&gt;@can&lt;/code&gt; and &lt;code&gt;@cannot&lt;/code&gt; do the same job without touching PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@can('view-admin-dashboard')
    &amp;lt;a href="/admin"&amp;gt;Admin Panel&amp;lt;/a&amp;gt;
@endcan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When to use Gates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authorization checks that aren't tied to a specific Eloquent model&lt;/li&gt;
&lt;li&gt;Global permissions like admin access, beta feature toggles, or subscription tier checks&lt;/li&gt;
&lt;li&gt;Simple one-off checks that would be over-engineered as a full Policy class&lt;/li&gt;
&lt;li&gt;Cross-cutting checks that apply across multiple models or resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;When not to use Gates:&lt;/strong&gt; The moment you find yourself passing a model instance to a Gate definition, stop. That's a Policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policies: Authorization Organized Around a Model
&lt;/h2&gt;

&lt;p&gt;A Policy is a class with one method per action a user can take on a resource. Generate 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:policy PostPolicy &lt;span class="nt"&gt;--model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--model&lt;/code&gt; flag populates the class with the standard methods: &lt;code&gt;viewAny&lt;/code&gt;, &lt;code&gt;view&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, &lt;code&gt;restore&lt;/code&gt;, and &lt;code&gt;forceDelete&lt;/code&gt;. You fill in the logic:&lt;br&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\Policies&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Auth\Access\Response&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;PostPolicy&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;viewAny&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;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// any authenticated user can list posts&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;published&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;create&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;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email_verified_at&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_admin&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;Notice &lt;code&gt;viewAny&lt;/code&gt; and &lt;code&gt;create&lt;/code&gt; don't take a &lt;code&gt;Post&lt;/code&gt; instance. There's no specific post to check against yet. &lt;code&gt;view&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, and &lt;code&gt;delete&lt;/code&gt; do, because they operate on a specific model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering Policies: Three Ways in Laravel 13
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Auto-discovery (Laravel 13 default):&lt;/strong&gt; If your &lt;code&gt;PostPolicy&lt;/code&gt; lives in &lt;code&gt;app/Policies/&lt;/code&gt; and your &lt;code&gt;Post&lt;/code&gt; model is in &lt;code&gt;app/Models/&lt;/code&gt;, Laravel finds the connection automatically through naming conventions. You don't register anything. This is the default for new Laravel 13 projects and works for the vast majority of cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual registration in AppServiceProvider:&lt;/strong&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;App\Models\Post&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\Policies\PostPolicy&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\Gate&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;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PostPolicy&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;Use this when the naming convention doesn't match, for example if your policy is named &lt;code&gt;ArticlePolicy&lt;/code&gt; but the model is &lt;code&gt;Post&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;#[UsePolicy]&lt;/code&gt; attribute (Laravel 13):&lt;/strong&gt; You can declare the policy directly on the model class using a PHP attribute:&lt;br&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\Models&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Policies\PostPolicy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Attributes\UsePolicy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[UsePolicy(PostPolicy::class)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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;This is the most explicit option. The relationship between the model and its policy is visible right where the model is defined, without jumping to &lt;code&gt;AppServiceProvider&lt;/code&gt;. It's worth using if your codebase has a lot of non-standard naming, or if you simply value that explicitness. You can find this and all the other &lt;code&gt;make:policy&lt;/code&gt; and related Artisan commands in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Call a Policy
&lt;/h2&gt;

&lt;p&gt;There are four places where you can trigger a policy check. Each has the right use case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. In the controller with &lt;code&gt;Gate::authorize()&lt;/code&gt;:&lt;/strong&gt; This is the most common pattern in Laravel 13. It throws a 403 exception automatically if the check fails:&lt;br&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\Gate&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;update&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;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Only runs if the user passed the policy check&lt;/span&gt;
    &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&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;redirect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts.index'&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;strong&gt;2. On the route with &lt;code&gt;-&amp;gt;can()&lt;/code&gt; middleware:&lt;/strong&gt; Good for protecting an entire route before it even reaches the controller. Works especially well with implicit model binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/posts/{post}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;PostController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'post'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;PostController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'create'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Via the &lt;code&gt;#[Authorize]&lt;/code&gt; attribute on controller methods (Laravel 13):&lt;/strong&gt; Clean and declarative if you're already using PHP attributes on your controllers:&lt;br&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\Routing\Attributes\Controllers\Authorize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Authorize('update', 'post')]&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&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;&lt;strong&gt;4. The &lt;code&gt;authorizeResource()&lt;/code&gt; shortcut for resource controllers:&lt;/strong&gt; One call in the constructor wires up authorization for all seven resource methods automatically. This is the most efficient option when you have a full resource controller paired with a matching Policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&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;authorizeResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'post'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This maps &lt;code&gt;index&lt;/code&gt; → &lt;code&gt;viewAny&lt;/code&gt;, &lt;code&gt;show&lt;/code&gt; → &lt;code&gt;view&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;/&lt;code&gt;store&lt;/code&gt; → &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;/&lt;code&gt;update&lt;/code&gt; → &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;destroy&lt;/code&gt; → &lt;code&gt;delete&lt;/code&gt;. The second argument (&lt;code&gt;'post'&lt;/code&gt;) tells Laravel which route parameter to resolve for model binding. If you have a full resource controller with a corresponding Policy, this is the cleanest option and removes a lot of repeated &lt;code&gt;Gate::authorize()&lt;/code&gt; calls from individual methods.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One setup step required in Laravel 11+:&lt;/strong&gt; The slim base controller in Laravel 11, 12, and 13 no longer includes &lt;code&gt;authorizeResource()&lt;/code&gt; by default. To use it, add the &lt;code&gt;AuthorizesRequests&lt;/code&gt; trait to your base controller at &lt;code&gt;app/Http/Controllers/Controller.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\Http\Controllers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Auth\Access\AuthorizesRequests&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\Routing\Controller&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;BaseController&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Controller&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseController&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;AuthorizesRequests&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;Once the trait is in place, &lt;code&gt;authorizeResource()&lt;/code&gt; works as expected in any controller that extends &lt;code&gt;Controller&lt;/code&gt;. If you'd rather not touch the base controller, the &lt;code&gt;-&amp;gt;can()&lt;/code&gt; route middleware is the cleanest alternative and requires no trait at all.&lt;/p&gt;

&lt;p&gt;This maps &lt;code&gt;index&lt;/code&gt; → &lt;code&gt;viewAny&lt;/code&gt;, &lt;code&gt;show&lt;/code&gt; → &lt;code&gt;view&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;/&lt;code&gt;store&lt;/code&gt; → &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;/&lt;code&gt;update&lt;/code&gt; → &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;destroy&lt;/code&gt; → &lt;code&gt;delete&lt;/code&gt;. The second argument (&lt;code&gt;'post'&lt;/code&gt;) tells Laravel which route parameter to resolve for model binding. If you have a full resource controller with a corresponding Policy, this is the cleanest option and removes a lot of repeated &lt;code&gt;Gate::authorize()&lt;/code&gt; calls from individual methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Policy &lt;code&gt;before()&lt;/code&gt; Method: Super-Admin Shortcut
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;before()&lt;/code&gt; method runs before every other method in a Policy. Use it to grant blanket access without touching every individual 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;before&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;string&lt;/span&gt; &lt;span class="nv"&gt;$ability&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_super_admin&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="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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// null means "proceed to the normal method"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;true&lt;/code&gt; grants access immediately. Returning &lt;code&gt;false&lt;/code&gt; denies it immediately. Returning &lt;code&gt;null&lt;/code&gt; (or not returning anything) falls through to the individual policy method. This pattern keeps admin bypass logic in one place instead of scattered across every method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; &lt;code&gt;before()&lt;/code&gt; is on the Policy, not the Gate. A similar &lt;code&gt;Gate::before()&lt;/code&gt; exists at the Gate level and intercepts all Gate and Policy checks globally. Use &lt;code&gt;Gate::before()&lt;/code&gt; sparingly. It applies everywhere, which can create confusing behaviour if a check somewhere expects a denial but a global &lt;code&gt;before()&lt;/code&gt; keeps returning &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Returning Rich Responses Instead of Booleans
&lt;/h2&gt;

&lt;p&gt;Policy methods don't have to return bare booleans. You can return a &lt;code&gt;Response&lt;/code&gt; object that includes a denial message. This is useful for APIs where the client needs to know why a request was rejected:&lt;br&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\Auth\Access\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
        &lt;span class="o"&gt;?&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;allow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;:&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;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'You do not own this post.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When using &lt;code&gt;Gate::inspect()&lt;/code&gt; instead of &lt;code&gt;Gate::authorize()&lt;/code&gt;, you can retrieve the full response including the message:&lt;br&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="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;denied&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;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pairs well with &lt;a href="https://hafiz.dev/blog/laravel-api-development-restful-best-practices" rel="noopener noreferrer"&gt;Laravel REST API&lt;/a&gt; patterns where the client needs to display the reason for rejection, not just a generic 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization in Blade
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;@can&lt;/code&gt; and &lt;code&gt;@cannot&lt;/code&gt; directives work with both Gates and Policies. For Policies, pass the model instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- Gate check --}}
@can('view-admin-dashboard')
    &amp;lt;a href="/admin"&amp;gt;Admin&amp;lt;/a&amp;gt;
@endcan

{{-- Policy check with model --}}
@can('update', $post)
    &amp;lt;a href="{{ route('posts.edit', $post) }}"&amp;gt;Edit&amp;lt;/a&amp;gt;
@endcan

@cannot('delete', $post)
    &amp;lt;span class="text-muted"&amp;gt;You can't delete this post&amp;lt;/span&amp;gt;
@endcannot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to remember: Blade directives only hide UI. They don't secure the underlying routes. Always check authorization server-side too. A user could navigate directly to &lt;code&gt;/posts/1/edit&lt;/code&gt; and bypass the hidden button completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization and Inertia: Sharing Policy Data with the Frontend
&lt;/h2&gt;

&lt;p&gt;If you're building with Inertia.js, your Vue or React components often need to know what the current user can do in order to show or hide UI elements. The cleanest pattern is sharing authorization data through Inertia's shared props in &lt;code&gt;HandleInertiaRequests&lt;/code&gt; 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;share&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;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="mf"&gt;...&lt;/span&gt;&lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'can'&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;'create_post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'create'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'manage_settings'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manage-settings'&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;Your frontend then reads &lt;code&gt;$page.props.can.create_post&lt;/code&gt; without making a separate API request. This approach is covered 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; for teams migrating their frontend setup, and it pairs well with the route-level &lt;code&gt;-&amp;gt;can()&lt;/code&gt; middleware handling the actual enforcement server-side.&lt;/p&gt;

&lt;p&gt;Keep the &lt;code&gt;can&lt;/code&gt; object lean. Only share what the current page's UI actually needs. Sharing every possible permission for every resource is wasteful and leaks your authorization surface to the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mistakes Worth Knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Checking authorization only in Blade.&lt;/strong&gt; The most common mistake. Hiding a button doesn't protect the endpoint. Always pair Blade &lt;code&gt;@can&lt;/code&gt; checks with server-side &lt;code&gt;Gate::authorize()&lt;/code&gt; or the &lt;code&gt;can&lt;/code&gt; middleware. A determined user can navigate directly to any URL regardless of what your Blade template shows them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authorizing inside Eloquent models.&lt;/strong&gt; Authorization logic doesn't belong in a model's events or observers. It belongs at the request layer: controller, middleware, or route. By the time a model method runs, the authorization window has passed and you've lost the request context you need to make the check meaningful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One giant Gate definition file.&lt;/strong&gt; If you have 40 &lt;code&gt;Gate::define()&lt;/code&gt; calls in &lt;code&gt;AppServiceProvider&lt;/code&gt;, that's a sign you should be using Policies. Gates are for the handful of non-model checks. Anything model-specific belongs in a Policy class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting &lt;code&gt;viewAny&lt;/code&gt; vs &lt;code&gt;view&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;viewAny&lt;/code&gt; authorizes the &lt;code&gt;index&lt;/code&gt; action: listing all records. &lt;code&gt;view&lt;/code&gt; authorizes reading a specific instance. Developers often only write &lt;code&gt;view&lt;/code&gt; and then wonder why their index route isn't protected. With &lt;code&gt;authorizeResource()&lt;/code&gt;, both get wired up automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calling &lt;code&gt;$this-&amp;gt;authorize()&lt;/code&gt; in Laravel 13 controllers.&lt;/strong&gt; In older Laravel versions, controllers extended a base class that provided a &lt;code&gt;$this-&amp;gt;authorize()&lt;/code&gt; helper method. In Laravel 13's leaner controller structure, the recommended approach is &lt;code&gt;Gate::authorize()&lt;/code&gt; directly. Both work, but &lt;code&gt;Gate::authorize()&lt;/code&gt; doesn't depend on inheriting from the base controller class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Returning false from &lt;code&gt;before()&lt;/code&gt; when you mean to skip.&lt;/strong&gt; The &lt;code&gt;before()&lt;/code&gt; method returns &lt;code&gt;null&lt;/code&gt; to "pass through" to the normal policy method, not &lt;code&gt;false&lt;/code&gt;. Returning &lt;code&gt;false&lt;/code&gt; explicitly denies the action. This trips developers up because the instinct is to return &lt;code&gt;false&lt;/code&gt; when you don't want &lt;code&gt;before()&lt;/code&gt; to take effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Spatie Permission Fits In
&lt;/h2&gt;

&lt;p&gt;Spatie's &lt;code&gt;spatie/laravel-permission&lt;/code&gt; package gives you database-driven roles and permissions rather than hardcoded authorization logic. It's the right addition when your application needs admins to assign and revoke permissions without a code deploy. The package stores roles and permissions in the database, so a super-admin can grant &lt;code&gt;edit-any-post&lt;/code&gt; to the &lt;code&gt;editor&lt;/code&gt; role through a UI without touching any PHP.&lt;/p&gt;

&lt;p&gt;It integrates directly with Policies via the &lt;code&gt;can()&lt;/code&gt; check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// owner can always edit their own post&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;$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="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&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="c1"&gt;// editors with the right permission can edit anything&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit-any-post'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule of thumb: use Gates and Policies for logic that's structural and fixed (owners can edit their own posts, admins can see the dashboard). Add Spatie Permission when the business rules themselves need to be configurable at runtime. The combination of both is covered in depth in the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;complete Laravel + Filament SaaS guide&lt;/a&gt;, which uses role-based access in a real admin panel context.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need to register a Policy manually in Laravel 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, not for standard naming conventions. If your &lt;code&gt;PostPolicy&lt;/code&gt; is in &lt;code&gt;app/Policies/&lt;/code&gt; and your &lt;code&gt;Post&lt;/code&gt; model is in &lt;code&gt;app/Models/&lt;/code&gt;, auto-discovery handles it. Only register manually via &lt;code&gt;Gate::policy()&lt;/code&gt; or &lt;code&gt;#[UsePolicy]&lt;/code&gt; when naming diverges from the convention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between &lt;code&gt;Gate::allows()&lt;/code&gt; and &lt;code&gt;Gate::authorize()&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Gate::allows()&lt;/code&gt; returns a boolean, useful when you want to branch logic. &lt;code&gt;Gate::authorize()&lt;/code&gt; throws an &lt;code&gt;AuthorizationException&lt;/code&gt; (HTTP 403) if denied, which stops execution immediately. Use &lt;code&gt;allows()&lt;/code&gt; for conditional logic, &lt;code&gt;authorize()&lt;/code&gt; for enforcement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a Policy method allow unauthenticated users?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By default, all policy and gate checks return false for unauthenticated requests. To allow guest access on a specific policy method, make the &lt;code&gt;$user&lt;/code&gt; argument nullable:&lt;br&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;published&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="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&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;strong&gt;Can I authorize multiple models in one Policy method?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Pass them as an array to the second argument:&lt;br&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;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$category&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then accept both in the policy 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Category&lt;/span&gt; &lt;span class="nv"&gt;$category&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;managedCategories&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;contains&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where should I put authorization logic for API routes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same place as web routes: controller-level &lt;code&gt;Gate::authorize()&lt;/code&gt; or route-level &lt;code&gt;-&amp;gt;can()&lt;/code&gt; middleware. The &lt;code&gt;can&lt;/code&gt; middleware works identically on API routes. The difference is in the response: a 403 JSON response rather than a redirect, which Laravel handles automatically for requests that accept JSON.&lt;/p&gt;




&lt;p&gt;Authorization done right is invisible. Users can only see and do what they're supposed to, and you never have to think about it again. The mistake is deferring it, scattering checks across controllers, duplicating logic, and ending up with authorization that's inconsistently applied. Starting with Policies from day one, even on a project that feels small, costs almost nothing and pays back every time you add a feature or onboard a developer who needs to understand what the app allows.&lt;/p&gt;

&lt;p&gt;Gates for the non-model checks. Policies for everything tied to an Eloquent model. &lt;code&gt;authorizeResource()&lt;/code&gt; when you have a full resource controller. That's the mental model that keeps a Laravel app secure and maintainable at any scale.&lt;/p&gt;

&lt;p&gt;Building something and not sure how to structure the authorization layer? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt; and I'm happy to take a look.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Laravel Telescope vs Pulse vs Nightwatch: Which Monitoring Tool Do You Actually Need?</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 13 Apr 2026 05:27:38 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-telescope-vs-pulse-vs-nightwatch-which-monitoring-tool-do-you-actually-need-2fig</link>
      <guid>https://dev.to/hafiz619/laravel-telescope-vs-pulse-vs-nightwatch-which-monitoring-tool-do-you-actually-need-2fig</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-telescope-vs-pulse-vs-nightwatch" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Three official monitoring tools. Same team. Zero clear explanation of when to use which one. If you've ever stared at the Laravel docs and thought "okay but which one do I actually need?" You're not alone. This is the post that should have existed when Nightwatch launched.&lt;/p&gt;

&lt;p&gt;The short version: Telescope is for local development debugging, Pulse is for high-level production metrics, and Nightwatch is for full production observability. But that framing alone doesn't help you make a decision. So let's go deeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Each Tool Is Actually Solving
&lt;/h2&gt;

&lt;p&gt;These three tools exist because monitoring is not a single problem. It's three separate problems that happen to sit next to each other:&lt;/p&gt;

&lt;p&gt;During local development, you need to know what your app is doing right now as you build it. You want to see every query, every job, every mail, every notification fired by that last request, in full detail.&lt;/p&gt;

&lt;p&gt;In production, you need two very different things depending on what's going wrong. When you want a health check ("is anything trending badly?"), you need aggregated metrics. When something has already gone wrong and a user is affected, you need the full story of exactly what happened, in what order, and to whom.&lt;/p&gt;

&lt;p&gt;That's three different needs. Telescope solves the first. Pulse solves the second. Nightwatch solves the third.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel Telescope: Your Local Debugging Companion
&lt;/h2&gt;

&lt;p&gt;Telescope is the most mature of the three. It's been in the Laravel ecosystem since 2018 and is free open source. Install it as a dev dependency, visit &lt;code&gt;/telescope&lt;/code&gt; in your browser, and you get a detailed log of everything happening in your app.&lt;/p&gt;

&lt;p&gt;Install it with &lt;code&gt;composer require laravel/telescope --dev&lt;/code&gt;, then run &lt;code&gt;php artisan telescope:install&lt;/code&gt; and &lt;code&gt;php artisan migrate&lt;/code&gt;. Every request, Eloquent query, job dispatch, mail, notification, event, cache operation, and exception shows up in a clean dashboard with the exact SQL, the stack trace, and the timing.&lt;/p&gt;

&lt;p&gt;The thing Telescope does that nothing else does: it shows you the full context of a single request. You can see that &lt;code&gt;GET /dashboard&lt;/code&gt; fired 47 queries, took 340ms, dispatched 2 jobs, and fired 6 events, all in one view, with every detail drillable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hard rule on Telescope: never run it in production.&lt;/strong&gt; It stores every request to your database, which creates serious performance overhead and a potential privacy liability. The official recommendation is to gate it behind an environment check. In &lt;code&gt;AppServiceProvider&lt;/code&gt;, check &lt;code&gt;App::isLocal()&lt;/code&gt; before registering Telescope. Or simply require it with &lt;code&gt;--dev&lt;/code&gt; and it won't be available in production at all.&lt;/p&gt;

&lt;p&gt;Telescope is free. It's the right tool for local development, staging debugging, and CI troubleshooting. It's the wrong tool for production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel Pulse: The Production Health Dashboard
&lt;/h2&gt;

&lt;p&gt;Pulse is newer (it shipped with Laravel 11) and sits at the opposite end of the spectrum from Telescope. Where Telescope is granular and detailed, Pulse is aggregated and high-level. It's built for the "is my app healthy right now?" question.&lt;/p&gt;

&lt;p&gt;Install it with &lt;code&gt;composer require laravel/pulse&lt;/code&gt;, then run &lt;code&gt;php artisan pulse:install&lt;/code&gt; and &lt;code&gt;php artisan migrate&lt;/code&gt;. Visit &lt;code&gt;/pulse&lt;/code&gt; and you'll see a dashboard showing CPU usage, memory, slow queries, slow routes, failed jobs, queue depth, and cache hit rates, all as aggregated metrics over a configurable time window.&lt;/p&gt;

&lt;p&gt;Pulse is free open source and runs inside your own infrastructure with zero external dependencies. Everything is stored in your own database. There's no external service, no SaaS account, no event quota. You own all the data.&lt;/p&gt;

&lt;p&gt;The limitation is what "aggregated" means in practice. Pulse will tell you that &lt;code&gt;/api/orders&lt;/code&gt; has a P95 response time of 800ms over the last hour. It won't tell you which specific request hit 2,400ms, which user triggered it, what queries ran during that request, or how those queries relate to each other. It gives you the trend. It doesn't give you the incident.&lt;/p&gt;

&lt;p&gt;Pulse also requires &lt;code&gt;php artisan pulse:work&lt;/code&gt; running as a daemon to process its queue. This is a lightweight process, but it's something to account for in your Supervisor configuration. Alternatively you can use the &lt;code&gt;inline&lt;/code&gt; driver for lower-traffic applications that don't need the separate daemon.&lt;/p&gt;

&lt;p&gt;Pulse is the right tool for a free production health dashboard when you want to spot trends and anomalies at a glance. It's the wrong tool when you need to investigate a specific incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel Nightwatch: Full Production Observability
&lt;/h2&gt;

&lt;p&gt;Nightwatch launched in June 2025 and fills the gap that Telescope and Pulse don't cover: you know something is wrong in production, and you need to understand exactly what happened.&lt;/p&gt;

&lt;p&gt;Where Pulse shows you aggregates, Nightwatch keeps every individual event. Where Telescope only works locally, Nightwatch is designed specifically for production. It connects the dots between the request, the user, the queries, the jobs, the exceptions, and the timeline, all in one view.&lt;/p&gt;

&lt;p&gt;Install the package with &lt;code&gt;composer require laravel/nightwatch&lt;/code&gt;. Add your &lt;code&gt;NIGHTWATCH_TOKEN&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt;. Then start the agent:&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 nightwatch:agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent runs as a sidecar process, buffering events locally and sending them to Nightwatch's servers roughly every 10 seconds. The overhead is minimal, under 3ms per request per the official documentation. On Forge, the agent setup is a one-click daemon. On Cloud, it runs automatically.&lt;/p&gt;

&lt;p&gt;What Nightwatch tracks out of the box, with no configuration: HTTP requests with full timing and user context, exceptions with stack traces and affected user counts, Eloquent queries with their source, queue jobs with execution time and retry history, outgoing HTTP requests, mail, notifications, scheduled tasks, cache operations, and Artisan commands.&lt;/p&gt;

&lt;p&gt;The pricing structure: the free plan covers 200,000 events per month, no limits on apps or environments, with 14-day reporting. The Pro plan starts at $20/month for 5M events, 30-day reporting, and unlimited seats. There are Team and Business tiers for higher volumes.&lt;/p&gt;

&lt;p&gt;The key difference from Pulse in practice: when a user reports that their checkout timed out at 11:47pm last Tuesday, Nightwatch can show you exactly that request. The route, the user, the 14 queries it ran, which one took 1,800ms, the job it dispatched, whether that job succeeded. Pulse can show you that Tuesday night had elevated P95 response times. That's the difference between a metric and an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One architectural note:&lt;/strong&gt; Nightwatch requires the sidecar agent to be running continuously. On serverless deployments like Laravel Vapor, you'll need a separate VM to run the agent. This isn't a dealbreaker, but it's something to plan for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Pick the Right Tool
&lt;/h2&gt;

&lt;p&gt;The decision tree is simpler than it looks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-telescope-vs-pulse-vs-nightwatch" 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;A few real-world scenarios to make this concrete:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solo developer, side project, no budget:&lt;/strong&gt; Install Telescope for local development. Add Pulse to production. You get free debugging locally and a free health dashboard in production. You'll be flying blind during incidents, but for a low-traffic side project that's an acceptable trade-off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small team, SaaS product with paying customers:&lt;/strong&gt; Add Nightwatch on the free plan. 200k events per month is enough for most early-stage SaaS applications, especially with sampling enabled. The moment a customer reports an issue, you'll have the data to investigate it. This is where "free but I'll get it wrong" becomes "I found the bug before the customer gave up."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Established product with meaningful traffic:&lt;/strong&gt; Run all three. Telescope locally, Pulse for the quick health dashboard, Nightwatch for incident investigation and production debugging. They're complementary, not competing. The Nightwatch free plan covers a surprisingly large amount of traffic if you set sampling to 10-20% on high-volume endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can You Use All Three Together?
&lt;/h2&gt;

&lt;p&gt;Yes, and the official guidance confirms it. Laravel will continue supporting Telescope and Pulse regardless of Nightwatch's growth. The tools overlap only superficially. Their actual use cases are distinct enough that running all three makes sense for any production application you care about.&lt;/p&gt;

&lt;p&gt;The practical setup for a team running all three:&lt;/p&gt;

&lt;p&gt;Telescope is a dev dependency, so it only exists locally and in staging. Pulse and Nightwatch both live in production. Pulse runs &lt;code&gt;php artisan pulse:work&lt;/code&gt; as a supervised daemon for metric aggregation. Nightwatch runs &lt;code&gt;php artisan nightwatch:agent&lt;/code&gt; as a supervised daemon for event collection. The two agents don't interfere with each other.&lt;/p&gt;

&lt;p&gt;One thing to do immediately if you add Nightwatch: disable it in your test environment. Add &lt;code&gt;NIGHTWATCH_ENABLED=false&lt;/code&gt; to your &lt;code&gt;phpunit.xml&lt;/code&gt; or test &lt;code&gt;.env&lt;/code&gt;. You don't want test runs burning through your event quota.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Telescope&lt;/th&gt;
&lt;th&gt;Pulse&lt;/th&gt;
&lt;th&gt;Nightwatch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (open source)&lt;/td&gt;
&lt;td&gt;Free (open source)&lt;/td&gt;
&lt;td&gt;Free plan + from $20/mo (Pro)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Environment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local / staging only&lt;/td&gt;
&lt;td&gt;Production-safe&lt;/td&gt;
&lt;td&gt;Production-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Installation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Composer package&lt;/td&gt;
&lt;td&gt;Composer package&lt;/td&gt;
&lt;td&gt;Composer package + cloud account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What it tracks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every request, query, job, mail, notification, dump&lt;/td&gt;
&lt;td&gt;Aggregated metrics: slow queries, CPU, queue depth, cache&lt;/td&gt;
&lt;td&gt;Individual requests, exceptions, jobs, queries, outgoing HTTP, scheduler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data granularity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Individual request-level detail&lt;/td&gt;
&lt;td&gt;Sampled aggregates over time windows&lt;/td&gt;
&lt;td&gt;Every individual event with full context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Alerting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Yes: Slack, Linear, webhooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hosted vs cloud&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;Cloud (SaaS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Debugging during development&lt;/td&gt;
&lt;td&gt;Production health dashboard&lt;/td&gt;
&lt;td&gt;Investigating production incidents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Setting Up Nightwatch on Laravel Forge (The Quickest Path)
&lt;/h2&gt;

&lt;p&gt;If you're on Forge, Nightwatch has a one-click integration. In your Forge site settings, find the Nightwatch section, enter your environment token, and Forge automatically creates the supervisor daemon, restarts it if it crashes, and wires up the environment variables. The whole thing takes about 90 seconds.&lt;/p&gt;

&lt;p&gt;For a non-Forge server, add the Supervisor config manually. Create &lt;code&gt;/etc/supervisor/conf.d/nightwatch.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:nightwatch]&lt;/span&gt;
&lt;span class="py"&gt;process_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%(program_name)s&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /var/www/your-app/artisan nightwatch:agent&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;www-data&lt;/span&gt;
&lt;span class="py"&gt;redirect_stderr&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;stdout_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/www/your-app/storage/logs/nightwatch.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run &lt;code&gt;sudo supervisorctl reread &amp;amp;&amp;amp; sudo supervisorctl update &amp;amp;&amp;amp; sudo supervisorctl start nightwatch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the same pattern you'd use for Horizon or any other long-running Laravel process. If you're already running Horizon for your queues, adding Nightwatch follows the exact same playbook. The &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue jobs guide&lt;/a&gt; covers this Supervisor setup in detail if you need a full walkthrough.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is Nightwatch worth it if I'm already using Sentry?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sentry is excellent at exception tracking specifically. It won't give you slow query data, queue depth, cache miss rates, or scheduler health. Nightwatch gives you the full picture including exceptions. They can run side by side if you want Sentry's issue tracking workflow, but Nightwatch alone covers more ground.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Pulse replace Telescope?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Pulse is for production metrics. Telescope is for local debugging. They serve completely different purposes and run in different environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Nightwatch run on shared hosting?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Nightwatch agent requires a long-running process, which most shared hosts don't support. You need a VPS, a Forge-managed server, Laravel Cloud, or a container environment. If you're on shared hosting, Pulse is your only production monitoring option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What counts as an "event" in Nightwatch's quota?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each of these counts as one event: an HTTP request, an outgoing HTTP request, a queue job execution, an Eloquent query, a mail send, a notification dispatch, a scheduled task run, a cache operation, an Artisan command run, and an exception. On a typical request that fires 12 queries, that's 13 events (1 request + 12 queries). Use the sampling configuration to manage quota on high-traffic endpoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I disable Nightwatch in local development?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can run it locally with a dedicated development environment in your Nightwatch account, but it will burn through your free quota unnecessarily. Set &lt;code&gt;NIGHTWATCH_ENABLED=false&lt;/code&gt; in your local &lt;code&gt;.env&lt;/code&gt; and use Telescope locally instead. That's what it's built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nightwatch in Practice: Where It Earns Its Keep
&lt;/h2&gt;

&lt;p&gt;The tools that benefit most from Nightwatch in production are the ones with the most moving parts. If you're running a &lt;a href="https://hafiz.dev/blog/laravel-api-development-restful-best-practices" rel="noopener noreferrer"&gt;Laravel REST API&lt;/a&gt; with multiple consumers, Nightwatch shows you which endpoints are slow for which users, and which outgoing HTTP calls to third-party services are dragging response times down.&lt;/p&gt;

&lt;p&gt;If you're building admin-heavy SaaS applications with Filament, Nightwatch is especially useful. Admin panels tend to run complex Eloquent queries against large datasets, and the N+1 issues that are invisible in development with a handful of records become obvious with real production data. The &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;complete Laravel + Filament SaaS guide&lt;/a&gt; covers the kind of query complexity that benefits most from production observability. When you can see in Nightwatch that your Filament resource list is firing 60 queries per page load on a table with 10,000 rows, you know exactly where to add eager loading and which relationship to optimise first.&lt;/p&gt;




&lt;p&gt;The most common mistake Laravel developers make with monitoring is treating it as binary: either you have it or you don't. The reality is that Telescope, Pulse, and Nightwatch solve three different problems at three different points in the development lifecycle. Start with Telescope locally. Add Pulse to production for free. Add Nightwatch when you have users who will notice when something breaks.&lt;/p&gt;

&lt;p&gt;Building a Laravel app and not sure which of these fits where you are right now? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt; and I'm happy to talk through your setup.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>nightwatch</category>
      <category>telescope</category>
      <category>devops</category>
    </item>
    <item>
      <title>Laravel Pest 4 Testing: The Complete Guide for Laravel 13 Developers</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Fri, 10 Apr 2026 06:28:47 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-pest-4-testing-the-complete-guide-for-laravel-13-developers-4i2a</link>
      <guid>https://dev.to/hafiz619/laravel-pest-4-testing-the-complete-guide-for-laravel-13-developers-4i2a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you've shipped 10, 20, even 50 Laravel projects and never written a single test, you're not alone. Testing in PHP has a reputation for being verbose and a bit joyless. Then Pest came along and made it something developers actually reach for. With Pest 4 and Laravel 13, there's no good reason to keep putting it off.&lt;/p&gt;

&lt;p&gt;This guide builds a real tested feature from scratch: an authenticated REST API for blog posts. By the end you'll have feature tests, validation tests using datasets, and arch tests running in parallel. No toy examples. Just the exact setup I'd use in a production Laravel 13 app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Pest Instead of PHPUnit
&lt;/h2&gt;

&lt;p&gt;PHPUnit is the standard. It works. But look at what a basic PHPUnit test actually looks like:&lt;br&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;PostTest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;TestCase&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;test_unauthenticated_user_cannot_access_posts&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&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;Now look at the Pest equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blocks unauthenticated users from the posts endpoint'&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="nf"&gt;getJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertUnauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same result. Half the code. It reads like a sentence, which matters more than you'd think when you're scanning a failing test suite at 11pm trying to figure out what broke.&lt;/p&gt;

&lt;p&gt;But syntax isn't the only reason to switch. The expectation API is where Pest really stands out. Instead of scattered &lt;code&gt;assertEquals&lt;/code&gt; calls with arguments in the wrong order (expected or actual first? nobody ever remembers), you write assertions that chain left to right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Hafiz'&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;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toHaveCount&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reads the way you think about the assertion. The value comes first, the expectation follows. It's a small change that compounds across hundreds of tests and makes the suite much easier to scan at a glance.&lt;/p&gt;

&lt;p&gt;Pest 4 runs on PHPUnit 12 under the hood. It's not a different framework. It's a better interface to the same machinery. You can mix Pest and PHPUnit test classes in the same project, so there's no big bang migration. You start using it today on new tests, convert old files when you touch them, and the existing ones keep working.&lt;/p&gt;

&lt;p&gt;Pest was created by Nuno Maduro, a Laravel core team member, so the Laravel integration is first-class from the start. &lt;code&gt;actingAs()&lt;/code&gt;, &lt;code&gt;RefreshDatabase&lt;/code&gt;, HTTP assertions, Livewire testing, Filament testing. Everything works exactly as you'd expect, with no adapter layer in the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Pest 4 in a Laravel 13 Project
&lt;/h2&gt;

&lt;p&gt;Requirements: PHP 8.3+ and any of Laravel 11, 12, or 13. The pest-plugin-laravel package supports all three versions.&lt;/p&gt;

&lt;p&gt;Start by swapping out PHPUnit. The &lt;code&gt;--with-all-dependencies&lt;/code&gt; flag handles any shared dependency resolution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer remove phpunit/phpunit
composer require pestphp/pest &lt;span class="nt"&gt;--dev&lt;/span&gt; &lt;span class="nt"&gt;--with-all-dependencies&lt;/span&gt;
composer require pestphp/pest-plugin-laravel &lt;span class="nt"&gt;--dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then initialise Pest in your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest &lt;span class="nt"&gt;--init&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;tests/Pest.php&lt;/code&gt;, the central configuration file for your entire test suite. You can find a full list of Pest-related Artisan commands alongside all the other generator commands in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;./vendor/bin/pest&lt;/code&gt; and you'll see the two example tests that come with a fresh Laravel install passing immediately under Pest syntax. That's it for setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure Pest.php Before Writing Anything
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;tests/Pest.php&lt;/code&gt; file is where global configuration lives. Set it up before writing your first test and you'll avoid repeating boilerplate across every file.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Testing\RefreshDatabase&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;Tests\TestCase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestCase&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;RefreshDatabase&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;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Feature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestCase&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;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unit'&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;RefreshDatabase&lt;/code&gt; wraps each test in a database transaction and rolls it back when the test finishes. Your database starts clean for every test. Because it's configured in &lt;code&gt;Pest.php&lt;/code&gt;, you never have to declare it in individual test files.&lt;/p&gt;

&lt;p&gt;You can also define reusable helper functions here that any test can use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;authenticatedUser&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;$attributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kt"&gt;User&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;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attributes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then anywhere in your feature tests: &lt;code&gt;actingAs(authenticatedUser())&lt;/code&gt;. One line, no repetition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Feature We're Testing
&lt;/h2&gt;

&lt;p&gt;For this tutorial we're building a simple posts API. Two endpoints: list posts, create a post. Both require Sanctum authentication.&lt;/p&gt;

&lt;p&gt;The controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Controllers/Api/PostController.php&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$posts&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;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;StorePostRequest&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;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&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;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routes:&lt;br&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;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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;PostController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;PostController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The form request validates incoming data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Requests/StorePostRequest.php&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;rules&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;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:255'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of setup you'll find in almost any &lt;a href="https://hafiz.dev/blog/laravel-api-development-restful-best-practices" rel="noopener noreferrer"&gt;Laravel REST API&lt;/a&gt;. Simple enough to follow clearly, realistic enough to cover the patterns that actually come up in production work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing Your First Feature Tests
&lt;/h2&gt;

&lt;p&gt;Create the test 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 make:test Api/PostTest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;Pest.php&lt;/code&gt; already applies the test case and &lt;code&gt;RefreshDatabase&lt;/code&gt;, your test file stays completely focused:&lt;br&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;App\Models\Post&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\User&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 unauthenticated users from listing posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;getJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertUnauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blocks unauthenticated users from creating posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&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;assertUnauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'returns all posts for an authenticated user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertJsonCount&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;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'creates a post with valid data'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'How I Finally Shipped My SaaS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'It took three rewrites and one epiphany.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertCreated&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertJsonFragment&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'How I Finally Shipped My SaaS'&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="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="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;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth highlighting here. No class, no &lt;code&gt;public function&lt;/code&gt;, no &lt;code&gt;$this&lt;/code&gt; for assertions. The HTTP helpers (&lt;code&gt;actingAs()&lt;/code&gt;, &lt;code&gt;getJson()&lt;/code&gt;, &lt;code&gt;postJson()&lt;/code&gt;) come in globally through the Pest Laravel plugin.&lt;/p&gt;

&lt;p&gt;Use named assertions over status codes. &lt;code&gt;assertUnauthorized()&lt;/code&gt; tells you what the test expects. &lt;code&gt;assertStatus(401)&lt;/code&gt; tells you a number. Both pass on a 401 response, but only one communicates intent to the developer reading it six months later. The same logic applies to &lt;code&gt;assertOk()&lt;/code&gt;, &lt;code&gt;assertCreated()&lt;/code&gt;, and &lt;code&gt;assertUnprocessable()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;expect(Post::count())-&amp;gt;toBe(1)&lt;/code&gt; line at the end is important. It verifies the database was actually written to, not just that the response body looked right. HTTP assertions alone don't prove persistence happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Deserves a Test
&lt;/h2&gt;

&lt;p&gt;New developers often freeze on this question. The answer isn't about percentages. It's about behaviour.&lt;/p&gt;

&lt;p&gt;Test the things users would notice if they broke. Authentication, authorisation, validation, business logic, queue jobs. If a bug in that code would cause a user to see wrong data, access something they shouldn't, or submit something they shouldn't be able to, it needs a test. That's a reliable filter.&lt;/p&gt;

&lt;p&gt;Three categories always worth covering:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication and authorisation.&lt;/strong&gt; Write tests that prove unauthenticated requests get rejected and that authorised users only access what they're allowed to. This is the most common source of security issues in web apps, and the easiest thing to accidentally break during a refactor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation rules.&lt;/strong&gt; Form requests with rules need tests, especially for edge cases: empty strings, strings that are one character over the limit, missing required fields. Datasets make this practical without multiplying boilerplate, as you'll see shortly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business logic in service classes or actions.&lt;/strong&gt; If you've extracted logic out of a controller into a dedicated class because it's complex, that class deserves unit tests. The complexity is exactly what makes it risky to change without coverage.&lt;/p&gt;

&lt;p&gt;What you can skip: Laravel itself, which is tested thoroughly by its own team. Simple Eloquent accessors that just return a property. One-line controller methods that delegate entirely to a service.&lt;/p&gt;

&lt;p&gt;The highest-value tests in a Laravel app are feature-level HTTP tests. One test that calls &lt;code&gt;actingAs($user)-&amp;gt;postJson(...)&lt;/code&gt; simultaneously exercises the route, middleware, controller, form request, model, and database layer. That's significant coverage for one test function. Start there before reaching for unit tests on individual methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared Setup with beforeEach
&lt;/h2&gt;

&lt;p&gt;When multiple tests in the same file need the same starting state, use &lt;code&gt;beforeEach()&lt;/code&gt; rather than repeating setup code in every test:&lt;br&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;App\Models\Post&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\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;beforeEach&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="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="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;'returns posts for the authenticated user'&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;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertJsonCount&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="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;'creates a post'&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="nf"&gt;actingAs&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;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;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/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;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Test Post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'body'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Test body.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertCreated&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="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="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;1&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;beforeEach()&lt;/code&gt; runs before every test in the file. State set inside it is available on &lt;code&gt;$this&lt;/code&gt;. This keeps tests short and focused: each one only shows what's unique about that particular assertion. The shared setup disappears into the background where it belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Datasets for Validation Testing
&lt;/h2&gt;

&lt;p&gt;This is the feature that makes validation testing actually maintainable at scale. PHPUnit has data providers that do the same thing, but Pest's syntax is significantly cleaner and the test output is far more readable.&lt;/p&gt;

&lt;p&gt;Instead of writing a separate test function for every invalid input combination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rejects invalid post data'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;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;string&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/posts'&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertUnprocessable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertJsonValidationErrors&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$field&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;with&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'missing title'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Some body text'&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;'missing body'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Some title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'empty title'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Some body text'&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;'title too long'&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;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str_repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Some body text'&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="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pest runs this four times, once per dataset entry. The named keys ('missing title', 'missing body', and so on) appear directly in the test output, so failures are immediately obvious without digging through a stack trace. You wrote one function. You got four test cases.&lt;/p&gt;

&lt;p&gt;For datasets you'll reuse across multiple files, extract them into &lt;code&gt;tests/Datasets/Posts.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="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invalid_post_payloads'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'missing title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Some body text'&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;'missing body'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Some title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'empty title'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Some body text'&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="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reference by name from any test file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rejects invalid post data'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;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;string&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;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;'invalid_post_payloads'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern works well in larger SaaS applications where the same validation rules appear across multiple endpoints. If you're building something with significant admin-side data management, this kind of test organisation pays off fast. The &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;complete Laravel + Filament SaaS guide&lt;/a&gt; shows the kind of app complexity where shared datasets start to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Testing: The Feature Nobody Covers
&lt;/h2&gt;

&lt;p&gt;Most Pest tutorials skip this section entirely. That's a shame because it's one of the most valuable things in the whole framework.&lt;/p&gt;

&lt;p&gt;Arch tests don't test your application's behaviour. They test its structure. Think of them as automated code review that runs on every commit. You define the rules once and Pest enforces them permanently, without anyone needing to remember to check.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;tests/Arch/AppTest.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="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'no debug statements left in the codebase'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toUse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'dd'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'dump'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'var_dump'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ray'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'models extend Eloquent'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Models'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toExtend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Illuminate\Database\Eloquent\Model'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeClasses&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'jobs implement ShouldQueue'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Jobs'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toImplement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Illuminate\Contracts\Queue\ShouldQueue'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'controllers stay in the HTTP layer'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Http\Controllers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toOnlyBeUsedIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Http'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'strict types declared everywhere'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toUseStrictTypes&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;dd&lt;/code&gt;/&lt;code&gt;dump&lt;/code&gt; rule catches more than you'd expect. There's always one lurking in a big codebase. Pest finds it instantly and names the exact file and line number. The &lt;code&gt;toUseStrictTypes()&lt;/code&gt; rule is the unforgiving one: any file in &lt;code&gt;App&lt;/code&gt; missing &lt;code&gt;declare(strict_types=1)&lt;/code&gt; breaks the build immediately.&lt;/p&gt;

&lt;p&gt;The jobs rule pairs naturally with proper queue architecture. If you're processing background jobs at scale, like the patterns covered in the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue jobs guide&lt;/a&gt;, this arch test makes sure nothing enters the &lt;code&gt;Jobs&lt;/code&gt; folder without implementing &lt;code&gt;ShouldQueue&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can also reach for Pest's built-in presets that bundle sensible defaults into a single 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="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;php&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;security&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ignoring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'md5'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;laravel&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;security&lt;/code&gt; preset catches &lt;code&gt;eval()&lt;/code&gt;, &lt;code&gt;system()&lt;/code&gt;, &lt;code&gt;shell_exec()&lt;/code&gt;, and other calls you never want in production code. The &lt;code&gt;laravel&lt;/code&gt; preset enforces framework conventions like keeping Facades out of domain classes. The &lt;code&gt;php&lt;/code&gt; preset handles &lt;code&gt;strict_types&lt;/code&gt;, suspicious characters in strings, and other language-level concerns.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-&amp;gt;ignoring()&lt;/code&gt; modifier lets you exclude specific namespaces when a rule doesn't apply to your context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;security&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ignoring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Services\LegacyBridge'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of the presets are all-or-nothing. Use what fits, skip what doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Tests in Parallel
&lt;/h2&gt;

&lt;p&gt;One flag changes the runtime dramatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest &lt;span class="nt"&gt;--parallel&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pest distributes the suite across multiple processes and runs them simultaneously. On a typical Laravel app with 50-100 tests, this cuts runtime roughly in half. The savings compound as the suite grows.&lt;/p&gt;

&lt;p&gt;For CI with GitHub Actions, combine parallel with sharding to split across multiple machines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest &lt;span class="nt"&gt;--parallel&lt;/span&gt; &lt;span class="nt"&gt;--shard&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1/4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workflow configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./vendor/bin/pest --parallel --shard=${{ matrix.shard }}/4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four machines, four shards. A 4-minute test suite becomes a 60-second one. The setup takes about 10 minutes and pays back on the first push.&lt;/p&gt;

&lt;p&gt;One thing to check before enabling parallel: your tests need to be database-isolated. &lt;code&gt;RefreshDatabase&lt;/code&gt; handles this correctly and is the right choice for parallel runs. If you're managing database state in other ways, verify that tests can't bleed into each other first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type Coverage
&lt;/h2&gt;

&lt;p&gt;Install the plugin:&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 pestphp/pest-plugin-type-coverage &lt;span class="nt"&gt;--dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with a minimum threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest &lt;span class="nt"&gt;--type-coverage&lt;/span&gt; &lt;span class="nt"&gt;--min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pest 4 made the type coverage engine 2x faster on first run and instant on subsequent runs. It also added sharding support, so large codebases don't see a significant CI overhead.&lt;/p&gt;

&lt;p&gt;This isn't a replacement for PHPStan or Psalm, but it's a fast sanity check with zero configuration overhead. If you're not running any static analysis yet, this is a practical first step toward it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does Pest 4 work with my existing PHPUnit tests?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Pest runs on PHPUnit 12 under the hood, so existing test classes keep working without any changes. Run &lt;code&gt;./vendor/bin/pest&lt;/code&gt; and it picks up both. You can migrate files gradually or leave old ones as-is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Pest with Livewire and Filament?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Absolutely. &lt;code&gt;Livewire::test()&lt;/code&gt;, Filament's test helpers, &lt;code&gt;actingAs()&lt;/code&gt;, and the full Laravel testing API all work exactly as they do in PHPUnit. No adapter, no wrapper. It just works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between &lt;code&gt;it()&lt;/code&gt; and &lt;code&gt;test()&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing functional. &lt;code&gt;it('creates a post')&lt;/code&gt; reads as a behaviour description. &lt;code&gt;test('post creation')&lt;/code&gt; is more PHPUnit-style. I use &lt;code&gt;it()&lt;/code&gt; for behaviour tests and &lt;code&gt;test()&lt;/code&gt; for unit-level tests, but that's personal preference, not a rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is arch testing worth adding to a small project?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, often more so than on large ones. On a small project the rules act as a forcing function, keeping structure clean from the start instead of creating expensive cleanup work later. The debug statement rule alone has saved me from at least three embarrassing incidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I run just one test or one file?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run a specific file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest tests/Feature/Api/PostTest.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter by test description: ""&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="s2"&gt;"creates a post"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run a specific group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest &lt;span class="nt"&gt;--group&lt;/span&gt; api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--filter&lt;/code&gt; flag accepts partial strings, so &lt;code&gt;--filter "creates"&lt;/code&gt; matches any test whose description contains the word.&lt;/p&gt;




&lt;p&gt;Testing isn't about achieving 100% coverage. It's about having enough confidence to deploy on a Friday without holding your breath. Pest 4 makes that bar significantly lower to clear, and Laravel 13 gives you a clean enough test setup to actually maintain long term.&lt;/p&gt;

&lt;p&gt;If you're building a Laravel SaaS and want a second opinion on your testing strategy before things get complicated, &lt;a href="https://hafiz.dev/contact" rel="noopener noreferrer"&gt;reach out&lt;/a&gt;. Happy to take a look at what you've got.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
      <category>pest</category>
    </item>
    <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://dev.to/hafiz619/livewire-4-single-file-components-build-a-live-search-in-one-file-5274</link>
      <guid>https://dev.to/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://dev.to/hafiz619/scotty-vs-laravel-envoy-spaties-new-deploy-tool-is-worth-the-switch-mfc</link>
      <guid>https://dev.to/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://dev.to/hafiz619/livewire-4-vs-inertiajs-3-which-laravel-frontend-stack-should-you-use-in-2026-47p4</link>
      <guid>https://dev.to/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://dev.to/hafiz619/inertiajs-v3-is-out-the-upgrade-guide-every-laravel-developer-actually-needs-419b</link>
      <guid>https://dev.to/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://dev.to/hafiz619/cachefunnel-in-laravel-concurrency-limiting-without-redis-2dkb</link>
      <guid>https://dev.to/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://dev.to/hafiz619/laravel-ai-sdk-3-multi-agent-patterns-worth-using-in-production-and-2-to-skip-4hh9</link>
      <guid>https://dev.to/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://dev.to/hafiz619/laravel-13-queueroute-one-place-to-control-your-entire-queue-topology-4mjg</link>
      <guid>https://dev.to/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>
  </channel>
</rss>
