<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Hafiz</title>
    <description>The latest articles on Forem by Hafiz (@hafiz619).</description>
    <link>https://forem.com/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>Forem: Hafiz</title>
      <link>https://forem.com/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>Generate Beautiful Open Graph Images for Your Laravel App with One Spatie Package</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 27 Apr 2026 05:22:10 +0000</pubDate>
      <link>https://forem.com/hafiz619/generate-beautiful-open-graph-images-for-your-laravel-app-with-one-spatie-package-2780</link>
      <guid>https://forem.com/hafiz619/generate-beautiful-open-graph-images-for-your-laravel-app-with-one-spatie-package-2780</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/generate-beautiful-og-images-laravel-spatie-og-image" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;When someone shares a link to your Laravel app on Twitter, LinkedIn, or Slack, the platform shows a preview image. That image is the Open Graph image. Most Laravel apps either ship without one, ship with the same generic image on every page, or rely on an external service like Cloudinary or a separate Node.js renderer.&lt;/p&gt;

&lt;p&gt;Spatie released &lt;a href="https://github.com/spatie/laravel-og-image" rel="noopener noreferrer"&gt;&lt;code&gt;laravel-og-image&lt;/code&gt;&lt;/a&gt; to solve this in a way that feels native to Laravel: define your OG image as HTML right inside your Blade views, let the package screenshot it, cache it, and serve it automatically. No external API. No separate CSS pipeline. No extra app.&lt;/p&gt;

&lt;p&gt;This is the practical walkthrough I wish I had when I first looked at it. Real-world setup, the gotchas, and the Cloudflare alternative for Forge users without Chromium.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this package matters
&lt;/h2&gt;

&lt;p&gt;Most Laravel developers I know fall into one of three buckets when it comes to OG images. They have a single static image used across every page. Or they generate images server-side using something like Browsershot directly, which works but means rebuilding the wheel every project. Or they use an external service which adds latency, cost, and another dependency to monitor.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;laravel-og-image&lt;/code&gt; is the Laravel-native solution. The killer feature: your OG image template lives on the actual page, so it inherits your existing Tailwind classes, fonts, and Vite assets. No separate stylesheet. No design system duplication. Whatever your site looks like, your OG images can match without effort.&lt;/p&gt;

&lt;p&gt;The pattern is borrowed from &lt;a href="https://ogkit.dev" rel="noopener noreferrer"&gt;OGKit&lt;/a&gt; by Peter Suhm, but where OGKit is a hosted service, &lt;code&gt;laravel-og-image&lt;/code&gt; runs entirely on your own server. Spatie also built it on top of their &lt;a href="https://github.com/spatie/laravel-screenshot" rel="noopener noreferrer"&gt;&lt;code&gt;laravel-screenshot&lt;/code&gt;&lt;/a&gt; package, which means you can swap drivers between Browsershot (local Chromium) and Cloudflare Browser Rendering depending on your infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it actually works
&lt;/h2&gt;

&lt;p&gt;The mental model is worth getting straight before you install anything. Here's the flow when a social platform crawls one of your pages:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/generate-beautiful-og-images-laravel-spatie-og-image" 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;Six steps that matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You drop an &lt;code&gt;&amp;lt;x-og-image&amp;gt;&lt;/code&gt; Blade component into your view with whatever HTML you want.&lt;/li&gt;
&lt;li&gt;The package renders that HTML inside a hidden &lt;code&gt;&amp;lt;template data-og-image&amp;gt;&lt;/code&gt; tag on the page. It's invisible to humans.&lt;/li&gt;
&lt;li&gt;Middleware automatically injects &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;twitter:image&lt;/code&gt;, and &lt;code&gt;twitter:card&lt;/code&gt; meta tags into your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The image URL contains an md5 hash of your HTML content. Change the content, hash changes, crawlers pick up the new image.&lt;/li&gt;
&lt;li&gt;When the image URL is first requested, the package visits your page with &lt;code&gt;?ogimage&lt;/code&gt; appended. This renders only the template content at 1200×630 with your full CSS available.&lt;/li&gt;
&lt;li&gt;The screenshot is saved to your public disk and served with &lt;code&gt;Cache-Control&lt;/code&gt; headers. Cloudflare or your CDN caches it from there.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point matters more than it sounds. Image generation only happens once per unique HTML content. After that you're serving a static JPEG with proper cache headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up on a Laravel app
&lt;/h2&gt;

&lt;p&gt;You need PHP 8.3+, Laravel 12+, and either Node.js with Chromium installed (for the default Browsershot driver) or a Cloudflare account with Browser Rendering enabled.&lt;/p&gt;

&lt;p&gt;Install the package (full docs are on &lt;a href="https://spatie.be/docs/laravel-og-image" rel="noopener noreferrer"&gt;Spatie's documentation site&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require spatie/laravel-og-image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package depends on &lt;code&gt;spatie/laravel-screenshot&lt;/code&gt;, which depends on Browsershot, which needs Node.js and Chrome/Chromium on the server. If you're on Laravel Forge with a standard Ubuntu droplet, you'll need to install these. On Laravel Cloud, the Browsershot driver isn't an option and you'll need the Cloudflare driver instead (covered below).&lt;/p&gt;

&lt;p&gt;Optionally publish the config file if you need to customize defaults:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"og-image-config"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire setup. The middleware that injects meta tags registers automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your first OG image
&lt;/h2&gt;

&lt;p&gt;Open any Blade view that you want to add an OG image to. For a Laravel blog, that's typically &lt;code&gt;resources/views/blog/show.blade.php&lt;/code&gt;, the single-post view. Drop in the 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;x-og-image&amp;gt;
    &amp;lt;div class="w-full h-full bg-slate-900 text-white flex flex-col justify-between p-16"&amp;gt;
        &amp;lt;div class="flex items-center gap-4"&amp;gt;
            &amp;lt;img src="{{ asset('logo.svg') }}" class="w-16 h-16" alt="hafiz.dev"&amp;gt;
            &amp;lt;span class="text-2xl font-semibold"&amp;gt;hafiz.dev&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;h1 class="text-7xl font-bold leading-tight"&amp;gt;
            {{ $post-&amp;gt;title }}
        &amp;lt;/h1&amp;gt;

        &amp;lt;div class="flex items-center justify-between text-2xl text-slate-400"&amp;gt;
            &amp;lt;span&amp;gt;By Hafiz Riaz&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;{{ $post-&amp;gt;published_at-&amp;gt;format('M j, Y') }}&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/x-og-image&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Refresh the page in your browser and view source. You'll see the package has injected meta tags into your head:&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;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://hafiz.dev/og-image/a3f8c2d1e9b4.jpeg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://hafiz.dev/og-image/a3f8c2d1e9b4.jpeg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your page already had OG meta tags from a layout file, remove the &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;twitter:image&lt;/code&gt;, and &lt;code&gt;twitter:card&lt;/code&gt; ones. The package handles those automatically. Keep your &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:type&lt;/code&gt;, and any other OG tags. The package only manages the image-related ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Previewing without sharing the link 100 times
&lt;/h2&gt;

&lt;p&gt;The most useful debugging tool in this package is the &lt;code&gt;?ogimage&lt;/code&gt; query parameter. Append it to any page URL and you'll see exactly what gets screenshotted, at the configured dimensions, with the page's full CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://hafiz.dev/blog/your-post-slug?ogimage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This loads in your browser as a 1200×630 viewport showing only your template content. You can iterate on the design directly here, watching it update as you tweak the Blade template. No need to actually fire the screenshot or share the URL on Twitter to see what it looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design tips that took me too long to learn
&lt;/h2&gt;

&lt;p&gt;A few things I wasted time on that you can skip:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;w-full h-full&lt;/code&gt; on your root element.&lt;/strong&gt; The template renders inside a 1200×630 viewport. If you don't fill it, you'll get whitespace around your design. This is the most common mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep text huge.&lt;/strong&gt; OG images are viewed as thumbnails on most platforms, often around 500px wide on a phone. Your 7xl Tailwind text becomes legible. Anything smaller than 4xl is hard to read. Test at the actual rendered size before shipping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stick to your existing brand.&lt;/strong&gt; Because the template inherits all your CSS, you can use your existing color tokens, fonts, and components. Don't redesign. Use what's already there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid background images that load externally.&lt;/strong&gt; Browsershot waits for network idle by default, but external images add latency. Use solid colors, gradients, or assets served from the same domain. SVG inline is best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test in the LinkedIn Post Inspector and Twitter Card Validator before publishing widely.&lt;/strong&gt; Both have rate limits but they're free. Cache busting on social platforms is a separate problem if you ship a bad image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using a Blade view instead of inline HTML
&lt;/h2&gt;

&lt;p&gt;If you want the same OG layout across many pages, or if the template is getting complex, reference a Blade view instead:&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;x-og-image view="og-image.post" :data="['title' =&amp;gt; $post-&amp;gt;title, 'author' =&amp;gt; $post-&amp;gt;author-&amp;gt;name]" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;resources/views/og-image/post.blade.php&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;&amp;lt;div class="w-full h-full bg-slate-900 text-white flex flex-col justify-between p-16"&amp;gt;
    &amp;lt;h1 class="text-7xl font-bold"&amp;gt;{{ $title }}&amp;lt;/h1&amp;gt;
    &amp;lt;div class="text-2xl text-slate-400"&amp;gt;by {{ $author }}&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;data&lt;/code&gt; array becomes the variables available in the view. This pattern is what I'd reach for if you have multiple post types or you want OG images on a documentation site with consistent branding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallback images for pages without templates
&lt;/h2&gt;

&lt;p&gt;What about pages that don't have an explicit &lt;code&gt;&amp;lt;x-og-image&amp;gt;&lt;/code&gt; component? Blog index pages, tag listings, your homepage. By default, those pages get no OG image at all. The package lets you define a fallback in your &lt;code&gt;AppServiceProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Http\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\OgImage\Facades\OgImage&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;OgImage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fallbackUsing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="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;'og-image.fallback'&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="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'tagline'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Laravel, Claude Code, and shipping fast'&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 closure receives the full Request, so you can use route parameters or model bindings to customize the fallback per URL. Return &lt;code&gt;null&lt;/code&gt; to skip the fallback for specific requests. Pages that have an explicit component are never affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare driver: when you can't run Chromium
&lt;/h2&gt;

&lt;p&gt;If you're on Laravel Cloud, a serverless platform, or you just don't want to install Chromium on your server, the Cloudflare driver is the answer. It uses Cloudflare's Browser Rendering API to take the screenshot remotely.&lt;/p&gt;

&lt;p&gt;To switch to it, configure the screenshot driver in &lt;code&gt;config/screenshot.php&lt;/code&gt; after publishing the screenshot config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SCREENSHOT_DRIVER'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cloudflare'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

&lt;span class="s1"&gt;'drivers'&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;'cloudflare'&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;'account_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CLOUDFLARE_ACCOUNT_ID'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'api_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CLOUDFLARE_API_TOKEN'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the Cloudflare credentials to your &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;SCREENSHOT_DRIVER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;cloudflare&lt;/span&gt;
&lt;span class="py"&gt;CLOUDFLARE_ACCOUNT_ID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your-account-id&lt;/span&gt;
&lt;span class="py"&gt;CLOUDFLARE_API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your-api-token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare's Browser Rendering API has a free tier that's generous enough for most blogs and small SaaS apps. The latency is slightly higher than local Chromium because of the round-trip, but the trade-off is no Chromium dependency on your server.&lt;/p&gt;

&lt;p&gt;If you're already on Cloudflare for DNS or CDN, this driver is the path of least resistance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-generating images so the first share never lags
&lt;/h2&gt;

&lt;p&gt;The first time the OG image URL is hit, the package generates the screenshot. That can take a few seconds, especially with the Cloudflare driver. If you tweet a link to a brand new post, the first crawler might time out before the image is ready.&lt;/p&gt;

&lt;p&gt;The fix is to pre-generate the image when the page is published:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\OgImage\Facades\OgImage&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;PublishPostAction&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;execute&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;void&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'published_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;

        &lt;span class="nf"&gt;dispatch&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;$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;OgImage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;generateForUrl&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;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This dispatches a job that generates the image after publishing. By the time anyone shares the URL, the image is already cached on disk. If you're using &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel Queue Jobs at scale&lt;/a&gt;, this slots into your existing queue infrastructure cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching, storage, and clearing old images
&lt;/h2&gt;

&lt;p&gt;By default, generated images are stored on the &lt;code&gt;public&lt;/code&gt; disk, served from &lt;code&gt;/og-image/{hash}.jpeg&lt;/code&gt;. The hash changes when the underlying HTML changes, so updates work automatically. But that means old images stay on disk forever unless you clean them up.&lt;/p&gt;

&lt;p&gt;The package includes an artisan command to clear them:&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 og-image:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can find this and the other &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Artisan commands&lt;/a&gt; the package adds in your standard &lt;code&gt;php artisan list&lt;/code&gt; output. I run the clear command monthly via the scheduler. The cost of stale images is minimal for a small blog, but if you're running a SaaS with thousands of dynamic pages, regular cleanup keeps your disk under control.&lt;/p&gt;

&lt;p&gt;For storage on S3 or another disk, configure it via the facade in &lt;code&gt;AppServiceProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nc"&gt;OgImage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webp'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'og-images'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebP gives smaller file sizes if your CDN supports it. JPEG is the safer default for older crawlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this package isn't the right tool
&lt;/h2&gt;

&lt;p&gt;A few cases where I'd reach for something else:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static images suffice.&lt;/strong&gt; If your app has a single OG image used everywhere and it never needs to change, Spatie's package is overkill. Just use a static asset and reference it in your layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need pixel-perfect control over fonts and rendering.&lt;/strong&gt; Browsershot uses headless Chromium, which is great but not identical to Photoshop. If your design team wants exact rendering parity, generate images in Figma or use a service like Bannerbear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're on a free Laravel Cloud tier with strict timeouts.&lt;/strong&gt; The first generation can be slow. Use the Cloudflare driver and pre-generate aggressively, or fall back to a static image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You don't have control over the page layout.&lt;/strong&gt; The package needs to inject meta tags into your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and a template into the body. If you're working inside an iframe or a constrained CMS where you can't control these, this won't work.&lt;/p&gt;

&lt;p&gt;For everyone else building a Laravel app where shareable URLs matter, this is the right tool.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this work with Laravel Cloud?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but only with the Cloudflare driver. Laravel Cloud doesn't include Chromium, so the default Browsershot driver won't work out of the box. Set up Cloudflare Browser Rendering, point the screenshot driver at it, and you're good. Pre-generation via queue jobs is also fine on Laravel Cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I make sure social platforms pick up the new image when I update a post?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The image URL contains a hash of your template HTML. When you change the HTML (like updating a post title), the hash changes, so the URL changes, and crawlers fetch the new image automatically. The catch is that platforms like Facebook and LinkedIn cache aggressively. Use their respective debug tools to force a refresh: Facebook Sharing Debugger and LinkedIn Post Inspector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I have different OG images for different page sections without writing custom logic?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Just place a different &lt;code&gt;&amp;lt;x-og-image&amp;gt;&lt;/code&gt; component in each Blade view. Each one generates its own image based on its HTML content. For pages without an explicit component, use the fallback closure to define page-specific defaults based on the request URL or route name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if Browsershot fails to generate the image?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The package logs the error and the meta tag URL still points to the path it would have been served from. The crawler gets a 404 or 500 response on the image URL. The page itself still loads fine. To handle this gracefully, monitor the og-image queue and alert on failures. If you're using a &lt;a href="https://hafiz.dev/blog/laravel-telescope-vs-pulse-vs-nightwatch" rel="noopener noreferrer"&gt;Laravel monitoring tool&lt;/a&gt; like Pulse or Nightwatch, watch for failed jobs related to image generation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this affect page load performance?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The component renders an empty hidden &lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt; tag in your HTML, which adds a few hundred bytes at most. The actual image generation happens out-of-band when the OG URL is requested by a crawler, not when a user loads your page. Your page weight is essentially unchanged.&lt;/p&gt;

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

&lt;p&gt;The whole setup takes about 30 minutes from &lt;code&gt;composer require&lt;/code&gt; to a working OG image, including some design iteration. The package does exactly what it advertises, the documentation is solid, and the Cloudflare driver makes it usable on platforms where Chromium isn't an option.&lt;/p&gt;

&lt;p&gt;If you're shipping a Laravel app where shareable URLs matter, blog posts, product pages, documentation, this is one of those packages that pays for itself the first time someone retweets your link. Set it up once, design the template once, never think about OG images again.&lt;/p&gt;

&lt;p&gt;Building something in Laravel where the marketing layer needs to actually work? &lt;a href="mailto:contact@hafiz.dev"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>spatie</category>
      <category>seo</category>
      <category>php</category>
    </item>
    <item>
      <title>Claude Opus 4.7: What Laravel AI SDK Developers Need to Check Before Upgrading</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Fri, 24 Apr 2026 05:44:46 +0000</pubDate>
      <link>https://forem.com/hafiz619/claude-opus-47-what-laravel-ai-sdk-developers-need-to-check-before-upgrading-232</link>
      <guid>https://forem.com/hafiz619/claude-opus-47-what-laravel-ai-sdk-developers-need-to-check-before-upgrading-232</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/claude-opus-4-7-laravel-ai-sdk-migration-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Claude Opus 4.7 dropped on April 16, 2026. If you're using the Laravel AI SDK with the Anthropic driver, there are breaking API changes that will throw 400 errors in your existing setup the moment you swap the model string. Not deprecation warnings. Not behavior shifts. Actual request failures.&lt;/p&gt;

&lt;p&gt;This isn't a "what's new" roundup. It's a migration guide for Laravel developers who already have Anthropic agents in production and want to know exactly what to touch before flipping the switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model string and pricing
&lt;/h2&gt;

&lt;p&gt;Start with the easy bit. The API model ID is &lt;code&gt;claude-opus-4-7&lt;/code&gt;. In your Laravel AI SDK agent, that's one 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="na"&gt;#[Provider(Lab::Anthropic)]&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'claude-opus-4-7'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  &lt;span class="c1"&gt;// was: 'claude-opus-4-6'&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;YourAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pricing is unchanged from Opus 4.6: $5 per million input tokens, $25 per million output. That said, keep reading before you celebrate, because the new tokenizer changes the effective cost even though the per-token rate didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three breaking changes that will actually bite you
&lt;/h2&gt;

&lt;p&gt;These apply to the Messages API. If you're using Claude Managed Agents, Anthropic says no breaking API changes are required beyond the model name. But the Laravel AI SDK talks to the Messages API under the hood, so you're affected.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The &lt;code&gt;#[Temperature]&lt;/code&gt; attribute breaks on Opus 4.7
&lt;/h3&gt;

&lt;p&gt;This is the one that will catch most Laravel AI SDK users off guard.&lt;/p&gt;

&lt;p&gt;Starting with Opus 4.7, setting &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, or &lt;code&gt;top_k&lt;/code&gt; to any non-default value returns a &lt;strong&gt;400 error&lt;/strong&gt;. Not a warning. A hard failure.&lt;/p&gt;

&lt;p&gt;The Laravel AI SDK's &lt;code&gt;#[Temperature]&lt;/code&gt; attribute passes that value directly to the Anthropic API. So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Provider(Lab::Anthropic)]&lt;/span&gt;
&lt;span class="na"&gt;#[Model('claude-opus-4-7')]&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Temperature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  &lt;span class="c1"&gt;// This will throw a 400 error&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;YourAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Will fail at runtime. The fix is to remove the attribute entirely when using Opus 4.7 with the Anthropic driver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Provider(Lab::Anthropic)]&lt;/span&gt;
&lt;span class="na"&gt;#[Model('claude-opus-4-7')]&lt;/span&gt;
&lt;span class="c1"&gt;// No #[Temperature] - Anthropic controls this internally on Opus 4.7&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;YourAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same applies to any code that passes &lt;code&gt;temperature&lt;/code&gt; directly through the SDK's fluent interface when calling Anthropic. Omit it.&lt;/p&gt;

&lt;p&gt;If you were using &lt;code&gt;temperature: 0&lt;/code&gt; for determinism, note that this never actually guaranteed identical outputs on previous models either. Opus 4.7 just makes it explicit by refusing the parameter.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Extended thinking is gone, swap it for adaptive thinking
&lt;/h3&gt;

&lt;p&gt;If you have any agents that used &lt;code&gt;thinking: {type: "enabled", budget_tokens: N}&lt;/code&gt;, that now returns a 400 error as well.&lt;/p&gt;

&lt;p&gt;Opus 4.7 replaces extended thinking with adaptive thinking. The model decides how much to think based on the task's complexity, guided by the effort level you set. You don't allocate a token budget manually anymore.&lt;/p&gt;

&lt;p&gt;For the Anthropic PHP SDK directly, the before/after looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (Opus 4.6)&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;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'model'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'claude-opus-4-6'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;64000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'thinking'&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;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'enabled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'budget_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;32000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'messages'&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;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// After (Opus 4.7)&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;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'model'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'claude-opus-4-7'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;64000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'thinking'&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;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'adaptive'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'output_config'&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;'effort'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'messages'&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;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="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;Adaptive thinking is &lt;strong&gt;off by default&lt;/strong&gt; on Opus 4.7. If you don't set &lt;code&gt;thinking: {type: "adaptive"}&lt;/code&gt; explicitly, the model runs without thinking, matching Opus 4.6's default behavior when no thinking was configured. Enable it explicitly when you want it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Thinking content is silently omitted
&lt;/h3&gt;

&lt;p&gt;This one doesn't throw an error, but it can cause a subtle bug if your agent streams reasoning to users or logs thinking blocks.&lt;/p&gt;

&lt;p&gt;On Opus 4.7, thinking blocks still appear in the response stream, but their &lt;code&gt;thinking&lt;/code&gt; field is empty by default. The previous default was to return summarized thinking text. If you have frontend code or logging that reads reasoning content from the response, it will now receive an empty string without any error telling you why.&lt;/p&gt;

&lt;p&gt;To restore visible reasoning, opt in explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'thinking'&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;'type'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'adaptive'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'display'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'summarized'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// default is 'omitted'&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're streaming responses and your UI shows a long pause before output starts, this is the cause. The model is thinking but not emitting visible progress. Set &lt;code&gt;display: 'summarized'&lt;/code&gt; and the progress comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tokenizer change: your bill may go up
&lt;/h2&gt;

&lt;p&gt;Opus 4.7 uses a new tokenizer. The same text now tokenizes to roughly &lt;strong&gt;1x to 1.35x as many tokens&lt;/strong&gt; as it did on Opus 4.6, varying by content. The per-token price didn't change. The token count for the same input did.&lt;/p&gt;

&lt;p&gt;On a small single-turn prompt this is negligible. For multi-turn conversations, long system prompts, or agentic loops with large tool results, the compounding effect is real. A workflow that cost $10/day on Opus 4.6 could cost up to $13.50/day on Opus 4.7 without changing a single line of prompt.&lt;/p&gt;

&lt;p&gt;Anthropic recommends updating your &lt;code&gt;max_tokens&lt;/code&gt; to give extra headroom, including any context compaction triggers you have set. The 1M context window is unchanged and comes with no long-context premium.&lt;/p&gt;

&lt;p&gt;Run your common prompts through &lt;code&gt;/v1/messages/count_tokens&lt;/code&gt; on &lt;code&gt;claude-opus-4-7&lt;/code&gt; before and after to see your actual multiplier. It varies by content type, and code-heavy prompts tend to tokenize differently than prose. Dense PHP files and long Blade templates may be closer to the 1.35x ceiling, while short conversational messages sit nearer 1x. Check before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  New features worth actually using
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;xhigh&lt;/code&gt; effort level
&lt;/h3&gt;

&lt;p&gt;Opus 4.7 adds &lt;code&gt;xhigh&lt;/code&gt; as a new effort level above &lt;code&gt;high&lt;/code&gt;. Anthropic recommends starting with &lt;code&gt;xhigh&lt;/code&gt; for coding and agentic use cases, and a minimum of &lt;code&gt;high&lt;/code&gt; for most intelligence-sensitive tasks. Lower effort levels (&lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;low&lt;/code&gt;) trade quality for speed and cost.&lt;/p&gt;

&lt;p&gt;This matters practically for the kind of agents covered in the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-multi-agent-patterns-production" rel="noopener noreferrer"&gt;multi-agent patterns post&lt;/a&gt;. A research agent that runs for several minutes benefits from &lt;code&gt;xhigh&lt;/code&gt;. A quick classification call doesn't need more than &lt;code&gt;medium&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The effort parameter is Messages API only. Claude Managed Agents handles effort automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Task budgets for long agentic loops
&lt;/h3&gt;

&lt;p&gt;This is worth knowing for anyone building workflows like the RAG support bot in &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 two of the AI SDK tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A task budget is an advisory token cap across the entire agentic loop, not per request. The model sees a running countdown and uses it to scope and prioritize work. It's distinct from &lt;code&gt;max_tokens&lt;/code&gt;, which is a hard per-request cap the model never sees.&lt;br&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;// Task budgets require the beta header + output_config&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;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'model'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'claude-opus-4-7'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;128000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'output_config'&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;'effort'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'task_budget'&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;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&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="mi"&gt;128000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'messages'&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;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="s1"&gt;'betas'&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;'task-budgets-2026-03-13'&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 task budgets when you need the model to self-moderate on a token allowance. Skip them for open-ended quality-first tasks where you don't care about scoping. The minimum value is 20k tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  High-resolution image support
&lt;/h3&gt;

&lt;p&gt;If your agent processes screenshots, documents, or charts, this matters. Max image resolution went from 1568px to 2576px on the long edge. That's a jump from 1.15MP to 3.75MP. Coordinate mapping is now 1:1 with actual pixels, so no more scale-factor math when using computer use workflows.&lt;/p&gt;

&lt;p&gt;High-res images use more tokens though. If you're sending images where the extra detail isn't needed, downsample before sending to avoid unnecessary token cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Behavior changes that might need prompt updates
&lt;/h2&gt;

&lt;p&gt;These aren't breaking changes, but they can make existing prompts behave differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More literal instruction following.&lt;/strong&gt; Opus 4.7 will not silently generalize an instruction from one item to another. If your prompt says "summarize the first document," it won't infer you also want the second one summarized. This is actually a net positive for structured workflows, but you might need to be more explicit in prompts that relied on the old model filling in gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fewer tool calls by default.&lt;/strong&gt; The model uses reasoning more and makes fewer tool calls at lower effort levels. If your agent is not invoking tools as expected after upgrading, raise the effort level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Response length calibrates to task complexity.&lt;/strong&gt; Opus 4.7 doesn't default to a fixed verbosity. Short tasks get shorter responses, complex ones get longer. If you had prompts that said "be concise" to fight verbose defaults, try removing that scaffolding after upgrading and see if it's still needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More direct tone.&lt;/strong&gt; Opus 4.7 is more opinionated and less validation-forward than 4.6. Fewer filler phrases, fewer emoji. For most developer-facing agents this is an improvement. If your product intentionally used a warmer persona, you may need to reinforce that in the system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration checklist
&lt;/h2&gt;

&lt;p&gt;Before upgrading any production agent to &lt;code&gt;claude-opus-4-7&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API breaking changes (fix these first):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Remove &lt;code&gt;#[Temperature]&lt;/code&gt; attribute on all Anthropic agents, or confirm your SDK version handles this automatically&lt;/li&gt;
&lt;li&gt;[ ] Search for &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, &lt;code&gt;top_k&lt;/code&gt; in any direct Anthropic API calls and remove them&lt;/li&gt;
&lt;li&gt;[ ] Search for &lt;code&gt;thinking: enabled&lt;/code&gt; or &lt;code&gt;budget_tokens&lt;/code&gt; patterns and migrate to &lt;code&gt;thinking: adaptive&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Check any code that reads &lt;code&gt;thinking&lt;/code&gt; content from responses and add &lt;code&gt;display: "summarized"&lt;/code&gt; if needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Token budget:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Run your heaviest prompts through &lt;code&gt;count_tokens&lt;/code&gt; on &lt;code&gt;claude-opus-4-7&lt;/code&gt; and compare with 4.6&lt;/li&gt;
&lt;li&gt;[ ] Update &lt;code&gt;max_tokens&lt;/code&gt; to give extra headroom on long agentic loops&lt;/li&gt;
&lt;li&gt;[ ] Adjust context compaction triggers if you have them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Behavior validation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Run your existing eval suite or a sample of real prompts through Opus 4.7 before switching production traffic&lt;/li&gt;
&lt;li&gt;[ ] Check tool-call rates in agentic workflows, raise effort if the model is under-calling&lt;/li&gt;
&lt;li&gt;[ ] Review any prompts that relied on the model generalizing instructions across items&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Automate the code changes:&lt;/strong&gt;&lt;br&gt;
Anthropic ships a Claude API skill for Claude Code that applies the model ID swap, breaking parameter changes, and effort calibration across your codebase automatically. In Claude Code, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/claude-api migrate this project to claude-opus-4-7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It covers the same steps as the manual checklist above and outputs a list of items to verify manually after. Worth running before you do anything by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the upgrade worth it?
&lt;/h2&gt;

&lt;p&gt;For agentic coding workflows: yes, without much debate. Opus 4.7 records 64.3% on SWE-bench Pro and 87.6% on SWE-bench Verified. If you're building agents that write, review, or refactor Laravel code (the kind of thing covered in 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;complete Laravel AI SDK guide&lt;/a&gt;), the improvement on long-horizon autonomy is real.&lt;/p&gt;

&lt;p&gt;For simple single-turn assistants: the breaking changes create migration work for no benefit if your use case doesn't involve agentic loops or vision. You can stay on Opus 4.6 for now. The model is not deprecated.&lt;/p&gt;

&lt;p&gt;For anything processing images or documents: the resolution jump makes this worth it. 2576px is meaningfully better for reading dense screenshots, technical diagrams, and multi-column PDFs.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do the breaking changes apply if I'm using Claude Managed Agents instead of the Messages API?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Anthropic explicitly states that Claude Managed Agents has no breaking API changes for Opus 4.7. You only need to update the model name. The parameter changes described in this post apply to the Messages API, which is what the Laravel AI SDK uses under the hood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run Opus 4.6 and Opus 4.7 in the same Laravel app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The model string is a per-agent attribute in the Laravel AI SDK, so you can point different agents at different models. Keep critical production agents on 4.6, migrate lower-stakes agents first, and validate before switching over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If I remove &lt;code&gt;#[Temperature]&lt;/code&gt;, how do I control the model's behavior?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prompting and the effort parameter. Anthropic's official guidance is to use prompting to guide behavior on Opus 4.7 rather than sampling parameters. If you need more creative outputs, say so in the system prompt. If you need more deterministic outputs, use stricter instructions and structured output schemas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will the tokenizer change affect my context window usage?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. With up to 35% more tokens for the same input, you'll hit compaction or truncation thresholds sooner on long conversations. If you have logic that triggers a context summary at a specific token threshold, lower that threshold to compensate for the new tokenizer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is temperature still available on other Anthropic models like Haiku 4.5?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Probably, but I wouldn't assume it. The official docs say "Starting with Claude Opus 4.7, setting temperature, top_p, or top_k to any non-default value will return a 400 error," which reads like it's scoped to Opus 4.7 specifically for now. Before removing &lt;code&gt;#[Temperature]&lt;/code&gt; from agents running Haiku or Sonnet, check the &lt;a href="https://platform.claude.com/docs/en/about-claude/models/overview" rel="noopener noreferrer"&gt;models overview&lt;/a&gt; directly rather than taking my word for it.&lt;/p&gt;

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

&lt;p&gt;The summary is short: three things break, one of them silently. Remove &lt;code&gt;#[Temperature]&lt;/code&gt; from Anthropic agents, update the extended thinking syntax if you use it, and check whether anything reads thinking content from responses. After that, Opus 4.7 is a meaningful upgrade for anything involving agentic coding or vision.&lt;/p&gt;

&lt;p&gt;Run the migration on a staging environment first, validate with real traffic, then switch production. The &lt;code&gt;/claude-api migrate&lt;/code&gt; skill handles most of the mechanical changes automatically.&lt;/p&gt;

&lt;p&gt;Building something with the Laravel AI SDK that needs an architecture review before you push to production? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt; and let's talk through it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>claudecode</category>
      <category>aidevelopment</category>
    </item>
    <item>
      <title>Claude Code Routines: Put Your Laravel Workflows on Autopilot</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 22 Apr 2026 04:45:27 +0000</pubDate>
      <link>https://forem.com/hafiz619/claude-code-routines-put-your-laravel-workflows-on-autopilot-56hh</link>
      <guid>https://forem.com/hafiz619/claude-code-routines-put-your-laravel-workflows-on-autopilot-56hh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/claude-code-routines-laravel-autopilot" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The problem with most AI coding workflows is they stop when you do. You close your laptop, the session ends. You're asleep, nothing runs. You come back Monday morning to a pile of unreviewed PRs and no idea whether Friday's deploy is healthy.&lt;/p&gt;

&lt;p&gt;Claude Code Routines changes that. They run on Anthropic-managed cloud infrastructure, which means the session keeps going whether your machine is on or not. You write the prompt once, wire up a trigger, and the work happens in the background.&lt;/p&gt;

&lt;p&gt;This is a practical guide to getting Routines set up on a real Laravel project. Five concrete use cases, actual prompts, and the gotchas you'll run into before you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Routines are (and what they're not)
&lt;/h2&gt;

&lt;p&gt;A Routine is a saved Claude Code configuration: a prompt, one or more GitHub repositories, and optional MCP connectors, packaged together and triggered automatically. Each run creates a full Claude Code cloud session on Anthropic's infrastructure. Claude clones your repo, does the work, and pushes to a &lt;code&gt;claude/&lt;/code&gt;-prefixed branch.&lt;/p&gt;

&lt;p&gt;Three things are worth knowing upfront.&lt;/p&gt;

&lt;p&gt;First, Routines are different from &lt;code&gt;/loop&lt;/code&gt; and Desktop scheduled tasks. &lt;code&gt;/loop&lt;/code&gt; runs prompts inside your current terminal session and dies when you close it. Desktop scheduled tasks run on a schedule but need your machine to be on and Claude Code Desktop open. Routines are the only option that runs fully unattended on cloud infrastructure.&lt;/p&gt;

&lt;p&gt;Second, this feature is currently in research preview. Behavior, the API shapes, and rate limits may change. That said, it's available right now on Pro, Max, Team, and Enterprise plans with Claude Code on the web enabled at &lt;a href="https://claude.ai/code" rel="noopener noreferrer"&gt;claude.ai/code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Third, Routines run fully autonomously. No approval prompts during a run. Whatever you write in the prompt is what runs. This is meaningfully different from a normal Claude Code session where you can steer mid-task. If you've used &lt;a href="https://hafiz.dev/blog/claude-code-channels-how-to-control-your-ai-agent-from-your-phone" rel="noopener noreferrer"&gt;Claude Code Channels&lt;/a&gt; to send commands remotely, think of Routines as the version that runs without you sending anything at all.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;Machine required?&lt;/th&gt;
&lt;th&gt;Survives closing terminal?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/loop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local terminal&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop scheduled task&lt;/td&gt;
&lt;td&gt;Your machine&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routine&lt;/td&gt;
&lt;td&gt;Anthropic cloud&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The three trigger types
&lt;/h2&gt;

&lt;p&gt;Every Routine needs at least one trigger. You can also stack them on the same Routine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schedule triggers&lt;/strong&gt; run on a recurring cadence. Hourly is the minimum interval. Daily, weekdays, and weekly are the built-in presets. For something like "every two hours" or "first Monday of the month," you pick the closest preset in the form and then run &lt;code&gt;/schedule update&lt;/code&gt; in the CLI to set a specific cron expression after creation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API triggers&lt;/strong&gt; give the Routine a dedicated HTTP endpoint. POST to it with a bearer token and optionally pass a &lt;code&gt;text&lt;/code&gt; field for run-specific context. This is what makes Routines composable with the rest of your stack: your deploy pipeline, your alerting system, a webhook from Sentry. Any service that can make an authenticated HTTP request can trigger a Routine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub triggers&lt;/strong&gt; react to repository events automatically. A PR is opened. A release is published. A labeled PR gets a new commit pushed. You add filters to narrow which events fire: base branch equals &lt;code&gt;main&lt;/code&gt;, is draft is &lt;code&gt;false&lt;/code&gt;, author contains a specific username.&lt;/p&gt;

&lt;p&gt;One Routine can use multiple triggers. A code review Routine might run nightly (for anything opened the day before), fire on every new PR, and be callable via API from your CI pipeline. All three wired to the same prompt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/claude-code-routines-laravel-autopilot" 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;
  
  
  Five Laravel use cases worth setting up
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Nightly PR review
&lt;/h3&gt;

&lt;p&gt;The most immediately useful Routine for any active Laravel project. Claude reviews all open PRs opened in the last 24 hours, runs your &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest test suite&lt;/a&gt;, flags anything touching auth or database migrations, and leaves inline comments.&lt;/p&gt;

&lt;p&gt;Prompt to use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Review all open pull requests in this repository that were opened in the last 24 hours and have no reviewer assigned.

For each PR:
- Run the Pest test suite and report any failures
- Check that migrations are reversible and include both up() and down() methods
- Flag any raw queries that bypass Eloquent
- Check that jobs implement ShouldQueue and define both $tries and $timeout
- Flag any use of env() outside of config files
- Leave inline review comments for issues found
- Add a summary comment listing all issues, or confirming the PR is clean

Do not approve the PR. Add a "needs-changes" label if any issues are found, and "passed-ai-review" if clean.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a schedule trigger for every weekday at 8:00 AM. You start the morning with feedback already posted. Human reviewers can focus on architecture instead of catching &lt;code&gt;env()&lt;/code&gt; in the wrong place.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Queue health check
&lt;/h3&gt;

&lt;p&gt;Horizon is great but it doesn't catch everything. A nightly Routine can run queue diagnostics on your Laravel project and ping you only when something is actually wrong, not just generate noise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Using the environment variables available in this session, run the following checks:

1. Run php artisan queue:monitor against all configured queue drivers
2. Check the failed_jobs table for any entries created in the last 24 hours
3. Hit the /horizon/api/stats endpoint and verify Horizon is running and workers are active
4. Check the application log for any CRITICAL or ERROR entries from the last 24 hours

If all checks pass, exit without posting anything.
If any check fails, post a message to the #alerts Slack channel with the failure details and the exact artisan commands to fix it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need a Slack MCP connector configured and your environment credentials set as environment variables in the cloud environment. For a full list of queue-related &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Artisan Commands&lt;/a&gt;, the reference page is worth bookmarking when you're building out the prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Automated code review on PR open
&lt;/h3&gt;

&lt;p&gt;Set a GitHub trigger to fire on &lt;code&gt;pull_request.opened&lt;/code&gt; with one filter: is draft is &lt;code&gt;false&lt;/code&gt;. This catches every non-draft PR the moment it's opened and runs your team's checklist before a human reviewer even looks at it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A new pull request has just been opened. Apply our Laravel code review checklist:

- New controllers must use the single-action pattern (one public __invoke method only)
- Form Requests used for all validation, never validate() inside controllers
- No env() calls outside of config files
- No missing $fillable on new Eloquent models
- All new jobs implement ShouldQueue and define $tries and $timeout
- Flag any database queries inside loops (N+1 problem) with the exact file and line
- No direct use of DB::statement() or DB::select() without a comment explaining why

Leave an inline comment for each violation. Post a summary comment at the bottom of the PR. Add label "passed-ai-review" if clean, or "needs-changes" if violations were found. Do not approve or request changes on the PR directly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter matters here. &lt;code&gt;is draft: false&lt;/code&gt; means the Routine only fires on PRs that are actually ready for review. You don't want it running every time someone pushes a commit to a draft.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Post-deploy smoke test via API
&lt;/h3&gt;

&lt;p&gt;Wire your CD pipeline to trigger a Routine every time a production deploy completes. The Routine runs smoke checks against the new build and posts the result to your release channel.&lt;/p&gt;

&lt;p&gt;The API trigger gives you a &lt;code&gt;text&lt;/code&gt; field you can use to pass deploy context. From your deploy script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.anthropic.com/v1/claude_code/routines/trig_01XXXXX/fire &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_ROUTINE_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"anthropic-beta: experimental-cc-routine-2026-04-01"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"anthropic-version: 2023-06-01"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"text": "Deploy complete. SHA: abc123. Environment: production."}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: the bearer token here is your Routine-specific token, not your Anthropic API key. Generate it from the API trigger setup in the web UI. It's shown once, so store it in your secrets manager immediately.&lt;/p&gt;

&lt;p&gt;The Routine prompt can reference the deploy context passed via &lt;code&gt;text&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;A production deploy has just completed. The deploy details are in your session context.

Run the following smoke checks:
1. Hit the /health endpoint and verify a 200 response
2. Run php artisan about to confirm the application is responding
3. Run php artisan queue:monitor and report queue status
4. Check the error log for any new CRITICAL entries in the last 5 minutes

Post results to the #deployments Slack channel. Include the deploy SHA from the context. Mark as PASS if all checks succeed, FAIL with details if any check fails.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Weekly documentation drift detection
&lt;/h3&gt;

&lt;p&gt;Docs get stale. A weekly Routine that scans merged PRs, compares changed methods against your &lt;code&gt;/docs&lt;/code&gt; directory, and opens update PRs when it finds drift handles this automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scan all pull requests merged in the last 7 days.

For each merged PR:
1. Identify which PHP files changed
2. Check whether the changed public methods or classes are referenced in the /docs directory
3. If documentation references a changed method but looks outdated based on the diff, open a PR against the docs branch with a suggested update

Skip PRs that only changed tests, migrations, config files, or frontend assets. Only flag documentation that references changed public-facing methods or API endpoints.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schedule it for Sunday at midnight. By Monday morning you have doc update PRs queued, not a manual task sitting in someone's backlog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up your first Routine
&lt;/h2&gt;

&lt;p&gt;The fastest path is through the web UI. Go to &lt;a href="https://claude.ai/code/routines" rel="noopener noreferrer"&gt;claude.ai/code/routines&lt;/a&gt; and click &lt;strong&gt;New routine&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Give it a descriptive name. "Nightly PR Review" is better than "Routine 1." Write the prompt. This is the most important part: the Routine runs without you in the loop, so the prompt must be explicit about what to do and what success looks like. Vague prompts produce vague results, and there's no one watching to redirect them.&lt;/p&gt;

&lt;p&gt;Select your GitHub repository. Claude clones it fresh at the start of every run, starting from the default branch.&lt;/p&gt;

&lt;p&gt;Pick an environment. The Default environment works for most cases. If your Routine needs to call external APIs or read secrets like Sentry tokens or Slack webhooks, create a custom environment first and set those values as environment variables there.&lt;/p&gt;

&lt;p&gt;Add your trigger. For a scheduled Routine, pick the frequency. Times are set in your local timezone and converted automatically, so "9:00 AM" fires at 9:00 AM wherever you are, not in some UTC offset you have to calculate.&lt;/p&gt;

&lt;p&gt;Review the connectors. All connected MCP connectors are included by default. Remove anything the Routine doesn't actually need. A Routine that only reviews code and opens PRs has no reason to have Linear or Google Drive in scope.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Create&lt;/strong&gt;. Hit &lt;strong&gt;Run now&lt;/strong&gt; on the detail page to test it immediately without waiting for the next scheduled time.&lt;/p&gt;

&lt;p&gt;From the CLI, run &lt;code&gt;/schedule&lt;/code&gt; in any Claude Code session to create a Routine conversationally. Useful when you know exactly what you want and don't want to click through the web UI. &lt;code&gt;/schedule list&lt;/code&gt; shows all your Routines, &lt;code&gt;/schedule update&lt;/code&gt; changes one, and &lt;code&gt;/schedule run&lt;/code&gt; triggers it immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branch permissions: the default that catches people
&lt;/h2&gt;

&lt;p&gt;By default, Routines can only push to branches prefixed with &lt;code&gt;claude/&lt;/code&gt;. This is intentional. An autonomous agent creating commits on &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;develop&lt;/code&gt; is risky if the prompt isn't perfectly scoped.&lt;/p&gt;

&lt;p&gt;When you add a repository to a Routine, there's an &lt;strong&gt;Allow unrestricted branch pushes&lt;/strong&gt; toggle. Leave it off unless you have a specific need. The &lt;code&gt;claude/&lt;/code&gt; prefix keeps automated work clearly identifiable and makes cleanup straightforward if something goes sideways.&lt;/p&gt;

&lt;p&gt;If your Routine is creating PRs that look right but fails on push, this is almost certainly the reason. Enable unrestricted pushes for that repo in the Routine's settings.&lt;/p&gt;

&lt;p&gt;The flip side: PRs created by a Routine appear under your GitHub identity. Commits carry your username. Slack messages sent by a Routine's connector use your Slack account. The Routine acts as you, not as a bot account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plans and limits
&lt;/h2&gt;

&lt;p&gt;Routines are available on Pro, Max, Team, and Enterprise plans. They draw from your regular Claude Code subscription usage the same way interactive sessions do. There's also a daily cap on how many Routine runs can start per account, visible at &lt;a href="https://claude.ai/settings/usage" rel="noopener noreferrer"&gt;claude.ai/settings/usage&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you hit the daily cap, additional runs are rejected until the window resets. On Team and Enterprise plans with extra usage enabled, Routines continue on metered overage instead.&lt;/p&gt;

&lt;p&gt;GitHub event triggers also have per-routine and per-account hourly caps during the research preview. Events beyond the limit are dropped until the window resets. Worth keeping in mind if you're setting up a trigger on a high-volume repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use GitHub Actions instead
&lt;/h2&gt;

&lt;p&gt;Routines are not a replacement for CI/CD. Running tests on every push, deploying to staging on merge, enforcing security checks before a PR can be merged, anything that needs to block a developer workflow belongs in GitHub Actions. It integrates with branch protections, runs in your own infrastructure, and has native visibility in the PR UI.&lt;/p&gt;

&lt;p&gt;Routines are better for background work that doesn't need to gate anything. Review, triage, documentation updates, monitoring. Think of it as the difference between a gatekeeper (CI/CD) and an assistant handling repetitive work in the background.&lt;/p&gt;

&lt;p&gt;Also, Routines run with no approval prompts. That's the point, but it's also a risk. Test any Routine with &lt;strong&gt;Run now&lt;/strong&gt; and review what it actually did before leaving it to run unattended. A badly scoped PR review prompt that opens 30 spurious PRs is not a great Monday morning.&lt;/p&gt;

&lt;p&gt;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;Claude Code ecosystem&lt;/a&gt; post covers where Routines fit alongside other pieces like CLAUDE.md, skills, MCP, and the plugin marketplace if you want the full picture.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do Routines work on private repositories?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Routines clone your connected GitHub repositories, including private ones, as long as you've granted the necessary access via the web setup flow or &lt;code&gt;/web-setup&lt;/code&gt; in the CLI. GitHub event triggers specifically require installing the Claude GitHub App on the repository. Repository access via &lt;code&gt;/web-setup&lt;/code&gt; is separate and doesn't install the App automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between a Routine and a GitHub Action that calls Claude Code?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GitHub Actions run on GitHub's infrastructure, count against your GitHub Actions minutes, and can gate PRs and deployments through required status checks. Routines run on Anthropic-managed infrastructure and count against your Claude Code subscription. Use GitHub Actions when you need to block something. Use Routines for background work that doesn't need to block anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I share Routines with teammates?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not currently. Routines belong to your individual claude.ai account and aren't shared. Everything a Routine does through your GitHub identity or connectors appears as you. For team workflows where multiple people need the same automation, each person sets up their own Routine, or you handle it through GitHub Actions with shared secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I pass different context on each API trigger call?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The optional &lt;code&gt;text&lt;/code&gt; field in the API request body is passed to the Routine as run-specific context alongside its saved prompt. Pass anything: a Sentry alert body, a deploy SHA, a list of changed files. The Routine receives it as a literal string, so don't send structured JSON expecting it to be parsed. If you need structured data, serialize it yourself and parse it in the prompt instructions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when a Routine run fails mid-session?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The run session stays visible at &lt;code&gt;claude.ai/code/routines&lt;/code&gt;. You can open it, see exactly what Claude did, and continue the conversation manually if needed. The Routine itself keeps running on future triggers. Only that individual run failed.&lt;/p&gt;

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

&lt;p&gt;Routines flip the default. Instead of Claude Code being a tool you actively drive, it becomes something that works in the background while you're focused on other things, or asleep. The nightly PR review alone is worth the setup time on any project with more than one contributor.&lt;/p&gt;

&lt;p&gt;Start with one. Set up the nightly PR review, run it manually a few times to tune the prompt, then leave it running. Once you trust it, you'll think of five more things to automate.&lt;/p&gt;

&lt;p&gt;Building a Laravel application that needs architectural review or a second set of eyes on code quality? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt; and let's talk about what that looks like.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>claudecode</category>
      <category>aidevelopment</category>
      <category>tutorial</category>
    </item>
    <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://forem.com/hafiz619/how-laravel-events-listeners-and-observers-actually-work-and-when-to-use-each-53c7</link>
      <guid>https://forem.com/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://forem.com/hafiz619/how-i-built-a-macos-menu-bar-app-with-nativephp-laravel-12-and-livewire-4-g21</link>
      <guid>https://forem.com/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://forem.com/hafiz619/laravel-policies-vs-gates-the-complete-authorization-guide-3bj9</link>
      <guid>https://forem.com/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://forem.com/hafiz619/laravel-telescope-vs-pulse-vs-nightwatch-which-monitoring-tool-do-you-actually-need-2fig</link>
      <guid>https://forem.com/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://forem.com/hafiz619/laravel-pest-4-testing-the-complete-guide-for-laravel-13-developers-4i2a</link>
      <guid>https://forem.com/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://forem.com/hafiz619/livewire-4-single-file-components-build-a-live-search-in-one-file-5274</link>
      <guid>https://forem.com/hafiz619/livewire-4-single-file-components-build-a-live-search-in-one-file-5274</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/livewire-4-single-file-components-tutorial" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




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

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

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

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

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

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

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

&lt;p&gt;Create one with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:livewire search-posts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Computed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Computed]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Computed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Computed]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

&lt;p&gt;For full-page components (search results as a standalone page, for example), use the &lt;code&gt;pages::&lt;/code&gt; namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:livewire pages::search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And register the route with &lt;code&gt;Route::livewire()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;livewire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/search'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pages::search'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Multi-file makes sense when a component gets complex enough that one file becomes hard to navigate, or when you want a dedicated test file alongside the component. Create one with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:livewire post.editor &lt;span class="nt"&gt;--mfc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;The directory structure doesn't change how you reference the component. &lt;code&gt;&amp;lt;livewire:post.editor /&amp;gt;&lt;/code&gt; still works. And you can convert between formats at any time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Single-file → Multi-file&lt;/span&gt;
php artisan livewire:convert search-posts

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

&lt;/div&gt;



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

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

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

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

&lt;p&gt;&lt;code&gt;app/Livewire/SearchPosts.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchPosts&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'livewire.search-posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'results'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;resources/views/livewire/search-posts.blade.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

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

&lt;/div&gt;



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

&lt;p&gt;&lt;code&gt;resources/views/components/⚡search-posts.blade.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Attributes\Computed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Computed]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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




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

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

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

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

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

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

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

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

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

&lt;p&gt;&lt;strong&gt;Plain bash with annotation comments.&lt;/strong&gt; Your script is a &lt;code&gt;Scotty.sh&lt;/code&gt; file with a &lt;code&gt;#!/usr/bin/env scotty&lt;/code&gt; shebang. Tasks are regular bash functions. Server targets and macros are annotation comments. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env scotty&lt;/span&gt;

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

&lt;p&gt;Here's the complete zero-downtime script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env scotty&lt;/span&gt;

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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




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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// resources/views/components/⚡counter.blade.php&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

&lt;p&gt;The setup story is also dramatically simpler. In v2, every project required a &lt;code&gt;resolve&lt;/code&gt; callback, a &lt;code&gt;setup&lt;/code&gt; callback, and a separate SSR entry point. In v3, you add the new Vite plugin and call &lt;code&gt;createInertiaApp()&lt;/code&gt; with no arguments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resources/js/app.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createInertiaApp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nf"&gt;createInertiaApp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Two new features deserve mention. &lt;code&gt;useHttp&lt;/code&gt; is a new hook for making plain HTTP requests (to a search endpoint or autocomplete API, for example) without triggering a page navigation. It mirrors the API of &lt;code&gt;useForm&lt;/code&gt;, so there's no new pattern to learn. And optimistic updates are now first-class. You can apply data changes instantly before the server responds, with automatic rollback on failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optimistic&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;likes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/like`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

&lt;p&gt;We also just went through a similar exercise with the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade&lt;/a&gt;, which had zero breaking changes. Inertia v3 is different. Worth actually reading before you touch anything.&lt;/p&gt;

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

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

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

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

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;useHttp&lt;/code&gt; hook.&lt;/strong&gt; This one fills a genuine gap. In v2, if you needed to make an HTTP request that didn't trigger a page visit, like hitting a search endpoint or submitting to an API route, you'd reach for Axios or raw fetch and lose the reactive state you get from &lt;code&gt;useForm&lt;/code&gt;. The new &lt;code&gt;useHttp&lt;/code&gt; hook gives you the same developer experience as &lt;code&gt;useForm&lt;/code&gt; (reactive &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;errors&lt;/code&gt;, &lt;code&gt;progress&lt;/code&gt;, &lt;code&gt;isDirty&lt;/code&gt; state) but for plain JSON requests. No page navigation, no full Inertia lifecycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Vue example&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useHttp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Optimistic updates.&lt;/strong&gt; Inertia now has first-class support for applying a UI change immediately before the server confirms it, then rolling it back automatically if the request fails. It works on the router, &lt;code&gt;useForm&lt;/code&gt;, and &lt;code&gt;useHttp&lt;/code&gt;. Concurrent optimistic requests are handled too, each with its own rollback snapshot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;router&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optimistic&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;likes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/like`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;One thing to check before you start: if you're using third-party Inertia packages like Inertia Modal, Inertia Table, or any community adapters, verify they have v3 support before upgrading. Some packages ship separate major versions for Inertia v3 compatibility (Inertia Table v3 for example targets Tailwind v4). Check each package's GitHub releases page. There's no point upgrading Inertia core if a critical package in your app isn't ready yet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install the client adapter (pick your framework)&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @inertiajs/vue3@^3.0
&lt;span class="c"&gt;# or: npm install @inertiajs/react@^3.0&lt;/span&gt;
&lt;span class="c"&gt;# or: npm install @inertiajs/svelte@^3.0&lt;/span&gt;

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

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

&lt;span class="c"&gt;# republish the config file (it has been restructured in v3)&lt;/span&gt;
php artisan vendor:publish &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Inertia&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;ServiceProvider"&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;Two global router events have been renamed. If you've got &lt;code&gt;router.on()&lt;/code&gt; listeners anywhere in your app, search for both of these.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v2)&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exception&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3)&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;httpException&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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



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

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

&lt;span class="c1"&gt;// To match v2 behavior exactly (sync only)&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelAll&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prefetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;Inertia::lazy()&lt;/code&gt; is removed
&lt;/h3&gt;

&lt;p&gt;If you were using &lt;code&gt;Inertia::lazy()&lt;/code&gt; on any backend response, switch it to &lt;code&gt;Inertia::optional()&lt;/code&gt;. Same behaviour, different name. The &lt;code&gt;LazyProp&lt;/code&gt; class is also removed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v2)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Users/Index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

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

&lt;/div&gt;



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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v2)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hideProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;revealProgress&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nf"&gt;hideProgress&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;revealProgress&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reveal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;If you were using v2's &lt;code&gt;future&lt;/code&gt; options in &lt;code&gt;createInertiaApp&lt;/code&gt;, just delete the entire &lt;code&gt;future&lt;/code&gt; block. All four options are now always enabled and can't be toggled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v2)&lt;/span&gt;
&lt;span class="nf"&gt;createInertiaApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;preserveEqualProps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;useDataInertiaHeadAttribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3): just remove it&lt;/span&gt;
&lt;span class="nf"&gt;createInertiaApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Config file restructuring
&lt;/h3&gt;

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

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

&lt;p&gt;All Inertia packages now ship as ES Modules only. CommonJS &lt;code&gt;require()&lt;/code&gt; imports no longer work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v2): worked in some setups&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After (v3): ESM only&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inertiajs/vue3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>laravel</category>
      <category>inertiajs</category>
      <category>javascript</category>
      <category>vue</category>
    </item>
  </channel>
</rss>
