<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hafiz</title>
    <description>The latest articles on DEV Community by Hafiz (@hafiz619).</description>
    <link>https://dev.to/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>DEV Community: Hafiz</title>
      <link>https://dev.to/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>Building an Audit Log in Laravel with spatie/laravel-activitylog v5</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 11 May 2026 05:29:50 +0000</pubDate>
      <link>https://dev.to/hafiz619/building-an-audit-log-in-laravel-with-spatielaravel-activitylog-v5-k3</link>
      <guid>https://dev.to/hafiz619/building-an-audit-log-in-laravel-with-spatielaravel-activitylog-v5-k3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-activity-log-v5-audit-trail-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every SaaS reaches a point where "who changed that?" stops being a casual question and starts being a support ticket. A team member deletes a project. A setting gets changed and nobody knows when. A user loses access and blames an admin. Without an audit log, you're guessing. And in enterprise deals, the absence of audit logging can be an actual blocker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spatie/laravel-activitylog&lt;/code&gt; has been the go-to solution for this in the Laravel ecosystem for years, with over 48 million Packagist installs. Version 5 shipped in late March 2026, and it's a meaningful upgrade: PHP 8.4+, a cleaner API, a new database schema, and properly swappable internals. This post walks through building a complete audit log system for a Laravel SaaS using v5, from installation to displaying the log in Filament.&lt;/p&gt;

&lt;p&gt;If you're already on v4, there's a migration section at the end covering the breaking changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in v5
&lt;/h2&gt;

&lt;p&gt;Freek covered the full list on his blog, but the things that matter most for day-to-day use:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No boilerplate for basic model logging.&lt;/strong&gt; In v4, adding the &lt;code&gt;LogsActivity&lt;/code&gt; trait to a model also required a &lt;code&gt;getActivitylogOptions()&lt;/code&gt; method even for the simplest cases. In v5, the trait alone is enough to start logging. You only override &lt;code&gt;getActivitylogOptions()&lt;/code&gt; when you need custom behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New &lt;code&gt;attribute_changes&lt;/code&gt; column.&lt;/strong&gt; The old &lt;code&gt;changes&lt;/code&gt; column is replaced by &lt;code&gt;attribute_changes&lt;/code&gt;, which stores a cleaner structure with &lt;code&gt;attributes&lt;/code&gt; (the new values) and &lt;code&gt;old&lt;/code&gt; (the previous values). This means a small schema migration if you're upgrading, but fresh installs get a better foundation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ActivityEvent&lt;/code&gt; enum for type-safe filtering.&lt;/strong&gt; v5 introduces an &lt;code&gt;ActivityEvent&lt;/code&gt; enum so you're not relying on raw strings when filtering by event type:&lt;br&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\Activitylog\Enums\ActivityEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Created&lt;/span&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="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Updated&lt;/span&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="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Deleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain strings still work for custom event names. But for the standard events, the enum gives you autocompletion and catches typos at the IDE level rather than at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customizable action classes.&lt;/strong&gt; The core operations (saving activities, cleaning old records) are now action classes you can extend and swap via config. This makes it practical to do things like queue activity saves during a request, or redact sensitive fields before anything hits the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Requires PHP 8.4+ and Laravel 12 or 13. Install the 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 require spatie/laravel-activitylog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publish and run the migrations:&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;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Spatie&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;ctivitylog&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;ctivitylogServiceProvider"&lt;/span&gt; &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"activitylog-migrations"&lt;/span&gt;
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates the &lt;code&gt;activity_log&lt;/code&gt; table with the new v5 schema. 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;p&gt;Optionally publish the config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;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;"Spatie&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;ctivitylog&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;ctivitylogServiceProvider"&lt;/span&gt; &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"activitylog-config"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config file at &lt;code&gt;config/activitylog.php&lt;/code&gt; controls the activity model class, the default log name, the number of days before old records get pruned, and the action classes used internally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual Activity Logging
&lt;/h2&gt;

&lt;p&gt;The simplest usage is logging arbitrary 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="nf"&gt;activity&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'User exported the reports CSV'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More useful is attaching context. You want to know what was affected and who did it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;performedOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;causedBy&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;withProperties&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'plan'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'via'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'settings-page'&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'upgraded plan'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrieving logged activities uses the &lt;code&gt;Activity&lt;/code&gt; model with a set of built-in query scopes:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;// All activity for a specific subject&lt;/span&gt;
&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forSubject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// All activity caused by a user&lt;/span&gt;
&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;causedBy&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Filter by event type&lt;/span&gt;
&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated'&lt;/span&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="c1"&gt;// Filter by log name (useful when grouping logs by domain)&lt;/span&gt;
&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;inLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Combine scopes&lt;/span&gt;
&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forSubject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;causedBy&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;Activity&lt;/code&gt; record gives you &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;causer&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt;, &lt;code&gt;properties&lt;/code&gt;, and &lt;code&gt;attribute_changes&lt;/code&gt;. The &lt;code&gt;getProperty()&lt;/code&gt; helper reads from the custom properties you attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automatic Model Event Logging
&lt;/h2&gt;

&lt;p&gt;This is where the package earns its place. Add the &lt;code&gt;LogsActivity&lt;/code&gt; trait to any Eloquent model and it automatically logs created, updated, and deleted 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="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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Activitylog\Models\Concerns\LogsActivity&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;Project&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LogsActivity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all you need for basic logging. Any create, update, or delete on this model now creates an activity record.&lt;/p&gt;

&lt;p&gt;To control which attributes get tracked, override &lt;code&gt;getActivitylogOptions()&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\Activitylog\Support\LogOptions&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;Project&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LogsActivity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$fillable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&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;'owner_id'&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;getActivitylogOptions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;LogOptions&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;LogOptions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOnly&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'owner_id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOnlyDirty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dontSubmitEmptyLogs&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;logOnly()&lt;/code&gt; limits tracking to specific attributes. &lt;code&gt;logOnlyDirty()&lt;/code&gt; means only attributes that actually changed get recorded, not everything including &lt;code&gt;updated_at&lt;/code&gt; noise. &lt;code&gt;dontSubmitEmptyLogs()&lt;/code&gt; skips saving a record when nothing meaningful changed.&lt;/p&gt;

&lt;p&gt;When a project gets updated, the activity record's &lt;code&gt;attribute_changes&lt;/code&gt; 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="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attribute_changes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// [&lt;/span&gt;
&lt;span class="c1"&gt;//     'attributes' =&amp;gt; [&lt;/span&gt;
&lt;span class="c1"&gt;//         'status' =&amp;gt; 'active',&lt;/span&gt;
&lt;span class="c1"&gt;//         'owner_id' =&amp;gt; 42,&lt;/span&gt;
&lt;span class="c1"&gt;//     ],&lt;/span&gt;
&lt;span class="c1"&gt;//     'old' =&amp;gt; [&lt;/span&gt;
&lt;span class="c1"&gt;//         'status' =&amp;gt; 'draft',&lt;/span&gt;
&lt;span class="c1"&gt;//         'owner_id' =&amp;gt; 7,&lt;/span&gt;
&lt;span class="c1"&gt;//     ],&lt;/span&gt;
&lt;span class="c1"&gt;// ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also use &lt;code&gt;logAll()&lt;/code&gt; combined with &lt;code&gt;logExcept()&lt;/code&gt; to track everything except specific fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LogOptions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logExcept&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'remember_token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'updated_at'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;logFillable()&lt;/code&gt; to automatically track whatever is in the &lt;code&gt;$fillable&lt;/code&gt; array, useful when your fillable list is the authoritative record of what users can change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LogOptions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logFillable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOnlyDirty&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a typical SaaS you'd apply this to several models at once. A project management app might log changes to &lt;code&gt;Project&lt;/code&gt;, &lt;code&gt;Team&lt;/code&gt;, &lt;code&gt;Invitation&lt;/code&gt;, and &lt;code&gt;Role&lt;/code&gt; models, each tracking different attributes:&lt;br&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;Team&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LogsActivity&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;getActivitylogOptions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;LogOptions&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;LogOptions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOnly&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'owner_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'plan'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOnlyDirty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setDescriptionForEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&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="s2"&gt;"Team was &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$event&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;dontSubmitEmptyLogs&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Invitation&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LogsActivity&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;getActivitylogOptions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;LogOptions&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;LogOptions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOnly&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'accepted_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="nf"&gt;logOnlyDirty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;useLogName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invitations'&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;setDescriptionForEvent()&lt;/code&gt; method lets you control the human-readable description that gets stored. The default is just the event name ("updated", "created"), but a more descriptive string is easier to read in an admin panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouping Activity with Named Logs
&lt;/h2&gt;

&lt;p&gt;By default everything lands in the &lt;code&gt;default&lt;/code&gt; log. For a SaaS with distinct domains (billing, security, content), separating into named logs keeps queries focused and makes it practical to surface the right activity in the right UI context.&lt;/p&gt;

&lt;p&gt;Set a log name on the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionChange&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LogsActivity&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;getActivitylogOptions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;LogOptions&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;LogOptions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;useLogName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOnly&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'plan'&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;'cancelled_at'&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;Or set it on a manual log 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;activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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;causedBy&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;withProperties&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'ip'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Two-factor authentication disabled'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then query each log independently:&lt;br&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;// Billing events only&lt;/span&gt;
&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;inLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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="c1"&gt;// Security events for a specific user&lt;/span&gt;
&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;inLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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;causedBy&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Filament, add a filter that lets admins switch between log channels:&lt;br&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;Tables\Filters\SelectFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'log_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;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Log'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'default'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'General'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'billing'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'security'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Security'&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 pattern avoids the query performance issues that come from filtering a single massive &lt;code&gt;activity_log&lt;/code&gt; table by subject type. Named logs give you logical partitioning without needing separate database tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enriching Logs Before They Save
&lt;/h2&gt;

&lt;p&gt;Sometimes you need to attach extra context right before an activity is persisted. The &lt;code&gt;beforeActivityLogged()&lt;/code&gt; method on your model runs at that moment:&lt;br&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;Project&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LogsActivity&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;beforeActivityLogged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Activity&lt;/span&gt; &lt;span class="nv"&gt;$activity&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;$eventName&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;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'ip_address'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'user_agent'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;userAgent&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 is the right place to add request context, session data, or anything that isn't on the model itself. Don't use model observers for this. The &lt;code&gt;beforeActivityLogged&lt;/code&gt; hook runs in the correct position in the activity lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redacting Sensitive Fields
&lt;/h2&gt;

&lt;p&gt;By default, if you log a &lt;code&gt;User&lt;/code&gt; model, attribute changes will include whatever fields you track. If that includes anything sensitive, you want to strip it before it hits the database.&lt;/p&gt;

&lt;p&gt;Create a custom action class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\ActivityLog&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Arr&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\Activitylog\Actions\LogActivityAction&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;RedactSensitiveFieldsAction&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;LogActivityAction&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;transformChanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Model&lt;/span&gt; &lt;span class="nv"&gt;$activity&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;$changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attribute_changes&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

        &lt;span class="nc"&gt;Arr&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$changes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'attributes.password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'old.password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'attributes.two_factor_secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'old.two_factor_secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attribute_changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$changes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in &lt;code&gt;config/activitylog.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="s1"&gt;'actions'&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;'log_activity'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;\App\ActivityLog\RedactSensitiveFieldsAction&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;Now password changes never appear in your logs, regardless of which model triggers them. You can also override &lt;code&gt;save()&lt;/code&gt; on the action class to dispatch a queued job instead of writing synchronously, which helps if you're concerned about activity logging adding latency during high-traffic requests. The &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue jobs guide&lt;/a&gt; covers the patterns that apply here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Displaying the Activity Log in Filament
&lt;/h2&gt;

&lt;p&gt;An audit log is only useful if someone can actually read it. If you're using Filament, the quickest way is a dedicated resource. If you're building a SaaS admin panel, the &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;Filament admin guide&lt;/a&gt; covers the broader setup.&lt;/p&gt;

&lt;p&gt;Generate the resource:&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:filament-resource ActivityLog &lt;span class="nt"&gt;--view&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the list table in &lt;code&gt;ActivityLogResource.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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Activitylog\Models\Activity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getModel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Table&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Table&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;$table&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Tables\Columns\TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'causer.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;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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;searchable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;

            &lt;span class="nc"&gt;Tables\Columns\TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'description'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Action'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;

            &lt;span class="nc"&gt;Tables\Columns\TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'subject_type'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Subject'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;formatStateUsing&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;$state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;class_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;

            &lt;span class="nc"&gt;Tables\Columns\TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'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="nf"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&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;$state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="s1"&gt;'created'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'updated'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'warning'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'deleted'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'danger'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="k"&gt;default&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gray'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;}),&lt;/span&gt;

            &lt;span class="nc"&gt;Tables\Columns\TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_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="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'When'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&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;defaultSort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'desc'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Tables\Filters\SelectFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'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="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                    &lt;span class="s1"&gt;'created'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'updated'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Updated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'deleted'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Deleted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Tables\Actions\ViewAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="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 the view page, show the &lt;code&gt;attribute_changes&lt;/code&gt; as a formatted diff so admins can see exactly what changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;infolist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Infolist&lt;/span&gt; &lt;span class="nv"&gt;$infolist&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Infolist&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;$infolist&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Infolists\Components\TextEntry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'causer.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;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'User'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Infolists\Components\TextEntry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'description'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Action'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Infolists\Components\TextEntry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_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="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'When'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Infolists\Components\KeyValueEntry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attribute_changes.attributes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'New values'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Infolists\Components\KeyValueEntry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attribute_changes.old'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Previous values'&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 gives admins a readable before/after comparison for any update event. For larger teams, you'd add subject-specific filters and restrict access to senior roles via Filament's policy integration, which is covered in the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;full SaaS guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning Up Old Records
&lt;/h2&gt;

&lt;p&gt;Activity logs grow fast. The package ships a built-in command that removes records older than the number of days set in config:&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 activitylog:clean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the retention period in &lt;code&gt;config/activitylog.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="s1"&gt;'delete_records_older_than_days'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schedule it in &lt;code&gt;routes/console.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="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'activitylog:clean'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;90 days is a reasonable default for most SaaS products. If you're in a regulated industry (healthcare, finance), you'll want to check your compliance requirements. Some industries mandate 12+ months of audit history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating from v4
&lt;/h2&gt;

&lt;p&gt;If you're upgrading an existing project, the breaking changes require attention. These are the ones that will actually affect your code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PHP and Laravel version requirements.&lt;/strong&gt; v5 requires PHP 8.4+ and Laravel 12+. If you're on older versions, stay on v4 until you've upgraded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New database column.&lt;/strong&gt; v5 introduces an &lt;code&gt;attribute_changes&lt;/code&gt; column that replaces the old &lt;code&gt;changes&lt;/code&gt; column. Create a migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'activity_log'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attribute_changes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'properties'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need to decide what to do with existing &lt;code&gt;changes&lt;/code&gt; data. For most teams, archiving the old column and letting new records use &lt;code&gt;attribute_changes&lt;/code&gt; is simpler than trying to migrate the data format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relation renames.&lt;/strong&gt; Two relations changed names:&lt;br&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;// v4&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;activity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// relation to activities caused by this user&lt;/span&gt;
&lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// relation to activities on this model&lt;/span&gt;

&lt;span class="c1"&gt;// v5&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;actions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// renamed&lt;/span&gt;
&lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// renamed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search your codebase for &lt;code&gt;-&amp;gt;activity&lt;/code&gt; and update accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessing changes.&lt;/strong&gt; The &lt;code&gt;changes()&lt;/code&gt; method became a property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// v4&lt;/span&gt;
&lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// v5&lt;/span&gt;
&lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or $activity-&amp;gt;attribute_changes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Removed config options.&lt;/strong&gt; &lt;code&gt;table_name&lt;/code&gt; and &lt;code&gt;database_connection&lt;/code&gt; were removed from the config file. If you need a custom table or connection, create a custom &lt;code&gt;Activity&lt;/code&gt; model with &lt;code&gt;$table&lt;/code&gt; and &lt;code&gt;$connection&lt;/code&gt; properties, then point &lt;code&gt;activity_model&lt;/code&gt; in config to that class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removed method:&lt;/strong&gt; &lt;code&gt;addLogChange()&lt;/code&gt;, &lt;code&gt;LoggablePipe&lt;/code&gt;, and &lt;code&gt;EventLogBag&lt;/code&gt; are gone. If you used these to manipulate the changes array, override &lt;code&gt;transformChanges()&lt;/code&gt; on a custom &lt;code&gt;LogActivityAction&lt;/code&gt; instead; the pattern is shown in the redacting section above.&lt;/p&gt;

&lt;p&gt;Before upgrading, check your composer.json for any secondary packages that depend on &lt;code&gt;spatie/laravel-activitylog&lt;/code&gt;. This is good practice any time you're doing major version bumps. The &lt;a href="https://hafiz.dev/blog/fake-laravel-packages-targeting-your-env-how-to-audit-composer-dependencies" rel="noopener noreferrer"&gt;auditing your Composer dependencies post&lt;/a&gt; covers the workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Your Activity Log
&lt;/h2&gt;

&lt;p&gt;The package ships with a &lt;code&gt;withoutLogs()&lt;/code&gt; helper that's useful in tests where you don't want activity logging to interfere:&lt;br&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;'updates a project'&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;$project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;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;// Disable logging just for this test&lt;/span&gt;
    &lt;span class="nf"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disableLogging&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Updated Name'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nf"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enableLogging&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;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;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;'Updated Name'&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;Activity&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;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you actually want to assert that activity was logged correctly, test it 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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'logs when a project status changes'&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;$project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;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="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'draft'&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="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$activity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Activity&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;first&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;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;causer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attribute_changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attributes'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$activity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attribute_changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'old'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'draft'&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;Testing the &lt;code&gt;beforeActivityLogged&lt;/code&gt; hook works the same way: update the model and assert the custom property was merged onto the activity record. The hook runs synchronously during the model save, so there's no async complexity to deal with in tests.&lt;/p&gt;

&lt;p&gt;One thing worth knowing: if you dispatch activity logging via a queued job (using the custom action class pattern), use &lt;code&gt;Queue::fake()&lt;/code&gt; in tests and assert the job was dispatched rather than asserting the activity was saved directly.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does v5 work with Laravel 12 and 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The package requires &lt;code&gt;illuminate/support: ^12.0 || ^13.0&lt;/code&gt;, so both are supported. Laravel 11 and older are not supported in v5. Stay on v4 if you haven't upgraded yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I log activity without a logged-in user?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. When there's no authenticated user, &lt;code&gt;causer&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; and the log still saves. This is useful for logging background jobs or system-triggered events. You can also set a causer explicitly with &lt;code&gt;causedBy($model)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I log soft-deleted models?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;LogsActivity&lt;/code&gt; trait hooks into Eloquent model events including &lt;code&gt;SoftDeleting&lt;/code&gt;. As long as your model uses &lt;code&gt;SoftDeletes&lt;/code&gt;, the activity log records &lt;code&gt;deleted&lt;/code&gt; events automatically. Restores are also captured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use multiple log channels?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Use &lt;code&gt;useLogName()&lt;/code&gt; in &lt;code&gt;getActivitylogOptions()&lt;/code&gt; to route activity to different named logs. Then query with &lt;code&gt;Activity::inLog('billing')&lt;/code&gt; or &lt;code&gt;Activity::inLog('security')&lt;/code&gt;. Useful when you want separate audit trails for different parts of your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I prevent logging during imports or seeders?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Call &lt;code&gt;activity()-&amp;gt;disableLogging()&lt;/code&gt; before the operation and &lt;code&gt;activity()-&amp;gt;enableLogging()&lt;/code&gt; after. This works in tests, seeders, and bulk import scripts anywhere you need a clean run without filling the activity log with noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build It Once, Thank Yourself Later
&lt;/h2&gt;

&lt;p&gt;Audit logs feel optional until the moment they aren't. A team member makes an unexpected change, a user disputes account history, a compliance requirement surfaces. By then it's too late to add the log retroactively.&lt;/p&gt;

&lt;p&gt;The good news is that v5 makes the setup surprisingly lightweight. Add the trait, configure what to track, schedule the cleanup command. That's the core of it. Filament display, sensitive field redaction, and queued saves are all additions you can layer in as your needs grow.&lt;/p&gt;

&lt;p&gt;If you're setting up activity logging on a production app and want a second set of eyes on the implementation, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>spatie</category>
      <category>security</category>
      <category>saas</category>
    </item>
    <item>
      <title>Laravel Now Has Native Passkeys: A Complete Guide to laravel/passkeys</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Sat, 09 May 2026 07:24:25 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-now-has-native-passkeys-a-complete-guide-to-laravelpasskeys-4151</link>
      <guid>https://dev.to/hafiz619/laravel-now-has-native-passkeys-a-complete-guide-to-laravelpasskeys-4151</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-native-passkeys-setup-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;For a long time, adding passkeys to a Laravel app meant reaching for a third-party package, assembling WebAuthn ceremonies by hand, or piecing together a tutorial that assumes you already know what a "relying party ID" is. That's done.&lt;/p&gt;

&lt;p&gt;In late April 2026, Laravel shipped &lt;code&gt;laravel/passkeys&lt;/code&gt;, a first-party package authored by Taylor Otwell that gives you a complete passkey story out of the box. Server package, npm client, Fortify integration. Three pieces that click together so passwordless auth is boring to wire up, which is exactly what you want from a security feature.&lt;/p&gt;

&lt;p&gt;I covered the &lt;a href="https://hafiz.dev/blog/passkeys-in-laravel-what-they-are-and-how-to-get-started" rel="noopener noreferrer"&gt;Spatie passkeys approach&lt;/a&gt; back in January, and that's still valid if you're Livewire-heavy or already have that package running. But the native package is the right call for new projects and anything using Fortify. Here's the full setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Ships in laravel/passkeys
&lt;/h2&gt;

&lt;p&gt;The passkey stack has three components that each handle a distinct concern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;laravel/passkeys&lt;/code&gt;&lt;/strong&gt; is the server-side Composer package. It handles WebAuthn ceremonies, manages a &lt;code&gt;passkeys&lt;/code&gt; database table, registers routes for login, confirmation, and credential management, and fires events you can hook into. If you need custom authorization logic or your own route definitions, escape hatches are built in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@laravel/passkeys&lt;/code&gt;&lt;/strong&gt; is the npm client. It handles browser-side ceremony coordination (registration and verification) and ships first-class helpers for React, Vue, and Svelte with SSR-safe hooks so client-only APIs don't fight your framework. The public API is two methods: &lt;code&gt;Passkeys.register()&lt;/code&gt; and &lt;code&gt;Passkeys.verify()&lt;/code&gt;. That's it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fortify integration&lt;/strong&gt; wires everything together via &lt;code&gt;Features::passkeys()&lt;/code&gt; in your app config and a &lt;code&gt;passkeys&lt;/code&gt; section in &lt;code&gt;config/fortify.php&lt;/code&gt;. Fortify apps get the same endpoints and the &lt;code&gt;PasskeyUser&lt;/code&gt; and &lt;code&gt;PasskeyAuthenticatable&lt;/code&gt; contracts without reimplementing any glue.&lt;/p&gt;

&lt;p&gt;The package is &lt;code&gt;v0.1.0&lt;/code&gt; but that's not a red flag. It's already the default in Laravel's official starter kits and used by Fortify in production. The version number signals that the public API may still evolve, not that the package is unstable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Start by pulling in the 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 require laravel/passkeys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publish and run the migrations to create the &lt;code&gt;passkeys&lt;/code&gt; table:&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;passkeys-migrations
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, add a secret to your &lt;code&gt;.env&lt;/code&gt; for deriving stable opaque user handles. This keeps passkey associations private even if your user IDs are sequential integers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASSKEYS_USER_HANDLE_SECRET=your-random-secret-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate a value 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 key:generate &lt;span class="nt"&gt;--show&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use that output as your secret. The package falls back to &lt;code&gt;APP_KEY&lt;/code&gt; if you leave this blank, but keeping them separate is better practice. If you ever rotate your app key, users won't lose their passkeys. You can find a full reference 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;
  
  
  Configuring Your User Model
&lt;/h2&gt;

&lt;p&gt;Add the &lt;code&gt;PasskeyUser&lt;/code&gt; contract and &lt;code&gt;PasskeyAuthenticatable&lt;/code&gt; trait to your User model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="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;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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Passkeys\Contracts\PasskeyUser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Passkeys\PasskeyAuthenticatable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PasskeyUser&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;PasskeyAuthenticatable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Rest of your model...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trait assumes your &lt;code&gt;users&lt;/code&gt; table has &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;email&lt;/code&gt; columns. Authenticators show these values in their UI during registration and account selection. &lt;code&gt;displayName&lt;/code&gt; falls back from &lt;code&gt;name&lt;/code&gt; to &lt;code&gt;email&lt;/code&gt; to the auth identifier. Same for &lt;code&gt;username&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you need different display values, override the methods directly on the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getPasskeyDisplayName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;full_name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&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;getPasskeyUsername&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&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;That's the only change your model needs. No extra migrations, no pivot tables. The &lt;code&gt;passkeys&lt;/code&gt; table handles credential storage and links to your user via a standard relationship that &lt;code&gt;PasskeyAuthenticatable&lt;/code&gt; sets up for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fortify Integration
&lt;/h2&gt;

&lt;p&gt;If you're using Laravel Fortify, enabling passkeys takes one line in your features array:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="s1"&gt;'features'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Features&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nc"&gt;Features&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resetPasswords&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nc"&gt;Features&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;emailVerification&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nc"&gt;Features&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;passkeys&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// Add this&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fortify automatically registers the passkey routes and wires up the contracts. Nothing else changes on the server side. Your existing &lt;a href="https://hafiz.dev/blog/laravel-policies-vs-gates-authorization-guide" rel="noopener noreferrer"&gt;authorization setup with policies and gates&lt;/a&gt; stays untouched: passkeys only replace the authentication step, not what happens after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Config File
&lt;/h2&gt;

&lt;p&gt;Publish the config if you need to customize anything:&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;"passkeys-config"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defaults in &lt;code&gt;config/passkeys.php&lt;/code&gt; are sensible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'relying_party_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.url'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;PHP_URL_HOST&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'allowed_origins'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.url'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="s1"&gt;'user_handle_secret'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PASSKEYS_USER_HANDLE_SECRET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.key'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'guard'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'middleware'&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;'web'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'management_middleware'&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;'password.confirm'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'throttle'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'throttle:6,1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'redirect'&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="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few worth understanding before you change anything.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;relying_party_id&lt;/code&gt; is your domain, derived from &lt;code&gt;APP_URL&lt;/code&gt;. Passkeys are cryptographically bound to this value. If the domain the browser accesses doesn't match, the ceremony fails. Make sure &lt;code&gt;APP_URL&lt;/code&gt; reflects the actual domain you're serving, especially in local development.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;management_middleware&lt;/code&gt; defaults to &lt;code&gt;password.confirm&lt;/code&gt;, which means users must re-confirm their password before adding or revoking passkeys. Don't disable this. It's the right friction for a security-critical action. The same principle applies here as with sensitive token operations in &lt;a href="https://hafiz.dev/blog/laravel-passport-vs-sanctum-which-one-do-you-actually-need" rel="noopener noreferrer"&gt;Passport vs Sanctum&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;throttle&lt;/code&gt; limits passkey attempts to 6 per minute. Sensible for production. Adjust it if you have unusual traffic patterns, but don't remove it entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routes the Package Registers
&lt;/h2&gt;

&lt;p&gt;You don't define any routes yourself. The server package registers these automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST   /passkeys/register/options   (generate registration challenge)
POST   /passkeys/register           (store the new credential)
POST   /passkeys/verify/options     (generate authentication challenge)
POST   /passkeys/verify             (authenticate with passkey)
DELETE /passkeys/{passkey}          (revoke a specific passkey)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need custom route definitions (different middleware, prefixes, or custom controllers), you can disable auto-registration in the config and define them yourself. The underlying action classes are all public and importable, so you're not losing functionality by taking manual control.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the WebAuthn Flow Works
&lt;/h2&gt;

&lt;p&gt;It helps to see the ceremony before writing the frontend code:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-native-passkeys-setup-guide" 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;Registration follows the same pattern: browser requests options, authenticator creates a key pair, public key gets stored on your server. Nothing sensitive ever leaves the device. The private key never travels over the network, which is the core security advantage over passwords. No credentials to steal from a database breach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend Integration (Vue)
&lt;/h2&gt;

&lt;p&gt;Install the npm client:&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; @laravel/passkeys
npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's a Vue 3 component that handles both registration (authenticated users adding a passkey) and login (on the login page):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&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;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Passkeys&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;@laravel/passkeys&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;registering&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifying&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;registerPasskey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;registering&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Passkeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;My Device&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="c1"&gt;// Passkey saved, refresh the list or show a success toast&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;registering&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loginWithPasskey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;verifying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Passkeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;// Redirects automatically on success&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;verifying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &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;"space-y-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- Show on profile/settings for authenticated users --&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"registerPasskey"&lt;/span&gt; &lt;span class="na"&gt;:disabled=&lt;/span&gt;&lt;span class="s"&gt;"registering"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;registering&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Registering...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Add a Passkey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

        &lt;span class="c"&gt;&amp;lt;!-- Show on your login page --&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"loginWithPasskey"&lt;/span&gt; &lt;span class="na"&gt;:disabled=&lt;/span&gt;&lt;span class="s"&gt;"verifying"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;verifying&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Verifying...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sign in with Passkey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-red-500 text-sm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&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;/&lt;/span&gt;&lt;span class="k"&gt;template&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;&lt;code&gt;Passkeys.register()&lt;/code&gt; handles the full browser ceremony: it fetches the challenge from &lt;code&gt;/passkeys/register/options&lt;/code&gt;, prompts the authenticator, and POSTs the resulting credential back to the server. &lt;code&gt;Passkeys.verify()&lt;/code&gt; does the same for login and then redirects to the path defined in &lt;code&gt;config/passkeys.php → redirect&lt;/code&gt; on success.&lt;/p&gt;

&lt;p&gt;For React, the import and API are identical. The Svelte helpers follow the same pattern. The package abstracts all the &lt;code&gt;@simplewebauthn/browser&lt;/code&gt; ceremony complexity behind a clean two-method interface, which is what you want when you're not trying to become a WebAuthn expert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing Registered Passkeys
&lt;/h2&gt;

&lt;p&gt;Users should be able to see and revoke their passkeys. This matters more than people expect. Users register on their laptop, their phone, and their work machine, then wonder why three entries show up. Give them the tools to clean it up.&lt;/p&gt;

&lt;p&gt;A basic controller 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;// PasskeyController.php&lt;/span&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;Laravel\Passkeys\Models\Passkey&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;PasskeyController&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;index&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="nv"&gt;$passkeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;passkeys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passkeys.index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passkeys'&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;destroy&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;Passkey&lt;/span&gt; &lt;span class="nv"&gt;$passkey&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;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'delete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$passkey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$passkey&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;back&lt;/span&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Passkey removed.'&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;passkeys()&lt;/code&gt; relationship is defined by the &lt;code&gt;PasskeyAuthenticatable&lt;/code&gt; trait. Each &lt;code&gt;Passkey&lt;/code&gt; record has a &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, and &lt;code&gt;last_used_at&lt;/code&gt; column. Surface all three in the UI so users can tell which device is which and spot ones they don't recognise.&lt;/p&gt;

&lt;p&gt;Wire the delete action to the &lt;code&gt;DELETE /passkeys/{passkey}&lt;/code&gt; route the package already registered. The &lt;code&gt;management_middleware&lt;/code&gt; (password confirm by default) protects both the management view and the delete action, so users need to re-authenticate before making changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing to spatie/laravel-passkeys
&lt;/h2&gt;

&lt;p&gt;Both packages use &lt;code&gt;web-auth/webauthn-lib&lt;/code&gt; under the hood and get you to the same outcome. The difference is approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;laravel/passkeys&lt;/code&gt; (native)&lt;/strong&gt; is first-party and stack-agnostic on the frontend. Right choice for new Laravel 11, 12, or 13 projects and anything using Fortify. If you're starting fresh, use this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;spatie/laravel-passkeys&lt;/code&gt;&lt;/strong&gt; ships Livewire components out of the box. If your app is already Livewire-heavy and you have Spatie's package working, there's no reason to migrate. The &lt;a href="https://hafiz.dev/blog/passkeys-in-laravel-what-they-are-and-how-to-get-started" rel="noopener noreferrer"&gt;earlier passkeys guide&lt;/a&gt; covers that setup in full.&lt;/p&gt;

&lt;p&gt;Don't run both at the same time. They register overlapping routes and you'll get conflicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to Get Right Before You Ship
&lt;/h2&gt;

&lt;p&gt;A few things that will save you a debugging session:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTPS is required.&lt;/strong&gt; WebAuthn only works on secure origins. For local development, use &lt;code&gt;valet secure&lt;/code&gt; (Valet or Herd) or configure SSL in Sail. If &lt;code&gt;APP_URL&lt;/code&gt; uses &lt;code&gt;http://&lt;/code&gt;, the browser refuses to run the ceremony entirely. No error message. Just silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep password auth as a fallback.&lt;/strong&gt; Not every user is on a passkey-capable device. Passkeys should be additive. Don't remove your existing login form. Make it an option alongside the passkey button, not a replacement for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account recovery needs thought.&lt;/strong&gt; If a user loses access to all their registered devices, how do they get back in? The package doesn't solve this. Email-based recovery or admin-initiated password resets are the standard approaches. Build this flow before you go live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple passkeys per user are supported by default.&lt;/strong&gt; Users register on multiple devices, and that's expected. Your management UI (a list with a revoke button per passkey) handles this. Show &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, and &lt;code&gt;last_used_at&lt;/code&gt; so users can make sense of what's there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;management_middleware&lt;/code&gt; default is &lt;code&gt;password.confirm&lt;/code&gt;.&lt;/strong&gt; Users re-confirm their password before adding or revoking passkeys. Don't strip it out. It's the same security pattern you'd apply to any sensitive account action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Development
&lt;/h2&gt;

&lt;p&gt;One thing that trips people up: &lt;code&gt;APP_URL&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; must match the domain you're actually accessing in the browser. A mismatch makes the relying party check fail, and the error can be cryptic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APP_URL=https://myapp.test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on Valet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;valet secure myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all you need. The package reads &lt;code&gt;APP_URL&lt;/code&gt; for its relying party config automatically.&lt;/p&gt;

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

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

&lt;p&gt;The package requires &lt;code&gt;illuminate/contracts: ^11.0|^12.0|^13.0&lt;/code&gt;, so all three versions are supported. You don't need to upgrade to Laravel 13 to use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Fortify to use this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Fortify integration is optional. The server package works standalone: you define your own routes and handle redirects. &lt;code&gt;Features::passkeys()&lt;/code&gt; just automates the setup if Fortify is already in your stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I'm already using spatie/laravel-passkeys?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stay on Spatie unless you have a specific reason to switch, especially if the Livewire setup is working. If you do migrate, uninstall the Spatie package and remove its service provider first. Don't run both simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is v0.1.0 stable enough for production?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The package is already the default in Laravel's official starter kits and backed by Fortify. The &lt;code&gt;v0.1.0&lt;/code&gt; label means the public API may evolve, not that it's experimental. For new projects, use it without hesitation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this without a JavaScript framework?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The framework-specific helpers (Vue, React, Svelte) are convenience wrappers around the same core API. If you're using Blade without a frontend framework, you can call &lt;code&gt;Passkeys.register()&lt;/code&gt; and &lt;code&gt;Passkeys.verify()&lt;/code&gt; from a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block after importing &lt;code&gt;@laravel/passkeys&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get It Wired Up
&lt;/h2&gt;

&lt;p&gt;Native passkeys is a small, focused addition to any Laravel project. The config is sensible by default, Fortify integration is a single line, and the frontend API is two method calls. If you're starting a new Laravel project today and want passwordless auth, this is the path.&lt;/p&gt;

&lt;p&gt;If you're adding passkeys to an existing production app or migrating a complex auth setup, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt; and we can work through the integration together.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>authentication</category>
      <category>security</category>
      <category>passkeys</category>
    </item>
    <item>
      <title>PHP 8.4 Features You're Probably Not Using Yet in Your Laravel App</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Fri, 08 May 2026 05:21:57 +0000</pubDate>
      <link>https://dev.to/hafiz619/php-84-features-youre-probably-not-using-yet-in-your-laravel-app-282h</link>
      <guid>https://dev.to/hafiz619/php-84-features-youre-probably-not-using-yet-in-your-laravel-app-282h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/php-8-4-features-not-using-yet-laravel-app" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you followed the &lt;a href="https://hafiz.dev/blog/4-composer-conflicts-blocking-laravel-13-upgrade" rel="noopener noreferrer"&gt;Laravel 13 upgrade path&lt;/a&gt;, you're running PHP 8.4 by now (or you should be, since Laravel 13.3+ pulls in Symfony 8 components that require it). The &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;upgrade guide&lt;/a&gt; covers the migration steps, but upgrading your runtime and actually using the new language features are two different things.&lt;/p&gt;

&lt;p&gt;Most Laravel developers upgrade PHP, confirm their tests pass, and keep writing the same PHP 8.1-style code they've always written. That works, but you're leaving real improvements on the table. PHP 8.4 shipped six features that directly clean up the kind of code you write in a Laravel app every day.&lt;/p&gt;

&lt;p&gt;Here's what each one does, with before and after examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Property Hooks: Replace Your Getters and Setters
&lt;/h2&gt;

&lt;p&gt;Property hooks let you define &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;set&lt;/code&gt; behavior directly on a class property. No more writing separate getter and setter methods for simple transformations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (PHP 8.3):&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PriceCalculator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$priceInCents&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;getPriceInCents&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;priceInCents&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;setPriceInCents&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;$value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Price cannot be negative.'&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;priceInCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$value&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;getPriceInDollars&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;priceInCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;After (PHP 8.4):&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PriceCalculator&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;float&lt;/span&gt; &lt;span class="nv"&gt;$priceInCents&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;set&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;$value&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Price cannot be negative.'&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;priceInCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$priceInDollars&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;priceInCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;$priceInDollars&lt;/code&gt; property is virtual. It doesn't store anything. It computes the value on read from &lt;code&gt;$priceInCents&lt;/code&gt;. And the &lt;code&gt;set&lt;/code&gt; hook on &lt;code&gt;$priceInCents&lt;/code&gt; validates the input without a separate method.&lt;/p&gt;

&lt;p&gt;Where this shines in Laravel: service classes, value objects, and DTOs where you'd normally write getters with transformation logic. Note that Eloquent models have their own accessor/mutator system via &lt;code&gt;Attribute::make()&lt;/code&gt;, so property hooks don't replace those directly. But for any non-Eloquent class in your app (and you should have plenty), property hooks remove a lot of boilerplate.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Asymmetric Visibility: Public Read, Private Write
&lt;/h2&gt;

&lt;p&gt;Before PHP 8.4, if you wanted a property that anyone could read but only the class itself could modify, you had two options: make it private and add a getter, or make it &lt;code&gt;readonly&lt;/code&gt;. Both had tradeoffs. &lt;code&gt;readonly&lt;/code&gt; can only be set once, which doesn't work if the value changes over the object's lifetime.&lt;/p&gt;

&lt;p&gt;Asymmetric visibility solves this cleanly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (PHP 8.3):&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;getStatus&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;markAsShipped&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&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;// Usage&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;getStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 'pending'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (PHP 8.4):&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&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;private&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;markAsShipped&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&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;// Usage&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 'pending' - direct access, no getter needed&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Error: Cannot modify private(set) property&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;public private(set)&lt;/code&gt; declaration means: anyone can read &lt;code&gt;$status&lt;/code&gt; directly, but only the class itself can change it. No getter needed. No &lt;code&gt;readonly&lt;/code&gt; restriction. The value can change internally through methods like &lt;code&gt;markAsShipped()&lt;/code&gt;, but external code can't tamper with it.&lt;/p&gt;

&lt;p&gt;This is ideal for data transfer objects in your Laravel app. API response DTOs (especially if you're following &lt;a href="https://hafiz.dev/blog/laravel-api-development-restful-best-practices" rel="noopener noreferrer"&gt;REST API best practices&lt;/a&gt;), configuration objects, form data objects. Anywhere you want external code to read properties directly without letting them modify the state.&lt;/p&gt;

&lt;p&gt;You can also use &lt;code&gt;public protected(set)&lt;/code&gt; to allow child classes to modify the property while keeping external write access restricted.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. array_find(): Stop Filtering When You Only Need One
&lt;/h2&gt;

&lt;p&gt;PHP has had &lt;code&gt;array_filter()&lt;/code&gt; forever, but if you only need the first element that matches a condition, you've been writing this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (PHP 8.3):&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="nv"&gt;$users&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="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Alice'&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;'admin'&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;'Bob'&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;'editor'&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;'Charlie'&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;'admin'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$firstAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$users&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;$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="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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="kc"&gt;null&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 ugly. &lt;code&gt;array_filter&lt;/code&gt; processes the entire array, &lt;code&gt;array_values&lt;/code&gt; re-indexes it, and the &lt;code&gt;[0] ?? null&lt;/code&gt; handles the empty case. Three operations for something that should be one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After (PHP 8.4):&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="nv"&gt;$firstAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;array_find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$users&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;$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="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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;array_find()&lt;/code&gt; returns the first matching element and stops iterating. No re-indexing, no null coalescing. If nothing matches, it returns &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PHP 8.4 also adds three related functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;array_find_key()&lt;/code&gt; returns the key of the first match instead of the value&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;array_any()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; if at least one element matches (like &lt;code&gt;Collection::contains()&lt;/code&gt; for arrays)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;array_all()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; if every element matches (like &lt;code&gt;Collection::every()&lt;/code&gt; for arrays)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Laravel, you'll mostly use these in places where you're working with raw arrays instead of collections: config processing, middleware logic, job payloads, or anywhere performance matters and you don't want to create a Collection instance just to call &lt;code&gt;-&amp;gt;first()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The #[\Deprecated] Attribute
&lt;/h2&gt;

&lt;p&gt;PHP has always had a way to deprecate built-in functions, but there was no native mechanism for marking your own functions, methods, or class constants as deprecated. You'd either put a &lt;code&gt;@deprecated&lt;/code&gt; docblock comment (which only IDE-level tools read) or throw a manual &lt;code&gt;trigger_error()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (PHP 8.3):&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="cd"&gt;/**
 * @deprecated Use calculateTotal() instead
 */&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;calculateSubtotal&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;$items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;trigger_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'calculateSubtotal() is deprecated, use calculateTotal()'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;E_USER_DEPRECATED&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;calculateTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&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;After (PHP 8.4):&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="na"&gt;#[\Deprecated(message: 'Use calculateTotal() instead', since: '2.0')]&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;calculateSubtotal&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;$items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&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;calculateTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&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;#[\Deprecated]&lt;/code&gt; attribute triggers a real &lt;code&gt;E_USER_DEPRECATED&lt;/code&gt; notice when the function is called. IDEs like PhpStorm show it with a strikethrough. Static analysis tools like PHPStan and Larastan flag it automatically. And you get a &lt;code&gt;since&lt;/code&gt; parameter to track when the deprecation started.&lt;/p&gt;

&lt;p&gt;Where this helps in Laravel: if you maintain internal packages, APIs with versioned endpoints, or shared service classes across teams, this is a cleaner way to signal "stop using this" than a docblock that nobody reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Method Chaining on new Without Parentheses
&lt;/h2&gt;

&lt;p&gt;A small quality-of-life improvement that removes an annoying syntax limitation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (PHP 8.3):&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="nv"&gt;$date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;'Y-m-d'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStatusCode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those extra parentheses around &lt;code&gt;new ClassName()&lt;/code&gt; were required to chain a method call. They look awkward and trip up developers who forget them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After (PHP 8.4):&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="nv"&gt;$date&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;DateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;'Y-m-d'&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$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;setStatusCode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No wrapping parentheses needed. You can also access properties directly: &lt;code&gt;new Foo()-&amp;gt;bar&lt;/code&gt;. This is a small change, but it cleans up code in places where you create and immediately use throwaway objects, which happens often in tests, seeders, and one-off scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Multibyte String Functions: trim, ltrim, rtrim
&lt;/h2&gt;

&lt;p&gt;PHP finally has multibyte-aware trim functions. If your app handles content in languages like Japanese, Chinese, Arabic, or Korean, you've probably been using workarounds with &lt;code&gt;preg_replace&lt;/code&gt; to trim multibyte whitespace characters that &lt;code&gt;trim()&lt;/code&gt; ignores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (PHP 8.3):&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="c1"&gt;// Standard trim doesn't handle multibyte whitespace like \u{3000} (ideographic space)&lt;/span&gt;
&lt;span class="nv"&gt;$cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^\s+|\s+$/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$input&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;After (PHP 8.4):&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="nv"&gt;$cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mb_trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP 8.4 adds &lt;code&gt;mb_trim()&lt;/code&gt;, &lt;code&gt;mb_ltrim()&lt;/code&gt;, and &lt;code&gt;mb_rtrim()&lt;/code&gt;. If your Laravel app processes user input from a multilingual audience, these are a direct improvement. Use them in your form request &lt;code&gt;prepareForValidation()&lt;/code&gt; methods or in custom Eloquent casts where you clean input before storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Start Using These
&lt;/h2&gt;

&lt;p&gt;You don't need to refactor your entire codebase. The practical approach:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use immediately in new code.&lt;/strong&gt; When you write a new service class, DTO, or value object, use property hooks and asymmetric visibility instead of getters/setters. When you write a new array operation on raw data, reach for &lt;code&gt;array_find()&lt;/code&gt; before &lt;code&gt;array_filter()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refactor gradually.&lt;/strong&gt; When you touch an existing class for a feature or bug fix, modernize it if it takes less than five minutes. Don't create refactoring PRs that touch 50 files. That's risk for no product value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't touch Eloquent models.&lt;/strong&gt; Eloquent has its own accessor/mutator system that doesn't need property hooks. And asymmetric visibility conflicts with how Eloquent hydrates properties. Keep Eloquent models using Laravel's patterns.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do property hooks work with Eloquent models?
&lt;/h3&gt;

&lt;p&gt;Not in the way you might expect. Eloquent uses dynamic property access via &lt;code&gt;__get&lt;/code&gt; and &lt;code&gt;__set&lt;/code&gt;, which doesn't interact cleanly with PHP property hooks. Stick with Eloquent's &lt;code&gt;Attribute::make()&lt;/code&gt; for model accessors and mutators. Use property hooks in your service classes, DTOs, form data objects, and other non-Eloquent classes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use asymmetric visibility with constructor promotion?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;public private(set) string $name&lt;/code&gt; works in constructor parameters:&lt;br&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;protected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$age&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 gives you a publicly readable, internally writable promoted property in one line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to upgrade PHPStan or Larastan for PHP 8.4 features?
&lt;/h3&gt;

&lt;p&gt;PHPStan 2.1+ fully supports property hooks, asymmetric visibility, and the &lt;code&gt;#[\Deprecated]&lt;/code&gt; attribute. If you're running an older version, upgrade before adopting these features, otherwise your CI pipeline will flag false positives. Larastan follows PHPStan's version, so updating Larastan pulls in the PHP 8.4 support automatically. If you're also upgrading your &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;testing setup to Pest 4&lt;/a&gt;, do both at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the minimum PHP 8.4 version I should run?
&lt;/h3&gt;

&lt;p&gt;PHP 8.4.1 or later. The 8.4.0 release had a few edge-case bugs with property hooks in certain inheritance scenarios that were fixed in 8.4.1. If you're deploying to production, start with the latest 8.4.x patch.&lt;/p&gt;

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

&lt;p&gt;PHP 8.4 isn't a small release. Property hooks and asymmetric visibility change how you structure classes in a fundamental way. The new array functions remove patterns you've been copy-pasting for years. And the &lt;code&gt;#[\Deprecated]&lt;/code&gt; attribute gives you a tool that PHP itself has had forever but never shared with userland code.&lt;/p&gt;

&lt;p&gt;If you haven't upgraded yet, the &lt;a href="https://hafiz.dev/blog/4-composer-conflicts-blocking-laravel-13-upgrade" rel="noopener noreferrer"&gt;4 composer conflicts post&lt;/a&gt; walks you through the blockers you'll hit on the way to PHP 8.4 and Laravel 13. And if you're building something with Laravel and want help modernizing your codebase for PHP 8.4, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>php84</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The 4 Composer Conflicts That Block Most Laravel 13 Upgrades (And How to Find Yours)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 06 May 2026 05:12:23 +0000</pubDate>
      <link>https://dev.to/hafiz619/the-4-composer-conflicts-that-block-most-laravel-13-upgrades-and-how-to-find-yours-137c</link>
      <guid>https://dev.to/hafiz619/the-4-composer-conflicts-that-block-most-laravel-13-upgrades-and-how-to-find-yours-137c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/4-composer-conflicts-blocking-laravel-13-upgrade" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most Laravel apps don't fail upgrades because of breaking changes. They fail because &lt;code&gt;composer update&lt;/code&gt; throws a wall of red text and the developer closes the terminal.&lt;/p&gt;

&lt;p&gt;Laravel 13 shipped with "zero breaking changes" to application code. That's true. Your routes, controllers, and Eloquent models don't need touching. But your &lt;code&gt;composer.json&lt;/code&gt; is a different story. Somewhere in your 30-50 dependencies, there's almost certainly a version constraint that won't resolve against &lt;code&gt;laravel/framework:^13.0&lt;/code&gt;. And finding it manually means running &lt;code&gt;composer why-not&lt;/code&gt;, reading cryptic output, fixing one conflict, discovering the next, and repeating until you either succeed or give up and decide to "upgrade later."&lt;/p&gt;

&lt;p&gt;Four specific conflicts catch most developers. After looking at how these surface in real &lt;code&gt;composer.json&lt;/code&gt; files, in GitHub issues, and in r/laravel threads, the same patterns keep showing up. Here's what each one looks like, why it happens, and how to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your PHP Constraint Is Too Loose
&lt;/h2&gt;

&lt;p&gt;This is the most common blocker and the easiest to miss. Your &lt;code&gt;composer.json&lt;/code&gt; probably says something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"require"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"php"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^8.1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel 13 requires PHP 8.3 as the minimum. That constraint above technically allows 8.1, 8.2, 8.3, and 8.4. Two things can go wrong here.&lt;/p&gt;

&lt;p&gt;First, if your server actually runs PHP 8.2, Composer will refuse to install Laravel 13 regardless of what your &lt;code&gt;composer.json&lt;/code&gt; says. The error looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your requirements could not be resolved to an installable set of packages.

Problem 1
  - laravel/framework v13.0.0 requires php ^8.3 -&amp;gt; your php version (8.2.28)
    does not satisfy that requirement.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, even if your server runs 8.3, a loose constraint like &lt;code&gt;^8.1&lt;/code&gt; means your app &lt;em&gt;could&lt;/em&gt; be deployed on 8.1 or 8.2, where Laravel 13 won't work. Tightening the constraint protects you from that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Run &lt;code&gt;php -v&lt;/code&gt; on production first. If it returns anything below 8.3, upgrade PHP before touching Composer. Then tighten your constraint to match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"require"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"php"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^8.3"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't skip this. Every other fix in this post is pointless if your PHP version is too low.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Symfony 8 Surprise
&lt;/h2&gt;

&lt;p&gt;This one is newer and won't hit you on day one. Laravel 13.0 through 13.2 work fine on PHP 8.3. But starting with Laravel 13.3, the framework allows Symfony 8 components (&lt;code&gt;symfony/error-handler&lt;/code&gt;, &lt;code&gt;symfony/console&lt;/code&gt;) that require PHP 8.4.&lt;/p&gt;

&lt;p&gt;If you're on PHP 8.3 and you run &lt;code&gt;composer update&lt;/code&gt; after the initial upgrade, you might get hit by this weeks later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Problem 1
  - laravel/framework v13.3.0 requires symfony/error-handler ^7.4.0 || ^8.0.0
    -&amp;gt; satisfiable by symfony/error-handler[v8.0.8].
  - symfony/error-handler v8.0.8 requires php &amp;gt;=8.4
    -&amp;gt; your php version (8.3.30) does not satisfy that requirement.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; You have two options. Either upgrade to PHP 8.4 (the better long-term choice), or pin Symfony to 7.4 in your &lt;code&gt;composer.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require symfony/console:&lt;span class="s2"&gt;"^7.4"&lt;/span&gt; symfony/error-handler:&lt;span class="s2"&gt;"^7.4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps you on Symfony 7.4 while running Laravel 13.3+. It works, but it's a temporary workaround. PHP 8.4 is where you want to be.&lt;/p&gt;

&lt;p&gt;If you're reading the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;full Laravel 13 upgrade guide&lt;/a&gt;, this particular conflict isn't covered there because it appeared after the initial release. It's exactly the kind of thing that catches you between "I upgraded successfully" and "why is production broken after composer update?"&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Spatie Packages Pinned to Old Major Versions
&lt;/h2&gt;

&lt;p&gt;If you use any Spatie packages (and most Laravel apps do), check their version constraints carefully. Older major versions often don't support Laravel 13. The most common offenders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;spatie/laravel-permission&lt;/code&gt; v5 doesn't support Laravel 13. You need at least v6.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spatie/laravel-medialibrary&lt;/code&gt; older major versions don't support Laravel 13. Check your installed version against the latest release.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spatie/laravel-activitylog&lt;/code&gt; requires at least v4.12 for Laravel 13 support. Earlier v4 releases won't resolve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The error looks like a typical version mismatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Problem 1
  - spatie/laravel-permission v5.11.1 requires illuminate/database ^9.0|^10.0|^11.0|^12.0
    -&amp;gt; found illuminate/database v13.0.0 but it does not match ^9.0|^10.0|^11.0|^12.0.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Upgrade each Spatie package to the latest major version &lt;em&gt;before&lt;/em&gt; upgrading Laravel. Check the GitHub releases page for each one. Most have migration guides. Spatie generally ships Laravel support within days of a new release, and they've even backported Laravel 13 compatibility to some older branches so third-party packages have time to catch up.&lt;/p&gt;

&lt;p&gt;One tip: run &lt;code&gt;composer outdated --major&lt;/code&gt; to see which packages have major version jumps available. That command shows you the gap without trying to resolve anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Testing Packages That Quietly Block Everything
&lt;/h2&gt;

&lt;p&gt;PHPUnit and Pest are required by almost every Laravel app, but they sit in &lt;code&gt;require-dev&lt;/code&gt; and tend to get ignored during upgrades. They shouldn't be.&lt;/p&gt;

&lt;p&gt;Laravel 13 requires &lt;code&gt;phpunit/phpunit:^11.5.50&lt;/code&gt; or &lt;code&gt;^12.0&lt;/code&gt;. If your &lt;code&gt;composer.json&lt;/code&gt; still has &lt;code&gt;"phpunit/phpunit": "^10.0"&lt;/code&gt;, that's a blocker. Same with Pest: you need at least Pest 3.8.5 for Laravel 13 support (earlier 3.x releases pin a PHPUnit version that's too low).&lt;/p&gt;

&lt;p&gt;The error often looks something like this, showing up in &lt;code&gt;require-dev&lt;/code&gt; where some developers skip reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Problem 1
  - phpunit/phpunit 10.5.46 requires sebastian/comparator ^5.0
    -&amp;gt; found sebastian/comparator 6.3.1 but it does not match ^5.0.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's PHPUnit 10 conflicting with a transitive dependency that Laravel 13's newer Symfony components pull in. It's not obvious at all from the error message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Update your testing packages first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; phpunit/phpunit:^12.0

&lt;span class="c"&gt;# Or if you use Pest:&lt;/span&gt;
composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; pestphp/pest:^3.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've been putting off the &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest 4 migration&lt;/a&gt;, now is the time. Pest 4 ships with full Laravel 13 support and a cleaner API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or Skip All of This
&lt;/h2&gt;

&lt;p&gt;Every conflict above follows the same pattern: something in your &lt;code&gt;composer.json&lt;/code&gt; doesn't match what Laravel 13 expects, and finding it requires running commands, reading error output, and debugging one conflict at a time.&lt;/p&gt;

&lt;p&gt;There's a faster way. Paste your &lt;code&gt;composer.json&lt;/code&gt; into the &lt;a href="https://hafiz.dev/laravel/upgrade-analyzer" rel="noopener noreferrer"&gt;Laravel Upgrade Analyzer&lt;/a&gt; and it'll show you exactly which dependencies need attention. It checks 33 packages (including PHP version, Symfony components, and testing tools) against Laravel 13's requirements and flags each one as Blocker (stops the upgrade entirely), Breaking (needs a major version bump), or Watch (minor bump, low risk). The whole thing takes about 5 seconds and nothing gets stored.&lt;/p&gt;

&lt;p&gt;If you went through the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade guide&lt;/a&gt; and already upgraded, the analyzer still catches stale package versions and constraint mismatches you might have missed.&lt;/p&gt;

&lt;p&gt;And if you don't want to deal with any of this yourself, I do Laravel upgrades. &lt;a href="mailto:contact@hafiz.dev"&gt;Send me your composer.json&lt;/a&gt; and I'll scope it.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does the Upgrade Analyzer work for older upgrades like Laravel 11 to 12?
&lt;/h3&gt;

&lt;p&gt;Right now it's focused on Laravel 13 specifically. The rules check PHP version requirements, first-party Laravel packages, and 33 of the most common third-party packages against Laravel 13 compatibility. Support for older upgrade paths may come later.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if one of my packages isn't in the analyzer's rules?
&lt;/h3&gt;

&lt;p&gt;The analyzer covers 33 packages (25 auto-derived from Packagist, 8 hand-curated). If your package isn't covered, you can check manually by running &lt;code&gt;composer why-not laravel/framework:^13.0&lt;/code&gt; or checking the package's GitHub releases for Laravel 13 support. Found a package that should be included? &lt;a href="mailto:contact@hafiz.dev"&gt;Let me know&lt;/a&gt; and I'll add it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is my composer.json stored or shared?
&lt;/h3&gt;

&lt;p&gt;No. The analyzer processes your file server-side and discards it immediately. Nothing is stored, logged, or shared. You can verify this in the page footer.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>upgradeguide</category>
      <category>composer</category>
    </item>
    <item>
      <title>Filament v5 Multi-Tenancy: The Complete Implementation Guide</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 04 May 2026 05:20:07 +0000</pubDate>
      <link>https://dev.to/hafiz619/filament-v5-multi-tenancy-the-complete-implementation-guide-25b2</link>
      <guid>https://dev.to/hafiz619/filament-v5-multi-tenancy-the-complete-implementation-guide-25b2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/filament-v5-multi-tenancy-complete-implementation-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every SaaS app eventually hits the same question: how do you make one application serve multiple customers with separate data? If you're building with Filament, the answer is closer than you think. Filament ships with a built-in tenancy system that handles tenant switching, automatic resource scoping, registration, and profile management out of the box.&lt;/p&gt;

&lt;p&gt;But here's the thing: the docs cover what's available without walking you through the full implementation. You get a list of methods and interfaces, and then you're on your own to wire them together. If you've read the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;Building a SaaS with Filament&lt;/a&gt; guide, you have the foundation. This post picks up where that left off: adding proper multi-tenancy so your users can belong to teams, switch between them, and see only their team's data.&lt;/p&gt;

&lt;p&gt;We'll build the full system: tenant model, user relationships, panel configuration, registration, profile editing, automatic scoping, and the security gotchas that trip up most developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Filament's Tenancy Works
&lt;/h2&gt;

&lt;p&gt;Before we write any code, it's worth understanding what Filament means by "multi-tenancy." It's not database-per-tenant isolation (like stancl/tenancy). Filament uses a shared database with a many-to-many relationship between users and tenants.&lt;/p&gt;

&lt;p&gt;The mental model: a user belongs to many teams. A team has many users. Every resource (projects, invoices, tickets, whatever you're building) belongs to a team. When a user logs in, they pick a team, and Filament automatically scopes all resources to that team. The user can switch teams from a dropdown in the sidebar.&lt;/p&gt;

&lt;p&gt;If your app has a simpler model where each user belongs to exactly one organization (one-to-many), you don't actually need Filament's tenancy system. You can use a global scope and a &lt;code&gt;belongsTo&lt;/code&gt; relationship instead. Filament's tenancy is designed for the many-to-many case where users switch between contexts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create the Tenant Model
&lt;/h2&gt;

&lt;p&gt;Your tenant can be called anything: Team, Organization, Company, Workspace. We'll use Team here. Create the model, migration, and pivot table:&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:model Team &lt;span class="nt"&gt;-m&lt;/span&gt;
php artisan make:migration create_team_user_table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Team migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'teams'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pivot table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_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="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cascadeOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cascadeOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'member'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'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;That &lt;code&gt;role&lt;/code&gt; column on the pivot is optional, but you'll want it eventually for permissions (owner, admin, member). Adding it now saves a migration later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Set Up the Relationships
&lt;/h2&gt;

&lt;p&gt;The Team model needs a &lt;code&gt;users&lt;/code&gt; relationship and the &lt;code&gt;HasName&lt;/code&gt; interface so Filament can display the team name in the switcher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\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;Filament\Models\Contracts\HasName&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Relations\BelongsToMany&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;Team&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;HasName&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$fillable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'slug'&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;users&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsToMany&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsToMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withPivot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withTimestamps&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;getFilamentName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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 User model needs the &lt;code&gt;HasTenants&lt;/code&gt; interface. This tells Filament which tenants the user belongs to and whether they can access a specific tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\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;Filament\Models\Contracts\FilamentUser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Models\Contracts\HasTenants&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Panel&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Relations\BelongsToMany&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;FilamentUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTenants&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;teams&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsToMany&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsToMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&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;withPivot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withTimestamps&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;getTenants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;Collection&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;teams&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;canAccessTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Model&lt;/span&gt; &lt;span class="nv"&gt;$tenant&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="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;canAccessPanel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&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="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;getTenants()&lt;/code&gt; returns the teams the user belongs to. Filament calls this to populate the tenant switcher dropdown. &lt;code&gt;canAccessTenant()&lt;/code&gt; is the security gate: it runs on every request to make sure the user actually belongs to the tenant in the URL. Don't skip this. Without it, a user could change the team ID in the URL and access another team's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Configure the Panel
&lt;/h2&gt;

&lt;p&gt;Open your panel provider (usually &lt;code&gt;app/Providers/Filament/AdminPanelProvider.php&lt;/code&gt;) and add the tenant configuration:&lt;br&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\Team&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Filament\Pages\Tenancy\RegisterTeam&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\Filament\Pages\Tenancy\EditTeamProfile&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;panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Panel&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;$panel&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slugAttribute&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenantRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RegisterTeam&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;tenantProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EditTeamProfile&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;The &lt;code&gt;slugAttribute: 'slug'&lt;/code&gt; parameter tells Filament to use the &lt;code&gt;slug&lt;/code&gt; column in URLs instead of the auto-incrementing ID. Your URLs will look like &lt;code&gt;/admin/acme-corp/projects&lt;/code&gt; instead of &lt;code&gt;/admin/1/projects&lt;/code&gt;. This is cleaner and doesn't leak information about how many teams exist.&lt;/p&gt;

&lt;p&gt;After a user logs in, Filament redirects them to their first team (from &lt;code&gt;getTenants()&lt;/code&gt;). If they don't have a team yet, they're sent to the registration page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Build the Registration Page
&lt;/h2&gt;

&lt;p&gt;Create a page that extends &lt;code&gt;RegisterTenant&lt;/code&gt;. This is what new users see when they don't belong to any team yet:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Team&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Components\TextInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Form&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Pages\Tenancy\RegisterTenant&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\Str&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;RegisterTeam&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RegisterTenant&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getLabel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Create a Team'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&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;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'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;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debounce&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;afterStateUpdated&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;$set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$state&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;$set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&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;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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;handleRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Team&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Team&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;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;users&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&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;'owner'&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;$team&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;handleRegistration&lt;/code&gt; method creates the team and attaches the current user as the owner. This is where you'd add any onboarding logic: creating default settings, seeding initial data, or sending a welcome notification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Build the Profile Page
&lt;/h2&gt;

&lt;p&gt;The profile page lets users edit their team settings. Same pattern:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Components\TextInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Form&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Pages\Tenancy\EditTenantProfile&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;EditTeamProfile&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;EditTenantProfile&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getLabel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Team Settings'&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;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&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;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'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;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&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 page is accessible from the tenant menu in the sidebar. Add fields as your team model grows: logo upload, billing email, timezone, whatever your app needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Add the Tenant Relationship to Your Resources
&lt;/h2&gt;

&lt;p&gt;This is the part most tutorials skip, and it's where data leaks happen.&lt;/p&gt;

&lt;p&gt;Every model that belongs to a team needs a &lt;code&gt;team_id&lt;/code&gt; column and a &lt;code&gt;belongsTo&lt;/code&gt; relationship:&lt;br&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;// In your migration&lt;/span&gt;
&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cascadeOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// In your model&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;team&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsTo&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&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;And the Team model needs the inverse:&lt;br&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;// In Team.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;projects&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;HasMany&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Project&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;Filament uses this relationship to automatically scope queries. When a user is viewing the "Acme Corp" team, &lt;code&gt;ProjectResource&lt;/code&gt; will only show projects where &lt;code&gt;team_id&lt;/code&gt; matches the current tenant. You don't need to add any &lt;code&gt;where&lt;/code&gt; clauses or global scopes yourself. Filament handles it.&lt;/p&gt;

&lt;p&gt;But you DO need to make sure the &lt;code&gt;team_id&lt;/code&gt; gets set when creating new records. The simplest way is a model observer or the &lt;code&gt;creating&lt;/code&gt; 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="c1"&gt;// In AppServiceProvider boot() or a dedicated observer&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Facades\Filament&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;creating&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;Project&lt;/span&gt; &lt;span class="nv"&gt;$project&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="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, new records won't have a &lt;code&gt;team_id&lt;/code&gt; and will be invisible to the tenant scoping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotcha That Trips Up Everyone
&lt;/h2&gt;

&lt;p&gt;Filament's automatic scoping works for resource tables and queries. But it does NOT automatically scope form components that load options from the database.&lt;/p&gt;

&lt;p&gt;If you have a &lt;code&gt;Select&lt;/code&gt; component that pulls options via a &lt;code&gt;relationship()&lt;/code&gt; method, those options are not filtered by tenant:&lt;br&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 will show ALL categories from ALL teams&lt;/span&gt;
&lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://filamentphp.com/docs/5.x/users/tenancy#multi-tenancy" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; are explicit about this. The form components live in a separate package and don't know about tenancy. You need to scope them manually:&lt;br&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;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereBelongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This applies to &lt;code&gt;Select&lt;/code&gt;, &lt;code&gt;CheckboxList&lt;/code&gt;, &lt;code&gt;Repeater&lt;/code&gt;, and &lt;code&gt;SelectFilter&lt;/code&gt;. Any component that fetches data from the database through a relationship needs manual scoping. Miss one, and users from Team A will see Team B's categories in their dropdown. That's a data leak.&lt;/p&gt;

&lt;p&gt;If you have a lot of these, consider creating a base resource class that overrides the form builder to inject tenant scoping automatically. Or add a global scope to the models themselves, though that can cause issues outside of Filament.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disabling Tenancy for Specific Resources
&lt;/h2&gt;

&lt;p&gt;Not everything belongs to a team. Settings, plans, or shared lookup tables might be global. You can opt a resource out of tenant scoping:&lt;br&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;PlanResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Resource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$isScopedToTenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or flip the default so resources are NOT scoped unless you explicitly opt in:&lt;br&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;// In a service provider&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Resources\Resource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Resource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;scopeToTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add &lt;code&gt;protected static bool $isScopedToTenant = true;&lt;/code&gt; to each resource that should be scoped. This opt-in approach is safer for apps with a mix of global and tenant-specific data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlling the Tenant Switcher
&lt;/h2&gt;

&lt;p&gt;By default, Filament shows a dropdown in the sidebar for switching between teams. You can customize it in several ways.&lt;/p&gt;

&lt;p&gt;Add a label above the current tenant name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Models\Contracts\HasCurrentTenantLabel&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;Team&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;HasName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasCurrentTenantLabel&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;getCurrentTenantLabel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Active team'&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;Set a default tenant when the user logs in:&lt;br&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;// In User.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;getDefaultTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you want to keep the tenant menu (for profile and billing links) but hide the switcher itself, Filament v5.2 added &lt;code&gt;switchableTenants()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;switchableTenants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful when tenants are selected through other means (like a URL subdomain) and the switcher UI is unnecessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subdomain-Based Tenancy
&lt;/h2&gt;

&lt;p&gt;Instead of path-based URLs (&lt;code&gt;/admin/acme-corp/projects&lt;/code&gt;), you can identify tenants by subdomain (&lt;code&gt;acme-corp.yourapp.com&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenantDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'{tenant:slug}.yourapp.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to know: when you use a domain parameter for the entire domain, Filament registers a global route parameter pattern that allows dots and hyphens. This might conflict with other panels or routes in your app. Test thoroughly if you have multiple panels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying Middleware to Tenant Routes
&lt;/h2&gt;

&lt;p&gt;If you need to run middleware on all tenant-aware routes (like checking subscription status), use &lt;code&gt;tenantMiddleware&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nc"&gt;\App\Http\Middleware\EnsureTeamIsSubscribed&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;This middleware runs after the tenant is resolved, so you have access to &lt;code&gt;Filament::getTenant()&lt;/code&gt; inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessing the Current Tenant Anywhere
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;Filament::getTenant()&lt;/code&gt; to get the current team in controllers, jobs, notifications, or anywhere else in your app:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$teamName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns &lt;code&gt;null&lt;/code&gt; outside of a Filament panel context, so check for that in shared code like jobs or API controllers.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can I use Filament's multi-tenancy with separate databases per tenant?
&lt;/h3&gt;

&lt;p&gt;Filament's built-in tenancy uses a shared database with relationship-based scoping. If you need database-per-tenant isolation, look at stancl/tenancy or the FilamentTenancy plugin by TomatoPHP, which bridges stancl/tenancy with Filament panels. These are separate packages, not part of Filament core.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need spatie/laravel-multitenancy alongside Filament's built-in tenancy?
&lt;/h3&gt;

&lt;p&gt;For most cases, no. Filament's tenancy handles the common SaaS pattern (users belong to teams, data is scoped by team) without additional packages. Spatie's package or stancl/tenancy adds features like database isolation, domain identification, and tenant-specific configurations. If your needs are simpler, Filament alone is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a user doesn't belong to any team?
&lt;/h3&gt;

&lt;p&gt;Filament redirects them to the tenant registration page (if you've configured one with &lt;code&gt;tenantRegistration()&lt;/code&gt;). If you haven't configured a registration page, the user will see an error. Always set up a registration page, even if new teams are created by admins. You can restrict who sees the registration page using a middleware or by conditionally setting it in the panel configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I invite users to a team?
&lt;/h3&gt;

&lt;p&gt;Filament doesn't include an invitation system out of the box. You'll need to build one yourself or use a package like &lt;code&gt;filament-companies&lt;/code&gt; by Andrew Wallo, which includes team invitations, role management, and profile features similar to Laravel Jetstream. The invitation flow typically involves creating an invitation record, sending an email with a signed URL, and attaching the user to the team when they accept.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the automatic resource scoping safe for production?
&lt;/h3&gt;

&lt;p&gt;The resource scoping is safe as long as you handle two things: implement &lt;code&gt;canAccessTenant()&lt;/code&gt; on your User model (to prevent URL manipulation), and manually scope form components that load options via relationships. If you skip either of these, tenant data can leak. Both are covered in this guide.&lt;/p&gt;

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

&lt;p&gt;If you followed the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS guide&lt;/a&gt; and the &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;admin dashboard guide&lt;/a&gt;, multi-tenancy is the natural next step. Your application now supports multiple customers, each with their own data, their own team settings, and the ability to switch between workspaces.&lt;/p&gt;

&lt;p&gt;For a deeper look at how multi-tenancy strategies compare at the database level (shared database vs. schema isolation vs. database per tenant), the &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;Laravel Multi-Tenancy&lt;/a&gt; post covers the broader architectural decisions.&lt;/p&gt;

&lt;p&gt;If you're building a multi-tenant SaaS with Filament and need help with architecture, data isolation, or production deployment, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>multitenancy</category>
      <category>saas</category>
    </item>
    <item>
      <title>Laravel AI SDK: Add Text-to-Speech and Voice to Your App in 20 Minutes</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Fri, 01 May 2026 06:12:17 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-ai-sdk-add-text-to-speech-and-voice-to-your-app-in-20-minutes-35fb</link>
      <guid>https://dev.to/hafiz619/laravel-ai-sdk-add-text-to-speech-and-voice-to-your-app-in-20-minutes-35fb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-text-to-speech-voice-tutorial" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Taylor Otwell dropped a one-liner on X yesterday that stopped me mid-scroll:&lt;br&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;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Hello, Laravel'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toAudio&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. One method call and your string becomes audio. No external SDK wiring, no Guzzle calls, no API response parsing. Just &lt;code&gt;-&amp;gt;toAudio()&lt;/code&gt; on a Stringable, the same way you'd call &lt;code&gt;-&amp;gt;upper()&lt;/code&gt; or &lt;code&gt;-&amp;gt;slug()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've been following the Laravel AI SDK through &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;Part 1 (building a smart assistant)&lt;/a&gt; and &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-part-2-build-a-rag-powered-support-bot-with-tools-and-memory" rel="noopener noreferrer"&gt;Part 2 (RAG-powered support bot)&lt;/a&gt;, you already know how text generation and tool calling work. But there's a whole side of the SDK that most developers haven't touched yet: audio. Text-to-speech generation, voice customization, speech-to-text transcription, queued processing, and testing support are all built in.&lt;/p&gt;

&lt;p&gt;Let's build with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;By the end of this tutorial, you'll have a Laravel app that can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Convert any text to natural-sounding audio and store it&lt;/li&gt;
&lt;li&gt;Choose between male, female, or specific voice IDs&lt;/li&gt;
&lt;li&gt;Coach the AI on &lt;em&gt;how&lt;/em&gt; the audio should sound (tone, pace, emotion)&lt;/li&gt;
&lt;li&gt;Transcribe uploaded audio files back to text (with speaker detection)&lt;/li&gt;
&lt;li&gt;Queue audio generation for background processing&lt;/li&gt;
&lt;li&gt;Test everything without hitting a single API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We'll start simple and build up. You don't need any AI experience to follow along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;If you already have the AI SDK installed from a previous tutorial, skip to the next section. Otherwise:&lt;br&gt;
&lt;/p&gt;

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

php artisan vendor:publish &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Laravel&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;i&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;iServiceProvider"&lt;/span&gt;

php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your provider credentials to &lt;code&gt;.env&lt;/code&gt;. For audio, you need at least one of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OPENAI_API_KEY=your-openai-key
ELEVENLABS_API_KEY=your-elevenlabs-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenAI supports both text-to-speech and speech-to-text. ElevenLabs supports both as well, plus Mistral handles transcription. The full provider matrix from the &lt;a href="https://laravel.com/docs/13.x/ai-sdk#provider-support" rel="noopener noreferrer"&gt;official docs&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Providers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TTS&lt;/td&gt;
&lt;td&gt;OpenAI, ElevenLabs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STT&lt;/td&gt;
&lt;td&gt;OpenAI, ElevenLabs, Mistral&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's it for setup. Let's generate some audio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your First Audio Generation
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Audio&lt;/code&gt; facade gives you a clean, fluent API:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Your order has been shipped and will arrive by Thursday.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$audio&lt;/code&gt; object holds the raw audio content. You can cast it to a string to get the bytes, or store 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="c1"&gt;// Store on your default disk&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Store with a specific path and filename&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'audio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'order-confirmation.mp3'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Store on the public disk&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storePublicly&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want to serve it directly from a controller? Return it as a response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="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;'/audio/preview'&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;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome to our support line.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="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="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$audio&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;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'audio/mpeg'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a working audio endpoint in five lines. And if you just need a quick one-liner somewhere in your code, the Stringable integration is even shorter:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Hello, Laravel'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toAudio&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 what Taylor tweeted about. The AI SDK registers &lt;code&gt;toAudio()&lt;/code&gt; as a method on the Stringable class, so you can chain it alongside &lt;code&gt;-&amp;gt;replace()&lt;/code&gt;, &lt;code&gt;-&amp;gt;trim()&lt;/code&gt;, or any other string method. It's the same pattern as &lt;code&gt;-&amp;gt;toEmbeddings()&lt;/code&gt; for vector search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a Voice
&lt;/h2&gt;

&lt;p&gt;The SDK gives you three ways to control the voice:&lt;br&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;// Quick gender selection&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome back!'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Your package is ready.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;male&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Specific voice by ID or name&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Breaking news: Laravel 14 announced.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alloy'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenAI offers 11+ voices including &lt;code&gt;alloy&lt;/code&gt;, &lt;code&gt;ash&lt;/code&gt;, &lt;code&gt;coral&lt;/code&gt;, &lt;code&gt;echo&lt;/code&gt;, &lt;code&gt;fable&lt;/code&gt;, &lt;code&gt;nova&lt;/code&gt;, &lt;code&gt;onyx&lt;/code&gt;, &lt;code&gt;sage&lt;/code&gt;, &lt;code&gt;shimmer&lt;/code&gt;, and &lt;code&gt;verse&lt;/code&gt;. Each has a distinct character. &lt;code&gt;alloy&lt;/code&gt; is neutral and balanced, good for most use cases. &lt;code&gt;nova&lt;/code&gt; sounds more expressive and warm. &lt;code&gt;onyx&lt;/code&gt; has a deeper, more authoritative tone. I'd suggest generating a short sample with each voice before committing to one for production. The difference is noticeable.&lt;/p&gt;

&lt;p&gt;If you're using ElevenLabs, you can pass any voice ID from your account, including custom cloned voices. ElevenLabs generally produces more natural-sounding output than OpenAI for longer narration, but OpenAI is faster and cheaper for short clips. The choice depends on what you're building. Quick notifications? OpenAI. Full blog post narrations or product demos? ElevenLabs is probably worth the extra cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Style Instructions
&lt;/h2&gt;

&lt;p&gt;This is where things get interesting. The &lt;code&gt;instructions&lt;/code&gt; method lets you coach the AI on how the audio should sound:&lt;br&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;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Ahoy! Your treasure has arrived!'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Speak like a friendly pirate captain'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'We regret to inform you that your account has been suspended.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;male&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Professional and empathetic, slow pace'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Think of &lt;code&gt;instructions&lt;/code&gt; as a system prompt for voice. You can control tone, pace, emotion, accent style, and delivery. It won't always nail it perfectly (this depends on the provider), but for most use cases it makes a noticeable difference. Try things like "cheerful and upbeat", "calm and measured", or "urgent, like a news anchor".&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Practical Use Cases
&lt;/h2&gt;

&lt;p&gt;Before we move on, here are patterns I think are worth building:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Blog post audio versions.&lt;/strong&gt; Generate an audio file when a post is published. Store it alongside the post and embed an HTML5 audio player. Accessibility win, and it keeps readers on the page longer.&lt;br&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;// In your Post observer or event listener&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plain_text_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Conversational and clear, like a podcast host'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&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="s2"&gt;"post-&lt;/span&gt;&lt;span class="si"&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;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp3"&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. Order confirmation voice messages.&lt;/strong&gt; E-commerce apps can generate a short audio clip summarizing the order and attach it to the confirmation email or display it on the "thank you" page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. In-app notifications with voice.&lt;/strong&gt; Instead of (or alongside) push notifications, generate a short spoken version. Useful for accessibility or for apps used in environments where reading a screen isn't practical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Interactive voice responses.&lt;/strong&gt; Build a simple IVR system. Use the AI SDK for TTS on the outbound side and transcription on the inbound side. No Twilio SDK needed for the voice generation part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Language learning tools.&lt;/strong&gt; Generate pronunciation examples dynamically. Pass different &lt;code&gt;instructions&lt;/code&gt; for different accents or speaking speeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speech-to-Text Transcription
&lt;/h2&gt;

&lt;p&gt;The SDK handles the reverse direction too. If you have an audio file, turn it into text:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;// From a file on disk&lt;/span&gt;
&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Transcription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'recordings/meeting.mp3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "Alright team, let's review the Q2 numbers..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also transcribe from a raw file path:&lt;br&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;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Transcription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/tmp/uploaded-audio.webm'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'audio/webm'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pairs naturally with file uploads. A typical controller might look 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;transcribe&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="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'audio'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|file|mimes:mp3,wav,webm'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'audio'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'temp'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Transcription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'audio'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMimeType&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;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="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;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a complete speech-to-text endpoint. Twenty lines, including validation and cleanup. Compare that to wiring up the OpenAI API manually with Guzzle, handling multipart uploads, parsing JSON responses, and managing error states. The SDK handles all of that behind a single &lt;code&gt;generate()&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;One thing to watch for: the transcription providers have file size limits. OpenAI's Whisper accepts files up to 25MB. For longer recordings, you'll need to split the audio into chunks first. FFmpeg handles this well, and you can use Laravel's &lt;code&gt;Process&lt;/code&gt; facade to run it:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nc"&gt;Process&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ffmpeg -i input.mp3 -f segment -segment_time 300 -c copy chunk_%03d.mp3"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That splits a recording into 5-minute chunks. Transcribe each one and concatenate the results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Speaker Diarization
&lt;/h3&gt;

&lt;p&gt;For meeting recordings or multi-speaker audio, the SDK supports speaker diarization, which identifies who spoke when. This is useful for automated meeting notes, call center analytics, or podcast transcription workflows.&lt;/p&gt;

&lt;p&gt;Not all providers handle diarization the same way. OpenAI's newer GPT-4o Transcribe model supports it natively, but the legacy Whisper model does not. ElevenLabs supports it as well. Check the &lt;a href="https://laravel.com/docs/13.x/ai-sdk#transcription" rel="noopener noreferrer"&gt;official AI SDK documentation&lt;/a&gt; for the exact method chain and provider requirements, as this feature is still evolving across providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queued Audio Generation
&lt;/h2&gt;

&lt;p&gt;Audio generation takes time, especially for longer text. You don't want users staring at a spinner while their blog post gets narrated. Queue it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plain_text_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Conversational, like a podcast'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateQueued&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK dispatches a job to your &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue system&lt;/a&gt;. If you need to do something with the audio after it generates (store it, notify the user), chain a callback:&lt;br&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;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plain_text_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateQueued&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$audio&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="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&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="s2"&gt;"post-&lt;/span&gt;&lt;span class="si"&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;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp3"&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;'has_audio'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the pattern I'd use for blog post narration. Publish the post, dispatch the audio job, and update the post record when the file is ready. The reader sees the audio player appear once it's processed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching Providers
&lt;/h2&gt;

&lt;p&gt;By default, the SDK uses whatever provider you've configured in &lt;code&gt;config/ai.php&lt;/code&gt;. But you can switch providers per request:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;// Use ElevenLabs for higher-quality voice&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Premium audio content'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;ElevenLabs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Use OpenAI for faster, cheaper generation&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Quick notification'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've read 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;AI SDK overview&lt;/a&gt;, you'll recognize this pattern. It's the same &lt;code&gt;Lab&lt;/code&gt; enum used for text generation. One SDK, one API, multiple providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Without API Calls
&lt;/h2&gt;

&lt;p&gt;You don't want your test suite hitting OpenAI's API every time it runs. The SDK has built-in fakes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Audio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Transcription&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;'generates audio for a new 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="nc"&gt;Audio&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;$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;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;// Your code that generates audio...&lt;/span&gt;

    &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertGenerated&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;$audio&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;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'order has been shipped'&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;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'transcribes uploaded audio'&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;Transcription&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="c1"&gt;// Your code that transcribes...&lt;/span&gt;

    &lt;span class="nc"&gt;Transcription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertGenerated&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;Audio::fake()&lt;/code&gt; prevents any HTTP requests and lets you assert that generation happened with the right inputs. Same pattern as &lt;code&gt;Http::fake()&lt;/code&gt;, &lt;code&gt;Mail::fake()&lt;/code&gt;, or &lt;code&gt;Queue::fake()&lt;/code&gt;. If you've tested anything in Laravel before, this is familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Considerations
&lt;/h2&gt;

&lt;p&gt;A few things I'd think about before shipping audio features to real users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache aggressively.&lt;/strong&gt; If the same text generates the same audio, store the result and serve it from disk next time. Don't regenerate audio for content that hasn't changed. For blog posts, generate once on publish and serve the stored file forever. Invalidate only when the content is updated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handle failures gracefully.&lt;/strong&gt; API calls fail. Rate limits hit. Provider outages happen. Wrap your audio generation in try/catch blocks and make sure the user experience degrades gracefully when audio isn't available. A missing audio player is better than a 500 error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'audio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"post-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp3"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Audio generation failed for post &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;$e&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="c1"&gt;// Continue without audio - the post still works&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;Set appropriate timeouts.&lt;/strong&gt; Longer text takes longer to generate. If you're narrating a 2,000-word blog post, the API might need 15-20 seconds. That's too long for a synchronous request. Use queued generation for anything over a paragraph or two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitor your spending.&lt;/strong&gt; Both OpenAI and ElevenLabs charge per character. If you have a webhook or background job that generates audio, a bug could run up a surprising bill fast. Set up billing alerts on your provider accounts and consider adding a character count guard in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Costs and Rate Limits
&lt;/h2&gt;

&lt;p&gt;A quick note on pricing, because this comes up every time someone talks about AI in production. As of right now, OpenAI's TTS costs roughly $15 per million characters. For context, a 2,000-word blog post is about 10,000 characters. That's $0.15 per post narrated. ElevenLabs offers 10,000 characters per month on their free tier, with paid plans starting around $5/month for higher quotas and premium voices.&lt;/p&gt;

&lt;p&gt;For transcription, OpenAI Whisper costs about $0.006 per minute of audio. A 30-minute meeting transcript runs roughly $0.18.&lt;/p&gt;

&lt;p&gt;These costs are low enough for most production use cases, but cache or store your generated audio. Don't regenerate the same content repeatedly.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can I use this without the AI SDK's agent features?
&lt;/h3&gt;

&lt;p&gt;Yes. The &lt;code&gt;Audio&lt;/code&gt; and &lt;code&gt;Transcription&lt;/code&gt; facades are completely standalone. You don't need to create agents, define tools, or set up conversations. Just &lt;code&gt;composer require laravel/ai&lt;/code&gt;, add your API key, and call &lt;code&gt;Audio::of('text')-&amp;gt;generate()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which providers support the &lt;code&gt;-&amp;gt;toAudio()&lt;/code&gt; Stringable method?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;-&amp;gt;toAudio()&lt;/code&gt; method uses whatever default provider you've configured for audio in &lt;code&gt;config/ai.php&lt;/code&gt;. You can set this to OpenAI or ElevenLabs. The Stringable shortcut doesn't accept provider arguments directly, so configure your preferred provider in the config file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with Laravel 12 or only Laravel 13?
&lt;/h3&gt;

&lt;p&gt;The AI SDK works with both Laravel 12 and 13. The &lt;code&gt;-&amp;gt;toAudio()&lt;/code&gt; Stringable integration, the &lt;code&gt;Audio&lt;/code&gt; facade, and the &lt;code&gt;Transcription&lt;/code&gt; class are available in the current stable version of the SDK regardless of which Laravel version you're running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I generate audio in languages other than English?
&lt;/h3&gt;

&lt;p&gt;Yes. Pass your text in any language the provider supports. OpenAI's TTS handles dozens of languages automatically based on the input text. For ElevenLabs, you may need to select a voice trained for your target language, or use their multilingual model.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long can the input text be?
&lt;/h3&gt;

&lt;p&gt;OpenAI's TTS endpoint accepts up to 4,096 characters per request. For longer content (like a full blog post), you'll need to split the text into chunks and generate separate audio files. Concatenation is straightforward with FFmpeg.&lt;/p&gt;

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

&lt;p&gt;Text generation, tool calling, RAG, and now audio. The AI SDK covers a lot of ground from a single &lt;code&gt;composer require&lt;/code&gt;. If you haven't tried the text features yet, start with &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; and &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-part-2-build-a-rag-powered-support-bot-with-tools-and-memory" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt;. If you're already using the SDK and want to explore what &lt;a href="https://hafiz.dev/blog/claude-opus-4-7-laravel-ai-sdk-migration-guide" rel="noopener noreferrer"&gt;Claude Opus 4.7 changes for your AI setup&lt;/a&gt;, that post covers the breaking changes and token adjustments you need to know about.&lt;/p&gt;

&lt;p&gt;If you're building voice features into a Laravel app and need help with architecture, queue strategies, or production scaling, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Stop an AI Agent from Destroying Your Laravel App</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 29 Apr 2026 05:26:24 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-to-stop-an-ai-agent-from-destroying-your-laravel-app-1k2k</link>
      <guid>https://dev.to/hafiz619/how-to-stop-an-ai-agent-from-destroying-your-laravel-app-1k2k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Last Friday, an AI coding agent running Claude Opus 4.6 inside Cursor deleted a startup's entire production database in 9 seconds. The company was PocketOS, a SaaS platform that car rental businesses depend on daily. The agent was working on a routine credential mismatch in a staging environment. It decided, on its own, to "fix" the problem by deleting a Railway volume. It found an API token in an unrelated file, used it to call Railway's GraphQL API, and wiped the production database along with all volume-level backups in a single API call.&lt;/p&gt;

&lt;p&gt;The founder's post went viral. 28,000+ posts on X. Coverage in The Register, Fast Company, Business Standard. The database was eventually recovered, but it took 30+ hours and Railway staff intervening directly.&lt;/p&gt;

&lt;p&gt;This isn't an abstract risk anymore. If you're using Claude Code, Cursor, or any AI coding agent on a Laravel project, here are the concrete things you should set up before something like this happens to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually went wrong at PocketOS
&lt;/h2&gt;

&lt;p&gt;Three failures stacked on top of each other.&lt;/p&gt;

&lt;p&gt;First, a Railway API token with root-level permissions was sitting in a file the agent could access. The token was originally created for adding custom domains via the Railway CLI, but Railway scoped it to allow any operation, including destructive ones. The agent found it and used it.&lt;/p&gt;

&lt;p&gt;Second, the agent ignored its own project rules. PocketOS had rules in their configuration that explicitly said "NEVER FUCKING GUESS" and "NEVER run destructive/irreversible commands unless the user explicitly requests them." The agent acknowledged these rules existed and violated them anyway.&lt;/p&gt;

&lt;p&gt;Third, Railway's API accepted the delete request without any confirmation step. And because Railway stores volume-level backups on the same volume, deleting the volume also deleted the backups.&lt;/p&gt;

&lt;p&gt;Any one of these failures alone wouldn't have caused the incident. All three together created a 9-second disaster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Laravel-specific safeguards
&lt;/h2&gt;

&lt;p&gt;Here's what you can do in your Laravel project right now. Each safeguard addresses one of the three failure modes above.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Lock down Claude Code's permissions with deny rules
&lt;/h3&gt;

&lt;p&gt;This is the single most important thing. Claude Code has a tiered permission system that most developers either don't know about or leave on defaults.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;.claude/settings.json&lt;/code&gt; in your project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(wget:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(railway:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(forge:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(rm -rf:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(DROP DATABASE:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(php artisan db:wipe:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(php artisan migrate:fresh:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Edit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(app/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(resources/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(tests/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(routes/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(config/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(database/migrations/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(php artisan test:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(php artisan make:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(php artisan migrate:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(php artisan tinker:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm run:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(composer:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"defaultMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deny rules are evaluated first, always. No allow rule can override a deny. This means even if Claude Code tries to run &lt;code&gt;curl&lt;/code&gt; with an API token it found in your &lt;code&gt;.env&lt;/code&gt; or a config file, the command gets blocked before it executes.&lt;/p&gt;

&lt;p&gt;The PocketOS agent used &lt;code&gt;curl&lt;/code&gt; to call Railway's GraphQL API. A single &lt;code&gt;"Bash(curl:*)"&lt;/code&gt; deny rule would have stopped the entire incident.&lt;/p&gt;

&lt;p&gt;For a deeper look at how the full Claude Code configuration system works, the &lt;a href="https://hafiz.dev/blog/the-complete-laravel-claude-code-ecosystem-every-tool-plugin-and-config-you-actually-need" rel="noopener noreferrer"&gt;complete ecosystem guide&lt;/a&gt; covers CLAUDE.md, settings, plugins, and MCP together.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Never store production credentials where the agent can read them
&lt;/h3&gt;

&lt;p&gt;The PocketOS agent found a Railway API token in an unrelated file inside the project directory. That's the root cause. The agent can read any file in your working directory and its subdirectories by default.&lt;/p&gt;

&lt;p&gt;For Laravel projects, this means:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your &lt;code&gt;.env&lt;/code&gt; file is readable by the agent.&lt;/strong&gt; If your &lt;code&gt;.env&lt;/code&gt; contains production database credentials, API keys with write access, or infrastructure tokens (Railway, Forge, DigitalOcean, AWS), the agent can see them and use them.&lt;/p&gt;

&lt;p&gt;The fix is straightforward:&lt;/p&gt;

&lt;p&gt;Never put production credentials in your local &lt;code&gt;.env&lt;/code&gt;. Use a separate &lt;code&gt;.env.production&lt;/code&gt; that only exists on the server and is never committed to the repository. Your local &lt;code&gt;.env&lt;/code&gt; should contain only local development values: &lt;code&gt;localhost&lt;/code&gt; database, test Stripe keys, local Redis.&lt;/p&gt;

&lt;p&gt;If you use Laravel Forge, your production environment variables live in Forge's UI, not in your codebase. Same with Laravel Cloud. The agent never sees them.&lt;/p&gt;

&lt;p&gt;For any credentials that must exist locally (third-party API keys for development), use scoped tokens with the minimum permissions possible. A Stripe test key can't delete your production customers. A Railway token scoped to read-only can't delete volumes. If your infrastructure provider doesn't support scoped tokens, that's a problem with the provider, not with you. But know the risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Add APP_ENV guard clauses to destructive Artisan commands
&lt;/h3&gt;

&lt;p&gt;Laravel's &lt;code&gt;APP_ENV&lt;/code&gt; variable is your built-in safety net. Use it.&lt;/p&gt;

&lt;p&gt;If you have any custom Artisan commands that perform destructive operations (clearing caches, resetting data, running seed scripts), wrap them with an environment 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResetDemoDataCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'app:reset-demo-data'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&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;environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'production'&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This command cannot run in production.'&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;Command&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FAILURE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// destructive operations here&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel already does this for &lt;code&gt;migrate:fresh&lt;/code&gt; and &lt;code&gt;db:wipe&lt;/code&gt;. In production, these commands prompt for confirmation. But if you've ever run Claude Code with &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; or &lt;code&gt;bypassPermissions&lt;/code&gt; mode, those confirmation prompts are skipped.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;APP_ENV&lt;/code&gt; check inside the command itself is the last line of defense. It runs regardless of how the command was invoked.&lt;/p&gt;

&lt;p&gt;For a full list of &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Artisan commands&lt;/a&gt; that are potentially destructive in production, check the reference page. Pay particular attention to &lt;code&gt;db:wipe&lt;/code&gt;, &lt;code&gt;migrate:fresh&lt;/code&gt;, &lt;code&gt;migrate:reset&lt;/code&gt;, &lt;code&gt;queue:flush&lt;/code&gt;, and &lt;code&gt;cache:clear&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Use read-only database credentials for AI agent sessions
&lt;/h3&gt;

&lt;p&gt;Most developers skip this one. Laravel supports multiple database connections out of the box. You can create a connection specifically for AI agent work that only has SELECT permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/database.php&lt;/span&gt;
&lt;span class="s1"&gt;'connections'&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;'mysql'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// your normal read-write connection&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="s1"&gt;'agent_readonly'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forge'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_AGENT_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'agent_reader'&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="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_AGENT_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'charset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8mb4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'collation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8mb4_unicode_ci'&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 create the MySQL user with restricted permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="s1"&gt;'agent_reader'&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="s1"&gt;'your-password'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;your_database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'agent_reader'&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="s1"&gt;'%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;FLUSH&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the AI agent needs to inspect the database (checking schema, reading data for debugging), point it at the read-only connection. The agent physically cannot run &lt;code&gt;DROP TABLE&lt;/code&gt;, &lt;code&gt;DELETE FROM&lt;/code&gt;, or &lt;code&gt;TRUNCATE&lt;/code&gt; because the MySQL user doesn't have those permissions.&lt;/p&gt;

&lt;p&gt;This is defense in depth. Even if every other safeguard fails, the database credentials themselves prevent destruction.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Scope your CLAUDE.md rules for what the agent should never do
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;CLAUDE.md&lt;/code&gt; file (or &lt;code&gt;.cursorrules&lt;/code&gt; for Cursor) is the project-level instruction set the agent reads before every session. PocketOS had rules in place. The agent violated them. But rules still matter because they reduce the probability of bad behavior even if they can't eliminate it entirely.&lt;/p&gt;

&lt;p&gt;Add a section specifically about destructive operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Safety Rules&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; NEVER make HTTP requests to infrastructure APIs (Railway, Forge, DigitalOcean, AWS, Cloudflare)
&lt;span class="p"&gt;-&lt;/span&gt; NEVER use API tokens found in config files, .env, or anywhere in the codebase for external API calls
&lt;span class="p"&gt;-&lt;/span&gt; NEVER run commands that delete, drop, truncate, or wipe data
&lt;span class="p"&gt;-&lt;/span&gt; NEVER modify .env files
&lt;span class="p"&gt;-&lt;/span&gt; NEVER run &lt;span class="sb"&gt;`php artisan migrate:fresh`&lt;/span&gt; or &lt;span class="sb"&gt;`php artisan db:wipe`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; If you encounter a credential mismatch or environment issue, STOP and ask the developer. Do not attempt to fix it.
&lt;span class="p"&gt;-&lt;/span&gt; If a task requires infrastructure changes, describe what needs to change and let the developer do it manually.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These rules aren't enforceable the way deny rules in &lt;code&gt;settings.json&lt;/code&gt; are. The agent can still violate them. But combined with the permission system, they create two layers: the permission system blocks the tool call, and the rules reduce the chance the agent even attempts it.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Run Claude Code in plan mode for unfamiliar tasks
&lt;/h3&gt;

&lt;p&gt;Claude Code has a &lt;code&gt;plan&lt;/code&gt; mode that lets the agent read and reason about code but blocks all writes and executions. It can analyze your codebase, propose changes, and explain what it would do, but it can't actually do anything.&lt;/p&gt;

&lt;p&gt;Use plan mode when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent is working on a part of the codebase you're not familiar with&lt;/li&gt;
&lt;li&gt;You're debugging a production issue and want the agent's analysis without risk&lt;/li&gt;
&lt;li&gt;You're onboarding the agent to a new project and want to see how it reasons before giving it write access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switch modes with Shift+Tab in your terminal, or set it in settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"defaultMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"plan"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start in plan mode. Review what the agent proposes. Then switch to &lt;code&gt;default&lt;/code&gt; or &lt;code&gt;acceptEdits&lt;/code&gt; for the execution phase. This is the equivalent of code review before merge, but for AI agent actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Isolate your CI/CD credentials from your development environment
&lt;/h3&gt;

&lt;p&gt;If your deploy pipeline uses Forge, Envoyer, or a custom script, those credentials should never be accessible from your local development environment. The PocketOS agent found a Railway CLI token because it was stored in a file within the project directory.&lt;/p&gt;

&lt;p&gt;For Laravel projects on Forge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forge API tokens live in Forge's web UI, not in your codebase&lt;/li&gt;
&lt;li&gt;Deploy scripts run on Forge's servers, not your local machine&lt;/li&gt;
&lt;li&gt;SSH keys for deployment should be deploy-specific, not your personal key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For custom deploy pipelines, use &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;Scotty&lt;/a&gt; or Envoy with credentials injected at runtime via CI secrets (GitHub Actions secrets, GitLab CI variables), never stored in the repository.&lt;/p&gt;

&lt;p&gt;The principle: if a credential can cause damage to production, it should not exist in any file that an AI agent can read during a development session. This applies to Forge tokens, Railway tokens, AWS keys, Cloudflare tokens, and anything else with write access to your infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layered defense model
&lt;/h2&gt;

&lt;p&gt;No single safeguard is enough. The PocketOS incident happened because three things failed simultaneously. Your goal is to stack enough layers that any single failure doesn't reach production.&lt;/p&gt;

&lt;p&gt;Here's the stack, from outermost to innermost:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Permission deny rules&lt;/strong&gt; block the agent from running dangerous commands at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential isolation&lt;/strong&gt; ensures the agent can't find tokens that would let it reach production infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only database users&lt;/strong&gt; prevent data destruction even if the agent somehow connects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;APP_ENV guards&lt;/strong&gt; stop destructive Artisan commands from executing in production&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLAUDE.md rules&lt;/strong&gt; reduce the probability the agent even attempts dangerous actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan mode&lt;/strong&gt; gives you review time before any execution happens&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you set up all six, an agent would need to bypass the permission system, find a production credential you didn't isolate, somehow connect with write permissions you didn't grant, get past the environment check, ignore your rules, and do it all outside of plan mode. That's not impossible, but it's a lot of failures that would have to stack simultaneously.&lt;/p&gt;

&lt;p&gt;Worth noting: if you're using &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, the same permission rules apply. A command sent via Telegram still runs through the permission system. But you should be extra careful about which tasks you trigger remotely, since you're not watching the agent's output in real time.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this apply to Cursor too, or just Claude Code?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both. The PocketOS incident happened in Cursor, not Claude Code. The permission system described here is Claude Code-specific (&lt;code&gt;.claude/settings.json&lt;/code&gt;), but the principles apply to any AI coding agent. For Cursor, use &lt;code&gt;.cursorrules&lt;/code&gt; for project rules and check Cursor's own permission settings. The credential isolation, database user, and &lt;code&gt;APP_ENV&lt;/code&gt; safeguards are Laravel-level and work regardless of which agent you use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I use &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; in CI. Is that safe?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only if the CI environment itself is the containment layer. A purpose-built Docker container with no production credentials, no external network access, and ephemeral storage is fine. The container boundary does the security work. But never use &lt;code&gt;bypassPermissions&lt;/code&gt; on your local machine with access to production credentials. That's the exact setup that enabled the PocketOS incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I stop using AI coding agents entirely?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The PocketOS incident involved multiple failures in credential management and infrastructure design, not a fundamental problem with AI agents. Developers with misconfigured CI/CD pipelines have been accidentally deleting production databases since long before AI agents existed. The agent just made it faster. Set up the safeguards, scope your credentials, and keep shipping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I need the agent to run migrations in development?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Allow &lt;code&gt;php artisan migrate&lt;/code&gt; but deny &lt;code&gt;php artisan migrate:fresh&lt;/code&gt; and &lt;code&gt;php artisan db:wipe&lt;/code&gt;. Regular migrations are additive (they run &lt;code&gt;up()&lt;/code&gt; methods). &lt;code&gt;migrate:fresh&lt;/code&gt; drops all tables and re-runs everything. The distinction matters. Your deny rules in &lt;code&gt;settings.json&lt;/code&gt; can be that specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do Claude Code Routines fit into this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hafiz.dev/blog/claude-code-routines-laravel-autopilot" rel="noopener noreferrer"&gt;Routines&lt;/a&gt; run autonomously on Anthropic's cloud infrastructure with no approval prompts during a run. That means the permission system and your CLAUDE.md rules are the only safeguards. If you're setting up Routines for production work, be even more aggressive with deny rules. A Routine that reviews PRs doesn't need &lt;code&gt;curl&lt;/code&gt; access. A Routine that runs tests doesn't need write access to &lt;code&gt;.env&lt;/code&gt;. Scope each Routine's permissions to the minimum it needs.&lt;/p&gt;

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

&lt;p&gt;The PocketOS incident is going to keep happening. Not because AI agents are inherently dangerous, but because developers are giving agents access to credentials and infrastructure they shouldn't have. The agent didn't hack into Railway. It used a token that was sitting in a file, exactly the way a developer would.&lt;/p&gt;

&lt;p&gt;The fix isn't to stop using agents. It's to stop treating your development environment like it's isolated from production when it isn't. Scope your credentials. Lock down your permissions. Add the guard clauses. And test your safeguards before you need them.&lt;/p&gt;

&lt;p&gt;If you're using AI coding agents on a Laravel project and want to make sure your permissions, credentials, and safety layers are right before something goes wrong, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>claudecode</category>
      <category>security</category>
      <category>aidevelopment</category>
    </item>
    <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://dev.to/hafiz619/generate-beautiful-open-graph-images-for-your-laravel-app-with-one-spatie-package-2780</link>
      <guid>https://dev.to/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://dev.to/hafiz619/claude-opus-47-what-laravel-ai-sdk-developers-need-to-check-before-upgrading-232</link>
      <guid>https://dev.to/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://dev.to/hafiz619/claude-code-routines-put-your-laravel-workflows-on-autopilot-56hh</link>
      <guid>https://dev.to/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://dev.to/hafiz619/how-laravel-events-listeners-and-observers-actually-work-and-when-to-use-each-53c7</link>
      <guid>https://dev.to/hafiz619/how-laravel-events-listeners-and-observers-actually-work-and-when-to-use-each-53c7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/how-laravel-events-listeners-observers-actually-work" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




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

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

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

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

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

    &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nc"&gt;Trial&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;provision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Slack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'New signup: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'User registered'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

&lt;p&gt;An event is a plain PHP class that represents something that happened. That's it. It carries data about the occurrence and nothing else.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Events\Dispatchable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\SerializesModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRegistered&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dispatchable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SerializesModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;Fire it anywhere in your application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// or&lt;/span&gt;
&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;A listener receives an event and does something with it. One event can have many listeners. Listeners don't know about each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:listener SendWelcomeEmail &lt;span class="nt"&gt;--event&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;UserRegistered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Events\UserRegistered&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Mail\WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Mail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each listener does one job. &lt;code&gt;SendWelcomeEmail&lt;/code&gt; sends a welcome email. &lt;code&gt;ProvisionFreeTrial&lt;/code&gt; provisions a trial. &lt;code&gt;NotifySlack&lt;/code&gt; posts to Slack. Adding a new step means adding a new listener. You don't touch the existing ones.&lt;/p&gt;

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

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

&lt;p&gt;Auto-discovery works by scanning &lt;code&gt;app/Listeners/&lt;/code&gt; and looking for &lt;code&gt;handle()&lt;/code&gt; methods. If a &lt;code&gt;handle()&lt;/code&gt; method type-hints an event class, Laravel automatically wires that listener to the event. No registration required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This listener is auto-discovered. No registration needed.&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because the old approach had a subtle maintenance problem: the &lt;code&gt;$listen&lt;/code&gt; array in &lt;code&gt;EventServiceProvider&lt;/code&gt; was a second source of truth. You could create a listener, forget to register it, and your code would run without errors. The listener just silently never fired. Auto-discovery eliminates that category of bug entirely.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserActivityListener&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleLogin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Login&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs on login&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleLogout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Logout&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs on logout&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel's scanner picks up both &lt;code&gt;handleLogin&lt;/code&gt; and &lt;code&gt;handleLogout&lt;/code&gt; automatically because they start with &lt;code&gt;handle&lt;/code&gt; and type-hint an event class.&lt;/p&gt;

&lt;p&gt;In production, cache the discovered listener manifest so Laravel doesn't scan on every request:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Clear it during deployment with:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;If you're on an older Laravel version or want explicit control, you can still register listeners in &lt;code&gt;AppServiceProvider::boot()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Sending emails, making HTTP calls, generating reports: these don't need to block the HTTP response. Implement &lt;code&gt;ShouldQueue&lt;/code&gt; and Laravel automatically dispatches the listener as a &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;background queue job&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\Queue\ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\InteractsWithQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'emails'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// seconds&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Handle failure: log it, alert, retry logic, etc.&lt;/span&gt;
        &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome email failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'user'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;failed()&lt;/code&gt; method is important. Queued listeners can fail. Define this method to handle failures gracefully rather than silently losing the email send.&lt;/p&gt;

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

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

&lt;p&gt;The fix is &lt;code&gt;ShouldHandleEventsAfterCommit&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProvisionFreeTrial&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ShouldHandleEventsAfterCommit&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Guaranteed to run only after the database transaction commits&lt;/span&gt;
        &lt;span class="nc"&gt;Trial&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Observers are a different tool for a different job. While events and listeners handle application-level signals, observers handle Eloquent model lifecycle events: &lt;code&gt;creating&lt;/code&gt;, &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;updating&lt;/code&gt;, &lt;code&gt;updated&lt;/code&gt;, &lt;code&gt;deleting&lt;/code&gt;, &lt;code&gt;deleted&lt;/code&gt;, and more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:observer UserObserver &lt;span class="nt"&gt;--model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;User
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

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

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

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserObserver&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs every time any User is created anywhere in the codebase&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs every time any User is updated&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Runs every time any User is deleted&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the observer on the model using the &lt;code&gt;#[ObservedBy]&lt;/code&gt; PHP attribute, introduced in Laravel 10.44 and fully supported in Laravel 11, 12, and 13:&lt;br&gt;
&lt;/p&gt;

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

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

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

&lt;span class="na"&gt;#[ObservedBy([UserObserver::class])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before this attribute existed, you'd register observers in a service provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserObserver&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both still work. The &lt;code&gt;#[ObservedBy]&lt;/code&gt; attribute is cleaner because the registration lives on the model itself. You can see at a glance that &lt;code&gt;UserObserver&lt;/code&gt; is active without hunting through providers.&lt;/p&gt;

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

&lt;p&gt;An observer class can define methods for any of the Eloquent lifecycle events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserObserver&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;   &lt;span class="c1"&gt;// After fetching from DB&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;creating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// Before insert&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;     &lt;span class="c1"&gt;// After insert&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;updating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// Before update&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;     &lt;span class="c1"&gt;// After update&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;saving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;      &lt;span class="c1"&gt;// Before create or update&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;       &lt;span class="c1"&gt;// After create or update&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;deleting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// Before delete&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;     &lt;span class="c1"&gt;// After delete&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;restoring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;   &lt;span class="c1"&gt;// Before soft-restore&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;restored&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;    &lt;span class="c1"&gt;// After soft-restore&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Here's how all three tools work together in a user registration flow. The controller fires one event. Two listeners react to that event asynchronously. The observer independently handles model-level concerns for every user creation, no matter where it originates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Controller stays clean&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RegisterController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RegisterRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Listener: sends welcome email (queued)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendWelcomeEmail&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Listener: provisions trial (queued, after commit)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProvisionFreeTrial&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ShouldHandleEventsAfterCommit&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Trial&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Observer: handles model-level concerns for ALL user creation&lt;/span&gt;
&lt;span class="na"&gt;#[ObservedBy([UserObserver::class])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserObserver&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AuditLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AuditLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.updated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDirty&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller fires one event. Two listeners react to that event in the background. The observer independently logs every user creation, including the one triggered by the controller. Each piece of code does one job and doesn't know about the others.&lt;/p&gt;

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

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

&lt;p&gt;Laravel's &lt;code&gt;Event::fake()&lt;/code&gt; replaces the event dispatcher with a fake that captures dispatched events without actually running listeners. This is what you want for most feature tests. You want to assert that an event was dispatched, not that a listener ran.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user registration dispatches UserRegistered event'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/register'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Hafiz Riaz'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'hafiz@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'password_confirmation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertDispatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'hafiz@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test the listener separately by instantiating it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Events\UserRegistered&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Listeners\SendWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Mail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Mail\WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SendWelcomeEmail listener sends welcome email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

    &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$mail&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For observers, &lt;code&gt;Event::fake()&lt;/code&gt; silences them by default. Model events don't fire when the dispatcher is faked. If you need observers to run inside a fake context, use &lt;code&gt;Event::fakeFor()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'observer logs user creation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Events are faked, so observers don't run here&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fakeFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// Event::assertDispatched() checks happen inside fakeFor&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// After fakeFor, events and observers run normally&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="c1"&gt;// Observer fires here&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also fake only specific events, leaving others to run normally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// Only UserRegistered is faked; all other events fire as usual&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/how-i-built-macos-menu-bar-app-nativephp-laravel-livewire" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: The background ticker (authoritative).&lt;/strong&gt; A dedicated Artisan command runs in an infinite loop as a persistent &lt;code&gt;ChildProcess&lt;/code&gt;. It ticks every second, decrements the cache, updates the menu bar label, and triggers the overlay when the timer hits zero. This runs whether the popover is open or not.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Console/Commands/TickMenuBarLabel.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lastLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'on_break'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$secondsLeft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'break_seconds_left'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'break_seconds_left'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secondsLeft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

            &lt;span class="c1"&gt;// Only call MenuBar::label() when the value actually changes&lt;/span&gt;
            &lt;span class="nv"&gt;$label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%02d:%02d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;intdiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secondsLeft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$secondsLeft&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$lastLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;MenuBar&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$lastLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

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

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sleepUntilNextSecond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sleepUntilNextSecond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$elapsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;usleep&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$remaining&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is registered in &lt;code&gt;NativeAppServiceProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChildProcess&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;artisan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app:tick-menubar-label'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ticker'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;persistent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;The fix: write the actual server URL to a file during boot, then read it in child processes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// NativeAppServiceProvider::boot()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mo"&gt;0755&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'SERVER_PORT'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;8100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app/server_url.txt'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s2"&gt;"http://127.0.0.1:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the ticker command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app/server_url.txt'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nc"&gt;Window&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'break-overlay'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$baseUrl&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/break-overlay'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;&lt;code&gt;Screen::displays()&lt;/code&gt; returns all connected displays with their bounds. Open one window per display:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$displays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Screen&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;displays&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$displays&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$display&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$bounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$display&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'bounds'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$windowId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'break-overlay'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"break-overlay-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;Window&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$windowId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$baseUrl&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/break-overlay'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;alwaysOnTop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;titleBarHidden&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;position&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'width'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'height'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>laravel</category>
      <category>nativephp</category>
      <category>livewire</category>
      <category>macos</category>
    </item>
  </channel>
</rss>
