<?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>Laravel Cloud vs Forge vs Hetzner: What I'd Actually Pick at Each Stage</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 01 Jun 2026 04:45:13 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-cloud-vs-forge-vs-hetzner-what-id-actually-pick-at-each-stage-12n</link>
      <guid>https://dev.to/hafiz619/laravel-cloud-vs-forge-vs-hetzner-what-id-actually-pick-at-each-stage-12n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most developers make the infrastructure decision based on what they know, not what fits the project. They pick Laravel Cloud because it's new, or Forge because a senior dev on their team uses it, or a bare VPS because it looks cheap. None of those are reasons. They're starting points.&lt;/p&gt;

&lt;p&gt;The right choice at 500 users is often the wrong one at 50,000. And the cost difference between these options isn't what most people expect. The invoice number is only part of it.&lt;/p&gt;

&lt;p&gt;Here's what each option actually costs at three stages, using current May 2026 pricing. The goal isn't to declare a winner. It's to give you the real numbers so you can make the call that fits where you are right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're comparing
&lt;/h2&gt;

&lt;p&gt;A quick clarification before the numbers, because people mix these up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel Cloud&lt;/strong&gt; is a managed PaaS built by the Laravel team. You push your code and it handles servers, autoscaling, databases, SSL, queue workers, and deployments. You never SSH anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel Forge&lt;/strong&gt; is a server management layer. You still own the VPS and pay for it separately. Forge handles provisioning, Nginx config, deployments, SSL, and queue workers. You keep server-level control without doing the tedious parts manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bare Hetzner VPS&lt;/strong&gt; is just a server. You install PHP, configure Nginx, manage SSL renewals, set up queue workers, write deploy scripts. Everything is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers at each stage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Under 1,000 users
&lt;/h3&gt;

&lt;p&gt;Side projects, early MVPs, apps you're not sure will stick.&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;Monthly base&lt;/th&gt;
&lt;th&gt;Typical all-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Starter&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$4-8/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Hobby + Hetzner CX22&lt;/td&gt;
&lt;td&gt;$12 + €4.49&lt;/td&gt;
&lt;td&gt;~$17/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare Hetzner CX22&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;€4.49 (~$5/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At this stage the differences are real but small in absolute terms. Cloud's Starter tier is pay-as-you-go with no base fee. A small app with modest traffic runs $4-8/month. The Forge Hobby + Hetzner CX22 combo costs more upfront but gives you direct server access. A bare Hetzner CX22 at €4.49/month after the April 2026 price increase is still excellent value for 2 vCPUs and 4GB RAM.&lt;/p&gt;

&lt;p&gt;Worth noting: Hetzner CX22 is roughly 3x cheaper than a comparable DigitalOcean droplet for equivalent specs. If you're choosing the VPS path, Hetzner is the obvious pick in Europe and increasingly popular for US projects too.&lt;/p&gt;

&lt;h3&gt;
  
  
  1,000 to 10,000 users
&lt;/h3&gt;

&lt;p&gt;You've found traction. Deployment and uptime start mattering.&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;Monthly base&lt;/th&gt;
&lt;th&gt;Typical all-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Growth&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$25-45/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Growth + Hetzner CX32&lt;/td&gt;
&lt;td&gt;$19 + ~€9&lt;/td&gt;
&lt;td&gt;~$30/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare Hetzner CX32&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;~€9 (~$10/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloud Growth at $20/month plus usage lands around $25-45/month depending on traffic patterns. Forge Growth at $19/month plus a mid-range Hetzner server is competitive at roughly $30/month total. The bare VPS is still cheapest in cash at $10/month, but the operational work starts adding up.&lt;/p&gt;

&lt;p&gt;For a developer shipping features, the real question isn't "which is cheapest," it's this: how many hours a month am I spending on server maintenance versus building product? At 5,000 users, a misconfigured Nginx config or a failed deployment script starts costing you real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  10,000 to 100,000 users
&lt;/h3&gt;

&lt;p&gt;Infrastructure decisions affect your margins and your incident response.&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;Typical monthly cost&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Growth&lt;/td&gt;
&lt;td&gt;$60-200+&lt;/td&gt;
&lt;td&gt;Usage scales with traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Business + multiple Hetzner&lt;/td&gt;
&lt;td&gt;$39 + $40-80&lt;/td&gt;
&lt;td&gt;More DevOps work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare VPS (load balanced)&lt;/td&gt;
&lt;td&gt;$25-60&lt;/td&gt;
&lt;td&gt;Cheapest, most complex&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At this scale, Cloud's autoscaling is the right call if your traffic is unpredictable. Traffic spikes that would take down a single Hetzner server get absorbed automatically. But that scaling costs money. Usage-based billing at this volume can push your monthly bill well above the base fee. Spend caps are announced for Cloud but not yet live as of this writing. The &lt;a href="https://hafiz.dev/blog/laravel-cloud-5-dollar-plan-spend-caps-scale-to-zero" rel="noopener noreferrer"&gt;Laravel Cloud post&lt;/a&gt; covers what's coming on that front.&lt;/p&gt;

&lt;p&gt;Forge at this scale means managing multiple servers, a load balancer, and probably a separate Redis instance. The Business plan at $39/month includes monitoring and automated database backups, which you need at 100k users. The Hetzner bill grows with your server count. Total lands at $80-120/month depending on architecture.&lt;/p&gt;

&lt;p&gt;A bare VPS setup at this scale requires real infrastructure work, load balancers, Redis clusters, backup scripts, monitoring. It's the cheapest option but it's a part-time job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What doesn't show up on the invoice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your time.&lt;/strong&gt; A bare VPS is cheap in cash but expensive in hours. Initial setup for someone who knows what they're doing is 3-5 hours. Ongoing maintenance is roughly 2 hours a month: security patches, PHP updates, debugging a failed deployment on a Friday evening. Multiply your hourly rate by that and the VPS isn't cheap anymore.&lt;/p&gt;

&lt;p&gt;Forge cuts ongoing maintenance to near zero and initial setup to under an hour. Cloud cuts it entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deployment pipeline.&lt;/strong&gt; Forge gives you zero-downtime deployments out of the box. On a bare VPS you configure that yourself. It's learnable. The &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;CI/CD with GitHub Actions guide&lt;/a&gt; covers the setup, but it takes time you could spend shipping. Cloud handles it without any configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autoscaling anxiety vs bill anxiety.&lt;/strong&gt; These are the two risks you're trading off. On a bare VPS or Forge, a traffic spike can take you down. On Cloud, a traffic spike drives up your bill. Neither is "safe," they're just different failure modes. Cloud's spend caps will help when they ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually pick at each stage
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Side project or early MVP:&lt;/strong&gt; Bare Hetzner CX22 at €4.49/month. It's cheap enough to be disposable and the constraint forces you to understand what you actually need. When server maintenance starts interrupting feature work, that's the signal to move up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solo developer with a growing SaaS:&lt;/strong&gt; Forge Growth + Hetzner CX32 at roughly $30/month total. You get the deployment automation, SSL, queue workers, and server monitoring without giving up control. It's where the best price-to-control ratio lives. Pair it with the &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; to get the security layer sorted once and forget about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small team building a product:&lt;/strong&gt; Laravel Cloud Growth. When multiple developers are deploying, having one person own the servers creates a bottleneck. Cloud removes that entirely. Preview environments per PR are a real productivity win at this stage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agency managing client sites:&lt;/strong&gt; Forge Business at $39/month. Unlimited servers, automated database backups, and server monitoring across all client projects. The per-client cost when split across 10 sites is negligible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App with unpredictable traffic:&lt;/strong&gt; Laravel Cloud. Product Hunt launches, viral moments, seasonal spikes. If your traffic can 10x overnight, you want autoscaling and you don't want to be the one managing it at midnight. The &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;full SaaS architecture guide&lt;/a&gt; covers how to structure the app layer to take full advantage of Cloud's scaling.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I switch from Forge or a bare VPS to Laravel Cloud later?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. It's a deployment change, not a code change. Your Laravel app runs identically on both. The migration is mainly about moving your database and updating your deployment pipeline. Most teams do it in a day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the real total cost of Forge?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Forge Growth is $19/month plus your VPS. A Hetzner CX22 at €4.49/month brings the total to roughly $24-25/month. That's competitive with Cloud's Starter tier but with unlimited servers and flat-rate billing, so no usage surprises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Laravel Cloud handle queue workers and cron jobs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Queue workers and cron jobs are first-class features on Growth and above. It's one of the things that makes Cloud well-suited for SaaS, where background processing is usually essential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Laravel Vapor?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vapor runs on AWS Lambda at $39/month plus AWS usage. It's the right choice for workloads with extreme traffic variability or teams deep in the AWS ecosystem. For most Laravel developers, Cloud or Forge fits better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the $5/month plan for Laravel Cloud available yet?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not as of this writing. The Laravel team announced it as coming soon in May 2026. The current entry point is the Starter tier with no base fee and pay-as-you-go usage, which runs $4-8/month for a small app.&lt;/p&gt;

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

&lt;p&gt;The developers who regret their infrastructure choice usually overbuilt early or held on to a cheap setup for too long. A €4.49 Hetzner server handles your first 10,000 users fine if it's set up correctly. Cloud makes sense when your traffic becomes unpredictable or when server maintenance is competing with feature work. Forge sits in the middle and is the right answer for more situations than it gets credit for.&lt;/p&gt;

&lt;p&gt;Start with the cheapest option that doesn't slow you down. Upgrade when the friction becomes real, not before.&lt;/p&gt;

&lt;p&gt;Got a side project or SaaS and want a second opinion on the infrastructure setup? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>deployment</category>
      <category>devops</category>
      <category>laravelcloud</category>
    </item>
    <item>
      <title>Laravel AI SDK Silently Kills Your Horizon Queue (And How to Fix It in 4 Config Changes)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 27 May 2026 09:20:13 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-ai-sdk-silently-kills-your-horizon-queue-and-how-to-fix-it-in-4-config-changes-2lcf</link>
      <guid>https://dev.to/hafiz619/laravel-ai-sdk-silently-kills-your-horizon-queue-and-how-to-fix-it-in-4-config-changes-2lcf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Your Horizon was healthy. Email jobs, notifications, small processing tasks, all running fine with zero issues. You added the AI SDK, wrapped a few agent calls in background jobs, and deployed to production. Within a week you noticed failed jobs nobody reported, one user received the same AI analysis twice, and on a Tuesday morning when five report jobs queued simultaneously, your email queue froze for eight minutes.&lt;/p&gt;

&lt;p&gt;Nothing threw an exception. No alerting fired. Horizon's dashboard showed a handful of failed jobs and then everything looked normal again. The failure mode is invisible because the worker doesn't crash. It gets killed mid-execution, Horizon records the failure silently, and there's no stack trace and no message, nothing to debug.&lt;/p&gt;

&lt;p&gt;The problem isn't the AI SDK. It's that Horizon's default configuration was designed for jobs measured in milliseconds. AI API calls take 30 to 120 seconds. Four specific default values in your config silently conflict with that reality, and three of them aren't in &lt;code&gt;config/horizon.php&lt;/code&gt;. They're scattered across your codebase in ways you'd only find if you knew to look.&lt;/p&gt;

&lt;p&gt;If you're new to Laravel's queue system and want to understand the fundamentals before tuning Horizon specifically, the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue jobs guide&lt;/a&gt; covers job structure, retries, and failure handling from the ground up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI jobs break Horizon's defaults
&lt;/h2&gt;

&lt;p&gt;Horizon's default supervisor configuration assumes jobs are fast. A worker polls the queue, picks up a job, executes it in a second or two, picks up the next one. The defaults reflect this: a 60-second timeout before the worker is killed, a &lt;code&gt;retry_after&lt;/code&gt; value in the 90-second range before a job is considered stuck, and no backoff delay between retries.&lt;/p&gt;

&lt;p&gt;AI SDK jobs break every one of these assumptions. A single &lt;code&gt;Agent::run()&lt;/code&gt; call that uses tools, generates structured output, or hands off to a sub-agent can legitimately take 45 to 90 seconds before it returns. When you layer in the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;sub-agent patterns&lt;/a&gt; that chain multiple providers, job duration climbs further.&lt;/p&gt;

&lt;p&gt;Here's what actually happens when that 90-second AI job runs on a default Horizon setup:&lt;/p&gt;

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

&lt;p&gt;The SIGKILL at step four is silent. Horizon marks the job as failed with no visible error in the dashboard UI beyond the failure count incrementing. There's no stack trace, no message from the AI provider, no PHP exception in your logs. Most developers spend hours looking for an application error that doesn't exist, because the job never threw one.&lt;/p&gt;

&lt;p&gt;The double-processing at step seven is the worst part. Laravel's official Horizon documentation warns about it explicitly: "the timeout value should always be at least a few seconds shorter than the &lt;code&gt;retry_after&lt;/code&gt; value... otherwise, your jobs may be processed twice." But almost nobody reads that warning until after a user reports seeing duplicate results.&lt;/p&gt;

&lt;p&gt;Four changes fix all of this cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 1: Set a supervisor timeout that reflects AI job duration
&lt;/h2&gt;

&lt;p&gt;Horizon's default supervisor timeout is 60 seconds. That's the value in &lt;code&gt;config/horizon.php&lt;/code&gt; under each supervisor's configuration. When a job runs longer than this, the worker process receives SIGKILL and is forcefully terminated.&lt;br&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/horizon.php: what ships by default&lt;/span&gt;
&lt;span class="s1"&gt;'supervisor-1'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// kills any job taking longer than 60 seconds&lt;/span&gt;
    &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For AI jobs, raise this to something that reflects the realistic upper bound of your slowest agent call. 300 seconds (five minutes) is a sensible ceiling for most production AI workloads:&lt;br&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;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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 Horizon docs note a critical constraint: "always ensure the Horizon timeout is greater than any job-level timeout, otherwise jobs may be terminated mid-execution." So if you define &lt;code&gt;$timeout&lt;/code&gt; on the job class itself, the supervisor timeout must exceed it by a few seconds.&lt;br&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;RunAiReportJob&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="c1"&gt;// Job-level timeout: must be &amp;lt; supervisor timeout&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;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&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;Setting the job-level timeout slightly below the supervisor timeout means clean termination happens at the job level first, which triggers the &lt;code&gt;failed()&lt;/code&gt; method and gives you a cleanup hook. A SIGKILL from the supervisor sends a process signal that skips &lt;code&gt;failed()&lt;/code&gt; entirely, leaving any in-progress state (partially written database rows, uncleaned temp files, open API sessions) without cleanup. For AI jobs that write intermediate results or update progress columns, this distinction matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 2: Fix retry_after to prevent double processing
&lt;/h2&gt;

&lt;p&gt;This is the one that causes duplicate user output, and it lives in &lt;code&gt;config/queue.php&lt;/code&gt;, not in &lt;code&gt;config/horizon.php&lt;/code&gt;. That's why most developers miss it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;retry_after&lt;/code&gt; value defines how many seconds Redis waits before assuming a job is stuck and re-queuing it. The default for the Redis connection is 90 seconds. After you raise your supervisor timeout to 300, that 90-second &lt;code&gt;retry_after&lt;/code&gt; means any job running longer than 90 seconds gets re-queued while the original is still executing.&lt;br&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/queue.php: the default Redis connection&lt;/span&gt;
&lt;span class="s1"&gt;'redis'&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;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_QUEUE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'retry_after'&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;span class="c1"&gt;// danger: shorter than the AI supervisor timeout&lt;/span&gt;
    &lt;span class="s1"&gt;'block_for'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel's docs say this directly: timeout must be shorter than &lt;code&gt;retry_after&lt;/code&gt;. So if your supervisor timeout is 300 seconds, &lt;code&gt;retry_after&lt;/code&gt; needs to be at least 300 plus a buffer. Add 60 seconds as the minimum buffer:&lt;br&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;'redis'&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;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_QUEUE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'retry_after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// supervisor timeout (300) + 60s buffer&lt;/span&gt;
    &lt;span class="s1"&gt;'block_for'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the change most likely to already be causing silent problems in your app. The double-processing scenario has a specific pattern: a user triggers an AI analysis, gets the result, then gets the same result again 90 seconds later with no explanation. If you've had users report duplicate outputs and assumed it was a UI bug or a double-click, it may have been this. The job ran, got killed, re-queued, and ran again from the beginning.&lt;/p&gt;

&lt;p&gt;If you have separate Redis connections for different queues, each connection needs its own &lt;code&gt;retry_after&lt;/code&gt; value matched to the highest supervisor timeout that uses it. A dedicated &lt;code&gt;redis-ai&lt;/code&gt; connection with its own &lt;code&gt;retry_after&lt;/code&gt; of 360 is cleaner than raising the value for every queue across the board.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 3: Add exponential backoff for rate limit failures
&lt;/h2&gt;

&lt;p&gt;AI API providers rate-limit aggressively. OpenAI, Anthropic, and Gemini all have per-minute token limits. When a job hits a rate limit, it throws an exception and fails. Without backoff configuration, Horizon retries immediately. Under sustained load, a burst of AI jobs exhausting the rate limit causes every retry to also hit the rate limit, causing another retry, in a tight loop that burns through your API budget and fills the failed jobs list.&lt;/p&gt;

&lt;p&gt;The fix is exponential backoff, which Horizon supports at both the supervisor level and the job class level.&lt;/p&gt;

&lt;p&gt;At the supervisor level:&lt;br&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;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'backoff'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// wait 30s, then 60s, then 120s between retries&lt;/span&gt;
    &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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 on the job class directly, which takes precedence over supervisor 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunAiReportJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Exponential backoff: 30s after first failure, 60s after second&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job class approach is more explicit and survives config changes without you needing to remember to update the supervisor. It's the pattern to prefer when different AI jobs have different retry requirements. A cheap text classification job can retry faster than an expensive multi-tool agent call.&lt;/p&gt;

&lt;p&gt;One thing worth noting: &lt;code&gt;tries&lt;/code&gt; counts total attempts, not retries. A job with &lt;code&gt;$tries = 3&lt;/code&gt; gets three chances total: the original execution plus two retries. Set it low enough that an actually broken job doesn't exhaust your API quota before hitting the failed jobs list. Three is usually the right number for AI jobs with rate limit risks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 4: Move AI jobs to a dedicated queue and supervisor
&lt;/h2&gt;

&lt;p&gt;Even with the timeout and retry_after fixed, AI jobs and fast jobs sharing the same supervisor still block each other. Here's why: if you have three workers in &lt;code&gt;supervisor-1&lt;/code&gt; and three 90-second AI jobs land simultaneously, all three workers are occupied for 90 seconds. Email jobs, notifications, and payment webhooks sit queued and waiting until one of those workers finishes.&lt;/p&gt;

&lt;p&gt;The solution is a dedicated queue for AI work and a supervisor that only handles it, completely isolated from your default queue.&lt;br&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 job class&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunAiReportJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ai'&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;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;array&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or when dispatching:&lt;br&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;RunAiReportJob&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai'&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 &lt;code&gt;config/horizon.php&lt;/code&gt;, run two supervisors with different constraints:&lt;br&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;'environments'&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;'production'&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;// Fast jobs: tight timeout, many workers&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&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;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;

        &lt;span class="c1"&gt;// AI jobs: long timeout, fewer workers, more memory&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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 result:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" 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;Three workers for AI is a starting point. The right number depends on your API rate limits and job volume. Three concurrent AI jobs consuming tokens simultaneously can hit per-minute limits quickly, so scaling the worker count upward requires monitoring your provider's rate limit headroom first.&lt;/p&gt;

&lt;p&gt;You might also want to set &lt;code&gt;balance&lt;/code&gt; to &lt;code&gt;simple&lt;/code&gt; for the AI supervisor rather than &lt;code&gt;auto&lt;/code&gt;. The &lt;code&gt;auto&lt;/code&gt; strategy dynamically scales workers based on queue depth, but it has a side effect: Horizon considers workers "hanging" when scaling down and will force-kill them after the supervisor timeout if they're mid-execution. For long-running AI jobs, &lt;code&gt;simple&lt;/code&gt; balance with a fixed process count is more predictable. You control the concurrency explicitly, and Horizon doesn't try to adjust it based on queue depth in a way that could interrupt active jobs.&lt;/p&gt;

&lt;p&gt;For a deeper look at structuring queue topology across multiple job types, the &lt;a href="https://hafiz.dev/blog/laravel-queue-route-centralize-queue-topology" rel="noopener noreferrer"&gt;Laravel queue route guide&lt;/a&gt; covers centralising queue assignments cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete production config
&lt;/h2&gt;

&lt;p&gt;Here's everything together as a reference:&lt;br&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/queue.php: raise retry_after to cover the longest AI job&lt;/span&gt;
&lt;span class="s1"&gt;'redis'&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;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_QUEUE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'retry_after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// supervisor timeout (300) + 60s buffer&lt;/span&gt;
    &lt;span class="s1"&gt;'block_for'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/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;// config/horizon.php: two supervisors, two concerns&lt;/span&gt;
&lt;span class="s1"&gt;'environments'&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;'production'&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;'supervisor-default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&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;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// App\Jobs\RunAiReportJob.php: job-level timeouts and backoff&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunAiReportJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ai'&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;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// slightly below supervisor timeout&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;array&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Your AI SDK call here&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;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$exception&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;// Log, notify, or clean up on final failure&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;After deploying, run &lt;code&gt;php artisan horizon:terminate&lt;/code&gt; to restart Horizon and pick up the new config. The &lt;code&gt;queue:restart&lt;/code&gt; signal alone doesn't reload Horizon's supervisor configuration.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does Horizon kill jobs at 60 seconds when I never set a timeout?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Horizon inherits the timeout from the queue worker defaults. If you don't explicitly set &lt;code&gt;timeout&lt;/code&gt; in the supervisor config, it falls back to the worker default of 60 seconds. This is documented but easy to miss, because the installed &lt;code&gt;config/horizon.php&lt;/code&gt; doesn't always include &lt;code&gt;timeout&lt;/code&gt; in every supervisor block. Absence isn't zero, it's the default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I just set a very high timeout everywhere instead of creating separate supervisors?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can, but it's the wrong call. A 300-second timeout on your email supervisor means a stuck email job (due to a mail server timeout, for example) occupies a worker for five minutes instead of one. Separate supervisors let you apply constraints appropriate to each job type. The email jobs don't need to know about AI job timeouts, and vice versa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the AI SDK dispatch jobs automatically, or do I need to wrap calls manually?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You wrap them manually. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-what-it-changes-why-it-matters-and-should-you-use-it" rel="noopener noreferrer"&gt;Laravel AI SDK&lt;/a&gt; runs synchronously by default. Dispatching an agent call as a background job is an explicit architectural choice you make by wrapping the &lt;code&gt;Agent::run()&lt;/code&gt; call in a &lt;code&gt;ShouldQueue&lt;/code&gt; class. The SDK doesn't push to queues automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to an AI job that exceeds &lt;code&gt;$timeout&lt;/code&gt; on the job class?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;$timeout&lt;/code&gt; is set on the job class and the supervisor timeout is higher, the job gets a &lt;code&gt;TimeoutExceededException&lt;/code&gt;, which triggers the &lt;code&gt;failed()&lt;/code&gt; method cleanly. This is the preferred failure mode: you get the cleanup hook, the exception is logged, and tries are decremented normally. A SIGKILL from the supervisor timeout skips all of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I verify the timeout and retry_after relationship is correct after deploying?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run a test job that deliberately sleeps for a duration between your supervisor timeout and retry_after, then watch Horizon's dashboard. If the job appears twice in Recent Jobs, your &lt;code&gt;retry_after&lt;/code&gt; is still too low. Also check the Redis key directly: &lt;code&gt;redis-cli LRANGE queues:ai 0 -1&lt;/code&gt; will show you if a job is sitting in both an executing state and the queue simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing to remember
&lt;/h2&gt;

&lt;p&gt;Most of the pain from adding AI SDK to an existing Laravel app isn't in the code. It's in configuration assumptions that were correct before and quietly broke afterward. None of these four changes are complex. They're just easy to not know about until something goes wrong in production.&lt;/p&gt;

&lt;p&gt;The timeout and retry_after relationship is the most dangerous of the four. It's in the official docs, it's a known issue with a clear warning, and it still catches experienced developers because the two values live in different config files with no cross-reference in the UI. You'd have to know to check one when you change the other.&lt;/p&gt;

&lt;p&gt;The queue isolation change has the biggest ongoing benefit. Once your AI jobs run in their own supervisor, you can tune that supervisor independently. You can scale the worker count up during peak AI usage without affecting the process count for your other queues. You can set different memory limits, different balancing strategies, and different alerting thresholds. Fast jobs and slow jobs just have different operational needs, and the config reflects that.&lt;/p&gt;

&lt;p&gt;If you're running AI workloads on Horizon right now without dedicated supervisors, go check your &lt;code&gt;retry_after&lt;/code&gt; value before anything else. If it's under 300, you may already be double-processing jobs and not seeing it in your logs.&lt;/p&gt;

&lt;p&gt;Building something with the AI SDK and want a review of the queue architecture before it hits production? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelhorizon</category>
      <category>queues</category>
      <category>laravelaisdk</category>
    </item>
    <item>
      <title>6 Eloquent Patterns That Silently Break MySQL Index Usage (And How to Fix Each One)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 25 May 2026 05:21:18 +0000</pubDate>
      <link>https://dev.to/hafiz619/6-eloquent-patterns-that-silently-break-mysql-index-usage-and-how-to-fix-each-one-3b9p</link>
      <guid>https://dev.to/hafiz619/6-eloquent-patterns-that-silently-break-mysql-index-usage-and-how-to-fix-each-one-3b9p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/eloquent-patterns-silently-break-mysql-index" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You added the index. You confirmed it's there with &lt;code&gt;SHOW INDEX FROM orders&lt;/code&gt;. The query is still timing out in production, against the same &lt;code&gt;created_at&lt;/code&gt; column you've had indexed for months.&lt;/p&gt;

&lt;p&gt;This happens more than it should. MySQL's B-tree index is sitting right next to your query, completely ignored, and Eloquent handed it a condition the optimizer can't act on. No error. No warning. No hint in the logs. Just a full table scan against 4 million rows while your dashboard waits.&lt;/p&gt;

&lt;p&gt;The reason almost always traces to one concept: sargability.&lt;/p&gt;

&lt;p&gt;A predicate is sargable (Search ARGument ABLE) when MySQL can use an index to resolve it directly. The moment a function wraps the indexed column, sargability breaks. MySQL has to compute that function for every single row, then filter the results. That means reading the entire table regardless of what indexes exist.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/eloquent-patterns-silently-break-mysql-index" 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 Eloquent patterns trigger this silently. You'll recognize at least two of them in your current codebase.&lt;/p&gt;

&lt;p&gt;Before going through them, set up &lt;code&gt;EXPLAIN&lt;/code&gt; as your verification tool. Wrap any suspicious query 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;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereDate&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="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nf"&gt;dd&lt;/span&gt;&lt;span class="p"&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;toSql&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPLAIN '&lt;/span&gt; &lt;span class="mf"&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;toSql&lt;/span&gt;&lt;span class="p"&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;getBindings&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the EXPLAIN output, the fields that matter most are &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;rows&lt;/code&gt;. A &lt;code&gt;type: ALL&lt;/code&gt; result means a full table scan. A &lt;code&gt;type: range&lt;/code&gt; or &lt;code&gt;type: ref&lt;/code&gt; means an index is in use. The &lt;code&gt;rows&lt;/code&gt; column tells you how many rows MySQL examined. On a table with 4 million rows, &lt;code&gt;rows: 4000000&lt;/code&gt; next to &lt;code&gt;type: ALL&lt;/code&gt; is the confirmation you're looking for.&lt;/p&gt;

&lt;p&gt;Two more fields worth checking: &lt;code&gt;key&lt;/code&gt; shows which index MySQL actually used (it'll be &lt;code&gt;NULL&lt;/code&gt; on a full scan), and &lt;code&gt;Extra&lt;/code&gt; shows &lt;code&gt;Using filesort&lt;/code&gt; or &lt;code&gt;Using temporary&lt;/code&gt; when MySQL had to do extra work outside the index. Any of those three signals in a single EXPLAIN row means you have a query worth fixing.&lt;/p&gt;

&lt;p&gt;Laravel Telescope is the easiest way to surface slow query candidates without configuring MySQL's slow query log. It logs every query with execution time. Sort by duration to find the biggest problems first.&lt;/p&gt;

&lt;p&gt;If you want to understand how to build the right indexes in the first place, the &lt;a href="https://hafiz.dev/blog/database-indexing-in-laravel-boost-mysql-performance-with-smart-indexes" rel="noopener noreferrer"&gt;Laravel database indexing guide&lt;/a&gt; covers composite indexes, covering indexes, and when index cardinality matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: whereDate() and its siblings
&lt;/h2&gt;

&lt;p&gt;This is the most documented one, and still the least-fixed. The &lt;code&gt;whereDate()&lt;/code&gt; method generates &lt;code&gt;DATE(created_at) = '2026-05-22'&lt;/code&gt;. That &lt;code&gt;DATE()&lt;/code&gt; wraps your column, sargability is gone, and the index on &lt;code&gt;created_at&lt;/code&gt; does nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bypasses index on created_at&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;whereDate&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="nf"&gt;today&lt;/span&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;// What MySQL actually runs:&lt;/span&gt;
&lt;span class="c1"&gt;// WHERE DATE(created_at) = '2026-05-22'&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: ALL, rows: 4,893,201&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same is true for &lt;code&gt;whereMonth()&lt;/code&gt;, &lt;code&gt;whereYear()&lt;/code&gt;, &lt;code&gt;whereDay()&lt;/code&gt;, and &lt;code&gt;whereTime()&lt;/code&gt;. Every one of them wraps the column in a MySQL function before comparing.&lt;/p&gt;

&lt;p&gt;A common place this compounds: reporting queries that filter by year and month separately.&lt;br&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;// Two function wraps, both non-sargable&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;whereYear&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="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereMonth&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// MySQL runs: WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 5&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: ALL, rows: 4,893,201&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both conditions examine every row. The fix is still a range:&lt;br&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;// Uses index on created_at&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;whereBetween&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="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;startOfDay&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;endOfDay&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// What MySQL actually runs:&lt;/span&gt;
&lt;span class="c1"&gt;// WHERE created_at &amp;gt;= '2026-05-22 00:00:00' AND created_at &amp;lt;= '2026-05-22 23:59:59'&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: range, rows: 183&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're ranging across multiple days, prefer &lt;code&gt;tomorrow()-&amp;gt;startOfDay()&lt;/code&gt; as the exclusive upper bound instead of &lt;code&gt;endOfDay()&lt;/code&gt;. The &lt;code&gt;endOfDay()&lt;/code&gt; helper returns &lt;code&gt;23:59:59&lt;/code&gt;, which can miss rows with microsecond timestamps right at midnight.&lt;/p&gt;

&lt;p&gt;One note about PostgreSQL: it supports functional indexes, so you can create an index directly on &lt;code&gt;DATE(created_at)&lt;/code&gt; and &lt;code&gt;whereDate()&lt;/code&gt; will use it. MySQL has no real equivalent. For MySQL apps, the range approach is the correct fix regardless of database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: LIKE with a leading wildcard
&lt;/h2&gt;

&lt;p&gt;Every developer knows &lt;code&gt;LIKE '%value%'&lt;/code&gt; is slow. What's less obvious is exactly why.&lt;/p&gt;

&lt;p&gt;MySQL's B-tree index stores values in lexicographical order, like a phone book. A leading &lt;code&gt;%&lt;/code&gt; tells MySQL "I don't know how this string starts," so the index is useless. MySQL has to read every row and apply the pattern match manually, front to back.&lt;/p&gt;

&lt;p&gt;A trailing wildcard without a leading one is fine: &lt;code&gt;LIKE 'value%'&lt;/code&gt; can use a B-tree index because the starting characters are known. The problem is specifically a &lt;code&gt;%&lt;/code&gt; at the start.&lt;br&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;// Can't use index on email&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;where&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;'LIKE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%@company.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// EXPLAIN type: ALL, rows: 2,100,000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have three practical options depending on what you're actually doing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Add a separate indexed column&lt;/strong&gt; for the thing you're filtering on. If you're always filtering by email domain, store 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="c1"&gt;// Migration: $table-&amp;gt;string('email_domain')-&amp;gt;index();&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email_domain'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'company.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: ref, rows: 14&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B: Use a FULLTEXT index&lt;/strong&gt; for text search within a column:&lt;br&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;// Requires: $table-&amp;gt;fullText('bio');&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;whereFullText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel developer'&lt;/span&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;FULLTEXT search is handled by MySQL's inverted index, which is built specifically for text matching. It doesn't do exact substring matches but it handles word-based search well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C: Move to a search engine&lt;/strong&gt; like Meilisearch or Algolia for any real search feature. Laravel Scout wraps the integration cleanly and the performance difference is not comparable to anything MySQL can do natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: Functions in orderByRaw()
&lt;/h2&gt;

&lt;p&gt;This one catches developers because it doesn't cause a slow WHERE filter. It causes a slow sort. When you apply a function to a column in ORDER BY, MySQL falls back to a filesort on the full result set rather than using the index for sorting.&lt;br&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;// Triggers filesort&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;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LOWER(name) ASC'&lt;/span&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;// EXPLAIN Extra: Using filesort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A filesort uses memory for smaller result sets and disk for larger ones. On a table with millions of rows, it's consistently slow.&lt;/p&gt;

&lt;p&gt;For case-insensitive sorting, the cleanest fix is using the right collation on the column. Most Laravel apps default to &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt;, which is already case-insensitive for comparisons and sorting. That means a plain &lt;code&gt;orderBy('name')&lt;/code&gt; is already case-insensitive if your column uses that collation. No &lt;code&gt;LOWER()&lt;/code&gt; needed at all.&lt;/p&gt;

&lt;p&gt;If you're on MySQL 8.0.13+ and actually need a functional index for a custom sort expression, you can create one:&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="c1"&gt;-- Functional index on MySQL 8.0.13+&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_name_lower&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&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;Then your &lt;code&gt;orderByRaw('LOWER(name)')&lt;/code&gt; query will use it. The extra parentheses around &lt;code&gt;LOWER(name)&lt;/code&gt; in the DDL are required syntax for functional indexes in MySQL 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: Type mismatch on string columns
&lt;/h2&gt;

&lt;p&gt;This is the sneakiest one, and Laravel's query builder documentation explicitly warns about it.&lt;/p&gt;

&lt;p&gt;MySQL and MariaDB automatically typecast values during string-to-integer comparisons. Non-numeric strings convert to &lt;code&gt;0&lt;/code&gt;. So when you write:&lt;br&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;// status is a varchar column: 'pending', 'processing', 'failed'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL casts every &lt;code&gt;status&lt;/code&gt; value to an integer before comparing. &lt;code&gt;'pending'&lt;/code&gt; becomes &lt;code&gt;0&lt;/code&gt;. &lt;code&gt;'processing'&lt;/code&gt; becomes &lt;code&gt;0&lt;/code&gt;. &lt;code&gt;'failed'&lt;/code&gt; becomes &lt;code&gt;0&lt;/code&gt;. Every non-numeric string matches, the result is wrong, and because the implicit conversion breaks sargability, you get a full table scan on top of it.&lt;/p&gt;

&lt;p&gt;You get bad data silently along with bad performance. That's the double hit.&lt;br&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;// Correct: compare string to string&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&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;If you're accepting filter values from a request, cast before querying:&lt;br&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;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This also matters inside local scopes. A scope that accepts a &lt;code&gt;$value&lt;/code&gt; parameter and passes it directly to &lt;code&gt;where()&lt;/code&gt; without type-checking produces this problem invisibly every time it's called with an integer.&lt;br&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;// Dangerous scope: no type check&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;scopeWithStatus&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="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Caller passes an int from a form request: implicit cast happens silently&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;withStatus&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;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status_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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add an explicit cast inside the scope so the problem can't sneak through regardless of what the caller passes:&lt;br&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;scopeWithStatus&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="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;$status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pattern 5: whereRaw() with function wraps
&lt;/h2&gt;

&lt;p&gt;Developers who've heard about &lt;code&gt;whereDate()&lt;/code&gt; sometimes switch to &lt;code&gt;whereRaw()&lt;/code&gt; thinking they're writing safer SQL. They're not. You can reproduce the exact same non-sargable query yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Still bypasses index on created_at&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;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'YEAR(created_at) = ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&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;// Still bypasses index on shipped_at&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;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DATE(shipped_at) &amp;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;'2026-01-01'&lt;/span&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;// Still bypasses index on id&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;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CAST(id AS CHAR) = ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$stringId&lt;/span&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;Any function that wraps an indexed column in a WHERE clause breaks sargability, whether Eloquent generates it or you write it manually. The optimizer sees the function and can't use the B-tree index. That's the rule.&lt;/p&gt;

&lt;p&gt;The year-based filtering fix uses a Carbon range:&lt;br&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;Carbon\Carbon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Full year range: sargable&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;whereBetween&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="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Carbon&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="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;startOfYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nc"&gt;Carbon&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="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;endOfYear&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;get&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 &lt;code&gt;CAST(id AS CHAR)&lt;/code&gt; case, the right fix is not to cast the column. Fix the type upstream. Cast the incoming string to an integer before passing it to the query. The database column should never need to change types mid-query just because the application passed the wrong type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 6: orWhere() on a composite index without grouping
&lt;/h2&gt;

&lt;p&gt;This one is less about function wrapping and more about how MySQL resolves OR conditions against composite indexes.&lt;/p&gt;

&lt;p&gt;Say you have a composite index on &lt;code&gt;(user_id, status)&lt;/code&gt; and you write:&lt;br&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;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orWhere&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;'urgent'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL generates &lt;code&gt;WHERE user_id = X OR status = 'urgent'&lt;/code&gt;. The first condition can use the composite index because &lt;code&gt;user_id&lt;/code&gt; is the leftmost prefix. But the second condition needs to find all rows where &lt;code&gt;status = 'urgent'&lt;/code&gt; regardless of &lt;code&gt;user_id&lt;/code&gt;, which points to a completely different part of the index. MySQL often decides a full table scan is cheaper than doing two separate index lookups and merging the results.&lt;br&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;// On a table with 2M rows&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: ALL, rows: 1,987,432&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL's Index Merge optimization can sometimes handle OR conditions, but it's unreliable. Merging two result sets adds CPU overhead, and the optimizer frequently skips it.&lt;/p&gt;

&lt;p&gt;Group your OR conditions explicitly using closures so the query intent is clear:&lt;br&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;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;where&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="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&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;'closed'&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;orWhere&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;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'priority'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'escalated_at'&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For cases where both branches are hitting large portions of the table, two separate queries with a union often outperform a single OR query. Each query can use its own index cleanly, and there's no merge overhead:&lt;br&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;$myOrders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&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="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&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="nv"&gt;$urgentOrders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'urgent'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&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="nv"&gt;$results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$myOrders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$urgentOrders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The union approach works best when the two sets don't overlap much. If they do overlap heavily, add &lt;code&gt;unionAll()&lt;/code&gt; to skip the deduplication cost, since UNION by default removes duplicate rows through a sort pass.&lt;/p&gt;

&lt;p&gt;One thing to watch: union queries return results in an undefined order unless you explicitly call &lt;code&gt;orderBy()&lt;/code&gt; on the outer query. Add it when the result order matters to the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;What MySQL runs&lt;/th&gt;
&lt;th&gt;Index?&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;whereDate('col', $date)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATE(col) = ?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Half-open range with &lt;code&gt;whereBetween()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;LIKE '%value'&lt;/code&gt; or &lt;code&gt;'%value%'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Full wildcard scan&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Separate column, FULLTEXT, or Scout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orderByRaw('LOWER(col)')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filesort&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Column collation or functional index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;where('varchar_col', 0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Implicit cast&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Compare with correct string type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;whereRaw('YEAR(col) = ?')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same as whereDate&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Carbon range with &lt;code&gt;whereBetween()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-&amp;gt;where()-&amp;gt;orWhere()&lt;/code&gt; on composite&lt;/td&gt;
&lt;td&gt;OR breaks range scan&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Grouped closures or union queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;&lt;strong&gt;How do I find these patterns in an existing app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enable MySQL's slow query log and set &lt;code&gt;long_query_time&lt;/code&gt; to something low like &lt;code&gt;0.1&lt;/code&gt; seconds. Then run &lt;code&gt;EXPLAIN&lt;/code&gt; on anything that shows up. Look for &lt;code&gt;type: ALL&lt;/code&gt; in the output. Laravel Telescope surfaces the generated SQL for every query, which makes it easy to copy into a MySQL client and run EXPLAIN manually. The &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;Laravel query optimization guide&lt;/a&gt; walks through the debugging workflow in more detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this apply to local scopes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, completely. A local scope that calls &lt;code&gt;whereDate()&lt;/code&gt; or uses a leading wildcard LIKE generates the exact same SQL. The ORM layer doesn't change what MySQL executes. Always check the generated SQL with &lt;code&gt;-&amp;gt;toSql()&lt;/code&gt; before assuming a scope is index-friendly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is whereDate() ever acceptable?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On small tables, under roughly 50,000 rows, the performance difference is imperceptible. The problems start when the table crosses a few hundred thousand rows. The range fix costs nothing extra, so there's no good reason not to use it regardless of table size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about PostgreSQL?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL supports functional indexes, so you can create an index on &lt;code&gt;DATE(created_at)&lt;/code&gt; directly and &lt;code&gt;whereDate()&lt;/code&gt; will use it. MySQL has no equivalent without generated columns, which add schema overhead. For MySQL apps, the range approach is always the cleaner fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why doesn't Eloquent warn you about these patterns?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because Eloquent doesn't know your schema. It doesn't know which columns are indexed, what types they are, or how many rows your table has. The ORM's job is to generate valid SQL. Understanding query plans is yours, and &lt;code&gt;EXPLAIN&lt;/code&gt; is the tool for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Most of these patterns look completely reasonable when you write them. That's what makes them dangerous. The fix isn't complicated in any of the six cases. It's usually a two-line change, but you have to know to look for it.&lt;/p&gt;

&lt;p&gt;The underlying rule is always the same: if a function wraps your indexed column in the WHERE or ORDER BY clause, MySQL can't use the index. Once you've internalized that, you'll spot non-sargable queries anywhere, not just in the six patterns above. It changes how you read Eloquent code, how you review PRs, and how you respond when someone reports a slow page. Instead of reaching for a new server or a caching layer first, you run EXPLAIN.&lt;/p&gt;

&lt;p&gt;A lot of Laravel performance problems that get blamed on infrastructure are actually query problems. The query optimizer doesn't care that you're using Eloquent. It doesn't know you expected the index to be used. It just follows the rules of the SQL it receives. Writing sargable queries is how you work with the optimizer rather than around it.&lt;/p&gt;

&lt;p&gt;Building something and want a second set of eyes on the query layer before it becomes a problem at scale? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mysql</category>
      <category>databaseperformance</category>
      <category>eloquent</category>
    </item>
    <item>
      <title>Code PHP From Your Phone: The VPS + Tmux + Termius Setup That Actually Works</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Sat, 23 May 2026 11:57:16 +0000</pubDate>
      <link>https://dev.to/hafiz619/code-php-from-your-phone-the-vps-tmux-termius-setup-that-actually-works-h2a</link>
      <guid>https://dev.to/hafiz619/code-php-from-your-phone-the-vps-tmux-termius-setup-that-actually-works-h2a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/code-php-from-your-phone-vps-tmux-termius" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I've been running this setup on a Hetzner server for a while. This post documents exactly how I set it up, including the mistakes I made along the way that most tutorials skip over. I went through the full setup live while writing this, so the gotchas are real.&lt;/p&gt;

&lt;p&gt;If you haven't secured the VPS itself yet, start with &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;how I hardened mine with SSH, Cloudflare, and Tailscale&lt;/a&gt; first. That post covers locking down SSH, hiding your server IP, and routing access through Tailscale. This post covers what comes after: the PHP stack, Tmux sessions, Claude Code, and connecting from your phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a remote dev environment
&lt;/h2&gt;

&lt;p&gt;Local dev has one fatal flaw. The moment your laptop closes, everything dies. Queue workers, running scripts, anything you left going. You come back and reconstruct context from scratch.&lt;/p&gt;

&lt;p&gt;A VPS with Tmux flips this. Sessions persist indefinitely. You detach when you're done, reattach from any device and your work is exactly where you left it. The server never sleeps.&lt;/p&gt;

&lt;p&gt;There's a practical benefit for anyone traveling or away from their desk. Your full dev environment runs on the server. Your local device is just a terminal window. An iPad, a phone, a borrowed laptop: they all get identical access to the same running environment.&lt;/p&gt;

&lt;p&gt;For Laravel specifically: queue workers run continuously on the VPS. No need to restart them every session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PHP 8.x + Composer already installed on the VPS? Skip Step 1.&lt;/li&gt;
&lt;li&gt;Tmux already installed? Skip Step 2.&lt;/li&gt;
&lt;li&gt;Just want the phone connection? Jump to Step 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Check and install the PHP stack
&lt;/h2&gt;

&lt;p&gt;First, see what's already on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PHP: not installed'&lt;/span&gt;
composer &lt;span class="nt"&gt;-V&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Composer: not installed'&lt;/span&gt;
tmux &lt;span class="nt"&gt;-V&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Tmux: not installed'&lt;/span&gt;
which claude 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Claude Code: not installed'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If PHP is missing, install PHP 8.4 with the extensions a Laravel app needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; software-properties-common
&lt;span class="nb"&gt;sudo &lt;/span&gt;add-apt-repository ppa:ondrej/php &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; php8.4 php8.4-cli php8.4-fpm php8.4-mbstring &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-xml php8.4-curl php8.4-zip php8.4-mysql &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-redis php8.4-bcmath php8.4-intl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install Composer if it's missing:&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;-sS&lt;/span&gt; https://getcomposer.org/installer | php
&lt;span class="nb"&gt;sudo mv &lt;/span&gt;composer.phar /usr/local/bin/composer
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/composer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the Laravel installer globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer global require laravel/installer
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PATH="$HOME/.config/composer/vendor/bin:$PATH"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up Git with your details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.name &lt;span class="s2"&gt;"Your Name"&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.email &lt;span class="s2"&gt;"your@email.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Tmux for sessions that survive disconnects
&lt;/h2&gt;

&lt;p&gt;Tmux is what makes this whole setup usable. Without it, every SSH disconnect kills your running processes. With it, sessions sit on the server indefinitely.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;tmux &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a session helper function to your &lt;code&gt;.bashrc&lt;/code&gt;. This is the &lt;code&gt;tm&lt;/code&gt; command you'll use every time you connect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'

# Tmux session helper: creates or reattaches to a named session
tm() {
  local name="&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
  name="&lt;/span&gt;&lt;span class="k"&gt;${&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;}&lt;/span&gt;&lt;span class="sh"&gt;"
  name="&lt;/span&gt;&lt;span class="k"&gt;${&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;}&lt;/span&gt;&lt;span class="sh"&gt;"
  if [ -n "&lt;/span&gt;&lt;span class="nv"&gt;$TMUX&lt;/span&gt;&lt;span class="sh"&gt;" ]; then
    tmux has-session -t "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" 2&amp;gt;/dev/null || tmux new-session -d -s "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" -c "&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="sh"&gt;"
    tmux switch-client -t "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;"
  else
    tmux attach -t "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" 2&amp;gt;/dev/null || tmux new -s "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" -c "&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="sh"&gt;"
  fi
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start a session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tm dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the Tmux status bar appear at the bottom of the terminal, showing the session name and timestamp. That bar is the confirmation that you're inside a persistent session.&lt;/p&gt;

&lt;p&gt;For a Laravel project, I keep four windows in a single session:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/code-php-from-your-phone-vps-tmux-termius" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Tmux commands you'll use daily:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Ctrl+B, D         &lt;span class="c"&gt;# detach: leaves session running on server&lt;/span&gt;
Ctrl+B, C         &lt;span class="c"&gt;# new window in current session&lt;/span&gt;
Ctrl+B, &lt;span class="o"&gt;[&lt;/span&gt;number]  &lt;span class="c"&gt;# switch to window 0, 1, 2...&lt;/span&gt;
Ctrl+B, ,         &lt;span class="c"&gt;# rename current window&lt;/span&gt;
tmux &lt;span class="nb"&gt;ls&lt;/span&gt;           &lt;span class="c"&gt;# list all sessions&lt;/span&gt;
tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; name  &lt;span class="c"&gt;# reattach to a named session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical gotcha:&lt;/strong&gt; Always detach with &lt;code&gt;Ctrl+B, D&lt;/code&gt;. Never close the terminal window or press &lt;code&gt;Ctrl+C&lt;/code&gt; on the shell. That kills the session. Detach keeps it running on the server. This distinction is the entire point of Tmux.&lt;/p&gt;

&lt;p&gt;For more on Laravel queue workers and why running them continuously matters, 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 production setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Claude Code on the VPS
&lt;/h2&gt;

&lt;p&gt;Install with the native installer (no Node.js required, and this is the recommended method as of 2026):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://claude.ai/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; The installer will warn you that &lt;code&gt;~/.local/bin&lt;/code&gt; is not in your PATH. It won't fix this for you. Run the fix manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PATH="$HOME/.local/bin:$PATH"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# 2.1.150 (Claude Code)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first run, &lt;code&gt;claude&lt;/code&gt; opens a browser authentication link. You need a Claude Pro ($20/month), Max, Team, Enterprise, or Console API account. The free tier doesn't include Claude Code access.&lt;/p&gt;

&lt;p&gt;Start Claude Code in a dedicated Tmux window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Ctrl+B, C         &lt;span class="c"&gt;# new window&lt;/span&gt;
Ctrl+B, ,         &lt;span class="c"&gt;# rename it "claude"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/my-laravel-app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code runs inside your project directory. It can read files, run artisan commands, write code, and manage git from the terminal. When you detach and reattach from any device, it's in the same state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Termius for phone access
&lt;/h2&gt;

&lt;p&gt;Termius is the best SSH client for iOS and Android. It has a custom keyboard row with Ctrl, Esc, Tab, and pipe. The keys you actually need in a terminal. Plus a keep-alive setting that prevents the connection dropping when you switch apps.&lt;/p&gt;

&lt;p&gt;Download from the App Store or Google Play.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha: create your account before adding anything.&lt;/strong&gt; The free tier includes sync. If you add hosts and keys without logging in first, your local data may not transfer to your account when you sign up later. Open Termius, tap Profile, create the account, then proceed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add your SSH key:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tap &lt;strong&gt;Vaults → Keychain → +&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Paste Key&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;On your Mac, run &lt;code&gt;cat ~/.ssh/id_rsa&lt;/code&gt; (or &lt;code&gt;id_ed25519&lt;/code&gt;) and copy the entire output including the header and footer lines&lt;/li&gt;
&lt;li&gt;Paste into the Private Key field, give it a label like &lt;code&gt;hetzner-key&lt;/code&gt;, save&lt;/li&gt;
&lt;/ol&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%2Fz6ya0w57xadb6xnyhgvd.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%2Fz6ya0w57xadb6xnyhgvd.webp" alt="SSH key saved in Termius Keychain" width="721" height="1568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add the host:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tap &lt;strong&gt;Vaults → Hosts → +&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label:&lt;/strong&gt; &lt;code&gt;Hetzner Dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP:&lt;/strong&gt; your server's IPv4 address (get it with &lt;code&gt;curl -4 ifconfig.me&lt;/code&gt; on the server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username:&lt;/strong&gt; &lt;code&gt;root&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tap &lt;strong&gt;Key, Certificate, FIDO2&lt;/strong&gt; → select &lt;code&gt;hetzner-key&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Save, then tap the host to connect&lt;/li&gt;
&lt;/ol&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%2Fdixgsp7jvn87p50r3qge.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%2Fdixgsp7jvn87p50r3qge.webp" alt="Termius host configured with root username and hetzner-key" width="721" height="1568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once connected, reattach to your session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyq9nl2ihk2towrgp2cok.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%2Fyq9nl2ihk2towrgp2cok.webp" alt="Tmux session running from iPhone showing the green status bar" width="716" height="1372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The status bar reappears. Your queue worker, Claude Code session, and anything else you left running are all exactly where you left them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a Startup Snippet to auto-attach on every connection:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Edit Host, tap &lt;strong&gt;Startup Snippet&lt;/strong&gt; and add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; dev 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; tmux new &lt;span class="nt"&gt;-s&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every time you open Termius and tap the host, you land directly in your Tmux session. No typing required. On a phone keyboard that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: the Termius AI Agent feature
&lt;/h2&gt;

&lt;p&gt;When adding a host, Termius shows an "AI Agent" section suggesting setup for Claude Code, Gemini, and OpenCode. This is a Termius Pro feature that integrates AI assistants directly into the mobile terminal. If you're on Termius Pro, it connects to the Claude Code session running in your Tmux window. On the free tier, you don't need it; Claude Code works fine via regular SSH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it works
&lt;/h2&gt;

&lt;p&gt;The test: create a session from your phone, start a long-running command, detach, reconnect, see it still running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From your phone, inside Tmux&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Connected from phone at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;9999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detach with &lt;code&gt;Ctrl+B, D&lt;/code&gt;. Run &lt;code&gt;tmux ls&lt;/code&gt; and the session shows as running. Reattach with &lt;code&gt;tmux attach -t dev&lt;/code&gt; and your sleep is still going. Open the same session from your Mac and you'll see the exact same state: the same session name, the same running command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Viewing the app from your phone browser
&lt;/h2&gt;

&lt;p&gt;If you want to actually preview the Laravel app in a browser from your phone, &lt;code&gt;php artisan serve&lt;/code&gt; alone won't work. It binds to &lt;code&gt;127.0.0.1:8000&lt;/code&gt; on the VPS. Your phone's browser can't reach that address.&lt;/p&gt;

&lt;p&gt;The clean solution: configure Caddy to serve the app on the server's Tailscale IP. Accessible from your phone's browser as long as Tailscale is running on both devices, invisible to the public internet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/caddy/Caddyfile
http://100.x.x.x {
    root * /var/www/my-laravel-app/public
    php_fastcgi unix//run/php/php8.4-fpm.sock
    file_server
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a coding and deployment workflow without browser preview, you don't need this. Claude Code on the VPS handles edits without a visual browser.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this survive a VPS reboot?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tmux sessions don't survive reboots. If the server restarts (for security updates, for example), your sessions are gone. Add a Supervisor config to restart queue workers and other persistent processes automatically on boot. Claude Code you restart manually when you reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I need to SSH from a machine without my key?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use your VPS provider's browser console. Hetzner calls it the Console button in the server dashboard. It gives you terminal access without SSH. Bookmark it before you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My phone keyboard can't send Ctrl+B properly. What do I do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use the Termius keyboard row above the standard keyboard. Tap &lt;code&gt;ctrl&lt;/code&gt;, then tap &lt;code&gt;b&lt;/code&gt;, then release. The modifier keys in Termius are designed for this. If you see &lt;code&gt;^B^B^B&lt;/code&gt; appearing as text, you're pressing &lt;code&gt;ctrl&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; simultaneously on the standard keyboard instead of using the Termius row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a Hetzner VPS specifically required?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any Ubuntu VPS works. DigitalOcean, Vultr, Linode: same commands, same setup. Hetzner is the one I use. The CX23 runs at €4/month with solid specs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Tailscale for this to work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not for the basic setup. If port 22 is open on your server, Termius connects via the public IP. Tailscale is the right next step if you want to close port 22 and keep SSH off the public internet, which the &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual workflow
&lt;/h2&gt;

&lt;p&gt;Once everything is running, the daily flow from your phone looks like this: open Termius, tap the host, land directly inside your Tmux session via the Startup Snippet. Queue worker is running. Claude Code is open in window 3. Your last git commit message is visible in window 0. Nothing to reconstruct.&lt;/p&gt;

&lt;p&gt;The whole setup took about an hour to get right, including the debugging. The Hetzner server costs less than a coffee per month. And the next time your laptop battery dies mid-flight, your dev environment is fine.&lt;/p&gt;

&lt;p&gt;If you build something with this setup and want to talk through the architecture, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vps</category>
      <category>php</category>
      <category>remotedevelopment</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>What's Coming to Laravel Cloud: $5/month Plan, Spend Caps, and Instant Scale-to-Zero</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 20 May 2026 05:41:15 +0000</pubDate>
      <link>https://dev.to/hafiz619/whats-coming-to-laravel-cloud-5month-plan-spend-caps-and-instant-scale-to-zero-2p9k</link>
      <guid>https://dev.to/hafiz619/whats-coming-to-laravel-cloud-5month-plan-spend-caps-and-instant-scale-to-zero-2p9k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cloud-5-dollar-plan-spend-caps-scale-to-zero" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The two things that kept most Laravel developers off Laravel Cloud were cost and the fear of a surprise bill. The Laravel team just announced both are being fixed, along with three other significant changes coming over the next few weeks.&lt;/p&gt;

&lt;p&gt;Here's what was announced and what it actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Announcements
&lt;/h2&gt;

&lt;p&gt;The announcement laid them out clearly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A new $5/month plan.&lt;/strong&gt; Previously, your options were Starter (free tier, pay-as-you-go, limited features) or Growth ($20/month). The new $5/month tier sits between them. The stated goal: "We want Cloud to be accessible to every Laravel developer."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Spend caps.&lt;/strong&gt; Set a spending limit on your account. When you hit it, Cloud will either alert you or shut off compute entirely. No more anxiety about waking up to an unexpected $300 bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. True scale-to-zero with 10x faster wake-up.&lt;/strong&gt; Hibernation has existed for a while, but wake-up previously took 5-20 seconds. App, database, and cache wake-up is now measured in milliseconds. Users won't notice it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Bot protection rules.&lt;/strong&gt; Keeps hibernated apps dormant longer by filtering bot traffic before it wakes your environment. This directly reduces compute spend. Bots won't trigger unnecessary wake-ups that eat into your budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Managed queues.&lt;/strong&gt; Queue clusters are being rebuilt into a new "managed queues" product. Set a maximum worker count and Cloud handles everything else. It scales on queue depth (not just CPU) and scales to zero when idle. No more guessing at worker sizing.&lt;/p&gt;

&lt;p&gt;These changes aren't live yet. The announcement said "coming over the next few weeks," so treat this as a planning-ahead post rather than a same-day implementation guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spend Cap Is the Real News
&lt;/h2&gt;

&lt;p&gt;The $5/month price point gets the headline, but spend caps are what actually unlocks Cloud for cautious teams.&lt;/p&gt;

&lt;p&gt;Usage-based pricing always carries anxiety. Even with hibernation enabled, one unexpected traffic spike, a crawling bot hammering your endpoints, or a runaway job queue could turn a $10 month into an $80 one. That unpredictability kept teams on fixed-cost Forge servers where the bill was predictable even if the compute was oversized.&lt;/p&gt;

&lt;p&gt;Spend caps change the risk profile completely. You can enable hibernation, deploy a small instance, and know that the maximum you'll pay in a worst-case month is whatever cap you set. That's a fundamentally different product than "usage-based with no ceiling."&lt;/p&gt;

&lt;p&gt;Combine spend caps with the new bot protection rules and the hibernation economics get significantly better. Bots are the silent killers of scale-to-zero apps. They wake your environment constantly without generating real revenue. Filtering them before they trigger a wake-up means your app actually stays dormant during off-peak hours instead of yo-yoing on and off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Millisecond Wake-Up Changes
&lt;/h2&gt;

&lt;p&gt;The previous 5-20 second cold start was the biggest practical barrier to using hibernation on anything user-facing. You could use it for staging environments and dev instances without issue, but putting a hibernating app in front of real users meant the first request after idle time got a slow response. Users noticed. Support tickets followed.&lt;/p&gt;

&lt;p&gt;Millisecond wake-up removes that constraint. If the wake-up is imperceptible, hibernation becomes a pure cost optimization with no UX downside. You can run a production app with a low-cost Flex instance, hibernate between traffic windows, and pay only for active time, without needing a "warm-up" strategy or a cron job to keep things alive.&lt;/p&gt;

&lt;p&gt;One question worth watching: the tweet mentions milliseconds for app, database, and cache wake-up. The database wake-up is the historically slow part. If serverless Postgres wakes in milliseconds consistently, that's a genuine infrastructure achievement. The community will stress-test this quickly once it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed Queues as a Quiet Win
&lt;/h2&gt;

&lt;p&gt;The managed queues announcement is being slightly overshadowed by the pricing news, but it's significant for teams running serious background processing.&lt;/p&gt;

&lt;p&gt;Queue workers have always required manual tuning. How many workers does this job type need? What happens if the queue backs up? How do you prevent workers from sitting idle at 3am burning compute? The standard answer was either Horizon with careful configuration or accepting occasional over-provisioning, patterns covered in the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue jobs guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Managed queues take that decision away. Cloud monitors queue depth and adjusts worker count automatically. When the queue drains, workers scale to zero. When it fills, they spin up. You set a maximum and stop thinking about it.&lt;/p&gt;

&lt;p&gt;For applications with variable job loads (processing payments, sending email campaigns, handling webhooks), this is cleaner than anything available on Forge or Vapor today. It ties directly into the monitoring and observability improvements that make Cloud worth the higher base cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Math: Cloud vs Forge vs Vapor
&lt;/h2&gt;

&lt;p&gt;With the new plan, the comparison looks different for small projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indie developer with multiple side projects:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud (new $5 tier + usage with hibernation): $5 + ~$2-5/month per app&lt;/li&gt;
&lt;li&gt;Forge: $17/month base (you can add cheap VPS servers, but Forge itself is $17)&lt;/li&gt;
&lt;li&gt;VPS direct: $5-10/month per server (you manage everything)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One developer in the announcement thread shared they're running seven apps with decent traffic for $6/month total using scale-to-zero. That number will improve further with bot protection and millisecond cold starts. For anyone currently running multiple low-traffic side projects on separate servers, the Cloud math starts looking favorable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-scale startup on Growth ($20/month):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud Growth: $20/month + ~$16-25 usage = ~$36-45/month&lt;/li&gt;
&lt;li&gt;Forge: $17/month + $20-40/month DigitalOcean/Hetzner VPS = ~$37-57/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this tier, the total cost is similar, but Cloud includes autoscaling, managed databases, preview environments, and zero DevOps overhead. Forge requires you to configure servers, manage Nginx, handle SSL renewal, and tune your own scaling. The value comparison has always favoured Cloud for teams without a dedicated DevOps person. The new pricing makes it viable to reach that tier sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vapor ($39/month):&lt;/strong&gt;&lt;br&gt;
Vapor runs on AWS Lambda, which gives you true serverless with different performance characteristics. It's still the right choice for teams deeply invested in the AWS ecosystem. But for most Laravel developers who just want their app deployed and scaled without thinking about infrastructure, Cloud's model is more Laravel-native and increasingly competitive on price. The &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;CI/CD and deployment guide&lt;/a&gt; from last week covers how to automate your Cloud deployments via GitHub Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Be Paying Attention
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Indie developers and side project builders.&lt;/strong&gt; The $5/month plan plus spend caps makes Cloud viable for projects that previously couldn't justify $20/month. Multiple side projects on a single Cloud account with hibernation enabled could cost less than one Forge server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small agencies running client sites.&lt;/strong&gt; Preview environments per pull request plus managed databases at $5-20/month per project is a clearly compelling agency workflow. The managed queues addition makes it viable for client projects with background job requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams on Forge who don't enjoy server management.&lt;/strong&gt; If you're paying someone to manage Forge servers (patching, monitoring, scaling), Cloud is worth a fresh look. The total cost might be similar, but the management overhead disappears. For teams building SaaS products on Laravel, the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;complete SaaS guide&lt;/a&gt; covers the broader architecture decisions where Cloud fits naturally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone who previously tried Cloud and left because of cost or cold start anxiety.&lt;/strong&gt; Both objections are being addressed directly. Worth revisiting once the features ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Stay Where They Are
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Developers who want full server control.&lt;/strong&gt; Forge gives you root access and complete flexibility. Cloud abstracts that away by design. If you need custom Nginx configs, non-standard PHP extensions, or specific OS-level tooling, Forge is still the better choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams on Vapor with existing AWS infrastructure.&lt;/strong&gt; The Lambda model suits certain workloads well and migrating away from a working setup for incremental cost savings rarely makes sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High-traffic, always-on applications.&lt;/strong&gt; Hibernation economics only work when your app has genuine idle periods. If you're running a busy production app that never sleeps, the cost advantage disappears and a dedicated server (Forge or otherwise) may still be cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;Scotty vs Envoy post&lt;/a&gt; from April made the case that the Laravel deployment tooling ecosystem is maturing fast. This announcement reinforces that. A $5/month managed hosting tier with spend caps and millisecond cold starts is a product that didn't exist anywhere in the PHP ecosystem six months ago.&lt;/p&gt;

&lt;p&gt;The spend caps and bot protection show the team is listening to actual adoption blockers rather than just adding features. Those two additions together fix the core reason cautious developers stayed away.&lt;/p&gt;

&lt;p&gt;The managed queues rebuild is the kind of unsexy infrastructure improvement that saves teams real operational hours once you're running it in production.&lt;/p&gt;

&lt;p&gt;Once these ship, the "why not just use Forge" argument gets harder to make for most new Laravel projects.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When are these features available?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The announcement said "coming over the next few weeks." No specific release date was given. Follow the &lt;a href="https://laravel.com/blog" rel="noopener noreferrer"&gt;official Laravel Cloud blog&lt;/a&gt; for the rollout timeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will the $5/month plan include custom domains?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not confirmed in the announcement. The current Starter (free) plan includes custom domains, so it's likely the $5 tier does too. Check the official pricing page once it's updated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the spend cap pause the app or just alert you?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The announcement confirmed it will either alert you or shut off compute. The configuration details (which trigger, per-environment vs per-account) will be in the documentation once it ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I migrate existing apps before these features land?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wait until they're live and documented before making migration decisions. The announcements are forward-looking; current Cloud behavior still applies today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does bot protection work with legitimate crawlers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Specific configuration details weren't shared in the announcement. Presumably you'll be able to allowlist crawlers like Googlebot. More detail will come in the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth Watching
&lt;/h2&gt;

&lt;p&gt;These announcements move Laravel Cloud from "interesting but expensive" to "worth serious consideration for almost any new Laravel project." The spend caps alone change the risk calculation. The millisecond wake-up makes hibernation viable for production. The $5 plan removes the cost objection for small projects.&lt;/p&gt;

&lt;p&gt;Whether this changes your deployment setup depends on where you are now. But if you wrote off Cloud six months ago, the next few weeks are worth another look.&lt;/p&gt;

&lt;p&gt;If you're evaluating Laravel Cloud for a specific project and want to think through the numbers, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>deployment</category>
      <category>laravelcloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>Using Ollama with the Laravel AI SDK: Run Local LLMs for Free</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 18 May 2026 05:11:59 +0000</pubDate>
      <link>https://dev.to/hafiz619/using-ollama-with-the-laravel-ai-sdk-run-local-llms-for-free-2do1</link>
      <guid>https://dev.to/hafiz619/using-ollama-with-the-laravel-ai-sdk-run-local-llms-for-free-2do1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-ollama-local-llms-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;API costs add up fast during AI development. You prompt an agent 50 times debugging a tool, that's 50 API calls. You run your test suite, that's another batch. Multiply that across a team and you're spending real money before shipping anything.&lt;/p&gt;

&lt;p&gt;Ollama solves this cleanly. It runs open-source models locally on your machine (Llama 3, Qwen, Mistral, and dozens more) and the Laravel AI SDK treats it as a first-party provider, exactly like OpenAI or Anthropic. Switch between them with a single environment variable. No code changes, no new packages, no API keys.&lt;/p&gt;

&lt;p&gt;This post covers the full setup: installing Ollama, configuring it in the Laravel AI SDK, building agents that run locally, and the dev/production workflow that lets you use Ollama locally while shipping with a cloud provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Ollama Does
&lt;/h2&gt;

&lt;p&gt;Ollama is a lightweight tool that downloads and serves open-source language models locally. Once it's running, it exposes an HTTP API on &lt;code&gt;localhost:11434&lt;/code&gt; that the Laravel AI SDK connects to directly.&lt;/p&gt;

&lt;p&gt;There's no internet connection required after the initial model download. No rate limits. No costs per token. If you've built your app with the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;Laravel AI SDK smart assistant tutorial&lt;/a&gt;, your existing agents work with Ollama with a one-line change.&lt;/p&gt;

&lt;p&gt;The tradeoff is hardware. Larger models need more RAM and a capable GPU to run at acceptable speeds. But for development, smaller models like Llama 3.2:3B run well on any modern developer machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Ollama
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;macOS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or download the macOS app from &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt; which installs as a menu bar app and starts automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ollama.com/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Windows:&lt;/strong&gt;&lt;br&gt;
Download the installer from &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt;. Ollama runs as a background service after installation.&lt;/p&gt;

&lt;p&gt;Once installed, verify it's running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434
&lt;span class="c"&gt;# Should return: Ollama is running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pulling Models
&lt;/h2&gt;

&lt;p&gt;Download a model with &lt;code&gt;ollama pull&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# General purpose, runs on any machine with 4GB+ RAM&lt;/span&gt;
ollama pull llama3.2

&lt;span class="c"&gt;# Smaller version, 2GB, good for constrained machines&lt;/span&gt;
ollama pull llama3.2:1b

&lt;span class="c"&gt;# Strong at code-related tasks, good for Laravel AI agents&lt;/span&gt;
ollama pull qwen2.5-coder:7b

&lt;span class="c"&gt;# Mistral, fast and capable general model&lt;/span&gt;
ollama pull mistral
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can list all downloaded models:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;And test a model from the terminal before wiring it into Laravel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama run llama3.2 &lt;span class="s2"&gt;"Explain Laravel service containers in one sentence"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;artisan commands&lt;/a&gt; used in this guide, having at least one model pulled before starting saves debugging time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring the Laravel AI SDK
&lt;/h2&gt;

&lt;p&gt;The SDK ships with Ollama support out of the box. The only &lt;code&gt;.env&lt;/code&gt; addition is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;OLLAMA_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave the value blank. Ollama doesn't require authentication for local use, but the SDK expects the variable to exist. Add it to your &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.example&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If Ollama is running on the default port, that's all you need. If you've changed the port or are running Ollama on a remote machine, configure the URL in &lt;code&gt;config/ai.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;'providers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// ... other providers&lt;/span&gt;

    &lt;span class="s1"&gt;'ollama'&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;'ollama'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'key'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OLLAMA_API_KEY'&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;'url'&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;'OLLAMA_URL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'http://localhost:11434/api'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in &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 ini"&gt;&lt;code&gt;&lt;span class="py"&gt;OLLAMA_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default URL is &lt;code&gt;http://localhost:11434/api&lt;/code&gt; so for standard setups you don't need to add this. It works without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using Ollama in Your Agents
&lt;/h2&gt;

&lt;p&gt;Two ways to route an agent to Ollama: set it as the default provider for a specific agent class, or override it per-prompt at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-Agent with PHP Attributes
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;#[Provider]&lt;/code&gt; and &lt;code&gt;#[Model]&lt;/code&gt; attributes to your agent class:&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Attributes\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;Laravel\Ai\Attributes\Provider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Enums\Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Provider(Lab::Ollama)]&lt;/span&gt;
&lt;span class="na"&gt;#[Model('llama3.2')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a helpful support agent. Answer questions about our product concisely.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every time you prompt this agent, it uses Ollama locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How do I reset my password?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$response&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 cleanest pattern for development. You write your agent once with Ollama attributes, build and test locally with no API costs, then change the attributes (or override them via &lt;code&gt;.env&lt;/code&gt;) when deploying to production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Overriding Per-Prompt
&lt;/h3&gt;

&lt;p&gt;For one-off local testing without modifying the agent class:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How do I reset my password?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Ollama&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'llama3.2'&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 you want to quickly compare responses between Ollama and a cloud provider without changing the agent configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dev/Production Workflow
&lt;/h2&gt;

&lt;p&gt;The cleanest approach is to set a default provider at the application level in &lt;code&gt;config/ai.php&lt;/code&gt;, driven by environment variables:&lt;br&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="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="s1"&gt;'provider'&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;'AI_PROVIDER'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'openai'&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="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AI_MODEL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&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 in your local &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 ini"&gt;&lt;code&gt;&lt;span class="py"&gt;AI_PROVIDER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;
&lt;span class="py"&gt;AI_MODEL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;llama3.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in production &lt;code&gt;.env&lt;/code&gt; (or your Forge/Vapor environment):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;AI_PROVIDER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;anthropic&lt;/span&gt;
&lt;span class="py"&gt;AI_MODEL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero code changes between environments. Your agents, tools, and structured output stay identical. Only the provider changes. This works well for any agent that doesn't use PHP attribute overrides; those take precedence over the default config.&lt;/p&gt;

&lt;p&gt;For agents with explicit &lt;code&gt;#[Provider]&lt;/code&gt; attributes, you'd need to either remove the attributes or use a different approach for environment-based switching. The attribute approach is better for agents that should always use a specific provider (a code review agent that truly needs a smart model in all environments). The default config approach is better for general-purpose agents where Ollama in dev and a cloud model in prod makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Models to Use
&lt;/h2&gt;

&lt;p&gt;Not all models are equal, and the right choice depends on what your agent is doing. Here's a practical guide based on common Laravel AI SDK use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Llama 3.2 (3B or 8B)&lt;/strong&gt; is the safe default for most use cases. The 3B version runs comfortably on any developer machine with 4GB RAM. The 8B version is noticeably better at following complex instructions but needs 8GB. Good for support agents, document summarisation, and general Q&amp;amp;A. Start here if you're not sure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qwen 2.5 Coder (7B)&lt;/strong&gt; is the right choice for agents that work with code. It outperforms Llama on code generation and review tasks despite similar size. If you're building an agent that analyzes PHP files, generates migrations, or reviews code quality, use this one instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistral (7B)&lt;/strong&gt; is fast and reliable for instruction-following tasks. If you need quick responses and the task isn't code-heavy, Mistral is worth trying. It tends to be faster than Llama 3.2 at the same quality level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid very large models (30B+)&lt;/strong&gt; for development. They're slow on typical developer machines and the speed penalty makes iteration painful. The quality gap between 7B and 30B matters less in development where you're primarily testing tool calls and output format, not production response quality. Save the big models for your production cloud provider.&lt;/p&gt;

&lt;p&gt;A practical setup for a Laravel SaaS would be: use &lt;code&gt;llama3.2:8b&lt;/code&gt; for general agents and &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; for any agent touching code. Both run on a 16GB machine without issues. If you're on a 8GB machine, use &lt;code&gt;llama3.2:3b&lt;/code&gt; for everything and accept slightly weaker instruction following in exchange for speed.&lt;/p&gt;

&lt;p&gt;If you've already built a multi-agent system with the SDK, you can route different sub-agents to different Ollama models the same way you'd assign different cloud models, and 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; covers the broader SDK capabilities worth knowing before diving into local model optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings with Ollama
&lt;/h2&gt;

&lt;p&gt;Ollama also works for local embeddings, which means you can do RAG development with zero API costs:&lt;br&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\Facades\Ai&lt;/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\Enums\Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'How do I cancel my subscription?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Ollama&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'nomic-embed-text'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull the embedding model first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull nomic-embed-text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nomic-embed-text&lt;/code&gt; is a solid local embedding model that produces 768-dimension vectors. For production RAG you'd swap to OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; or a similar cloud model, but for building and testing your vector search logic, Ollama keeps costs at zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Ollama Doesn't Support
&lt;/h2&gt;

&lt;p&gt;The Laravel AI SDK's Ollama integration covers text generation and embeddings. It does not support image generation, text-to-speech, speech-to-text, or file uploads. If your agents use those capabilities, you'll need a cloud provider for those specific features.&lt;/p&gt;

&lt;p&gt;This is usually fine for a dev/production split. Most agent logic (tools, structured output, conversation flow) doesn't depend on images or audio. You can run the core agent logic against Ollama locally, and the multimedia features only come into play in staging or production against cloud providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Agents That Use Ollama
&lt;/h2&gt;

&lt;p&gt;One thing to be aware of: when running your test suite, you probably don't want tests making real Ollama calls any more than you'd want real OpenAI calls. The SDK's fake testing utilities work regardless of which provider is configured:&lt;br&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;'responds to password reset questions'&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;SupportAgent&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="s1"&gt;'To reset your password, visit the login page and click "Forgot password".'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How do I reset my password?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&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;$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;toContain&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Faking the agent response means your tests are fast, deterministic, and don't depend on Ollama being installed or running. The &lt;a href="https://hafiz.dev/blog/how-to-stop-an-ai-agent-from-destroying-your-laravel-app" rel="noopener noreferrer"&gt;agent safety post&lt;/a&gt; covers more on keeping agent behavior predictable in tests.&lt;/p&gt;

&lt;p&gt;The development workflow then becomes: build and iterate against real Ollama locally, run the test suite with faked responses, deploy with cloud providers in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ollama on a Shared Dev Server
&lt;/h2&gt;

&lt;p&gt;If your team uses a shared development server, you can run Ollama there and point everyone's local Laravel instances at it. Just update &lt;code&gt;OLLAMA_URL&lt;/code&gt; in each developer's &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 ini"&gt;&lt;code&gt;&lt;span class="py"&gt;OLLAMA_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://your-dev-server:11434/api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure Ollama is configured to accept connections from outside localhost on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OLLAMA_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0 ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means one machine does the model serving and your team shares it, without everyone needing to pull and run models locally. Useful if some team members are on constrained hardware.&lt;/p&gt;

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

&lt;h4&gt;
  
  
  Does Ollama work with Laravel AI SDK agents that use tools?
&lt;/h4&gt;

&lt;p&gt;Yes, but model quality matters more for tool use. Some smaller models handle tool calls inconsistently. Llama 3.2 8B is reliable for tool use. If you're seeing missed or malformed tool calls, try a larger or more capable model.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I use Ollama in production?
&lt;/h4&gt;

&lt;p&gt;You can if you have dedicated server hardware with enough RAM and ideally a GPU. Most teams use Ollama for local development and testing, then cloud providers in production. The cost and maintenance overhead of running Ollama in production usually outweighs the savings unless you have high volume and a specific privacy requirement.&lt;/p&gt;

&lt;h4&gt;
  
  
  What's the difference between Ollama and running models via API?
&lt;/h4&gt;

&lt;p&gt;With Ollama, the model runs on your machine. No data leaves your network. With cloud APIs (OpenAI, Anthropic), your prompts are sent to the provider's servers. For development involving sensitive or proprietary data, Ollama is the better choice.&lt;/p&gt;

&lt;h4&gt;
  
  
  Do I need a GPU?
&lt;/h4&gt;

&lt;p&gt;No. Most models run on CPU, just more slowly. For development iteration a CPU is fine. Responses take 5-15 seconds depending on model size and your hardware. A GPU drops that to under 2 seconds for 7B models.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I use Ollama with the sub-agents pattern?
&lt;/h4&gt;

&lt;p&gt;Yes. Each sub-agent can have its own &lt;code&gt;#[Provider(Lab::Ollama)]&lt;/code&gt; and &lt;code&gt;#[Model]&lt;/code&gt; attributes. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;sub-agents guide&lt;/a&gt; covers the full pattern; the Ollama attributes drop in without any other changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start Locally
&lt;/h2&gt;

&lt;p&gt;The setup comes down to four steps: install Ollama, pull a model, add &lt;code&gt;OLLAMA_API_KEY=&lt;/code&gt; to your &lt;code&gt;.env&lt;/code&gt;, and add &lt;code&gt;#[Provider(Lab::Ollama)]&lt;/code&gt; to your agent class. After that, you're running AI locally with no API costs and no rate limits while you build.&lt;/p&gt;

&lt;p&gt;In production, switch back to OpenAI or Anthropic by changing the provider attribute or your default config. The rest of your code stays exactly the same.&lt;/p&gt;

&lt;p&gt;If you're setting this up for a team or have questions about the dev/production split, &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>aidevelopment</category>
      <category>ollama</category>
    </item>
    <item>
      <title>Laravel CI/CD with GitHub Actions: Tests, Code Quality, and Deployment</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 14 May 2026 05:08:36 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-cicd-with-github-actions-tests-code-quality-and-deployment-o8j</link>
      <guid>https://dev.to/hafiz619/laravel-cicd-with-github-actions-tests-code-quality-and-deployment-o8j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you're still deploying Laravel by running &lt;code&gt;git pull&lt;/code&gt; on the server and crossing your fingers, this post is for you. And if you've got tests but they only run when you remember to run them locally, this post is for you too.&lt;/p&gt;

&lt;p&gt;GitHub Actions gives you a free CI/CD pipeline that runs on every push. For Laravel, a complete pipeline means: style checks, static analysis, your test suite, asset builds, and an automated deploy when everything passes. Set it up once and you never think about it again.&lt;/p&gt;

&lt;p&gt;This post builds the complete pipeline from scratch. Every step is explained, the full workflow file appears at the end as a copy-paste block, and the deployment section covers three different approaches depending on how you host.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Pipeline Does
&lt;/h2&gt;

&lt;p&gt;Before writing any YAML, here's the full flow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-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;Code quality checks run first. No point running 400 tests if the formatting is broken. Tests run after. Deployment only triggers on the &lt;code&gt;main&lt;/code&gt; branch after everything else passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the Workflow File
&lt;/h2&gt;

&lt;p&gt;GitHub Actions workflows live in &lt;code&gt;.github/workflows/&lt;/code&gt;. Create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;.github/&lt;/span&gt;
  &lt;span class="s"&gt;workflows/&lt;/span&gt;
    &lt;span class="s"&gt;ci.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with the trigger and environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Laravel CI/CD&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;develop&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.4'&lt;/span&gt;
  &lt;span class="na"&gt;NODE_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs on every push to &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;develop&lt;/code&gt;, and on every pull request targeting &lt;code&gt;main&lt;/code&gt;. Adjust the branches to match your workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Checkout and PHP Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup PHP&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shivammathur/setup-php@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;php-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.PHP_VERSION }}&lt;/span&gt;
          &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mbstring, xml, ctype, json, bcmath, pdo_sqlite&lt;/span&gt;
          &lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shivammathur/setup-php&lt;/code&gt; is the community standard for PHP in GitHub Actions. Setting &lt;code&gt;coverage: none&lt;/code&gt; is important: it skips loading Xdebug, which meaningfully speeds up the setup step. Only enable coverage if you need coverage reports.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pdo_sqlite&lt;/code&gt; is in the extensions list because we'll run tests against an in-memory SQLite database, which is faster and simpler than spinning up a MySQL service container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install Dependencies with Caching
&lt;/h2&gt;

&lt;p&gt;Composer downloads can take a while. Caching the &lt;code&gt;vendor&lt;/code&gt; directory means subsequent runs skip the download if &lt;code&gt;composer.lock&lt;/code&gt; hasn't changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cache Composer packages&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}&lt;/span&gt;
          &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-composer-&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Composer dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.NODE_VERSION }}&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install NPM dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;actions/setup-node@v4&lt;/code&gt; handles npm caching natively when you pass &lt;code&gt;cache: 'npm'&lt;/code&gt;. No separate cache step needed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;composer install&lt;/code&gt; flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--no-interaction&lt;/code&gt;: prevents prompts that would hang the CI runner&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--prefer-dist&lt;/code&gt;: downloads zip archives instead of git clones, faster&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--optimize-autoloader&lt;/code&gt;: generates an optimized classmap&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--no-progress&lt;/code&gt;: cleaner output in CI logs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Prepare the Laravel Environment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Copy environment file&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cp .env.example .env.ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate application key&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php artisan key:generate --env=ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set directory permissions&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chmod -R 755 storage bootstrap/cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a &lt;code&gt;.env.ci&lt;/code&gt; file in your repo with CI-specific settings. The critical part is pointing the database at SQLite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;APP_ENV&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;testing&lt;/span&gt;
&lt;span class="py"&gt;APP_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sqlite&lt;/span&gt;
&lt;span class="py"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;:memory:&lt;/span&gt;
&lt;span class="py"&gt;CACHE_DRIVER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;array&lt;/span&gt;
&lt;span class="py"&gt;SESSION_DRIVER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;array&lt;/span&gt;
&lt;span class="py"&gt;QUEUE_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sync&lt;/span&gt;
&lt;span class="py"&gt;MAIL_MAILER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;array&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;DB_DATABASE=:memory:&lt;/code&gt; means no file gets created, no cleanup needed, and tests run significantly faster. For the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;artisan commands&lt;/a&gt; that reference the database during testing, this just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Code Style with Laravel Pint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check code style with Pint&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/pint --test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--test&lt;/code&gt; flag is essential here. Without it, Pint would fix style issues and commit them. You don't want your CI runner making commits. With &lt;code&gt;--test&lt;/code&gt;, it exits with code 1 if issues are found, failing the build.&lt;/p&gt;

&lt;p&gt;Pint runs first because it's the cheapest check. If someone pushes without running &lt;code&gt;pint&lt;/code&gt; locally, CI catches it immediately without burning time on tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Static Analysis with Larastan
&lt;/h2&gt;

&lt;p&gt;Larastan is PHPStan configured for Laravel. It understands facades, magic methods, relationships, and request properties that vanilla PHPStan would flag as errors:&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 nunomaduro/larastan &lt;span class="nt"&gt;--dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;phpstan.neon&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 plaintext"&gt;&lt;code&gt;includes:
    - vendor/nunomaduro/larastan/extension.neon

parameters:
    paths:
        - app
    level: 5
    ignoreErrors:
        - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Level 5 is a solid starting point. It catches undefined method calls and type mismatches without being so strict that you spend more time on type annotations than features. In the workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run static analysis&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/phpstan analyse --memory-limit=512M --no-progress&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--memory-limit=512M&lt;/code&gt; prevents PHPStan from hitting PHP's memory limit on large codebases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Run the Test Suite
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sqlite&lt;/span&gt;
          &lt;span class="na"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:memory:'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/pest --parallel&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing &lt;code&gt;DB_CONNECTION&lt;/code&gt; and &lt;code&gt;DB_DATABASE&lt;/code&gt; as env vars here ensures they override whatever's in your &lt;code&gt;.env.ci&lt;/code&gt;. The &lt;code&gt;--parallel&lt;/code&gt; flag runs test files concurrently across available CPU cores. On a 4-core GitHub Actions runner, parallel mode typically cuts test suite time by 50-60%.&lt;/p&gt;

&lt;p&gt;If you're still on PHPUnit, replace &lt;code&gt;pest&lt;/code&gt; with &lt;code&gt;phpunit&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Build Frontend Assets
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build assets with Vite&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step serves two purposes. It catches import errors or missing dependencies that would break the frontend. And in some deployment setups, you'll want to upload the built assets rather than building on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment Options
&lt;/h2&gt;

&lt;p&gt;This is where setups diverge. The approach depends on how you host. Three options, in order of complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: Laravel Forge (Simplest)
&lt;/h3&gt;

&lt;p&gt;Forge has a deploy hook, a URL you trigger to run your deploy script. Copy it from your Forge site's Deployments tab and store it as a GitHub secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main' &amp;amp;&amp;amp; github.event_name == 'push'&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Trigger Forge deployment&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curl -s "${{ secrets.FORGE_DEPLOY_HOOK }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;needs: build-and-test&lt;/code&gt; line means this job only runs if the previous job passed. &lt;code&gt;if: github.ref == 'refs/heads/main'&lt;/code&gt; restricts deployment to the main branch. PRs run tests but don't deploy.&lt;/p&gt;

&lt;p&gt;This is the lowest-friction option. Forge handles the deploy script, zero-downtime switching, and restart management on the server side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: SSH Deployment
&lt;/h3&gt;

&lt;p&gt;For VPS deployments not managed by Forge, use &lt;code&gt;appleboy/ssh-action&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy via SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@master&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;cd /var/www/myapp&lt;/span&gt;
            &lt;span class="s"&gt;git pull origin main&lt;/span&gt;
            &lt;span class="s"&gt;composer install --no-dev --optimize-autoloader&lt;/span&gt;
            &lt;span class="s"&gt;php artisan migrate --force&lt;/span&gt;
            &lt;span class="s"&gt;php artisan config:cache&lt;/span&gt;
            &lt;span class="s"&gt;php artisan route:cache&lt;/span&gt;
            &lt;span class="s"&gt;php artisan view:cache&lt;/span&gt;
            &lt;span class="s"&gt;php artisan queue:restart&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add these secrets to your GitHub repository under Settings &amp;gt; Secrets and variables &amp;gt; Actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SSH_HOST&lt;/code&gt;: your server's IP or domain&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SSH_USER&lt;/code&gt;: the deploy user (create a dedicated non-root user)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SSH_PRIVATE_KEY&lt;/code&gt;: the private key whose public key is in the server's &lt;code&gt;authorized_keys&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;php artisan migrate --force&lt;/code&gt; is required in non-interactive environments. Without &lt;code&gt;--force&lt;/code&gt;, Laravel prompts for confirmation before running migrations in production. The &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue restart command&lt;/a&gt; signals workers to gracefully restart after code is updated, so they pick up the new code rather than continuing to run old code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option C: Scotty (SSH Task Runner)
&lt;/h3&gt;

&lt;p&gt;If you prefer defining your deploy steps as reusable scripts rather than inline YAML, Scotty pairs well with this setup. Scotty uses plain bash syntax and gives you better deploy output than raw SSH scripts. The &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spaties-new-deploy-tool-is-worth-the-switch" rel="noopener noreferrer"&gt;Scotty vs Envoy comparison&lt;/a&gt; covers when it's worth the switch.&lt;/p&gt;

&lt;p&gt;You'd SSH into the server and run your Scotty deploy task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy with Scotty&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@master&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;cd /var/www/myapp&lt;/span&gt;
            &lt;span class="s"&gt;git pull origin main&lt;/span&gt;
            &lt;span class="s"&gt;./vendor/bin/scotty run deploy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Managing Secrets and Environment Variables
&lt;/h2&gt;

&lt;p&gt;GitHub Secrets are encrypted environment variables stored at the repository level. They're never exposed in logs, even if a step tries to print them. Add them under Settings &amp;gt; Secrets and variables &amp;gt; Actions.&lt;/p&gt;

&lt;p&gt;For a typical Laravel CI/CD setup, you'll need:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;Used in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FORGE_DEPLOY_HOOK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Forge webhook URL to trigger deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SSH_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server IP or hostname for SSH deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SSH_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SSH username&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SSH_PRIVATE_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Private key content (the full key, not a path)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For &lt;code&gt;SSH_PRIVATE_KEY&lt;/code&gt;, copy the full content of your private key file (typically &lt;code&gt;~/.ssh/id_rsa&lt;/code&gt; or &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt;). Paste the entire thing into the secret value, including the &lt;code&gt;-----BEGIN&lt;/code&gt; and &lt;code&gt;-----END&lt;/code&gt; lines.&lt;/p&gt;

&lt;p&gt;One mistake that trips people up: the &lt;code&gt;.env.example&lt;/code&gt; file in your repo gets copied to &lt;code&gt;.env.ci&lt;/code&gt; during the workflow, but any variables that are genuinely secret (API keys, payment credentials) should not be in &lt;code&gt;.env.example&lt;/code&gt;. Use GitHub Secrets for those and inject them as environment variables in the relevant step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sqlite&lt;/span&gt;
          &lt;span class="na"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:memory:'&lt;/span&gt;
          &lt;span class="na"&gt;STRIPE_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.STRIPE_SECRET }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/pest --parallel&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never commit real secrets to your repo. Even in private repositories. The &lt;a href="https://hafiz.dev/blog/fake-laravel-packages-targeting-your-env-how-to-audit-composer-dependencies" rel="noopener noreferrer"&gt;Composer dependency audit post&lt;/a&gt; covers how supply chain attacks target credentials left in repositories. The same principle applies to your CI configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a Status Badge
&lt;/h2&gt;

&lt;p&gt;Once your workflow is running, you can add a status badge to your &lt;code&gt;README.md&lt;/code&gt;. It shows the current state of your main branch pipeline:&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="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;Laravel CI&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://github.com/{owner}/{repo}/actions/workflows/ci.yml/badge.svg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;{owner}&lt;/code&gt; and &lt;code&gt;{repo}&lt;/code&gt; with your GitHub username and repository name. The badge updates automatically after each run. Green means everything passed, red means something failed. Useful at a glance and signals to contributors that the project takes CI seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branch Strategy
&lt;/h2&gt;

&lt;p&gt;If you're building a SaaS product on Laravel, a working CI/CD pipeline from the start saves significant pain later. The &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS with Laravel and Filament guide&lt;/a&gt; covers the broader architecture, and this pipeline slots in as the deployment layer on top of it.&lt;/p&gt;

&lt;p&gt;A pipeline that runs identically on every branch isn't optimized. Here's a sensible split:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On pull requests (any branch → main):&lt;/strong&gt; Run Pint, Larastan, and tests. Block merging if anything fails. No deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On push to main:&lt;/strong&gt; Run everything. Deploy only if all checks pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On push to develop:&lt;/strong&gt; Run checks and tests. No deployment (or deploy to a staging environment if you have one).&lt;/p&gt;

&lt;p&gt;The workflow trigger at the top handles this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;develop&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the deployment job's &lt;code&gt;if&lt;/code&gt; condition handles the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main' &amp;amp;&amp;amp; github.event_name == 'push'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Complete Workflow File
&lt;/h2&gt;

&lt;p&gt;Here's the full &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; for copy-pasting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Laravel CI/CD&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;develop&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.4'&lt;/span&gt;
  &lt;span class="na"&gt;NODE_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup PHP&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shivammathur/setup-php@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;php-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.PHP_VERSION }}&lt;/span&gt;
          &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mbstring, xml, ctype, json, bcmath, pdo_sqlite&lt;/span&gt;
          &lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cache Composer packages&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}&lt;/span&gt;
          &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-composer-&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Composer dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.NODE_VERSION }}&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install NPM dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Copy environment file&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cp .env.example .env.ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate application key&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php artisan key:generate --env=ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set directory permissions&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chmod -R 755 storage bootstrap/cache&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check code style with Pint&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/pint --test&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run static analysis&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/phpstan analyse --memory-limit=512M --no-progress&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sqlite&lt;/span&gt;
          &lt;span class="na"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:memory:'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/pest --parallel&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build assets&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main' &amp;amp;&amp;amp; github.event_name == 'push'&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="c1"&gt;# Choose one of the deployment options above and add it here&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curl -s "${{ secrets.FORGE_DEPLOY_HOOK }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Do I need a paid GitHub plan to use GitHub Actions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. GitHub Actions is free for public repositories and includes 2,000 minutes per month for private repositories on free plans. Most Laravel projects fit comfortably within that limit. The &lt;code&gt;ubuntu-latest&lt;/code&gt; runner costs 1 minute per minute of usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I don't have Larastan set up yet?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Remove the static analysis step and add it back once you've configured &lt;code&gt;phpstan.neon&lt;/code&gt;. Don't skip Pint. It takes 10 seconds to set up and pays off immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run tests against MySQL instead of SQLite?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Add a MySQL service container to your job, then update the database env vars. The tradeoff is slower pipelines (MySQL startup adds 15-30 seconds) and the added complexity of service container health checks. SQLite in-memory is the right default for most apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;npm ci&lt;/code&gt; instead of &lt;code&gt;npm install&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm ci&lt;/code&gt; installs exactly what's in &lt;code&gt;package-lock.json&lt;/code&gt; and fails if there are any discrepancies. &lt;code&gt;npm install&lt;/code&gt; can update lockfiles silently. In CI you want reproducibility, so &lt;code&gt;npm ci&lt;/code&gt; is correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My tests pass locally but fail in CI. Where do I start?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nine times out of ten it's an environment difference. Check: missing PHP extensions, &lt;code&gt;.env.ci&lt;/code&gt; values not matching what tests expect, or missing &lt;code&gt;APP_KEY&lt;/code&gt;. Add a debug step early in the workflow that runs &lt;code&gt;php artisan about&lt;/code&gt;, which surfaces environment details quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put It in Place
&lt;/h2&gt;

&lt;p&gt;The workflow file goes in &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;. Add &lt;code&gt;.env.ci&lt;/code&gt; to your repo with your CI-specific values. Add secrets to your repository settings. Push to a branch, open a pull request, and watch the checks run.&lt;/p&gt;

&lt;p&gt;After that, every PR gets a green or red status before it's merged. Every push to main deploys automatically when it passes. You stop thinking about deployment and start thinking about what you're building.&lt;/p&gt;

&lt;p&gt;If you're setting this up for the first time and hit a wall, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt; and we can work through it together.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>devops</category>
      <category>githubactions</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Laravel AI SDK Sub-Agents: Build Multi-Agent Systems That Actually Scale</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Tue, 12 May 2026 05:03:07 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-ai-sdk-sub-agents-build-multi-agent-systems-that-actually-scale-2hd4</link>
      <guid>https://dev.to/hafiz619/laravel-ai-sdk-sub-agents-build-multi-agent-systems-that-actually-scale-2hd4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Taylor Otwell shipped sub-agent support to the Laravel AI SDK. The announcement is short: return an agent from another agent's &lt;code&gt;tools()&lt;/code&gt; method and the parent can delegate focused tasks to it. But what it unlocks is significant.&lt;/p&gt;

&lt;p&gt;Before this, you could simulate sub-agents by wrapping &lt;code&gt;agent()&lt;/code&gt; calls inside a tool's &lt;code&gt;handle()&lt;/code&gt; method. It worked, and 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; covers that approach in detail. But it was a workaround. The agent logic lived inside a tool class, not in a proper Agent class with its own instructions, tools, provider config, and context.&lt;/p&gt;

&lt;p&gt;Now sub-agents are first-class citizens. This post covers how the new API works and how to build a realistic multi-agent system with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Sub-Agents Actually Are
&lt;/h2&gt;

&lt;p&gt;A sub-agent is a dedicated Laravel AI Agent class that a parent agent can invoke as a tool. The parent delegates work to it exactly the way it would call any other tool. The difference is that the sub-agent runs with full autonomy: its own instructions, its own tool set, its own provider configuration, and its own isolated context window.&lt;/p&gt;

&lt;p&gt;This matters for a few reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isolation.&lt;/strong&gt; The parent's conversation history doesn't bleed into the sub-agent. The sub-agent starts fresh with just its own instructions and what the parent passes to it. No context pollution, no token waste from irrelevant history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specialization.&lt;/strong&gt; Each sub-agent is a proper PHP class with its own &lt;code&gt;instructions()&lt;/code&gt;, &lt;code&gt;tools()&lt;/code&gt;, and optional &lt;code&gt;schema()&lt;/code&gt;. You can build a billing specialist, a technical support specialist, and an order lookup specialist, each configured precisely for its job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model flexibility.&lt;/strong&gt; A sub-agent can run on a different provider or model than its parent. Route simple queries to a cheap model. Route complex reasoning to a capable one. The parent doesn't know or care.&lt;/p&gt;

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

&lt;p&gt;Before sub-agents, you'd build a multi-agent orchestrator by wrapping agents in tool classes. The workaround looked roughly 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HandleRefundTool&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;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;'Process a refund request for a customer.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Agent logic stuffed inside a tool class&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withInstructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'You are a refund specialist...'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;This works, but the agent logic is buried in a tool. There's no proper &lt;code&gt;instructions()&lt;/code&gt; method, no &lt;code&gt;tools()&lt;/code&gt; method, no structured output. It's a second-class agent.&lt;/p&gt;

&lt;p&gt;With sub-agents, you return a real Agent class directly from &lt;code&gt;tools()&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomerSupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a customer support orchestrator. Analyze the customer\'s 
                message and delegate to the appropriate specialist agent.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&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;TechnicalSupportAgent&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;OrderAgent&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;Each of those is a full Agent class with its own configuration. The SDK handles the delegation. The parent sees them as tools and calls them when it decides the task fits their domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Real Example
&lt;/h2&gt;

&lt;p&gt;Let's build a customer support system for a SaaS product. Users send messages. A parent orchestrator agent decides which specialist handles the response.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" 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;Start with the sub-agents. Each is a focused specialist.&lt;/p&gt;

&lt;h3&gt;
  
  
  BillingAgent
&lt;/h3&gt;



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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\ProcessRefund&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\CheckSubscriptionStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\UpdatePaymentMethod&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasTools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a billing specialist. You handle refund requests, 
                subscription changes, and payment issues. Be concise and 
                solution-focused. Always confirm before processing any changes.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessRefund&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;CheckSubscriptionStatus&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;UpdatePaymentMethod&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;h3&gt;
  
  
  TechnicalSupportAgent
&lt;/h3&gt;



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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\QueryKnowledgeBase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\CreateSupportTicket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasTools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a technical support engineer. Diagnose issues, 
                search the knowledge base for solutions, and escalate 
                to a ticket when the problem requires engineering attention.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryKnowledgeBase&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;CreateSupportTicket&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;h3&gt;
  
  
  OrderAgent
&lt;/h3&gt;



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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\LookupOrder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\TrackShipment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasTools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are an order specialist. Look up order status, 
                track shipments, and resolve delivery issues.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LookupOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrackShipment&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;h3&gt;
  
  
  CustomerSupportAgent (the orchestrator)
&lt;/h3&gt;



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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;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;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasTools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"You are a customer support orchestrator for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;company_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. 
                Analyze incoming messages and delegate to the right specialist:
                - BillingAgent: refunds, subscription changes, payment problems
                - TechnicalSupportAgent: bugs, errors, feature questions  
                - OrderAgent: order status, shipping, delivery

                Don't answer questions yourself. Always delegate to the appropriate 
                specialist and return their response directly."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&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;TechnicalSupportAgent&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;OrderAgent&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;Prompting it from a 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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CustomerSupportAgent&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="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;prompt&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;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'message'&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;'reply'&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;$response&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 parent receives the message, decides which specialist fits, delegates to that Agent, and returns the result. You didn't hardcode routing logic. The model figures out the delegation from the instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using Different Models Per Sub-Agent
&lt;/h2&gt;

&lt;p&gt;This is where sub-agents clearly beat the old workaround. You can configure a different provider or model on each sub-agent using PHP attributes directly on the class.&lt;/p&gt;

&lt;p&gt;Simple billing queries don't need a powerful model. Complex technical debugging does. The official API uses the &lt;code&gt;#[Provider]&lt;/code&gt; and &lt;code&gt;#[Model]&lt;/code&gt; attributes from &lt;code&gt;Laravel\Ai\Attributes&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;Laravel\Ai\Attributes\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;Laravel\Ai\Attributes\Provider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasTools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Enums\Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Provider(Lab::OpenAI)]&lt;/span&gt;
&lt;span class="na"&gt;#[Model('gpt-4o-mini')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a billing specialist...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessRefund&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;CheckSubscriptionStatus&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="na"&gt;#[Provider(Lab::Anthropic)]&lt;/span&gt;
&lt;span class="na"&gt;#[Model('claude-opus-4-5')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TechnicalSupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a technical support engineer...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryKnowledgeBase&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;CreateSupportTicket&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 SDK also ships &lt;code&gt;#[UseCheapestModel]&lt;/code&gt; and &lt;code&gt;#[UseSmartestModel]&lt;/code&gt; convenience attributes if you'd rather let the provider decide which model to use rather than hardcoding a model string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Attributes\UseCheapestModel&lt;/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\Attributes\UseSmartestModel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[UseCheapestModel]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;#[UseSmartestModel]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TechnicalSupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&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 parent orchestrator doesn't know or care which model its sub-agents use. Each runs with its own attributes. This is the practical cost-reduction pattern: cheap models for routine work, capable models only where reasoning depth matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sub-Agents with Structured Output
&lt;/h2&gt;

&lt;p&gt;Sub-agents support structured output the same way regular agents do. If you want the BillingAgent to always return a typed 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasStructuredOutput&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a billing specialist. Always return structured results.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessRefund&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;CheckSubscriptionStatus&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'action_taken'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'resolved'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&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="s1"&gt;'message'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parent receives the structured result from the sub-agent and can use it in its own response or pass it directly back to the caller. This is useful when you need predictable shapes in downstream code, not just a string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sub-Agents vs Regular Tools vs agent() Helper
&lt;/h2&gt;

&lt;p&gt;The question you'll hit: when does a sub-agent make sense vs a regular tool vs the &lt;code&gt;agent()&lt;/code&gt; helper approach?&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;regular tool&lt;/strong&gt; is right when you're executing a deterministic operation. Looking up a database record, calling an API, running a calculation. No LLM needed in the tool itself; just PHP logic.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;&lt;code&gt;agent()&lt;/code&gt; helper&lt;/strong&gt; (covered in the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-multi-agent-patterns-production" rel="noopener noreferrer"&gt;multi-agent patterns guide&lt;/a&gt;) is right for quick inline delegation where you don't need a reusable, testable agent class. It's faster to write but harder to test and reuse.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;sub-agent&lt;/strong&gt; is right when the delegated work itself requires AI reasoning, has its own set of tools, needs different model config, or is complex enough to warrant its own class with proper instructions. If the delegated task would make sense as a standalone agent on its own, it should be a sub-agent.&lt;/p&gt;

&lt;p&gt;A practical rule: if the thing you're delegating to could be independently useful in another context (a BillingAgent, a CodeReviewAgent, an OnboardingAgent), make it a sub-agent. If it's a one-off operation that only makes sense in this specific orchestrator, a regular tool is simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Reach for Sub-Agents
&lt;/h2&gt;

&lt;p&gt;Sub-agents shine in three specific scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain routing.&lt;/strong&gt; You have a broad entry point (customer support, document processing, code review) that needs to delegate to specialists. Each specialist has meaningfully different instructions and tools. The orchestrator shouldn't need to know how each domain works. This is the cleanest use case and the one Taylor's tweet shows directly: a parent agent that routes to a &lt;code&gt;RefundAgent&lt;/code&gt; without knowing anything about how refunds actually work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost-optimized workflows.&lt;/strong&gt; Different tasks warrant different model tiers. Route classification and simple lookups to cheaper models. Reserve the expensive models for tasks that actually need reasoning depth. Sub-agents let you encode that decision in configuration rather than in logic. The billing example above uses &lt;code&gt;gpt-4o-mini&lt;/code&gt; for straightforward billing queries but &lt;code&gt;claude-opus-4-5&lt;/code&gt; for technical debugging. You set it once per sub-agent class and it applies everywhere that agent is used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large context isolation.&lt;/strong&gt; If each sub-agent starts with a clean context, you avoid burning tokens on conversation history that's irrelevant to the current task. The parent passes only what the sub-agent needs to know. This is particularly useful when you're also dealing with &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;agent safety concerns&lt;/a&gt;, a sub-agent with limited context has a smaller blast radius. A billing sub-agent that only sees the billing-related part of the request can't accidentally act on unrelated data it shouldn't have access to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to skip sub-agents.&lt;/strong&gt; Don't reach for them when a single well-prompted agent handles the task cleanly, or when the overhead of multiple LLM calls is disproportionate to the task's complexity. If your use case is a simple chatbot or a single-domain assistant, sub-agents add latency and cost without adding capability. Start simple. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;smart assistant tutorial&lt;/a&gt; shows what a well-built single-agent system looks like, and a lot of products never need to go beyond that.&lt;/p&gt;

&lt;p&gt;The right question is: does the delegated task benefit from having its own dedicated AI reasoning, its own tools, and isolation from the parent's context? If yes, it's a sub-agent. If it's just a database lookup or a deterministic operation, keep it as a regular tool. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-part-2-build-a-rag-powered-support-bot-with-tools-and-memory" rel="noopener noreferrer"&gt;tools and memory tutorial&lt;/a&gt; covers the regular tool pattern if you need a refresher on when tools are enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing Context to Sub-Agents
&lt;/h2&gt;

&lt;p&gt;One thing worth knowing: sub-agents are resolved from the container just like top-level agents. That means you can inject dependencies into them through the constructor.&lt;/p&gt;

&lt;p&gt;If your BillingAgent needs access to a specific user's subscription data, pass it through:&lt;br&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;CustomerSupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a customer support orchestrator...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BillingAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TechnicalSupportAgent&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;OrderAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the BillingAgent:&lt;br&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;BillingAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$plan&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;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;subscription&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"You are a billing specialist for a &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; plan customer. 
                Customer name: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. 
                Handle refund requests, subscription changes, and billing issues."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CheckSubscriptionStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="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 sub-agent's instructions are dynamically built from the injected user. This is a clean pattern when the specialist's behaviour needs to vary by user context (plan level, account age, support tier). The orchestrator passes the relevant model down to each sub-agent that needs it, keeping the routing logic clean and the specialist logic contained.&lt;/p&gt;

&lt;p&gt;Don't over-inject. Sub-agents with too many dependencies become hard to test and understand. The goal is focused specialists. If a sub-agent needs more than one or two injected dependencies, that's often a sign it's trying to do too much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Sub-Agents
&lt;/h2&gt;

&lt;p&gt;The Laravel AI SDK ships full faking support for agents, confirmed in the official docs: "Fake agents, images, audio, transcriptions, embeddings, reranking, and file stores, so you can ship AI features with real test coverage." This applies to sub-agents the same way it applies to top-level agents.&lt;/p&gt;

&lt;p&gt;The general approach is to test each sub-agent in isolation with fake responses, then test the orchestrator separately to verify routing behaviour. Sub-agents are just regular Agent classes, so the same testing patterns from the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;AI SDK tutorial series&lt;/a&gt; apply directly.&lt;/p&gt;

&lt;p&gt;For the exact faking API syntax, check the Testing section of the &lt;a href="https://laravel.com/docs/13.x/ai-sdk#testing-agents" rel="noopener noreferrer"&gt;Laravel AI SDK docs&lt;/a&gt;. The sub-agent tests follow the same structure as single-agent tests. The only difference is you test each class independently rather than the full orchestration chain at once.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do sub-agents share conversation history with the parent?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Each sub-agent has isolated context. The parent passes a task to the sub-agent and the sub-agent works from its own instructions. This is by design and is one of the main benefits of the sub-agent pattern over stuffing agent logic into a tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a sub-agent have its own sub-agents?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Since a sub-agent is just a regular Agent class, it can return other Agent classes from its own &lt;code&gt;tools()&lt;/code&gt; method. You can nest as deeply as you need, though more than two levels of nesting adds latency and complexity quickly. Most use cases are well-served with one level of sub-agents.&lt;/p&gt;

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

&lt;p&gt;Yes. Sub-agents support streaming the same way top-level agents do. If the parent agent streams, the response from sub-agents flows through naturally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Laravel and PHP versions are required?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Laravel AI SDK requires PHP 8.2+ and Laravel 12 or 13. Sub-agents are part of the same package, so the same requirements apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does billing work with multiple LLM calls?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each sub-agent invocation is a separate API call, billed separately at whatever rates your configured provider charges. The cost-optimization angle of using cheaper models for simpler sub-agents is real and worth planning upfront, especially for high-volume endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start Building
&lt;/h2&gt;

&lt;p&gt;Sub-agents are a clean solution to the complexity ceiling that single-agent systems eventually hit. The orchestrator stays focused on routing. Each specialist stays focused on its domain. Provider costs stay proportionate to task complexity.&lt;/p&gt;

&lt;p&gt;The API is exactly what you'd expect from a Laravel feature: one method change, no boilerplate, full PHP class support. If you've already got agents running in your app, adding sub-agents is a refactor, not a rewrite.&lt;/p&gt;

&lt;p&gt;If you're building a multi-agent system and want a second opinion on the architecture, &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>aidevelopment</category>
      <category>agents</category>
    </item>
    <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>
  </channel>
</rss>
