<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Hafiz</title>
    <description>The latest articles on Forem by Hafiz (@hafiz619).</description>
    <link>https://forem.com/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>Forem: Hafiz</title>
      <link>https://forem.com/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>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://forem.com/hafiz619/using-ollama-with-the-laravel-ai-sdk-run-local-llms-for-free-2do1</link>
      <guid>https://forem.com/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://forem.com/hafiz619/laravel-cicd-with-github-actions-tests-code-quality-and-deployment-o8j</link>
      <guid>https://forem.com/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://forem.com/hafiz619/laravel-ai-sdk-sub-agents-build-multi-agent-systems-that-actually-scale-2hd4</link>
      <guid>https://forem.com/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://forem.com/hafiz619/building-an-audit-log-in-laravel-with-spatielaravel-activitylog-v5-k3</link>
      <guid>https://forem.com/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://forem.com/hafiz619/laravel-now-has-native-passkeys-a-complete-guide-to-laravelpasskeys-4151</link>
      <guid>https://forem.com/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://forem.com/hafiz619/php-84-features-youre-probably-not-using-yet-in-your-laravel-app-282h</link>
      <guid>https://forem.com/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://forem.com/hafiz619/the-4-composer-conflicts-that-block-most-laravel-13-upgrades-and-how-to-find-yours-137c</link>
      <guid>https://forem.com/hafiz619/the-4-composer-conflicts-that-block-most-laravel-13-upgrades-and-how-to-find-yours-137c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/4-composer-conflicts-blocking-laravel-13-upgrade" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

&lt;p&gt;Your tenant can be called anything: Team, Organization, Company, Workspace. We'll use Team here. Create the model, migration, and pivot table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:model Team &lt;span class="nt"&gt;-m&lt;/span&gt;
php artisan make:migration create_team_user_table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;role&lt;/code&gt; column on the pivot is optional, but you'll want it eventually for permissions (owner, admin, member). Adding it now saves a migration later.&lt;/p&gt;

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

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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Models\Contracts\HasName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Relations\BelongsToMany&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Team&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;HasName&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$fillable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsToMany&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsToMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withPivot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withTimestamps&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getFilamentName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The User model needs the &lt;code&gt;HasTenants&lt;/code&gt; interface. This tells Filament which tenants the user belongs to and whether they can access a specific tenant:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Models\Contracts\FilamentUser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Models\Contracts\HasTenants&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Panel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Relations\BelongsToMany&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Auth\User&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;FilamentUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTenants&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsToMany&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsToMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withPivot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withTimestamps&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTenants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;Collection&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;canAccessTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Model&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;canAccessPanel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getTenants()&lt;/code&gt; returns the teams the user belongs to. Filament calls this to populate the tenant switcher dropdown. &lt;code&gt;canAccessTenant()&lt;/code&gt; is the security gate: it runs on every request to make sure the user actually belongs to the tenant in the URL. Don't skip this. Without it, a user could change the team ID in the URL and access another team's data.&lt;/p&gt;

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

&lt;p&gt;Open your panel provider (usually &lt;code&gt;app/Providers/Filament/AdminPanelProvider.php&lt;/code&gt;) and add the tenant configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Team&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Filament\Pages\Tenancy\RegisterTeam&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Filament\Pages\Tenancy\EditTeamProfile&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Panel&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slugAttribute&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenantRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RegisterTeam&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenantProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EditTeamProfile&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;slugAttribute: 'slug'&lt;/code&gt; parameter tells Filament to use the &lt;code&gt;slug&lt;/code&gt; column in URLs instead of the auto-incrementing ID. Your URLs will look like &lt;code&gt;/admin/acme-corp/projects&lt;/code&gt; instead of &lt;code&gt;/admin/1/projects&lt;/code&gt;. This is cleaner and doesn't leak information about how many teams exist.&lt;/p&gt;

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

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

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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Team&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Components\TextInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Form&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Pages\Tenancy\RegisterTenant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RegisterTeam&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RegisterTenant&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getLabel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Create a Team'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debounce&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;afterStateUpdated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Team&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;users&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'owner'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;handleRegistration&lt;/code&gt; method creates the team and attaches the current user as the owner. This is where you'd add any onboarding logic: creating default settings, seeding initial data, or sending a welcome notification.&lt;/p&gt;

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

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

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Components\TextInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Form&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Pages\Tenancy\EditTenantProfile&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditTeamProfile&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;EditTenantProfile&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getLabel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Team Settings'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This page is accessible from the tenant menu in the sidebar. Add fields as your team model grows: logo upload, billing email, timezone, whatever your app needs.&lt;/p&gt;

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

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

&lt;p&gt;Every model that belongs to a team needs a &lt;code&gt;team_id&lt;/code&gt; column and a &lt;code&gt;belongsTo&lt;/code&gt; relationship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In your migration&lt;/span&gt;
&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cascadeOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// In your model&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsTo&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Team model needs the inverse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In Team.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;HasMany&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filament uses this relationship to automatically scope queries. When a user is viewing the "Acme Corp" team, &lt;code&gt;ProjectResource&lt;/code&gt; will only show projects where &lt;code&gt;team_id&lt;/code&gt; matches the current tenant. You don't need to add any &lt;code&gt;where&lt;/code&gt; clauses or global scopes yourself. Filament handles it.&lt;/p&gt;

&lt;p&gt;But you DO need to make sure the &lt;code&gt;team_id&lt;/code&gt; gets set when creating new records. The simplest way is a model observer or the &lt;code&gt;creating&lt;/code&gt; event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In AppServiceProvider boot() or a dedicated observer&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Facades\Filament&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;creating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Project&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;If you have a &lt;code&gt;Select&lt;/code&gt; component that pulls options via a &lt;code&gt;relationship()&lt;/code&gt; method, those options are not filtered by tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This will show ALL categories from ALL teams&lt;/span&gt;
&lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://filamentphp.com/docs/5.x/users/tenancy#multi-tenancy" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; are explicit about this. The form components live in a separate package and don't know about tenancy. You need to scope them manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereBelongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Filament&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getTenant&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;Not everything belongs to a team. Settings, plans, or shared lookup tables might be global. You can opt a resource out of tenant scoping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlanResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Resource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$isScopedToTenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or flip the default so resources are NOT scoped unless you explicitly opt in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In a service provider&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Resources\Resource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

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

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Team&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;HasName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasCurrentTenantLabel&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getCurrentTenantLabel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Active team'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a default tenant when the user logs in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In User.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getDefaultTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nc"&gt;\App\Http\Middleware\EnsureTeamIsSubscribed&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This middleware runs after the tenant is resolved, so you have access to &lt;code&gt;Filament::getTenant()&lt;/code&gt; inside it.&lt;/p&gt;

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




&lt;p&gt;Taylor Otwell dropped a one-liner on X yesterday that stopped me mid-scroll:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Hello, Laravel'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toAudio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. One method call and your string becomes audio. No external SDK wiring, no Guzzle calls, no API response parsing. Just &lt;code&gt;-&amp;gt;toAudio()&lt;/code&gt; on a Stringable, the same way you'd call &lt;code&gt;-&amp;gt;upper()&lt;/code&gt; or &lt;code&gt;-&amp;gt;slug()&lt;/code&gt;.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$audio&lt;/code&gt; object holds the raw audio content. You can cast it to a string to get the bytes, or store it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Store on your default disk&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

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

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/audio/preview'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome to our support line.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'audio/mpeg'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Hello, Laravel'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toAudio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what Taylor tweeted about. The AI SDK registers &lt;code&gt;toAudio()&lt;/code&gt; as a method on the Stringable class, so you can chain it alongside &lt;code&gt;-&amp;gt;replace()&lt;/code&gt;, &lt;code&gt;-&amp;gt;trim()&lt;/code&gt;, or any other string method. It's the same pattern as &lt;code&gt;-&amp;gt;toEmbeddings()&lt;/code&gt; for vector search.&lt;/p&gt;

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

&lt;p&gt;The SDK gives you three ways to control the voice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Quick gender selection&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome back!'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Your package is ready.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;male&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

&lt;p&gt;This is where things get interesting. The &lt;code&gt;instructions&lt;/code&gt; method lets you coach the AI on how the audio should sound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Ahoy! Your treasure has arrived!'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Speak like a friendly pirate captain'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

&lt;p&gt;&lt;strong&gt;1. Blog post audio versions.&lt;/strong&gt; Generate an audio file when a post is published. Store it alongside the post and embed an HTML5 audio player. Accessibility win, and it keeps readers on the page longer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In your Post observer or event listener&lt;/span&gt;
&lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plain_text_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Conversational and clear, like a podcast host'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"post-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp3"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Order confirmation voice messages.&lt;/strong&gt; E-commerce apps can generate a short audio clip summarizing the order and attach it to the confirmation email or display it on the "thank you" page.&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;You can also transcribe from a raw file path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Transcription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/tmp/uploaded-audio.webm'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'audio/webm'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pairs naturally with file uploads. A typical controller might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'audio'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|file|mimes:mp3,wav,webm'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

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

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

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

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;The SDK dispatches a job to your &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue system&lt;/a&gt;. If you need to do something with the audio after it generates (store it, notify the user), chain a callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plain_text_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;female&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateQueued&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"post-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp3"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'has_audio'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;If you've read the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-what-it-changes-why-it-matters-and-should-you-use-it" rel="noopener noreferrer"&gt;AI SDK overview&lt;/a&gt;, you'll recognize this pattern. It's the same &lt;code&gt;Lab&lt;/code&gt; enum used for text generation. One SDK, one API, multiple providers.&lt;/p&gt;

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

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

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

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'generates audio for a new post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Your code that generates audio...&lt;/span&gt;

    &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertGenerated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'order has been shipped'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'transcribes uploaded audio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Transcription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Your code that transcribes...&lt;/span&gt;

    &lt;span class="nc"&gt;Transcription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertGenerated&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Audio::fake()&lt;/code&gt; prevents any HTTP requests and lets you assert that generation happened with the right inputs. Same pattern as &lt;code&gt;Http::fake()&lt;/code&gt;, &lt;code&gt;Mail::fake()&lt;/code&gt;, or &lt;code&gt;Queue::fake()&lt;/code&gt;. If you've tested anything in Laravel before, this is familiar.&lt;/p&gt;

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$audio&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'audio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"post-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp3"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Audio generation failed for post &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="c1"&gt;// Continue without audio - the post still works&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Set appropriate timeouts.&lt;/strong&gt; Longer text takes longer to generate. If you're narrating a 2,000-word blog post, the API might need 15-20 seconds. That's too long for a synchronous request. Use queued generation for anything over a paragraph or two.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;If you have any custom Artisan commands that perform destructive operations (clearing caches, resetting data, running seed scripts), wrap them with an environment check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResetDemoDataCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'app:reset-demo-data'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'production'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This command cannot run in production.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FAILURE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

    &lt;span class="s1"&gt;'agent_readonly'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forge'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_AGENT_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'agent_reader'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_AGENT_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'charset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8mb4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'collation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8mb4_unicode_ci'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create the MySQL user with restricted permissions:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Worth noting: if you're using &lt;a href="https://hafiz.dev/blog/claude-code-channels-how-to-control-your-ai-agent-from-your-phone" rel="noopener noreferrer"&gt;Claude Code Channels&lt;/a&gt; to send commands remotely, the same permission rules apply. A command sent via Telegram still runs through the permission system. But you should be extra careful about which tasks you trigger remotely, since you're not watching the agent's output in real time.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>laravel</category>
      <category>claudecode</category>
      <category>security</category>
      <category>aidevelopment</category>
    </item>
    <item>
      <title>Generate Beautiful Open Graph Images for Your Laravel App with One Spatie Package</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 27 Apr 2026 05:22:10 +0000</pubDate>
      <link>https://forem.com/hafiz619/generate-beautiful-open-graph-images-for-your-laravel-app-with-one-spatie-package-2780</link>
      <guid>https://forem.com/hafiz619/generate-beautiful-open-graph-images-for-your-laravel-app-with-one-spatie-package-2780</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/generate-beautiful-og-images-laravel-spatie-og-image" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




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

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

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

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

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

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

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

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

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/generate-beautiful-og-images-laravel-spatie-og-image" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Six steps that matter:&lt;/p&gt;

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;Open any Blade view that you want to add an OG image to. For a Laravel blog, that's typically &lt;code&gt;resources/views/blog/show.blade.php&lt;/code&gt;, the single-post view. Drop in the component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-og-image&amp;gt;
    &amp;lt;div class="w-full h-full bg-slate-900 text-white flex flex-col justify-between p-16"&amp;gt;
        &amp;lt;div class="flex items-center gap-4"&amp;gt;
            &amp;lt;img src="{{ asset('logo.svg') }}" class="w-16 h-16" alt="hafiz.dev"&amp;gt;
            &amp;lt;span class="text-2xl font-semibold"&amp;gt;hafiz.dev&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;

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

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

&lt;/div&gt;



&lt;p&gt;That's it. Refresh the page in your browser and view source. You'll see the package has injected meta tags into your head:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://hafiz.dev/og-image/a3f8c2d1e9b4.jpeg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://hafiz.dev/og-image/a3f8c2d1e9b4.jpeg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;p&gt;If you want the same OG layout across many pages, or if the template is getting complex, reference a Blade view instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-og-image view="og-image.post" :data="['title' =&amp;gt; $post-&amp;gt;title, 'author' =&amp;gt; $post-&amp;gt;author-&amp;gt;name]" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;resources/views/og-image/post.blade.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div class="w-full h-full bg-slate-900 text-white flex flex-col justify-between p-16"&amp;gt;
    &amp;lt;h1 class="text-7xl font-bold"&amp;gt;{{ $title }}&amp;lt;/h1&amp;gt;
    &amp;lt;div class="text-2xl text-slate-400"&amp;gt;by {{ $author }}&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OgImage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fallbackUsing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'og-image.fallback'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'tagline'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Laravel, Claude Code, and shipping fast'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The closure receives the full Request, so you can use route parameters or model bindings to customize the fallback per URL. Return &lt;code&gt;null&lt;/code&gt; to skip the fallback for specific requests. Pages that have an explicit component are never affected.&lt;/p&gt;

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

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

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

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

&lt;span class="s1"&gt;'drivers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'cloudflare'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'account_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CLOUDFLARE_ACCOUNT_ID'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'api_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CLOUDFLARE_API_TOKEN'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PublishPostAction&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'published_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;

        &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;OgImage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;generateForUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;The package includes an artisan command to clear them:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

&lt;p&gt;Start with the easy bit. The API model ID is &lt;code&gt;claude-opus-4-7&lt;/code&gt;. In your Laravel AI SDK agent, that's one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Provider(Lab::Anthropic)]&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'claude-opus-4-7'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  &lt;span class="c1"&gt;// was: 'claude-opus-4-6'&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;YourAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;span class="c1"&gt;// After (Opus 4.7)&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'model'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'claude-opus-4-7'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;64000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'thinking'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'adaptive'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'output_config'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'effort'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'messages'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adaptive thinking is &lt;strong&gt;off by default&lt;/strong&gt; on Opus 4.7. If you don't set &lt;code&gt;thinking: {type: "adaptive"}&lt;/code&gt; explicitly, the model runs without thinking, matching Opus 4.6's default behavior when no thinking was configured. Enable it explicitly when you want it.&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;This is worth knowing for anyone building workflows like the RAG support bot in &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-part-2-build-a-rag-powered-support-bot-with-tools-and-memory" rel="noopener noreferrer"&gt;part two of the AI SDK tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A task budget is an advisory token cap across the entire agentic loop, not per request. The model sees a running countdown and uses it to scope and prioritize work. It's distinct from &lt;code&gt;max_tokens&lt;/code&gt;, which is a hard per-request cap the model never sees.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Task budgets require the beta header + output_config&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'model'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'claude-opus-4-7'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;128000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'output_config'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'effort'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'task_budget'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;128000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'messages'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="s1"&gt;'betas'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'task-budgets-2026-03-13'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use task budgets when you need the model to self-moderate on a token allowance. Skip them for open-ended quality-first tasks where you don't care about scoping. The minimum value is 20k tokens.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;For agentic coding workflows: yes, without much debate. Opus 4.7 records 64.3% on SWE-bench Pro and 87.6% on SWE-bench Verified. If you're building agents that write, review, or refactor Laravel code (the kind of thing covered in the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-what-it-changes-why-it-matters-and-should-you-use-it" rel="noopener noreferrer"&gt;complete Laravel AI SDK guide&lt;/a&gt;), the improvement on long-horizon autonomy is real.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>claudecode</category>
      <category>aidevelopment</category>
    </item>
  </channel>
</rss>
