<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hafiz</title>
    <description>The latest articles on DEV Community by Hafiz (@hafiz619).</description>
    <link>https://dev.to/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>DEV Community: Hafiz</title>
      <link>https://dev.to/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>Sitemap Timing Test</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 20 Aug 2026 13:02:18 +0000</pubDate>
      <link>https://dev.to/hafiz619/sitemap-timing-test-1f0d</link>
      <guid>https://dev.to/hafiz619/sitemap-timing-test-1f0d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/sitemap-timing-test" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;x&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Your Laravel MCP Server Is a Public API for Robots. Here's How to Lock It Down.</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 19 Aug 2026 04:15:10 +0000</pubDate>
      <link>https://dev.to/hafiz619/your-laravel-mcp-server-is-a-public-api-for-robots-heres-how-to-lock-it-down-2bej</link>
      <guid>https://dev.to/hafiz619/your-laravel-mcp-server-is-a-public-api-for-robots-heres-how-to-lock-it-down-2bej</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-mcp-server-security-authorization" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;There are about six good tutorials on building a Laravel MCP server. Every one of them ends at the same place: you make a tool, you connect Claude, the agent calls your tool, everyone claps. Then the tutorial stops.&lt;/p&gt;

&lt;p&gt;That's the exact moment the interesting part starts. Because what you just built is an HTTP endpoint that lets a language model run your code. &lt;code&gt;Mcp::web('/mcp/orders', OrderServer::class)&lt;/code&gt; is a public route by default, and the tool behind it might refund a payment, delete a record, or read a customer's data. The getting-started guides walk you right up to that door and then wave goodbye before anyone locks it.&lt;/p&gt;

&lt;p&gt;This post is the lock. Authentication, per-tool authorization, the annotation trap that looks like a safety feature and isn't, rate limiting an endpoint an AI can call in a loop, and how to test that all of it actually holds. If you've read the &lt;a href="https://laravel.com/docs/13.x/mcp" rel="noopener noreferrer"&gt;Laravel MCP getting-started guide&lt;/a&gt; or built the app-friendly version I covered in &lt;a href="https://hafiz.dev/blog/how-to-make-your-laravel-app-ai-agent-friendly-the-complete-2026-guide" rel="noopener noreferrer"&gt;making your Laravel app AI-agent-friendly&lt;/a&gt;, this is the next step you actually need before any of it goes near production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually exposing
&lt;/h2&gt;

&lt;p&gt;Start by being honest about the threat model, because it's different from a normal API.&lt;/p&gt;

&lt;p&gt;A normal REST endpoint is called by code you wrote or a client you documented. An MCP tool is called by a language model interpreting natural language, sometimes from a user you've never met, sometimes with arguments the model invented to fit what it thought you meant. The caller is non-deterministic by design. That's the whole point of the protocol, and it's also the whole problem.&lt;/p&gt;

&lt;p&gt;So three things are true at once. The endpoint is public unless you protect it. The arguments are model-generated, so they can be malformed or adversarial in ways a normal client never would be. And the tool descriptions you write are read by the model to decide what to call, which means a badly scoped tool gets invoked in situations you didn't picture. A &lt;code&gt;deleteRecords&lt;/code&gt; tool with a vague description is a loaded gun with a helpful label.&lt;/p&gt;

&lt;p&gt;None of this means MCP is unsafe. It means the safety is your job, and Laravel gives you every piece you need. The pieces just aren't assembled anywhere, so let's assemble them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: authentication, and why local is not exempt
&lt;/h2&gt;

&lt;p&gt;A web server registered with &lt;code&gt;Mcp::web()&lt;/code&gt; is reachable by anyone who finds the URL. Authenticate it. The docs give you two real options and one trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanctum&lt;/strong&gt; is the pragmatic choice for most apps. Add the middleware and require a bearer token:&lt;br&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\Mcp\Servers\OrderServer&lt;/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\Mcp\Facades\Mcp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request now needs &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;, and inside your tools &lt;code&gt;$request-&amp;gt;user()&lt;/code&gt; resolves to the token's owner. For an internal server, or one your own product's agents call, this is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth via Passport&lt;/strong&gt; is what the MCP spec actually standardizes on, and it's the right call when third-party MCP clients (someone else's Claude, a tool you don't control) need to connect:&lt;br&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;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;oauthRoutes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth:api'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the part worth internalizing before you rely on it: Laravel MCP uses OAuth as a translation layer to your authenticatable model, and it advertises a single &lt;code&gt;mcp:use&lt;/code&gt; scope. Custom scopes aren't supported. So OAuth authenticates &lt;em&gt;who&lt;/em&gt; the agent is acting as, but it does not carve up &lt;em&gt;what&lt;/em&gt; they can do. If your mental model of OAuth includes fine-grained scopes gating individual tools, drop it here. Authentication tells you the user. Authorization is still entirely on you, and that's the next layer.&lt;/p&gt;

&lt;p&gt;The trap is thinking &lt;strong&gt;local servers&lt;/strong&gt; don't need any of this. A local server runs as an Artisan command for agents on the same machine, which feels safe. But it runs with your application's full privileges, every binding in your container, your database, your filesystem. The security boundary for a local server is whatever can start that process. If that's a coding agent executing on your dev machine with your credentials, the blast radius is your entire local environment. Treat the machine boundary as the auth boundary, and don't register a local server that does anything you wouldn't let a shell script do unattended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: authorization, per tool, inside handle
&lt;/h2&gt;

&lt;p&gt;Authentication gets you a user. Now decide what that user can do, and do it in two places, because they guard different things.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;shouldRegister&lt;/code&gt; controls whether a tool is &lt;em&gt;visible&lt;/em&gt; in the server's tool list:&lt;br&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;shouldRegister&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;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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refund-orders'&lt;/span&gt;&lt;span class="p"&gt;)&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;This is good practice. It keeps tools a user can't use out of the list the model sees, which means the model won't try to call them and won't hallucinate around their existence. But understand exactly what it does: it hides the tool. Hiding is not blocking. A caller who knows the tool name can still attempt to invoke it, and &lt;code&gt;shouldRegister&lt;/code&gt; returning false is not a guaranteed rejection at the point of execution the way an authorization check is.&lt;/p&gt;

&lt;p&gt;So the actual gate goes inside &lt;code&gt;handle()&lt;/code&gt;, every time, on every tool that does anything sensitive:&lt;br&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\Mcp\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\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;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;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refund-orders'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'You are not authorized to issue refunds.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$validated&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;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'exists:orders,id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'min:1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:500000'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two things are happening there and both matter. The &lt;code&gt;can()&lt;/code&gt; check is your real authorization boundary, the one that actually stops execution. And the validation is not optional politeness, it's your defense against model-generated arguments. The &lt;code&gt;exists&lt;/code&gt; rule stops a refund against an order that isn't there. The &lt;code&gt;max&lt;/code&gt; stops the model refunding fifty thousand euros because it misread "500" as cents-or-not. Model input is untrusted input. Validate it exactly as hard as you'd validate a public form, because functionally that's what it is.&lt;/p&gt;

&lt;p&gt;The pattern I hold to: &lt;code&gt;shouldRegister&lt;/code&gt; for visibility, &lt;code&gt;can()&lt;/code&gt; for the gate, &lt;code&gt;validate()&lt;/code&gt; for the arguments. Skip any one of them and you've left a hole that the other two don't cover. This is the same principle behind &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;stopping an AI agent from destroying your Laravel app&lt;/a&gt;, applied at the protocol boundary instead of the SDK. If you want the review-before-execution version of the same instinct, the AI SDK's &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-human-in-the-loop-tool-approval" rel="noopener noreferrer"&gt;human-in-the-loop tool approval&lt;/a&gt; pauses a call for a person; this is the authorization layer underneath it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-mcp-server-security-authorization" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The annotation trap
&lt;/h2&gt;

&lt;p&gt;Laravel MCP lets you tag tools with annotations: &lt;code&gt;#[IsReadOnly]&lt;/code&gt;, &lt;code&gt;#[IsDestructive]&lt;/code&gt;, &lt;code&gt;#[IsIdempotent]&lt;/code&gt;, &lt;code&gt;#[IsOpenWorld]&lt;/code&gt;. They look like access controls. They are not.&lt;/p&gt;

&lt;p&gt;These are &lt;em&gt;hints to the client&lt;/em&gt;. They travel to the AI client as metadata so it can make smarter decisions, like warning a user before calling a destructive tool or preferring a read-only one. That's useful for a well-behaved client. But nothing in your server enforces them. A client can ignore &lt;code&gt;#[IsReadOnly]&lt;/code&gt; completely, and a compromised or hostile client absolutely will. Marking a tool &lt;code&gt;#[IsReadOnly]&lt;/code&gt; does not prevent it writing if its &lt;code&gt;handle()&lt;/code&gt; writes.&lt;/p&gt;

&lt;p&gt;So use annotations, they improve the experience with honest clients and they're good documentation of intent. Just never let one stand in for a check. If a tool must not modify data, the guarantee lives in what &lt;code&gt;handle()&lt;/code&gt; does and what &lt;code&gt;can()&lt;/code&gt; allows, not in an attribute the client is free to disregard. The annotation describes the tool's behavior; it doesn't constrain it.&lt;/p&gt;

&lt;p&gt;This is the single most likely place for a false sense of security in the whole feature, because the attribute reads like a policy and sits right there in the class looking authoritative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: rate limiting an endpoint a robot can loop
&lt;/h2&gt;

&lt;p&gt;A human hits your API at human speed. An agent in a retry loop, or a streaming tool processing a batch, can hit it as fast as the network allows. And MCP tools can return generators that hold an SSE stream open, which is a different resource profile from a normal request-response.&lt;/p&gt;

&lt;p&gt;Throttle the server route like you'd throttle a login endpoint:&lt;br&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;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:mcp'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then define the limiter against the authenticated user, not the IP, because many agents sit behind shared egress addresses:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mcp'&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="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;?&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;by&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;by&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;ip&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty a minute is a starting point, not a recommendation. Set it from what a legitimate session actually needs, then add headroom. The point is that an unbounded MCP endpoint is a cost and availability risk the moment a model decides to call your tool in a loop, and models do decide that. An agent will happily call the same lookup tool a dozen times in one turn, each result making it think of a new question. Cap it.&lt;/p&gt;

&lt;p&gt;For anything expensive behind a tool, the throttle is the first line, not the only one. Push the heavy work to a queue and return a job reference, the same way you would for any slow endpoint, which I went through in &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing thousands of queued jobs without breaking&lt;/a&gt;. A tool that kicks off a job and returns immediately can't hold a worker hostage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing that the locks hold
&lt;/h2&gt;

&lt;p&gt;The reason this feature survives contact with production is that all of it is testable without a live model or a network call. Laravel MCP ships test helpers that let you invoke a tool as a specific user and assert on the result.&lt;/p&gt;

&lt;p&gt;The tests that matter here aren't the happy path. They're the negative cases, the ones that prove your gates actually reject:&lt;br&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;'refuses refunds for unauthorized users'&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="c1"&gt;// no refund permission&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;OrderServer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundOrderTool&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="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5000&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertHasErrors&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;'rejects an amount above the ceiling'&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;$manager&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;refundManager&lt;/span&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderServer&lt;/span&gt;&lt;span class="o"&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;$manager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundOrderTool&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="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;99999999&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertHasErrors&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the "unauthorized user is refused" test for every sensitive tool. Write the "bad arguments are rejected" test for every tool that takes model input, which is all of them. If you set up your suite the way I described in the &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest testing guide&lt;/a&gt;, these drop straight into it. A green suite here means the model can throw whatever it wants at your server and the boundaries hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick word on tool descriptions as an attack surface
&lt;/h2&gt;

&lt;p&gt;One thing that isn't obvious until it bites you: the description you write on a tool is instructions to the model. A vague or overreaching description causes the model to call the tool in situations you didn't intend, which is a security issue dressed as a copywriting issue.&lt;/p&gt;

&lt;p&gt;"Manages orders" invites the model to reach for that tool for anything order-shaped. "Issues a refund against a specific order, for a specific amount, when a customer reports a defect" tells the model exactly when this fires and when it doesn't. Narrow descriptions are narrow attack surfaces. Write them like you're briefing an over-eager junior who takes every instruction literally, because you are.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;The rule I'd publish on the team wiki: authenticate the server, authorize inside every sensitive &lt;code&gt;handle()&lt;/code&gt;, validate every argument, throttle the route, and never trust an annotation as a control. Five things, all of them boring, all of them enforced in code you can test.&lt;/p&gt;

&lt;p&gt;The one I'd emphasize hardest is that authentication and authorization are different jobs and MCP only hands you the first one cleanly. The single &lt;code&gt;mcp:use&lt;/code&gt; scope means the framework knows &lt;em&gt;who&lt;/em&gt; is calling but has no opinion on &lt;em&gt;what&lt;/em&gt; they may do. Every "what" decision is a &lt;code&gt;can()&lt;/code&gt; check you write. Miss that distinction and you'll ship a server that's authenticated and wide open, which is arguably worse than one with no auth at all, because it looks secure in the code review.&lt;/p&gt;

&lt;p&gt;I'll also say the honest thing: MCP is young, the security patterns around it are younger, and the most dangerous tools are the ones that felt harmless in isolation. A read tool that exposes one customer's data is a read tool that exposes every customer's data if the authorization is wrong. Start with your least dangerous tool, get the five layers right on it, and use it as the template for everything else. Don't expose the refund tool until the lookup tool's tests are green.&lt;/p&gt;

&lt;p&gt;Build the server. Then lock it before anyone, human or model, walks through the door.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does a local MCP server need authentication?
&lt;/h3&gt;

&lt;p&gt;Not in the HTTP sense, since it runs as an Artisan command rather than a route. But it runs with your full application privileges, so the security boundary becomes whatever can start the process. On a dev machine driven by a coding agent, that's your entire local environment. Treat the machine access as the auth boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use OAuth scopes to control which tools an agent can call?
&lt;/h3&gt;

&lt;p&gt;Not with Laravel MCP's built-in OAuth. It uses OAuth as a translation layer to your authenticatable model and advertises a single &lt;code&gt;mcp:use&lt;/code&gt; scope; custom scopes aren't supported. Per-tool authorization is done with &lt;code&gt;can()&lt;/code&gt; checks inside each tool's &lt;code&gt;handle()&lt;/code&gt; method, not with scopes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do the &lt;code&gt;#[IsReadOnly]&lt;/code&gt; and &lt;code&gt;#[IsDestructive]&lt;/code&gt; annotations prevent a tool from doing damage?
&lt;/h3&gt;

&lt;p&gt;No. They're advisory metadata sent to the AI client to help it make decisions. Your server doesn't enforce them, and a client can ignore them. A tool is only read-only if its &lt;code&gt;handle()&lt;/code&gt; method doesn't write. Never rely on an annotation as an access control.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop an AI agent from calling my tool in a loop?
&lt;/h3&gt;

&lt;p&gt;Apply Laravel's &lt;code&gt;throttle&lt;/code&gt; middleware to the MCP route and define a rate limiter keyed by the authenticated user rather than the IP, since agents often share egress addresses. For expensive operations, push the work to a queue and return a job reference so a single call can't tie up a worker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is validation really necessary if I've defined an input schema?
&lt;/h3&gt;

&lt;p&gt;Yes. The JSON schema gives the model a shape to aim for, but it isn't enforcement, and model-generated arguments can still be malformed or adversarial. Validate inside &lt;code&gt;handle()&lt;/code&gt; with Laravel's validator exactly as you would for a public form, including existence and range checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Building an MCP server is a solved problem with six good tutorials. Securing one isn't, because the docs hand you the pieces (Sanctum, Passport, &lt;code&gt;can()&lt;/code&gt;, &lt;code&gt;shouldRegister&lt;/code&gt;, annotations, throttling, test helpers) without assembling them into a threat model. The model is simple once you see it: the endpoint is public, the caller is non-deterministic, the arguments are untrusted, and the annotations are hints. Everything else follows.&lt;/p&gt;

&lt;p&gt;Authenticate the route, authorize inside &lt;code&gt;handle()&lt;/code&gt;, validate every argument, throttle the endpoint, and test the negative cases. Do that and you've got an MCP server you'd actually put on the internet.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mcp</category>
      <category>aiagents</category>
      <category>security</category>
    </item>
    <item>
      <title>How I Moved a Live Laravel SaaS to a New Server With 2 Minutes of Downtime</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:54:18 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-i-moved-a-live-laravel-saas-to-a-new-server-with-2-minutes-of-downtime-bf2</link>
      <guid>https://dev.to/hafiz619/how-i-moved-a-live-laravel-saas-to-a-new-server-with-2-minutes-of-downtime-bf2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Last Thursday morning, I moved a production Laravel SaaS with paying customers from one server to another. The maintenance page was up for 1 minute and 55 seconds. I know the exact number because I checked the nginx access log on the new box afterwards: old server frozen at 09:19:59 UTC, first live request served by the new one at 09:21:54. The whole server migration, planning included, took one morning.&lt;/p&gt;

&lt;p&gt;The app is &lt;a href="https://promptoptimizer.tools" rel="noopener noreferrer"&gt;Prompt Optimizer&lt;/a&gt;, a prompt optimization tool with a free tier doing tens of thousands of optimizations a month and a paid tier billing real money through Stripe. So this wasn't a hobby project where downtime means nobody notices. A botched cutover here means failed checkouts and a Chrome extension that stops working for people who paid for it.&lt;/p&gt;

&lt;p&gt;This post is the full playbook: why I moved, how the cutover stayed under two minutes, and more importantly, the four things that almost broke silently. Because the rsync commands are the easy part. The dangerous part is everything that lives outside your app directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I moved at all
&lt;/h2&gt;

&lt;p&gt;The old server was a 1GB DigitalOcean droplet hosting several apps at once. It worked fine until the app grew, and then it didn't.&lt;/p&gt;

&lt;p&gt;Two incidents forced the decision. First, PHP-FPM worker starvation: my optimize endpoint calls an AI provider that can take up to 45 seconds to respond, and with &lt;code&gt;pm.max_children = 5&lt;/code&gt;, five slow AI calls meant the entire site stopped answering. Health checks stayed green the whole time, which made it worse. Second, an earlier out-of-memory crash had already forced me to ban every-minute cron jobs on that box, which quietly meant my Laravel scheduler never ran in production. More on that later, because it hid a broken feature for weeks.&lt;/p&gt;

&lt;p&gt;You can patch around a resource ceiling for a while. Raise a worker count here, cache a query there. But when the fixes start fighting each other, the honest answer is a bigger box. I went with a small Hetzner server with 4GB of RAM and two vCPUs, which I compared against the managed options in &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;Laravel Cloud vs Forge vs a plain VPS&lt;/a&gt;. For a solo developer running multiple small apps, the VPS still wins on cost by a wide margin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unfair advantage: Cloudflare was already in front
&lt;/h2&gt;

&lt;p&gt;Here's the thing that made a two-minute cutover possible at all: the domain was already proxied through Cloudflare.&lt;/p&gt;

&lt;p&gt;When your DNS is proxied, visitors never connect to your server's IP directly. They connect to Cloudflare's edge, and Cloudflare connects to whatever origin IP you've configured. Changing that origin is a dashboard edit that takes effect in seconds. No TTL waiting, no propagation anxiety, no "some users see the old server for six hours" nonsense. The classic migration problem simply doesn't exist.&lt;/p&gt;

&lt;p&gt;If your production domain is not behind a proxy like this, set that up weeks before you migrate, not the day of. I covered the base setup in &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;How I Hardened My VPS in One Afternoon&lt;/a&gt;, and the same stack carried this migration: Cloudflare in front, Tailscale between the boxes for private server-to-server transfers.&lt;/p&gt;

&lt;p&gt;For TLS on the new origin I used a Cloudflare &lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/" rel="noopener noreferrer"&gt;origin certificate&lt;/a&gt; instead of certbot. It's valid for 15 years, only Cloudflare needs to trust it, and there's no renewal cron to migrate. One less moving part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing the new server while the old one serves traffic
&lt;/h2&gt;

&lt;p&gt;Everything in this phase happened while the site ran normally. Zero risk, no time pressure.&lt;/p&gt;

&lt;p&gt;The new box got a dedicated PHP-FPM pool with 12 workers, sized so those 45-second AI calls can't starve anyone again. The queue worker became a proper systemd service instead of a supervisor config. Nginx got the vhost with the origin cert. Then I synced the whole deployed app tree over Tailscale: 227MB of code and vendor directory, about 20 seconds.&lt;/p&gt;

&lt;p&gt;The database needed more thought. The app runs on SQLite in production (yes, really, 123MB serving a six-figure monthly request count without complaint). You can't just copy a SQLite file that's being written to, because you might catch it mid-transaction. SQLite has a clean answer: &lt;a href="https://www.sqlite.org/lang_vacuum.html" rel="noopener noreferrer"&gt;&lt;code&gt;VACUUM INTO&lt;/code&gt;&lt;/a&gt;, available since 3.27, which writes a consistent snapshot to a new file without blocking writers.&lt;br&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;// One consistent snapshot, no write freeze, no downtime&lt;/span&gt;
&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"VACUUM INTO '/tmp/snapshot.sqlite'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That snapshot went to the new server as a rehearsal database. And then came the step I'd argue is the single most valuable trick in this whole post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rehearse through the real edge with a probe subdomain
&lt;/h2&gt;

&lt;p&gt;Testing the new server with &lt;code&gt;curl --resolve&lt;/code&gt; proves your nginx config works. It does not prove that Cloudflare's edge can talk to your new origin. SSL mode mismatches and origin cert problems only show up on that hop, and the classic failure is discovering them after you flip DNS, live, while your site throws 526 errors.&lt;/p&gt;

&lt;p&gt;So before touching the real records, I added one DNS entry: &lt;code&gt;neworigin.mydomain.com&lt;/code&gt;, proxied, pointing at the new server's IP. The origin cert was a wildcard, so it covered the probe subdomain for free. Then I opened it in a browser and ran a full user flow against the rehearsal database, through Cloudflare's actual edge, TLS handshake and all.&lt;/p&gt;

&lt;p&gt;It worked. Which meant the cutover would change exactly one variable: the origin IP behind a path already proven end to end. That's what makes a migration boring, and boring is the goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cutover: 1 minute 55 seconds
&lt;/h2&gt;

&lt;p&gt;Here's the full sequence. My part was scripted; the human part was clicking two DNS records.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stop the queue worker on the old server, then &lt;code&gt;php artisan down&lt;/code&gt;. Writers stopped first, then the maintenance page. The order matters: a worker that keeps processing jobs during your final sync is how you lose data.&lt;/li&gt;
&lt;li&gt;Take a fresh &lt;code&gt;VACUUM INTO&lt;/code&gt; snapshot. Writes are frozen now, so it's the final, complete state.&lt;/li&gt;
&lt;li&gt;Rsync the snapshot plus the storage delta to the new server. The pre-sync days earlier meant this final pass moved almost nothing.&lt;/li&gt;
&lt;li&gt;On the new box: &lt;code&gt;php artisan config:cache&lt;/code&gt;, fix file ownership, start the queue worker, install the crontab. The full cache command list is in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan commands reference&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Flip the two A records in Cloudflare to the new origin.&lt;/li&gt;
&lt;li&gt;Verify on the live domain: health endpoint, homepage, pricing, one real optimization, and a live &lt;a href="https://hafiz.dev/blog/stripe-integration-in-laravel-complete-guide-to-subscriptions-one-time-payments" rel="noopener noreferrer"&gt;Stripe checkout&lt;/a&gt; session to confirm billing quotes the right amount.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The old server stayed exactly as it was, frozen in maintenance mode. That's the rollback plan, and it's beautifully simple: flip the two records back, run &lt;code&gt;php artisan up&lt;/code&gt;, restart the worker. The point of no return is the first real payment on the new box, because after that, rolling back means losing data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that almost broke
&lt;/h2&gt;

&lt;p&gt;This is the part I wish someone had written before I started. Every one of these was invisible in my planning and got caught by testing rather than foresight.&lt;/p&gt;

&lt;h3&gt;
  
  
  The maintenance flag stowed away in the rsync
&lt;/h3&gt;

&lt;p&gt;Right after the final sync, the new server started answering 503. Confusing, until I realized why: &lt;code&gt;php artisan down&lt;/code&gt; creates a flag file at &lt;code&gt;storage/framework/down&lt;/code&gt;, and my final rsync had faithfully copied it to the new server. The new box wasn't broken. It was dutifully in maintenance mode because I'd shipped the maintenance flag along with the data. One &lt;code&gt;php artisan up&lt;/code&gt; on the new server fixed it, and cost about 30 seconds of the downtime window.&lt;/p&gt;

&lt;h3&gt;
  
  
  The SSR service that existed nowhere in the app tree
&lt;/h3&gt;

&lt;p&gt;The app uses Inertia SSR, which runs as a separate node process managed by a systemd unit. That unit isn't in the git repo. It's not in the crontab. It's not in the supervisor config. Its name didn't even contain the app's name. My server inventory missed it completely, and the site ran client-side-only for about 40 minutes before a deploy script warning surfaced it. For an app where most traffic comes from organic search, silently losing server-side rendering is a real SEO problem.&lt;/p&gt;

&lt;p&gt;The lesson: list every systemd service on the old box and ask what each one does. An app's runtime can include services you forgot you created.&lt;/p&gt;

&lt;h3&gt;
  
  
  The backup that would have failed silently every night at 2:30
&lt;/h3&gt;

&lt;p&gt;The database backup script ships snapshots offsite to Cloudflare R2 on a cron. After the migration I ran it by hand once instead of waiting for the schedule. Exit code 127. The new server didn't have the &lt;code&gt;sqlite3&lt;/code&gt; CLI installed, and the cron line ends in &lt;code&gt;&amp;gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt;, so the nightly run would have failed silently forever. I'd have discovered it the day I actually needed a backup, which is the one day you can't afford to.&lt;/p&gt;

&lt;p&gt;Run every cron job by hand once on the new machine. Crons that discard output don't fail loudly. They just stop existing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The midnight log rotation trap
&lt;/h3&gt;

&lt;p&gt;Laravel's daily log driver creates a fresh log file at the first write after midnight. Whoever writes first owns the file. If that's ever root (a stray artisan command over SSH is enough), the web user can't write to it, and every request starts failing at midnight while you sleep. I'd been bitten by this before, so this time I set a default ACL on the log directory that makes any new file writable by the web user no matter who creates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setfacl &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; u:www-data:rwX storage/logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I proved it: created a file as root, appended to it as www-data, watched it succeed. The first unattended night passed clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to do differently
&lt;/h2&gt;

&lt;p&gt;Honestly? Not much about the process. But I hold two opinions after this that I didn't hold as strongly before.&lt;/p&gt;

&lt;p&gt;First, rehearse through the real path, not a simulation of it. The probe subdomain took two minutes to set up and removed the only scary unknown in the plan. Every migration guide tells you to test the new server. Almost none tell you to test the edge-to-origin hop, and that's the one that bites.&lt;/p&gt;

&lt;p&gt;Second, the boring deploy stack held up. My deploy is a bash script doing a local build and an rsync. No containers, no orchestration. There are sharper tools, and I've compared some of them in &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;Scotty vs Laravel Envoy&lt;/a&gt;, but a deploy you fully understand beats a sophisticated one you half understand, especially at 11 in the morning with customers on the site. The same deploy script also caught the missing SSR service, which is a strong argument for running a no-change deploy as a post-migration test.&lt;/p&gt;

&lt;p&gt;And one bonus: moving to a box that could afford an every-minute &lt;code&gt;schedule:runcron&lt;/code&gt; revealed that some of my scheduled tasks had never actually run on the old server. Nothing was broken in the code. The entries existed in the scheduler, but no cron was firing them. Run &lt;code&gt;php artisan schedule:list&lt;/code&gt; on your new box and check every line against what you believed was running.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I need to lower DNS TTLs before migrating?
&lt;/h3&gt;

&lt;p&gt;Not if your domain is proxied through Cloudflare or a similar edge. Visitors connect to the edge, not your origin, so changing the origin IP takes effect in seconds regardless of TTL. If your DNS points directly at your server, then yes, drop TTLs to 60 seconds at least a day before.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you copy a SQLite database that's in use?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;VACUUM INTO&lt;/code&gt; writes a consistent snapshot without blocking writers, which is perfect for rehearsal copies while the site runs. For the final sync, stop your writers first (queue worker, then maintenance mode), take one last snapshot, and ship that. Never plain-copy a SQLite file under active writes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just use Laravel Forge or Laravel Cloud?
&lt;/h3&gt;

&lt;p&gt;Both are good answers for teams that value their time over their invoice. I run several small apps on one box, I already had the hardened VPS setup, and the economics of a fixed-price server win at my scale. The trade is that every one of the gotchas in this post becomes your job. That trade is worth it to me. It might not be to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should the old server stay available?
&lt;/h3&gt;

&lt;p&gt;I froze mine for a week: maintenance mode on, workers stopped, crons commented out, nothing deleted. Rollback is a DNS flip away for the whole window. Decommission only after the new box has survived real traffic, a real payment, and at least one full backup cycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  What breaks most often after a server move?
&lt;/h3&gt;

&lt;p&gt;Things that live outside your app directory: systemd services, cron entries, CLI packages your scripts assume exist, credentials in &lt;code&gt;/root&lt;/code&gt;, log rotation permissions. Your code survives the rsync fine. The runtime around it is what gets forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist is the takeaway
&lt;/h2&gt;

&lt;p&gt;The migration took a morning, the downtime took 1 minute and 55 seconds, and the four near-misses took a healthy dose of paranoia to catch. If you're planning the same move, steal the sequence: prepare everything while the old server runs, rehearse through the real edge with a probe subdomain, freeze writers before the final sync, and then run every cron and service by hand once on the other side.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/live-laravel-saas-server-migration-2-minutes-downtime" rel="noopener noreferrer"&gt;View the interactive component on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>laravel</category>
      <category>devops</category>
      <category>deployment</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>Laravel Head vs SEOTools vs Your Hand-Rolled Meta Partial: Who Should Actually Migrate</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:34:06 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-head-vs-seotools-vs-your-hand-rolled-meta-partial-who-should-actually-migrate-5h9o</link>
      <guid>https://dev.to/hafiz619/laravel-head-vs-seotools-vs-your-hand-rolled-meta-partial-who-should-actually-migrate-5h9o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-head-vs-seotools-migration-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every production Laravel app solved the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; problem years ago, one of three ways: artesaos/seotools with its family of facades, ralphjsmit/laravel-seo with SEO models in the database, or a hand-rolled Blade partial stuffed with &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; lines that grew one tag at a time since 2021. This blog runs the third kind.&lt;/p&gt;

&lt;p&gt;Then Taylor showed &lt;a href="https://github.com/laravel/head" rel="noopener noreferrer"&gt;Laravel Head&lt;/a&gt; on stage during the day-one Laracon US keynote, and now there's a first-party answer: &lt;code&gt;composer require laravel/head&lt;/code&gt;, a fluent API for titles, descriptions, canonicals, Open Graph, X cards, robots directives, JSON-LD, resource hints, and favicons, resolved per request across Blade, Livewire, and Inertia.&lt;/p&gt;

&lt;p&gt;Laravel News already covered how to use it, and the &lt;a href="https://laravel.com/docs/13.x/head" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; are thorough. The question nobody has answered is the one existing apps actually have: I have SEOTools calls in thirty controllers, or SEO rows in my database, or a 120-line partial. Is switching worth it, and what does the mapping look like? That answer is different for each of the three groups, so let's take them one at a time.&lt;/p&gt;

&lt;p&gt;First, the gate everyone hits before the interesting questions: Laravel Head requires PHP 8.3 and Laravel 13.17 or later. Below that, this whole post is academic until you upgrade. It needs 13.17 specifically because &lt;code&gt;withHead()&lt;/code&gt; is built on the native route metadata API that shipped in that release, which I covered in &lt;a href="https://hafiz.dev/blog/laravel-route-metadata-5-real-problems-it-finally-solves" rel="noopener noreferrer"&gt;the route metadata breakdown&lt;/a&gt; when it landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Head actually is, in ninety seconds
&lt;/h2&gt;

&lt;p&gt;Metadata resolves through five layers, lowest to highest priority: page defaults, route group metadata, route metadata, runtime metadata, and error metadata. Higher layers replace lower ones one field at a time, so a runtime title overrides the route's title without touching the route's description.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-head-vs-seotools-migration-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;Site-wide defaults live in a service provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Head\Enums\OgType&lt;/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\Head\Enums\TwitterCard&lt;/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\Head\Facades\Head&lt;/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\Head\HeadBuilder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Head&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;HeadBuilder&lt;/span&gt; &lt;span class="nv"&gt;$head&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$head&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hafiz.dev'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;' - hafiz.dev'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Laravel, shipped honestly.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;og&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;siteName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'hafiz.dev'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OgType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Website&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TwitterCard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;SummaryWithLargeImage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchableByRobots&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Static pages attach metadata to the route itself, and it survives route caching because &lt;code&gt;withHead()&lt;/code&gt; writes plain arrays through the route metadata 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="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&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;'/contact'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'contact'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'contact'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Contact'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Get in touch.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dynamic pages set the rest at runtime:&lt;br&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;show&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Head&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;title&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;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&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;excerpt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ogImage&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;featuredImageUrl&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&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;isDraft&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;$head&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;$head&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hiddenFromRobots&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;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the layout renders everything with one directive:&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;head&amp;gt;
    &amp;lt;meta charset="utf-8"&amp;gt;
    @head
&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole model. Now the migration questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group one: artesaos/seotools apps
&lt;/h2&gt;

&lt;p&gt;SEOTools is the incumbent at 5.2 million installs, and if you've maintained a Laravel app for more than a few years, odds are decent you're in this group. The migration is the most mechanical of the three, and it's also the one with the clearest payoff, because of something I'll call the duplication tax.&lt;/p&gt;

&lt;p&gt;Here's a representative SEOTools controller method, straight out of the pattern its own README teaches:&lt;br&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;Artesaos\SEOTools\Facades\SEOMeta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Artesaos\SEOTools\Facades\OpenGraph&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Artesaos\SEOTools\Facades\TwitterCard&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Artesaos\SEOTools\Facades\JsonLd&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;show&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;SEOMeta&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&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;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;SEOMeta&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setDescription&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;excerpt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;SEOMeta&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setCanonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts.show'&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="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&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;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setDescription&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;excerpt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts.show'&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="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addImage&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;featuredImageUrl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nc"&gt;OpenGraph&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'article'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;TwitterCard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&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;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;TwitterCard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setImage&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;featuredImageUrl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;JsonLd&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setTitle&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;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;JsonLd&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setDescription&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;excerpt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;JsonLd&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addImage&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;featuredImageUrl&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;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four facades, and the title appears four times. That's the duplication tax: SEOTools treats meta, Open Graph, Twitter, and JSON-LD as four independent surfaces, so every value you care about gets set on each of them, and every future edit happens in four places. Miss one and your &lt;code&gt;og:title&lt;/code&gt; silently drifts from your &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, which is exactly the class of bug nobody notices until a shared link renders wrong.&lt;/p&gt;

&lt;p&gt;The same method under Head:&lt;br&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\Head\Facades\Head&lt;/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\Head\Facades\Schema&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;show&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Head&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;title&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;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&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;excerpt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ogImage&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;featuredImageUrl&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;og&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OgType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;)&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;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;blogPosting&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&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;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&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;excerpt&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;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The document title and description fill in &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, and the Twitter card values automatically, and the canonical comes from &lt;code&gt;canonical()&lt;/code&gt; in your defaults, which uses the current request URL and normalizes it to https without being told. Fifteen lines became six, and the four-surface synchronization problem is gone because there's one source of truth.&lt;/p&gt;

&lt;p&gt;The layout side shrinks the same way. SEOTools renders through four separate calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{!! SEOMeta::generate() !!}
{!! OpenGraph::generate() !!}
{!! Twitter::generate() !!}
{!! JsonLd::generate() !!}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of that becomes &lt;code&gt;@head&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's also a maintenance argument that's uncomfortable to say out loud but belongs in the decision. SEOTools has been community-maintained since 2015, sits at v1.4.1, and its Packagist page currently lists three security advisories in its history. It works, and the maintainers deserve credit for a decade of service. But a first-party package that just got keynote billing is going to out-develop it from here, and the direction of that gap only points one way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for this group: migrate, and don't drag it out.&lt;/strong&gt; The mapping is mechanical, each page gets shorter, and running both packages during a transition is safe as long as only one of them renders a given tag. Move page by page, and delete the SEOTools generate calls from the layout last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group two: ralphjsmit/laravel-seo apps
&lt;/h2&gt;

&lt;p&gt;This one is a different animal, and anyone telling you it's the same migration hasn't read what the package does. ralphjsmit/laravel-seo stores SEO as Eloquent models: a &lt;code&gt;HasSEO&lt;/code&gt; trait on your &lt;code&gt;Post&lt;/code&gt; creates and associates an SEO row in the database, editable through whatever admin you've built, retrievable through a &lt;code&gt;seo&lt;/code&gt; relationship.&lt;/p&gt;

&lt;p&gt;Laravel Head has no persistence layer at all. It resolves metadata from code, per request, and stores nothing. That's a deliberate design choice, not a gap they forgot, but it means the thing ralphjsmit users actually bought (SEO as content, editable without a deploy) doesn't come in the box.&lt;/p&gt;

&lt;p&gt;The honest migration for this group keeps the database and swaps the rendering. Your SEO columns or the existing SEO model rows stay exactly where they are, and Head becomes the output layer:&lt;br&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;show&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Head&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;title&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;seo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&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;seo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ogImage&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;seo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;featuredImageUrl&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;'posts.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works, and it's less code than it looks once you extract it into a small &lt;code&gt;HasHeadMetadata&lt;/code&gt; trait of your own. But notice what happened: you rebuilt the package's model integration yourself, thinly. Whether that trade is worth it depends on how much you use the rest of what Head brings, especially the route-level metadata and the Inertia story below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for this group: no urgency.&lt;/strong&gt; Your package is actively maintained (1.8.1 shipped in March, Laravel 13 supported), and the DB-backed workflow is a real feature Head doesn't replace. Migrate if you want the first-party trajectory and are willing to own a thin bridge trait, or when your admin-editable SEO turns out to be three fields nobody edits, which is more common than anyone admits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group three: the hand-rolled partial
&lt;/h2&gt;

&lt;p&gt;This is the group I'm in. hafiz.dev renders its meta tags from a Blade partial fed by the Post model: title, description, canonical, OG image with dimensions, article timestamps, Twitter card. It predates both packages' current versions, it works, and like every hand-rolled meta partial it has accumulated conditionals nobody remembers the reason for.&lt;/p&gt;

&lt;p&gt;For this group the migration case isn't about deleting a dependency, since there's no dependency to delete. It's about three things the partial approach can't do well:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static pages stop needing controller ceremony.&lt;/strong&gt; Contact, about, pricing, legal, the whole marketing shell around a &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS panel&lt;/a&gt;: every hand-rolled setup either hardcodes their meta in per-page views or threads variables through view composers. &lt;code&gt;withHead()&lt;/code&gt; on the route definition replaces both, and it's cacheable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The admin noindex problem gets one line.&lt;/strong&gt; Every app has route groups that should never be indexed. With a partial, that's a conditional inside the partial checking the route name, which is exactly the kind of accumulated conditional I just complained about. With Head it's &lt;code&gt;Route::withHead(robots: 'noindex, nofollow')&lt;/code&gt; on the group, next to the routes it describes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error pages get real metadata.&lt;/strong&gt; Hand-rolled partials almost never handle 404 and 500 pages properly because the layout context is different there. Head lets you register per-status title, description, and robots values once in a service provider, and that's a category of page most of us silently ship with default or broken meta.&lt;/p&gt;

&lt;p&gt;The migration itself is pleasant for this group because you're not translating an API, you're replacing string soup with structure. My plan for this blog is exactly the page-by-page approach: defaults into a provider first, then the blog post route, diffing the rendered &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; against the current partial's output before and after. The &lt;code&gt;Head::toArray()&lt;/code&gt; method is quietly useful here, since it gives you the resolved metadata as a structured array you can assert against in a test instead of regex-matching HTML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for this group: migrate at your own pace, newest pages first.&lt;/strong&gt; Nothing is broken today, but every new page you add to the partial is a page you'll migrate later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Head doesn't do, so you don't uninstall the wrong thing
&lt;/h2&gt;

&lt;p&gt;Three jobs commonly live next to meta tags, and none of them moved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sitemaps.&lt;/strong&gt; Head has &lt;code&gt;paginate()&lt;/code&gt;, &lt;code&gt;alternates()&lt;/code&gt;, and &lt;code&gt;feed()&lt;/code&gt; links, but it doesn't generate sitemap.xml. spatie/laravel-sitemap keeps its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OG image generation.&lt;/strong&gt; Head references image URLs, it doesn't create the images. If you generate OG images dynamically, the setup from &lt;a href="https://hafiz.dev/blog/generate-beautiful-og-images-laravel-spatie-og-image" rel="noopener noreferrer"&gt;my spatie/laravel-og-image walkthrough&lt;/a&gt; still owns that step, and its output URL is what you hand to &lt;code&gt;ogImage()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored, admin-editable SEO content.&lt;/strong&gt; As covered above: no database, no admin UI, by design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your mental model is "Head replaces my SEO stack", adjust it to "Head replaces the rendering layer of my SEO stack". Everything that produces the values still exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Inertia part, which is quietly the biggest upgrade
&lt;/h2&gt;

&lt;p&gt;If you run Inertia, this is the section that should decide you, because the old world was bad: metadata managed by a client-side &lt;code&gt;&amp;lt;Head&amp;gt;&lt;/code&gt; component means crawlers and link-preview bots that don't execute JavaScript see whatever your SSR setup happens to emit, which for many apps is nothing.&lt;/p&gt;

&lt;p&gt;Head shares the resolved metadata as rendered element strings on a &lt;code&gt;head&lt;/code&gt; prop, and with Inertia's &lt;code&gt;serverHead&lt;/code&gt; option (Inertia 3.5+) those tags land in the initial HTML response. Each element carries a stable &lt;code&gt;data-inertia&lt;/code&gt; key that Inertia adopts and keeps synchronized across visits and back/forward navigation. Preview bots read real tags without running a line of JavaScript, and you delete your client-side &lt;code&gt;&amp;lt;Head&amp;gt;&lt;/code&gt; usage entirely, which the docs are explicit about: don't let both systems manage the same element.&lt;/p&gt;

&lt;p&gt;Session-static tags (viewport, favicons, manifest) register once through &lt;code&gt;Head::inertiaGlobals()&lt;/code&gt; and stay out of the per-page prop. It's a carefully thought-through design, and it solves a problem the community packages never fully cracked because it needed cooperation from Inertia itself. First-party coordination is the whole pitch, and this is what it looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;For new apps this isn't a decision, it's the default: Head from day one, spatie/laravel-sitemap next to it, done.&lt;/p&gt;

&lt;p&gt;For existing apps, the three verdicts above compress to one sentence each. SEOTools apps should migrate because they're paying the duplication tax on every page and holding a package whose maintenance trajectory now points the wrong way. ralphjsmit apps should wait until the DB-backed workflow stops earning its keep, then bridge. Hand-rolled apps should migrate opportunistically, newest pages first, because the partial only grows.&lt;/p&gt;

&lt;p&gt;And one caution against the enthusiasm direction: don't migrate the week before something matters. Meta tags are the definition of quiet infrastructure, and the failure mode of a botched migration is invisible until search traffic dips or a shared link renders blank. Diff the rendered &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; per page type before and after, and keep the old system rendering until the diff is clean. This is a change you make boring on purpose.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What are Laravel Head's minimum requirements?
&lt;/h3&gt;

&lt;p&gt;PHP 8.3 and Laravel 13.17 or later. The 13.17 floor exists because route-level metadata through &lt;code&gt;withHead()&lt;/code&gt; is built on the native route metadata API introduced in that release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel Head replace artesaos/seotools completely?
&lt;/h3&gt;

&lt;p&gt;For rendering meta tags, Open Graph, Twitter cards, and JSON-LD, yes, and with less code because titles and descriptions propagate to the social tags automatically. It doesn't generate sitemaps, which SEOTools never did either.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run Laravel Head and my current SEO package at the same time?
&lt;/h3&gt;

&lt;p&gt;Yes, during a migration. The rule is that only one system may render a given tag on a given page, so move page by page and remove the old package's render calls from the layout only when every page has switched.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel Head work with route caching?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;withHead()&lt;/code&gt; stores plain arrays through Laravel's native route metadata API, which is fully compatible with &lt;code&gt;route:cache&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel Head store SEO data in the database?
&lt;/h3&gt;

&lt;p&gt;No. It resolves everything from code at request time. If you need admin-editable SEO content, keep your existing storage (columns or a package like ralphjsmit/laravel-seo's models) and feed those values into Head at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The interesting thing about Laravel Head isn't any single feature, it's that the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; element finally has a first-party owner, which means the Blade, Livewire, and Inertia rendering paths all got solved by one team with access to all three codebases. That's the thing no community package could ever fully deliver, and it's why the Inertia integration is the best part of the release.&lt;/p&gt;

&lt;p&gt;Figure out which of the three groups you're in, apply that group's verdict, and whichever it is, diff your rendered head before and after. Quiet infrastructure deserves boring migrations.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>seo</category>
      <category>packages</category>
      <category>php</category>
    </item>
    <item>
      <title>Filament v4.12 and v5.7: 92% Faster Forms, and the Security Fixes You Still Have to Turn On</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:49:53 +0000</pubDate>
      <link>https://dev.to/hafiz619/filament-v412-and-v57-92-faster-forms-and-the-security-fixes-you-still-have-to-turn-on-47d</link>
      <guid>https://dev.to/hafiz619/filament-v412-and-v57-92-faster-forms-and-the-security-fixes-you-still-have-to-turn-on-47d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/filament-v4-12-v5-7-performance-security-breakdown" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Filament shipped its biggest performance release since the v4 launch on August 6. Versions v4.12.6 and v5.7.6 promote the beta the team asked the community to test in late June onto the stable channels for both major versions, and the headline numbers are real: form fields render up to 92% faster, tables up to 52% faster, all in a non-breaking minor release.&lt;/p&gt;

&lt;p&gt;So run &lt;code&gt;composer update&lt;/code&gt;. Today. The same release resolves what the team describes as a handful of CVEs, and staying behind on a release with security content is not a position you want to defend.&lt;/p&gt;

&lt;p&gt;But here's the thing the announcement undersells, and the reason this post exists: two of the three new security features in this release do nothing until you write code. Updating gets you the patched vulnerabilities and all of the performance. It does not turn on the CSV formula-injection protection, and it does not sanitize a single CSS color value. If you read "please update to keep your application secure" and stop there, you'll be running the new version with the new protections sitting unused.&lt;/p&gt;

&lt;p&gt;Let's take the release apart properly: what's automatic, what's opt-in, and what the benchmark numbers actually mean for a real admin panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The performance numbers, and what they measure
&lt;/h2&gt;

&lt;p&gt;The team published internal benchmarks with &lt;a href="https://filamentphp.com/insights/alexandersix-major-performance-improvements-and-security-patches-for-filament-v4-12-and-v5-7" rel="noopener noreferrer"&gt;the release&lt;/a&gt;, and they're worth reading precisely because of what they measure: median render time per component, not page load.&lt;/p&gt;

&lt;p&gt;For forms and schema components:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Previous&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TextInput&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.559 ms&lt;/td&gt;
&lt;td&gt;0.044 ms&lt;/td&gt;
&lt;td&gt;~92% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Select&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.634 ms&lt;/td&gt;
&lt;td&gt;0.052 ms&lt;/td&gt;
&lt;td&gt;~92% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FileUpload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.586 ms&lt;/td&gt;
&lt;td&gt;0.083 ms&lt;/td&gt;
&lt;td&gt;~86% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Repeater&lt;/code&gt; (10 items, 2 fields)&lt;/td&gt;
&lt;td&gt;28.077 ms&lt;/td&gt;
&lt;td&gt;2.885 ms&lt;/td&gt;
&lt;td&gt;~90% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Checkbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.314 ms&lt;/td&gt;
&lt;td&gt;0.033 ms&lt;/td&gt;
&lt;td&gt;~90% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Half a millisecond per text input doesn't sound like much until you multiply it by a real resource form, the kind you end up with on any serious &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;admin dashboard&lt;/a&gt;. Thirty fields at ~0.5 ms each was 15 ms of pure framework rendering overhead before your queries, your Livewire round trip, or your validation ran at all. That's now about 1.5 ms. And every Livewire interaction that re-renders the form pays this cost again, so the win compounds across a session, not just on first load.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Repeater&lt;/code&gt; row is the one that matters most in practice. Repeaters are where Filament forms get heavy: line items on an invoice, variants on a product, questions on a quiz. Going from 28 ms to under 3 ms for a ten-item repeater changes the feel of exactly the forms people complain about.&lt;/p&gt;

&lt;p&gt;Tables got a second pass too (the team already did a big tables performance round in an earlier version):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Previous&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50 rows, 5 &lt;code&gt;TextColumn&lt;/code&gt;s, 3 row &lt;code&gt;Action&lt;/code&gt;s&lt;/td&gt;
&lt;td&gt;27.53 ms&lt;/td&gt;
&lt;td&gt;13.11 ms&lt;/td&gt;
&lt;td&gt;~52% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 rows, 5 columns, 1 &lt;code&gt;ActionGroup&lt;/code&gt; with 3 &lt;code&gt;Action&lt;/code&gt;s&lt;/td&gt;
&lt;td&gt;33.09 ms&lt;/td&gt;
&lt;td&gt;16.73 ms&lt;/td&gt;
&lt;td&gt;~49% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One honest caveat the team includes and most coverage will drop: these are internal development benchmarks, and your results will vary. Component-level medians are a fair way to measure framework overhead, but your panel's bottleneck might still be an N+1 query the renderer can't save you from.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they did it, and why it's a little funny
&lt;/h2&gt;

&lt;p&gt;The forms speedup comes from ripping out Blade component rendering paths and replacing them with direct PHP rendering. &lt;a href="https://github.com/filamentphp/filament/pull/19550" rel="noopener noreferrer"&gt;The pull request&lt;/a&gt; is refreshingly blunt about the technique: large portions of repeated Blade component calls were replaced with, in the team's own words, our dear friend the old &lt;code&gt;&amp;lt;?php&lt;/code&gt; tag.&lt;/p&gt;

&lt;p&gt;There's a real lesson in that. Blade components are a beautiful authoring experience, and every single one carries framework overhead: resolving the component class, building the data array, rendering the view. That cost is invisible until you're rendering hundreds of them per request, which is exactly what a Filament form does. The fastest Blade component is the one you don't render, and the Filament team just applied that at framework scale. If you've ever profiled a slow Filament page and wondered why so much time sat inside view rendering rather than your own code, this release is the answer arriving.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/filamentphp/filament/pull/19551" rel="noopener noreferrer"&gt;tables work&lt;/a&gt; is subtler: lighter rendering paths for common actions, more efficient attribute handling, and per-record caching of visibility and authorization decisions. That last one deserves a highlight. If your table calls a policy or a visibility closure per action, per record, those decisions were being recomputed repeatedly within a single render. Now they're cached per record. Tables with lots of conditional visibility and authorization logic, which describes most serious multi-user panels, benefit the most. If your panel gates actions by role or tenant the way I set up in the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS panel guide&lt;/a&gt;, this is the part of the release working for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security half, sorted by what actually happens when you update
&lt;/h2&gt;

&lt;p&gt;This is where the release notes needed a second read. Sorted by what you get automatically versus what you have to do:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic when you update: the patched CVEs.&lt;/strong&gt; The release resolves several vulnerabilities. The team hasn't itemized them in the announcement beyond "a handful of CVEs", which is normal for coordinated disclosure, and it's also the entire argument for updating now rather than during next month's maintenance window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Also already in if you're current: the MFA ordering fix.&lt;/strong&gt; A week before this release, v4.12.5 and v5.7.5 quietly shipped a fix titled "Check panel access before presenting MFA challenge." Before that patch, the MFA challenge screen could be presented before panel authorization ran. If you use Filament's multi-factor authentication, that ordering fix alone justified the update, and it rode in with no announcement at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Opt-in: CSV formula-injection protection.&lt;/strong&gt; When a spreadsheet app opens a CSV, cell values starting with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, or a tab character can execute as formulas. An attacker who controls a value that ends up in your admin export (a display name, a note field) can plant a formula that fires on the machine of whoever opens the file. Filament now ships protection that prefixes dangerous cells with a &lt;code&gt;"&lt;/code&gt; so they stay text.&lt;/p&gt;

&lt;p&gt;It's off by default, and for a defensible reason: legitimate values like international phone numbers start with &lt;code&gt;+&lt;/code&gt;, and silently rewriting them would corrupt real exports. The team made the right call. But that means the protection only exists in panels where someone consciously enabled it, and "someone" is you, this week, for every exporter that includes user-controlled text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual: CSS color sanitization.&lt;/strong&gt; There's a new &lt;code&gt;Str::sanitizeCssColor()&lt;/code&gt; helper for the case where user input ends up inside a &lt;code&gt;style=""&lt;/code&gt; attribute. A malicious "color" value can break out of the attribute and inject extra CSS or attributes. The helper closes that, but it's a helper. It sanitizes nothing until you call it at your injection points. Grep your custom columns, entries, and blade views for &lt;code&gt;style=&lt;/code&gt; interpolations before deciding you don't have any.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configure it: query builder limits.&lt;/strong&gt; New &lt;code&gt;maxRules()&lt;/code&gt; and &lt;code&gt;maxNestingDepth()&lt;/code&gt; methods bound how many rules and how much nesting the Query Builder accepts, enforced in the UI and against tampered payloads. The team files this under performance, but read the failure mode: a hand-crafted payload with an enormous rule tree could previously burn CPU and memory until response times cratered or the server fell over. That's a denial-of-service vector through a form, and if you expose the Query Builder to any non-admin user tier, setting these limits is a security decision, not a tuning knob.&lt;/p&gt;

&lt;p&gt;So the accurate summary of this release is: the performance is free, the patched CVEs are free, and the three new defenses are tools you now own but haven't picked up. Update first, then budget fifteen minutes for the checklist below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fifteen-minute audit after updating
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep for CSV exporters.&lt;/strong&gt; Anywhere you export tables containing user-controlled text, enable the formula-injection protection, then check whether any legitimate values (phone numbers, negative amounts) get mangled and handle those columns explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for &lt;code&gt;style=&lt;/code&gt; interpolations.&lt;/strong&gt; Custom columns, infolist entries, and blade views that build inline styles from stored values get wrapped in &lt;code&gt;Str::sanitizeCssColor()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound your query builders.&lt;/strong&gt; If non-admin users can touch a Query Builder, set &lt;code&gt;maxRules()&lt;/code&gt; and &lt;code&gt;maxNestingDepth()&lt;/code&gt; to the most restrictive values your real use cases allow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm you're on at least .5 of your branch.&lt;/strong&gt; That's the MFA ordering fix. The current .6 includes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-run your slowest panel page and enjoy.&lt;/strong&gt; No action needed, this one's the reward.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The quality-of-life additions worth knowing
&lt;/h2&gt;

&lt;p&gt;The release also carries a set of smaller changes, a few of which solve real irritations. User and tenant menu items can now be registered in groups instead of one flat list, and the menu actions get memoized once per request as a bonus. Nested modals get &lt;code&gt;modalDismissesParentActions()&lt;/code&gt;, so closing an inner modal can abandon a whole multi-step flow instead of forcing users to close each layer individually. Chart widgets can define proper empty states, which finally distinguishes "no data yet" from "broken". And a scattering of dynamic configuration wins: panel content width, sidebar width, dark mode availability, and the theme switcher can all take closures at runtime now, and navigation children can reference parents by a stable key instead of the displayed label, which stops translations from silently breaking your nav hierarchy.&lt;/p&gt;

&lt;p&gt;None of these changes the argument for updating, but the grouped menus and the stable navigation keys are the kind of small fixes that let you delete workarounds from real codebases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you update today?
&lt;/h2&gt;

&lt;p&gt;Yes, and this is an easier call than most minor releases.&lt;/p&gt;

&lt;p&gt;The case is unusually clean: it's a non-breaking minor on both branches, the beta ran in community applications for over a month before promotion, there's security content, and the performance work requires zero code changes to benefit from. The team explicitly designed the release so the upgrade is nothing more than bumping to the next minor version.&lt;/p&gt;

&lt;p&gt;Two groups should look slightly closer before hitting update in production. If you maintain a panel with heavy custom theming or custom field components, the rendering path changed underneath you, so click through your custom components in staging first; direct PHP rendering should be visually identical, but "should" is doing work in that sentence when your CSS targets Blade component markup. And if you maintain or depend on plugins, check them against the new version before rolling out, since anything that hooked into the old rendering paths is the most likely thing to notice the refactor. Maintaining a &lt;a href="https://hafiz.dev/blog/filament-billing-the-missing-stripe-admin-layer-for-your-panel" rel="noopener noreferrer"&gt;Filament billing plugin&lt;/a&gt; myself, running the plugin test suite against both new branches is the first item on my list before shipping the bump to clients.&lt;/p&gt;

&lt;p&gt;If you're still on v3, this release doesn't reach you, and that's the quiet message in it: the performance investment is going into v4 and v5 only. The earlier security round in June patched v3.3 alongside v4 and v5, so v3 still gets security fixes, but the gap between the branches just got wider. If you've been deferring the upgrade decision, the &lt;a href="https://hafiz.dev/blog/filament-v5-released-whats-new-what-changed-and-should-you-upgrade" rel="noopener noreferrer"&gt;v5 breakdown I wrote at release&lt;/a&gt; covers what the jump involves, and this release just added 92% faster forms to the "upgrade" side of the ledger.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I get the performance improvements without changing any code?
&lt;/h3&gt;

&lt;p&gt;Yes. The rendering optimizations are internal to Filament. Update to v4.12.6 or v5.7.6 and every form, schema component, and table renders through the faster paths automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the CSV formula-injection protection enabled by default?
&lt;/h3&gt;

&lt;p&gt;No, it's deliberately opt-in, because prefixing cells that start with characters like &lt;code&gt;+&lt;/code&gt; would corrupt legitimate values such as international phone numbers. You need to enable it for your exports and verify your real data survives it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which versions contain the MFA fix?
&lt;/h3&gt;

&lt;p&gt;The "check panel access before presenting MFA challenge" fix shipped in v4.12.5 and v5.7.5 on July 31. The current v4.12.6 and v5.7.6 include it. If you're on any earlier patch of these branches and use Filament's MFA, update now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this release affect Filament v3?
&lt;/h3&gt;

&lt;p&gt;No. The performance work and the new security features target v4.12 and v5.7 only. The June security round was the last one to include a v3 patch (v3.3.52). If you're on v3, the case for planning the v4/v5 upgrade keeps getting stronger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will the update break my custom theme or plugin?
&lt;/h3&gt;

&lt;p&gt;The release is designed as a non-breaking minor, and it went through a month-long community beta. That said, the forms refactor replaced Blade component rendering with direct PHP rendering, so custom components and CSS that target Filament's internal markup deserve a staging pass before production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;This is what a good maintenance release looks like: a real performance refactor, security content, and no breaking changes, shipped through a public beta rather than straight to stable. The one improvement I'd ask of the announcement is honesty of emphasis. "Update to keep your application secure" is true for the CVEs and incomplete for the rest, because the most interesting defenses in this release are ones you have to reach for.&lt;/p&gt;

&lt;p&gt;So do both halves. Run the update today, then spend the fifteen minutes on the audit. The panels that get the full value of this release are the ones where someone read past the benchmark tables.&lt;/p&gt;

</description>
      <category>filament</category>
      <category>laravel</category>
      <category>performance</category>
      <category>security</category>
    </item>
    <item>
      <title>Laravel's New Image API Doesn't Replace Intervention or Spatie Media Library</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 05 Aug 2026 07:46:39 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravels-new-image-api-doesnt-replace-intervention-or-spatie-media-library-20ca</link>
      <guid>https://dev.to/hafiz619/laravels-new-image-api-doesnt-replace-intervention-or-spatie-media-library-20ca</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-image-api-vs-intervention-vs-spatie-media-library" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel 13.20 landed on 14 July with a first-party image API, and within a week the takes were everywhere. Laravel replaces Intervention Image. Drop your Spatie dependency. One less package in your &lt;code&gt;composer.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the first line of the installation section in the official docs:&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 intervention/image:^4.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot use Laravel's image manipulation without installing Intervention Image, short of writing your own driver from scratch. The GD and Imagick drivers are both backed by it. &lt;code&gt;Illuminate\Image\Drivers\InterventionDriver&lt;/code&gt; is the only driver that ships. Laravel didn't replace Intervention, it adopted it and put a facade in front.&lt;/p&gt;

&lt;p&gt;So the comparison people are reaching for doesn't hold. What you actually have to decide is which layer of a four-layer stack your particular job belongs at, and that's a more useful question anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, honestly
&lt;/h2&gt;

&lt;p&gt;Four layers, and most of the confusion comes from treating them as competitors when three of them sit on top of each other.&lt;/p&gt;

&lt;p&gt;At the bottom are the PHP extensions, GD and Imagick, which do the pixel work. Above them sit the manipulation libraries: &lt;code&gt;intervention/image&lt;/code&gt; and &lt;code&gt;spatie/image&lt;/code&gt;, each wrapping the extensions in a sane API. Above those is &lt;code&gt;Illuminate\Image&lt;/code&gt;, Laravel's new fluent wrapper, which drives Intervention. And off to one side is &lt;code&gt;spatie/laravel-medialibrary&lt;/code&gt;, which builds on &lt;code&gt;spatie/image&lt;/code&gt; and does something categorically different: attaching files to Eloquent models.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-image-api-vs-intervention-vs-spatie-media-library" 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;Read that diagram and the question answers itself. If your job is "transform this image right now", you want the middle of the stack. If your job is "this Product has five photos, each needing a thumbnail and a web-sized version", you want Media Library, and Laravel 13.20 changed nothing for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 13.20 actually replaces
&lt;/h2&gt;

&lt;p&gt;Not a package. A class you wrote yourself.&lt;/p&gt;

&lt;p&gt;Nearly every Laravel app that handles uploads has one. It's called &lt;code&gt;ImageService&lt;/code&gt; or &lt;code&gt;ImageManager&lt;/code&gt; or &lt;code&gt;HandlesImageUploads&lt;/code&gt;, it lives in &lt;code&gt;app/Services&lt;/code&gt;, and it's about forty lines of Intervention calls glued to a &lt;code&gt;Storage::put()&lt;/code&gt;. Most of them were written against Intervention v3, so they look something 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;ImageService&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;storeAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UploadedFile&lt;/span&gt; &lt;span class="nv"&gt;$file&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;$manager&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;ImageManager&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;Driver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRealPath&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&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="s1"&gt;'avatars/'&lt;/span&gt;&lt;span class="mf"&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;uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.webp'&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;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toWebp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&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;$path&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;That whole class is now one chain in your 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="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="nf"&gt;image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'avatar'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toWebp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&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;span class="s1"&gt;'avatars'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public'&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;$request-&amp;gt;image()&lt;/code&gt; returns an &lt;code&gt;Illuminate\Image\Image&lt;/code&gt; or null when the field is absent, so validate the upload first as you normally would. Every transformation returns a new instance, nothing executes until you ask for output, and the storage methods generate a unique filename and hand back the path exactly like &lt;code&gt;store()&lt;/code&gt; does for ordinary uploads.&lt;/p&gt;

&lt;p&gt;Deleting a service class is a small win, but it's a real one, and it's the kind of decision I've written about before in the &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;service, action, or job decision tree&lt;/a&gt;. A wrapper that exists only to make a package feel Laravel-native stops earning its keep the moment the framework ships the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't touch: Spatie Media Library
&lt;/h2&gt;

&lt;p&gt;Media Library is at v11.23.2 and past 44 million installs, and none of what it does overlaps with the new API.&lt;/p&gt;

&lt;p&gt;It associates files with Eloquent models. It registers named conversions on the model that generate automatically on upload and queue themselves by default. It generates responsive image variants along with the &lt;code&gt;srcset&lt;/code&gt; markup to serve them. It handles multiple collections per model, per-collection disks, ordering, and a &lt;code&gt;media-library:regenerate&lt;/code&gt; command for when you change a conversion and need to reprocess everything you've already stored.&lt;br&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;Product&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;HasMedia&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithMedia&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;registerMediaConversions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?Media&lt;/span&gt; &lt;span class="nv"&gt;$media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="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="nf"&gt;addMediaConversion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'thumb'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;368&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;232&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sharpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addMediaConversion&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&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;'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;quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&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;Try to rebuild that on top of &lt;code&gt;Illuminate\Image&lt;/code&gt; and you're writing a media table, a conversions registry, a queue pipeline, a URL generator, and a regeneration command. That's not a weekend. Media Library is a different product that happens to also resize images.&lt;/p&gt;

&lt;p&gt;The one honest thing to say is that some apps pulled in Media Library purely to resize an avatar and never touched a conversion or a collection. If that's you, the new API is a genuine simplification. If you have &lt;code&gt;registerMediaConversions&lt;/code&gt; anywhere in your codebase, it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you still drop to Intervention directly
&lt;/h2&gt;

&lt;p&gt;The Laravel API covers &lt;code&gt;resize&lt;/code&gt;, &lt;code&gt;scale&lt;/code&gt;, &lt;code&gt;cover&lt;/code&gt;, &lt;code&gt;contain&lt;/code&gt;, &lt;code&gt;crop&lt;/code&gt;, &lt;code&gt;orient&lt;/code&gt;, &lt;code&gt;rotate&lt;/code&gt;, &lt;code&gt;blur&lt;/code&gt;, &lt;code&gt;grayscale&lt;/code&gt;, &lt;code&gt;sharpen&lt;/code&gt;, both flips, seven output formats, &lt;code&gt;quality&lt;/code&gt;, and &lt;code&gt;optimize&lt;/code&gt;. That's the common set, and for most apps it's everything.&lt;/p&gt;

&lt;p&gt;What it doesn't expose is most of what makes Intervention v4 interesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text and fonts.&lt;/strong&gt; No &lt;code&gt;text()&lt;/code&gt; method, no font files, no wrapping, alignment, or stroke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition.&lt;/strong&gt; No &lt;code&gt;insert()&lt;/code&gt; or &lt;code&gt;place()&lt;/code&gt;, so no watermarks and no layering one image over another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drawing.&lt;/strong&gt; No rectangles, circles, lines, or polygons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animation.&lt;/strong&gt; No frame-by-frame work on animated GIFs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colorspaces and ICC profiles.&lt;/strong&gt; Nothing for print-accurate color.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Need any of those and you use Intervention directly, which you already have installed. There's no conflict in doing both: the fluent API for uploads, the library for the one endpoint that stamps a watermark.&lt;/p&gt;

&lt;p&gt;There is a middle path worth knowing about. You can register a custom transformation as a value object and teach a driver how to apply 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;App\Images\Transformations\Pixelate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Image&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Intervention\Image\Interfaces\ImageInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transformUsing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gd'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Pixelate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ImageInterface&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Pixelate&lt;/span&gt; &lt;span class="nv"&gt;$transformation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pixelate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$transformation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your transformation class implements &lt;code&gt;Illuminate\Contracts\Image\Transformation&lt;/code&gt; and carries its own arguments. After registering the handler in a service provider, &lt;code&gt;-&amp;gt;transform(new Pixelate(12))&lt;/code&gt; drops into any pipeline. That's how you get watermarking into the fluent chain without abandoning it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare driver that never shipped
&lt;/h2&gt;

&lt;p&gt;If you've read about a Cloudflare driver for this API, complete with &lt;code&gt;Image::pruneOrphaned('cloudflare')&lt;/code&gt; on a schedule and validation rules to keep BMP files away from it, that's real code but it isn't in Laravel.&lt;/p&gt;

&lt;p&gt;It was in the pull request during review and got pulled before merge. The PR is explicit that the Cloudflare-specific driver and support were removed to keep the first version focused on local processing through GD and Imagick via Intervention. The shipped &lt;code&gt;config/images.php&lt;/code&gt; supports two drivers. Setting &lt;code&gt;IMAGE_DRIVER=cloudflare&lt;/code&gt; gets you an exception, not remote processing.&lt;/p&gt;

&lt;p&gt;Some of the write-ups circulating were based on the PR description rather than the released code, which is an easy mistake to make when a feature changes shape during review. Check &lt;code&gt;php artisan config:publish images&lt;/code&gt; and read what's actually in the file. While you're in there, the full command list lives 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;The interesting part is what the removal left behind. Custom drivers are a documented extension point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\Image\Driver&lt;/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\Image\ImagePipeline&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;VipsDriver&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Driver&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;process&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;$contents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ImagePipeline&lt;/span&gt; &lt;span class="nv"&gt;$pipeline&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;// Apply the pipeline's transformations and output options...&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$contents&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;transformUsing&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;$transformation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&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="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 that with &lt;code&gt;Image::extend('vips', fn () =&amp;gt; new VipsDriver)&lt;/code&gt; and switch per image with &lt;code&gt;-&amp;gt;using('vips')&lt;/code&gt; or globally with &lt;code&gt;IMAGE_DRIVER=vips&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That example name isn't arbitrary. libvips is a fast, low-memory image library that outperforms both GD and Imagick, and Intervention already maintains an official driver for it in &lt;code&gt;Intervention/image-driver-vips&lt;/code&gt;. Spatie Media Library already accepts &lt;code&gt;vips&lt;/code&gt; as an &lt;code&gt;image_driver&lt;/code&gt; value too. Laravel's &lt;code&gt;InterventionDriver&lt;/code&gt; only exposes &lt;code&gt;gd&lt;/code&gt; and &lt;code&gt;imagick&lt;/code&gt;, so you can't reach vips through &lt;code&gt;IMAGE_DRIVER&lt;/code&gt; today. But the hard part is done. A Laravel driver for it is a thin adapter over a maintained package, not a from-scratch build, and it's the most obvious gap in the ecosystem right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things the docs don't make obvious
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The queueing advice contradicts the API.&lt;/strong&gt; The introduction tells you to move heavy processing to a queued job, which is correct advice. But &lt;code&gt;Image&lt;/code&gt; instances cannot be serialized, and passing one to a job throws an &lt;code&gt;ImageException&lt;/code&gt;. You store first and pass the path, then rebuild with &lt;code&gt;Image::fromStorage()&lt;/code&gt; inside the job. Worth knowing before you dispatch, and if you're pushing real volume through, the patterns in &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;processing 10,000 queued tasks without breaking&lt;/a&gt; apply here too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Processing is lazy and cached, which cuts both ways.&lt;/strong&gt; Nothing runs until you call &lt;code&gt;toBytes()&lt;/code&gt;, &lt;code&gt;width()&lt;/code&gt;, &lt;code&gt;store()&lt;/code&gt;, or cast to string. After that first output the result is reused, so calling &lt;code&gt;width()&lt;/code&gt; then &lt;code&gt;store()&lt;/code&gt; doesn't process twice. The trap is the other direction: because instances are immutable, branching off a base image to make three variants means three separate pipelines, each doing its own decode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage failures return false, they don't throw.&lt;/strong&gt; &lt;code&gt;store()&lt;/code&gt;, &lt;code&gt;storeAs()&lt;/code&gt;, and the public variants return &lt;code&gt;false&lt;/code&gt; if the image couldn't be stored. If you assign the result straight to a database column you'll write a boolean into a string field and find out later. Check it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your job&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resize or convert an upload in a controller&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Illuminate\Image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate a few variants from one source&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Illuminate\Image&lt;/code&gt;, one pipeline each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model has attached files with named sizes&lt;/td&gt;
&lt;td&gt;Spatie Media Library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Responsive images with &lt;code&gt;srcset&lt;/code&gt; markup&lt;/td&gt;
&lt;td&gt;Spatie Media Library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watermarks, text, drawing, animated GIFs&lt;/td&gt;
&lt;td&gt;Intervention Image directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, but inside the fluent chain&lt;/td&gt;
&lt;td&gt;Custom &lt;code&gt;Transformation&lt;/code&gt; class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large volumes, memory pressure&lt;/td&gt;
&lt;td&gt;Custom driver over libvips&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory and CPU are the constraint that decides most of this in production. If you're processing anything sizeable in the request cycle you'll hit it, and the same advice applies as with any big upload path, which I've covered in &lt;a href="https://hafiz.dev/blog/handling-large-file-uploads-in-laravel-without-crashing-your-server" rel="noopener noreferrer"&gt;handling large file uploads without crashing your server&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;Use it. For the resize-an-upload case it's cleaner than what you have, it's one less abstraction you maintain, and the immutable pipeline is a better design than the mutable managers both underlying libraries expose.&lt;/p&gt;

&lt;p&gt;The criticism raised during review deserves an airing, though. A reviewer argued that bolting image manipulation onto &lt;code&gt;Request&lt;/code&gt;, a class already at critical mass, adds bloat, and that image processing is a resource hog that shouldn't be quite this reachable. Both points are fair. &lt;code&gt;$request-&amp;gt;image('avatar')-&amp;gt;cover(400, 400)-&amp;gt;store('avatars')&lt;/code&gt; is a very short path to doing something expensive inside a web request, and the docs' warning about queueing is one sentence in an introduction most people skim.&lt;/p&gt;

&lt;p&gt;My rule: if the source is user-supplied and unbounded in size, it goes to a queue regardless of how convenient the one-liner looks. The API being easy doesn't make the work cheap.&lt;/p&gt;

&lt;p&gt;The part I'd actually watch is the driver contract. A fluent, Laravel-native API where the engine underneath is swappable is more interesting than the transformation methods, because it means the ceiling on this component isn't set by what GD can do. That's also why the Cloudflare removal reads as deliberate rather than a retreat. Ship the extension point, let the drivers arrive later.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does Laravel 13.20 mean I can remove Intervention Image?
&lt;/h3&gt;

&lt;p&gt;No, the opposite. You have to install &lt;code&gt;intervention/image:^4.0&lt;/code&gt; for the GD and Imagick drivers to work. It isn't a hard requirement in the framework's own dependencies, but it's required in practice for anything the shipped drivers do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I replace Spatie Media Library with the new API?
&lt;/h3&gt;

&lt;p&gt;Only if you never used what Media Library is for. If your codebase has &lt;code&gt;registerMediaConversions&lt;/code&gt;, media collections, or responsive images, you'd be rebuilding a mature package by hand. If you pulled it in just to crop an avatar, switching is a reasonable simplification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I pass an image instance to a queued job?
&lt;/h3&gt;

&lt;p&gt;No. Image instances aren't serializable and attempting it throws an &lt;code&gt;ImageException&lt;/code&gt;. Store the image first, pass the path, and rebuild inside the job with &lt;code&gt;Image::fromStorage()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a Cloudflare driver?
&lt;/h3&gt;

&lt;p&gt;Not in the released framework. It existed in the pull request and was removed before merge so the first version could focus on GD and Imagick. Only &lt;code&gt;gd&lt;/code&gt; and &lt;code&gt;imagick&lt;/code&gt; are supported out of the box, though the driver contract is public if you want to build your own.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add a watermark with the new API?
&lt;/h3&gt;

&lt;p&gt;Not with the built-in methods, since there's no composition or text support. Either use Intervention Image directly for that operation, or write a custom &lt;code&gt;Transformation&lt;/code&gt; class and register a handler with &lt;code&gt;Image::transformUsing()&lt;/code&gt; so it works inside the fluent chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The question worth asking isn't which of these three to pick. It's how far up the stack your problem lives. Transforming bytes right now is the bottom, attaching media to models is the top, and 13.20 filled a gap in the middle that most of us had been filling with a service class of our own.&lt;/p&gt;

&lt;p&gt;If you're picking between the three for an existing app, start by grepping for &lt;code&gt;registerMediaConversions&lt;/code&gt;. If it's there, nothing changes for you. If it isn't, and you've got a hand-rolled image wrapper somewhere in &lt;code&gt;app/Services&lt;/code&gt;, you can probably delete it this afternoon. That's also the moment to revisit whether your upload path belongs in the request at all, something I went through in detail while &lt;a href="https://hafiz.dev/blog/building-a-full-stack-file-upload-system-with-laravel-vuejs-and-s3" rel="noopener noreferrer"&gt;building a full-stack upload system with Vue and S3&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>images</category>
      <category>spatie</category>
      <category>php</category>
    </item>
    <item>
      <title>Human-in-the-Loop for Laravel AI Agents: Stop Your Agent Before It Refunds the Wrong Order</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 03 Aug 2026 05:54:31 +0000</pubDate>
      <link>https://dev.to/hafiz619/human-in-the-loop-for-laravel-ai-agents-stop-your-agent-before-it-refunds-the-wrong-order-2gib</link>
      <guid>https://dev.to/hafiz619/human-in-the-loop-for-laravel-ai-agents-stop-your-agent-before-it-refunds-the-wrong-order-2gib</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-human-in-the-loop-tool-approval" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Your support agent has a refund tool. A customer sends a message that sounds close enough to a refund request, the model decides that's what it wants, and four thousand euros leaves your Stripe account before a human reads a single word of the conversation.&lt;/p&gt;

&lt;p&gt;Until 21 July there was no framework-level way to stop that. Once a Laravel AI SDK agent started its tool loop, it ran to the end on its own. You could validate arguments inside &lt;code&gt;handle()&lt;/code&gt;. You could build allowlists. You could keep the dangerous tools out of the agent entirely and accept that your agent is now read-only. What you couldn't do was put a person in the middle of the loop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;laravel/ai&lt;/code&gt; v0.10.0 changed that. The &lt;a href="https://laravel.com/docs/13.x/ai-sdk#human-tool-approval" rel="noopener noreferrer"&gt;human-in-the-loop API&lt;/a&gt; pauses the agent before an approvable tool executes, hands the pending call back to you, and waits. The Laravel team announced it at Laracon US, the code landed in &lt;a href="https://github.com/laravel/ai/pull/773" rel="noopener noreferrer"&gt;laravel/ai#773&lt;/a&gt;, and the docs cover the happy path well.&lt;/p&gt;

&lt;p&gt;This post assumes you've already got an agent running. If you haven't, start with &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;building your first Laravel AI SDK assistant&lt;/a&gt; and come back.&lt;/p&gt;

&lt;p&gt;The happy path isn't the interesting part. The interesting part is what happens when generation fails halfway through, when two tools land in the same step, and when the person clicking "approve" isn't the person who owns the conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually shipped
&lt;/h2&gt;

&lt;p&gt;Approval is opt-in, per tool. A tool becomes approvable when it implements the &lt;code&gt;Approvable&lt;/code&gt; contract and uses the &lt;code&gt;InteractsWithApprovals&lt;/code&gt; trait. That's the whole 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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Tools&lt;/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\Order&lt;/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\Contracts\JsonSchema\JsonSchema&lt;/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\Concerns\InteractsWithApprovals&lt;/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\Approvable&lt;/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\Tool&lt;/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\Tools\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;Stringable&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;IssueRefund&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Approvable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithApprovals&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;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&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;'Refund an amount against a customer order.'&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;Stringable&lt;/span&gt;&lt;span class="o"&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;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&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;'order_id'&lt;/span&gt;&lt;span class="p"&gt;]);&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;refund&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;'amount'&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;"Refunded &lt;/span&gt;&lt;span class="si"&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;'amount'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; on order &lt;/span&gt;&lt;span class="si"&gt;{&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;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="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;'order_id'&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;integer&lt;/span&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;'amount'&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;integer&lt;/span&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="c1"&gt;// cents&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;Once that trait is on the class, the tool requires approval every single time. Which is almost never what you want.&lt;/p&gt;

&lt;p&gt;Everything else about the tool stays the same. The &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;handle&lt;/code&gt;, and &lt;code&gt;schema&lt;/code&gt; methods work exactly as they do on any other tool, so if you've already &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;built an agent with tools and conversation memory&lt;/a&gt;, you're adding two lines to a class you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approving everything is the wrong default
&lt;/h2&gt;

&lt;p&gt;A tool that asks permission for every call trains people to click approve without reading. That's worse than no approval at all, because now you have an audit trail that says a human reviewed something nobody reviewed.&lt;/p&gt;

&lt;p&gt;Gate it on the arguments instead. Define a &lt;code&gt;needsApproval&lt;/code&gt; method that returns a boolean, or an &lt;code&gt;Approval&lt;/code&gt; instance carrying the reason the model's caller will see:&lt;br&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\Approvals\Approval&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;needsApproval&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;Approval&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&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;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'amount'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Approval&lt;/span&gt;&lt;span class="o"&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;'Refunds over 200 EUR need a manager.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Amounts are in cents here, so anything up to 200 EUR goes straight through and anything above it stops. The reason string matters more than it looks, because it's what gets rendered next to the approve button, and a reason like "approval required" tells the reviewer nothing about why this specific call is different.&lt;/p&gt;

&lt;p&gt;You can also flip the requirement where the agent registers its tools, which is useful when the same tool is safe in one agent and dangerous in another:&lt;br&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;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="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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withoutApproval&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IssueRefund&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;requireApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Every refund gets reviewed.'&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;h2&gt;
  
  
  The prerequisite buried at the top of the docs
&lt;/h2&gt;

&lt;p&gt;Tool approval needs a &lt;code&gt;Conversational&lt;/code&gt; agent whose history is actually persisted. There has to be something to resume from, and an agent that rebuilds its messages from an array in memory has nothing to come back to.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;RemembersConversations&lt;/code&gt; trait handles 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="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\IssueRefund&lt;/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;Laravel\Ai\Concerns\RemembersConversations&lt;/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\Conversational&lt;/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;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="nc"&gt;Conversational&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="nc"&gt;RemembersConversations&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 help customers with order and billing questions.'&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;IssueRefund&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;One trap here predates the approval API and still catches people: if you define a &lt;code&gt;messages&lt;/code&gt; method on an agent that uses &lt;code&gt;RemembersConversations&lt;/code&gt;, your method wins and the trait never loads history from the database. No conversation, nothing to resume.&lt;/p&gt;

&lt;p&gt;Put an approvable tool on an agent that can't be resumed at all and you don't get a silent failure. The SDK throws &lt;code&gt;ApprovalNotResumableException&lt;/code&gt; rather than handing back a pause nobody can resolve, which is the right call and saves you a debugging session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pause and the resume
&lt;/h2&gt;

&lt;p&gt;When the model calls an approvable tool, the agent stops short of running it. The pending calls come back on the 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="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;SupportAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forUser&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Refund the damaged headphones on order 4192.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPendingApprovals&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pendingApprovals&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$approval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// $approval-&amp;gt;id&lt;/span&gt;
        &lt;span class="c1"&gt;// $approval-&amp;gt;tool&lt;/span&gt;
        &lt;span class="c1"&gt;// $approval-&amp;gt;arguments&lt;/span&gt;
        &lt;span class="c1"&gt;// $approval-&amp;gt;reason&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To resume, you continue the conversation and pass a &lt;code&gt;Decisions&lt;/code&gt; instance keyed by tool call ID:&lt;br&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\Approvals\Decision&lt;/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\Approvals\Decisions&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;SupportAgent&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;continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="o"&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decisions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'call_abc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'call_ghi'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This order is outside the return window.'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's happening there. The decisions are the prompt. You're not calling some separate approval endpoint on the SDK, you're continuing the same conversation with a different kind of input, which is why the routing for this collapses into one endpoint later.&lt;/p&gt;

&lt;p&gt;Booleans work as shorthand for approve and reject. Every pending call needs a decision or you get an &lt;code&gt;ApprovalMismatchException&lt;/code&gt;, and that same exception covers IDs you made up and calls that were already resolved. If you don't want to enumerate all of them, set a default for the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$decisions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decisions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'call_abc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;rejectRemaining&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Not approved.'&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;Decision::approveAll()&lt;/code&gt; and &lt;code&gt;Decision::rejectAll()&lt;/code&gt; cover the blanket cases and return a &lt;code&gt;Decisions&lt;/code&gt; instance, so they drop straight into the same &lt;code&gt;prompt&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;There's a meaningful difference between the two kinds of rejection. &lt;code&gt;Decision::reject('reason')&lt;/code&gt; sends that string back to the model, which keeps responding and can explain the refusal to the customer. A rejection with no result records the rejection and stops the generation loop right there. Pick deliberately. A silent stop looks like a hung chat window to whoever is on the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third decision nobody covered
&lt;/h2&gt;

&lt;p&gt;Approve and reject are the two everyone writes about. There's a third:&lt;br&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\Approvals\Decision&lt;/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\Approvals\Decisions&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;SupportAgent&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;continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="o"&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decisions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'call_abc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&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;Decision::edit()&lt;/code&gt; approves the call with replaced arguments. The model asked to refund 4,000 EUR, the manager decides 200 is right, and the tool runs with the corrected amount. No rejection, no second round trip, no asking the customer to explain themselves again.&lt;/p&gt;

&lt;p&gt;This is the decision type that makes approval feel like review rather than a gate, and it's the one missing from every writeup of the release so far. One rule to remember: an edit decision has to carry arguments. A bare one throws.&lt;/p&gt;

&lt;p&gt;Here's the full round trip:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-human-in-the-loop-tool-approval" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Three failure modes worth knowing before you ship
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The approval you already spent.&lt;/strong&gt; Laravel records an approved tool's result &lt;em&gt;before&lt;/em&gt; it asks the model to continue generating. If generation blows up after that point, the tool already ran. Resubmitting the same decisions throws an &lt;code&gt;ApprovalMismatchException&lt;/code&gt;, and the reference HTTP flow turns that into a 409 carrying the current pending approvals so a stale approval screen can refresh itself. Recover by continuing with an ordinary text prompt, never by replaying the decision map. Worth knowing alongside this: the SDK will not fail over to a backup provider on a resume once approved tools have executed, so a provider outage mid-resume surfaces as an error instead of quietly running your refund somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pauses are per call, not per step.&lt;/strong&gt; If the model requests three tools in one step and only one of them is approvable, the other two run immediately while the third waits. Anything with an external side effect needs to be idempotent, and &lt;code&gt;$request-&amp;gt;toolCallId()&lt;/code&gt; gives you the key to deduplicate on. This is the part most people will get wrong, because the mental model everyone brings is "the agent stops", and what actually stops is one call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming and broadcasting need their own handling.&lt;/strong&gt; Approval works with &lt;code&gt;prompt&lt;/code&gt;, &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;queue&lt;/code&gt;, &lt;code&gt;broadcast&lt;/code&gt;, &lt;code&gt;broadcastNow&lt;/code&gt;, and &lt;code&gt;broadcastOnQueue&lt;/code&gt;. During streaming a pause arrives as a &lt;code&gt;tool_approval_request&lt;/code&gt; event, and under the Vercel AI SDK stream protocol it maps to that protocol's native tool approval parts. Queued agents pass the response to the &lt;code&gt;then&lt;/code&gt; callback and dispatch a &lt;code&gt;ToolApprovalRequested&lt;/code&gt; event. There's a matching &lt;code&gt;ToolApprovalResolved&lt;/code&gt; event too, which is the one you want for your audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization is still your job
&lt;/h2&gt;

&lt;p&gt;There's a sentence in the conversations docs that most people skim: &lt;code&gt;continue()&lt;/code&gt; does not verify that the participant you pass actually owns the conversation. Authorizing that is on you.&lt;/p&gt;

&lt;p&gt;The approval work narrows what that means. The hardening pass on the pull request added an ownership check before a resume runs any gated tool, so the obvious nightmare, one user approving a refund sitting in someone else's conversation, is closed at the framework level. Good. What isn't closed is everything around it. An unauthorized &lt;code&gt;continue()&lt;/code&gt; still reads back conversation history, and that history now includes the arguments of every pending tool call: order IDs, amounts, file paths, whatever your tools take.&lt;/p&gt;

&lt;p&gt;So authorize the conversation, every time:&lt;br&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/chat/{conversation}'&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="kt"&gt;Conversation&lt;/span&gt; &lt;span class="nv"&gt;$conversation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$conversation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then go one step past that. The SDK checks ownership, not authority. "Owns this conversation" and "may approve a 4,000 euro refund" are two different questions, and only the first one has a built-in answer. If your reviewers have tiers, inspect the tool name and arguments on the pending approval before you accept a decision, not after. A junior support rep owning the conversation they're working is normal. A junior support rep clearing a four-figure refund usually isn't.&lt;/p&gt;

&lt;p&gt;For the coding-agent version of the same question, I wrote about &lt;a href="https://hafiz.dev/blog/how-to-stop-ai-agent-destroying-your-laravel-app" rel="noopener noreferrer"&gt;what happens when you hand an AI agent too much authority in a Laravel app&lt;/a&gt; a few months back. Different threat model, same root question: what can this thing reach, and who said it could.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into one endpoint
&lt;/h2&gt;

&lt;p&gt;The whole thing fits in a single route, because decisions and messages are both just prompts.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate that you got one or the other, never both.&lt;/strong&gt; A request carrying a &lt;code&gt;message&lt;/code&gt; and a &lt;code&gt;decisions&lt;/code&gt; array at the same time is ambiguous, so make the rules mutually exclusive with &lt;code&gt;required_without&lt;/code&gt; and &lt;code&gt;prohibited_with&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map the incoming array into &lt;code&gt;Decision&lt;/code&gt; objects.&lt;/strong&gt; Each key is a tool call ID and each value carries an action plus an optional result string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the prompt.&lt;/strong&gt; Either a &lt;code&gt;Decisions&lt;/code&gt; instance or the raw message string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continue the conversation&lt;/strong&gt; with &lt;code&gt;-&amp;gt;continue($conversation-&amp;gt;id, as: $request-&amp;gt;user())&lt;/code&gt; and pass the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return the status.&lt;/strong&gt; If &lt;code&gt;hasPendingApprovals()&lt;/code&gt; comes back true, hand &lt;code&gt;pendingApprovals&lt;/code&gt; to the frontend and let it render the buttons.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$validated&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;validate&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'nullable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'required_without:decisions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'prohibited_with:decisions'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'decisions'&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;'nullable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'required_without:message'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'prohibited_with:message'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'decisions.*.action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required_with:decisions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Rule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'approve'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reject'&lt;/span&gt;&lt;span class="p"&gt;])],&lt;/span&gt;
    &lt;span class="s1"&gt;'decisions.*.result'&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;'nullable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'decisions'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Decisions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'decisions'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&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;array&lt;/span&gt; &lt;span class="nv"&gt;$decision&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;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decision&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="p"&gt;{&lt;/span&gt;
            &lt;span class="s1"&gt;'approve'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'reject'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'result'&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;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;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$validated&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="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;SupportAgent&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;continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$conversation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;as&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prompt&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;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPendingApprovals&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'awaiting_approval'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'complete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'approvals'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pendingApprovals&lt;/span&gt;&lt;span class="p"&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 frontend posts back a shape like &lt;code&gt;{"decisions": {"call_abc": {"action": "approve"}}}&lt;/code&gt;, keyed by the tool call ID it received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it without touching a provider
&lt;/h2&gt;

&lt;p&gt;This is the part of the release that got no coverage at all, and it's the part that decides whether the feature survives contact with your CI pipeline.&lt;/p&gt;

&lt;p&gt;You can fake a response that's awaiting approval:&lt;br&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\Approvals\PendingApproval&lt;/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\Responses\AgentResponse&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="nc"&gt;AgentResponse&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fakeWithPendingApprovals&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;PendingApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'call_abc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'IssueRefund'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;400000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Refunds over 200 EUR need a manager.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="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;SupportAgent&lt;/span&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;'Refund order 4192.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPendingApprovals&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you can assert on the decisions that were submitted:&lt;br&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;SupportAgent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertPrompted&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;AgentPrompt&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasApprovalDecisions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;approvalDecisions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'call_abc'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isApproved&lt;/span&gt;&lt;span class="p"&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 means the interesting cases are all testable without a network call. Does an unauthorized user get blocked before their decision reaches the agent? Does a partial decision set throw? Does a rejection with no result actually stop the loop? Write those before you ship, not after the first incident. If you're setting up the suite, my &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest testing guide&lt;/a&gt; covers the structure I'd use here, and &lt;a href="https://hafiz.dev/blog/pest-5-tia-run-only-tests-your-change-touched" rel="noopener noreferrer"&gt;Pest 5's test impact analysis&lt;/a&gt; will keep these off the runner when you're changing something unrelated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading to 0.10
&lt;/h2&gt;

&lt;p&gt;Three breaking changes, two of which you'll actually feel. There's a new nullable &lt;code&gt;approval_state&lt;/code&gt; column on the conversation messages table, so publish and run the migrations. Any custom &lt;code&gt;ConversationStore&lt;/code&gt; implementation now has to provide a &lt;code&gt;storeApprovalResults()&lt;/code&gt; method. And the &lt;code&gt;Agent&lt;/code&gt; contract's prompt methods widened from &lt;code&gt;string&lt;/code&gt; to accept decisions as well, which is a no-op if your agents use the &lt;code&gt;Promptable&lt;/code&gt; trait and a signature change if you implement the contract yourself.&lt;/p&gt;

&lt;p&gt;Then there's the behavior change that isn't filed as breaking and will surprise you anyway: the built-in &lt;code&gt;WriteFile&lt;/code&gt;, &lt;code&gt;CopyFile&lt;/code&gt;, and &lt;code&gt;DeleteFile&lt;/code&gt; filesystem tools now require approval by default. If you hand agents a disk with &lt;code&gt;FileStorage::all()&lt;/code&gt;, those three start pausing the moment you upgrade. Decide deliberately which ones get &lt;code&gt;withoutApproval()&lt;/code&gt; rather than discovering it from a stuck conversation.&lt;/p&gt;

&lt;p&gt;Worth saying plainly: this package is at 0.10. It's pre-1.0, the API surface around approvals is young, and the repository has open work on multi-step approval resume. Pin your version rather than floating on &lt;code&gt;dev-main&lt;/code&gt;, and read the diff on minor bumps.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;Use this on anything that spends money, deletes data, or sends a message to a third party. Don't use it on reads. That line is easy to hold and it survives code review, which is more than you can say for most security policies.&lt;/p&gt;

&lt;p&gt;The trade-off is real, though. Every approval gate is a place where a conversation stops and waits for a human who may be asleep, and an agent that pauses six times per session is a worse product than one that pauses once. Threshold-based &lt;code&gt;needsApproval&lt;/code&gt; is the answer, and the threshold should come from your actual refund data, not from a number that felt safe on a Tuesday.&lt;/p&gt;

&lt;p&gt;I'd also push back gently on the framing that this makes agents "safe". It makes one class of action reviewable. The agent can still be prompt-injected into calling a tool with plausible-looking arguments, and a reviewer skimming ten approvals an hour will wave that through. Approval is a control, not a guarantee. Pair it with argument validation inside &lt;code&gt;handle()&lt;/code&gt; and allowlists at the schema level, the way the SDK's own database tool guidance recommends.&lt;/p&gt;

&lt;p&gt;The API itself is good. It's small, it's opt-in per tool, it degrades to a normal prompt, and it's testable. That's about as much as you can ask from a first release.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does tool approval work with queued agents?
&lt;/h3&gt;

&lt;p&gt;Yes. It's supported by &lt;code&gt;prompt&lt;/code&gt;, &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;queue&lt;/code&gt;, &lt;code&gt;broadcast&lt;/code&gt;, &lt;code&gt;broadcastNow&lt;/code&gt;, and &lt;code&gt;broadcastOnQueue&lt;/code&gt;. For queued agents the response arrives in the &lt;code&gt;then&lt;/code&gt; callback and Laravel dispatches a &lt;code&gt;ToolApprovalRequested&lt;/code&gt; event you can listen for.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if I only decide on some of the pending calls?
&lt;/h3&gt;

&lt;p&gt;You get an &lt;code&gt;ApprovalMismatchException&lt;/code&gt;. Every pending call needs a decision. Use &lt;code&gt;approveRemaining()&lt;/code&gt; or &lt;code&gt;rejectRemaining()&lt;/code&gt; to set a default for the ones you didn't name explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I change the arguments before the tool runs?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;Decision::edit(['amount' =&amp;gt; 20000])&lt;/code&gt; approves the call with replaced arguments instead of the ones the model proposed. The edit has to carry arguments; a bare edit decision throws.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a database for this?
&lt;/h3&gt;

&lt;p&gt;Yes. Approval requires a &lt;code&gt;Conversational&lt;/code&gt; agent with persisted history, which means running the AI SDK migrations and using &lt;code&gt;RemembersConversations&lt;/code&gt; or your own &lt;code&gt;ConversationStore&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is one approval gate enough to secure an agent?
&lt;/h3&gt;

&lt;p&gt;No. It reviews one action at one moment. You still want validated tool schemas, allowlisted tables and columns for anything touching the database, and authorization on the conversation itself before a decision is accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The pattern that matters here isn't the trait or the contract. It's that a dangerous tool call is now a piece of state your application can hold, inspect, authorize, and resolve later, instead of something that either happened or didn't while nobody was watching.&lt;/p&gt;

&lt;p&gt;Start with one tool. Pick the one that would ruin your week if it fired wrong, make it approvable, gate it on arguments, and authorize the conversation before you accept a decision. That's an afternoon of work and it changes the risk profile of the whole agent.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelaisdk</category>
      <category>aiagents</category>
      <category>php</category>
    </item>
    <item>
      <title>Pest 5 TIA: Run Only the Tests Your Code Change Touched</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 30 Jul 2026 05:22:01 +0000</pubDate>
      <link>https://dev.to/hafiz619/pest-5-tia-run-only-the-tests-your-code-change-touched-1k3d</link>
      <guid>https://dev.to/hafiz619/pest-5-tia-run-only-the-tests-your-code-change-touched-1k3d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/pest-5-tia-run-only-tests-your-change-touched" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Nuno Maduro announced Pest 5 on stage at Laracon US yesterday, and one feature is worth your attention today: the Tia Engine. TIA stands for Test Impact Analysis, and the pitch is simple enough to sound too good to be true. A Laravel suite that took 10 minutes now finishes in around 4 seconds, because Pest reruns only the tests your latest change could actually break and replays cached results for everything else.&lt;/p&gt;

&lt;p&gt;This shipped yesterday, so this is a walkthrough of how it works from the docs and the release, not a battle-tested war story. I'll be clear about what's proven and what you'll want to verify on your own suite. But the mechanism is well documented, the numbers come from Pest directly, and the setup really is a one-flag start. Here's what TIA does, how to turn it on, the CI trick that makes it fast for your whole team, and the catch nobody mentions in the announcement tweets.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Test Impact Analysis Actually Does
&lt;/h2&gt;

&lt;p&gt;The idea behind TIA isn't new (large monorepos at Google and Meta have done impact analysis for years), but it's new to have it built into a PHP testing framework you already use. The concept: most of your test suite is irrelevant to any given change. You edit one model, and 765 of your 774 tests couldn't possibly behave differently. Running all 774 is wasted time. TIA figures out which handful actually depend on what you touched, runs those, and reconstructs the rest from cache.&lt;/p&gt;

&lt;p&gt;The first run builds a baseline: Pest records a dependency graph of which tests exercise which files. Every run after that is a replay. Pest diffs your working tree against the baseline and reruns only the affected tests. The output tells you exactly what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tests:    774 passed (2658 assertions, 7 affected, 2 uncached, 765 replayed)
Duration: 3.92s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that line: 7 tests actually ran because their dependencies changed, 2 ran because no cached result existed yet, and 765 were served from cache. Four seconds instead of ten minutes.&lt;/p&gt;

&lt;p&gt;The part I found most reassuring in the docs: a replay isn't a shortcut that skips coverage. Pest stores what each test covered, down to lines and branches, so a replayed run reports the same coverage as a full run. Your &lt;code&gt;--coverage&lt;/code&gt; report, coverage thresholds, and &lt;code&gt;--min&lt;/code&gt; all behave as if every test executed. You get the speed without lying to your coverage gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning It On
&lt;/h2&gt;

&lt;p&gt;One flag on any Pest invocation:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;There's one hard requirement, and it's the thing that'll trip people up: &lt;strong&gt;TIA needs a code coverage driver, either PCOV or Xdebug, installed and enabled.&lt;/strong&gt; Pest uses it to record which files each test touches while building the baseline. No coverage driver, no dependency graph, no TIA. If you already run coverage in CI you have this; if you've never installed PCOV locally, that's your first step.&lt;/p&gt;

&lt;p&gt;The other requirement is the bigger gate: &lt;strong&gt;Pest 5 requires PHP 8.4 or higher.&lt;/strong&gt; It's built on PHP 8.4 and PHPUnit 13. If you're on an older PHP version, TIA is a reason to plan the upgrade, not something you can try this afternoon. (If you're mid-migration on the framework side, my &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade guide&lt;/a&gt; covers that half.)&lt;/p&gt;

&lt;p&gt;If you'd rather not type the flag every time, configure it in &lt;code&gt;tests/Pest.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="nf"&gt;pest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tia&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;always&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;// run TIA on every invocation, no --tia flag&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;locally&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="c1"&gt;// but only on local machines, skip on CI&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;baselined&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// fetch the shared baseline from CI when missing&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// narrow PHPUnit to only affected test files&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;always()-&amp;gt;locally()&lt;/code&gt; pairing is the sensible default for most teams: fast replays while you work, full runs on CI where you want every test to execute for real. An explicit &lt;code&gt;--tia&lt;/code&gt; always wins, and &lt;code&gt;--no-tia&lt;/code&gt; disables it for a single run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How TIA Decides What To Rerun
&lt;/h2&gt;

&lt;p&gt;This is where it stops being magic and starts being understandable, and understanding it is what tells you whether to trust it. TIA maps different file types to tests in different ways:&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;PHP source files&lt;/strong&gt;, the coverage driver does the work. Change &lt;code&gt;app/Models/User.php&lt;/code&gt; and Pest reruns only tests that touched &lt;code&gt;User&lt;/code&gt;. For &lt;strong&gt;migrations&lt;/strong&gt;, Pest intersects the change against the tables each test queried during the baseline, so a column rename in the users migration reruns only tests that hit the &lt;code&gt;users&lt;/code&gt; table. &lt;strong&gt;Blade templates&lt;/strong&gt; rerun only the tests that rendered them. If you're on Inertia, Pest walks Vite's module graph to trace which pages import a changed component and reruns only the tests that server-side-rendered those pages.&lt;/p&gt;

&lt;p&gt;Then there's the honest fallback. Config files, route files, fixtures, anything outside the recorded graph triggers a broader pattern. Edit &lt;code&gt;config/app.php&lt;/code&gt; and Pest reruns the entire suite, because it can't statically prove which tests depend on it. That's the correct behavior, and it's a good sign: TIA errs toward running too much rather than missing a regression. Some changes rebuild the graph itself, like &lt;code&gt;composer.lock&lt;/code&gt;, &lt;code&gt;phpunit.xml&lt;/code&gt;, and Node lockfiles.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/pest-5-tia-run-only-tests-your-change-touched" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Detail That Sells It: Cosmetic Edits Run Nothing
&lt;/h2&gt;

&lt;p&gt;Here's the feature that makes TIA feel different from a dumb file-timestamp cache. Pest normalizes file content before comparing. PHP files get whitespace, line comments, and docblocks stripped before hashing. Blade strips its &lt;code&gt;{{-- --}}&lt;/code&gt; comments. JS, TS, Vue, and Svelte lose their comments too.&lt;/p&gt;

&lt;p&gt;The practical result: a comment-only edit, a Prettier reformat, a &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Pint&lt;/a&gt; pass, or a README tweak produces an identical hash. The file never enters the changed set. Zero tests run. If you've ever watched your full suite grind through CI because someone reformatted a file, you understand why that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing the Baseline: The CI Trick
&lt;/h2&gt;

&lt;p&gt;Recording the baseline takes as long as one full suite run, which on a large project is minutes. Paying that on every developer's machine, and every fresh checkout, would undercut the whole point. Pest's answer: record the baseline once in CI, and have everyone else download it.&lt;/p&gt;

&lt;p&gt;You enable fetching with &lt;code&gt;pest()-&amp;gt;tia()-&amp;gt;baselined()&lt;/code&gt; in &lt;code&gt;tests/Pest.php&lt;/code&gt; (preferred for teams). When Pest finds no local graph, it uses the GitHub CLI to pull the &lt;code&gt;pest-tia-baseline&lt;/code&gt; artifact from your latest successful baseline workflow, validates it against your project state, and adopts it if it matches. Every developer's first &lt;code&gt;--tia&lt;/code&gt; run then replays immediately, paying no record cost.&lt;/p&gt;

&lt;p&gt;The workflow that produces that artifact, dropped into &lt;code&gt;.github/workflows/tia-baseline.yml&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TIA Baseline&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="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&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="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&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;baseline&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;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="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&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="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&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="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;xdebug&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&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&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./vendor/bin/pest --parallel --tia --coverage --fresh&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;Resolve TIA baseline path&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;baseline&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;echo "path=$(./vendor/bin/pest --baseline)" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&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;Upload TIA baseline&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pest-tia-baseline&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;${{ steps.baseline.outputs.path }}&lt;/span&gt;
          &lt;span class="na"&gt;include-hidden-files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know here. &lt;code&gt;./vendor/bin/pest --baseline&lt;/code&gt; prints the absolute path to the TIA storage directory (&lt;code&gt;~/.pest/tia/&amp;lt;project-key&amp;gt;/&lt;/code&gt;), which is what the upload step bundles. And &lt;code&gt;include-hidden-files: true&lt;/code&gt; is required, because that directory is dot-prefixed and Actions skips hidden files by default. Fetching relies on the GitHub CLI being installed and authenticated, so it's GitHub-only for now; on any failure Pest falls back to recording locally and tells you why. If you don't have a Pest CI pipeline yet, my &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;GitHub Actions guide for Laravel&lt;/a&gt; is the place to start before wiring this in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Catch Nobody's Tweeting About
&lt;/h2&gt;

&lt;p&gt;The announcement numbers are real, but here's what to keep in mind before you assume your suite drops to 4 seconds tomorrow.&lt;/p&gt;

&lt;p&gt;TIA's speedup is proportional to how well-isolated your tests are. If most of your tests are unit tests touching a handful of classes, a small change reruns almost nothing and you get the headline numbers. If your suite is mostly full feature tests that boot the whole app and hit the database, more of them qualify as "affected" by any given change, and the win shrinks. The 10-minutes-to-4-seconds figure describes a well-structured suite, not a guaranteed outcome for every codebase.&lt;/p&gt;

&lt;p&gt;There's also a trust question worth being deliberate about. TIA is deciding not to run tests. That's the entire point, and the coverage-fidelity design is reassuring, but this is day-one software. The safe pattern is exactly what the config nudges you toward: &lt;code&gt;always()-&amp;gt;locally()&lt;/code&gt; for fast local replays, and full untia'd runs on CI as the source of truth. Let TIA speed up your inner loop first. Trust it to gate your deploys only once you've watched it behave on your real codebase for a while. That's not skepticism about Pest; it's how you'd adopt any tool that skips work on your behalf.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Else Shipped in Pest 5
&lt;/h2&gt;

&lt;p&gt;TIA is the headline, but the release bundled several first-party plugins that matured across the Pest 4 cycle. Worth knowing they exist:&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Agent plugin&lt;/strong&gt; gives AI coding agents a single command to verify a change actually works against your real suite, and with the Browser plugin, in a real browser too. &lt;strong&gt;Evals&lt;/strong&gt; let you score LLM output and AI-generated results from inside your test suite, mixing deterministic checks with AI scorers. And &lt;strong&gt;PHPStan and Rector are now built in&lt;/strong&gt;, so static analysis and automated refactoring live alongside your tests instead of as separate tooling. If you're still on Pest 4, my &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;complete Pest 4 testing guide&lt;/a&gt; still applies for the fundamentals; Pest 5's testing API is the same, with these plugins layered on top.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I need to change how I write tests to use Pest 5 TIA?
&lt;/h3&gt;

&lt;p&gt;No. TIA works with your existing tests unchanged. You add the &lt;code&gt;--tia&lt;/code&gt; flag (or configure it in &lt;code&gt;tests/Pest.php&lt;/code&gt;) and Pest builds the dependency graph automatically from a normal run. Your test files, expectations, and structure stay exactly as they are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does TIA reduce my code coverage numbers?
&lt;/h3&gt;

&lt;p&gt;No, and this is the clever part. When Pest caches a test it stores the exact lines and branches that test covered, so a replayed run reports the same coverage as a full run. Coverage thresholds, the &lt;code&gt;--coverage&lt;/code&gt; report, and &lt;code&gt;--min&lt;/code&gt; all behave as if every test executed from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when I change a config or route file?
&lt;/h3&gt;

&lt;p&gt;Pest reruns the entire suite. Files outside the recorded dependency graph, including config and route files, can't be statically traced to specific tests, so TIA falls back to running everything rather than risk missing a regression. It errs toward safety.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can my whole team skip the slow first baseline run?
&lt;/h3&gt;

&lt;p&gt;Yes, if you're on GitHub. Record the baseline once in a CI workflow, upload it as the &lt;code&gt;pest-tia-baseline&lt;/code&gt; artifact, and enable &lt;code&gt;pest()-&amp;gt;tia()-&amp;gt;baselined()&lt;/code&gt;. Each developer's first &lt;code&gt;--tia&lt;/code&gt; run downloads that baseline and replays immediately instead of recording locally. It relies on the GitHub CLI, so it's GitHub-only for now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I trust TIA to gate my production deploys?
&lt;/h3&gt;

&lt;p&gt;Be cautious at first. TIA shipped at Laracon US 2026, so it's brand new. The sensible adoption path is &lt;code&gt;always()-&amp;gt;locally()&lt;/code&gt;: fast replays while you develop, full runs on CI as your source of truth. Once you've watched TIA behave correctly on your real suite over time, you can decide whether to lean on it further.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Pest 5 TIA need a specific PHP version?
&lt;/h3&gt;

&lt;p&gt;Yes. Pest 5 requires PHP 8.4 or greater and is built on PHPUnit 13. TIA also needs a coverage driver (PCOV or Xdebug) to record the baseline. If you're on an older PHP version, upgrading is a prerequisite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;TIA is the kind of feature that changes a daily habit rather than adding one. The slow test suite you run less often than you should, or gate behind a coffee break, becomes something fast enough to run after every save. Add &lt;code&gt;--tia&lt;/code&gt;, make sure you've got PHP 8.4 and a coverage driver, and let it speed up your local loop first. Wire up the CI baseline once your team wants the shared graph. And keep your CI running the full suite untia'd until you've earned trust in the replays on your own codebase.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>pest</category>
      <category>testing</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Laravel Scout in 2026: Meilisearch vs Typesense vs Algolia vs Database Driver</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 27 Jul 2026 06:41:24 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-scout-in-2026-meilisearch-vs-typesense-vs-algolia-vs-database-driver-2omi</link>
      <guid>https://dev.to/hafiz619/laravel-scout-in-2026-meilisearch-vs-typesense-vs-algolia-vs-database-driver-2omi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-scout-2026-meilisearch-vs-typesense-vs-algolia-vs-database" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel Scout gives you full-text search on your Eloquent models with one trait and a config value. What it doesn't give you is an opinion. &lt;a href="https://laravel.com/docs/13.x/scout" rel="noopener noreferrer"&gt;The docs&lt;/a&gt; list five drivers (Algolia, Meilisearch, Typesense, database, and collection) and treat them as interchangeable. They're not. The driver you pick decides whether search costs you nothing, a fixed monthly fee, or a bill that grows every time your product succeeds.&lt;/p&gt;

&lt;p&gt;I've shipped Scout with three of these drivers across client projects and my own products. This post is the comparison I wish existed: what each driver actually does, what it costs at real usage levels, and a straight answer on which one to pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Scout Actually Abstracts
&lt;/h2&gt;

&lt;p&gt;Quick grounding for anyone who hasn't used it. Scout syncs your models to a search index automatically. Add the &lt;code&gt;Searchable&lt;/code&gt; trait, and every create, update, and delete flows through to your search engine via model observers:&lt;br&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\Scout\Searchable&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;Post&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Searchable&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;toSearchableArray&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strip_tags&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;body&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;Searching is one line: &lt;code&gt;Post::search('pgbouncer')-&amp;gt;paginate(15)&lt;/code&gt;. The abstraction is the whole point: your application code stays identical across all five drivers, which means the driver decision is about operations and money, not code.&lt;/p&gt;

&lt;p&gt;One setting that isn't optional in production: &lt;code&gt;'queue' =&amp;gt; true&lt;/code&gt; in &lt;code&gt;config/scout.php&lt;/code&gt;. Without it, every model save blocks on an HTTP call to your search engine. Push index updates through &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;your queue workers&lt;/a&gt; and the write path stays fast. And a detail that surprises people: even with queueing off, Algolia and Meilisearch index asynchronously on their side. Your job finishing doesn't mean the record is searchable yet. Write your tests accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Real Contenders
&lt;/h2&gt;

&lt;p&gt;The collection driver filters records in PHP after pulling them all from the database. It exists for local development and works everywhere, including SQLite. It's not a production option, so it's out of the comparison. That leaves four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database driver&lt;/strong&gt; runs full-text indexes and LIKE clauses against MySQL or PostgreSQL. No new infrastructure, no sync problems (it queries your actual tables), no cost. Also: no typo tolerance, no relevance tuning worth the name, no facets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meilisearch&lt;/strong&gt; is an open-source engine written in Rust. Sub-50ms responses, typo tolerance, faceting, and highlighting out of the box with almost no configuration. Its defaults are good enough that most projects never touch relevance settings. Self-host it free (MIT license) or use Meilisearch Cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typesense&lt;/strong&gt; is the closest thing Meilisearch has to a twin: open-source, typo-tolerant, instant search, written in C++. It differentiates on operational predictability. Typesense Cloud gives you a dedicated cluster priced by RAM and CPU per hour, with no per-record or per-operation charges at all. It also has more mature vector and geo search than Meilisearch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Algolia&lt;/strong&gt; is the incumbent hosted service. The most polished dashboards, the best frontend libraries (InstantSearch), AI re-ranking, and enterprise features nobody else matches. You pay for it with usage-based pricing that scales with your traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Comparison: Where They Actually Differ
&lt;/h2&gt;

&lt;p&gt;On core search quality (typo tolerance, prefix matching, ranking) Meilisearch, Typesense, and Algolia are closer than their marketing suggests. All three return results in tens of milliseconds and handle "labrvel" finding "laravel" without configuration. For a typical SaaS search box, users can't tell them apart.&lt;/p&gt;

&lt;p&gt;The differences live at the edges. Algolia leads on merchandising and AI features: synonyms management, A/B testing relevance, personalization. If you're building e-commerce search where a 2% conversion lift pays for the tooling, that matters. Typesense leads on vector search and its Search Delivery Network, which replicates your index across regions like a CDN. Meilisearch leads on getting to good results with the least configuration of the three.&lt;/p&gt;

&lt;p&gt;The database driver isn't playing the same sport. It matches words, roughly in the order your database returns them. Search for "posgres" and you get nothing, because there's no typo tolerance. For a public-facing product search, that's disqualifying. For an admin panel where your team searches customers by name or email, it's completely fine, and I'd argue anything more is over-engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Money: What Search Actually Costs
&lt;/h2&gt;

&lt;p&gt;This is where the comparison stops being close, so let's do real math. All prices verified this week; search providers change pricing often, so treat these as July 2026 numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Algolia&lt;/strong&gt; has a free Build tier (10,000 search requests a month) that's generous for prototyping. The paid Grow plan is pay-as-you-go: $0.50 per 1,000 search requests and $0.40 per 1,000 records beyond the included 10,000 requests and 100,000 records. Grow Plus, which adds the AI features, jumps to $1.75 per 1,000 requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meilisearch Cloud&lt;/strong&gt; starts at $30/month for the Build plan and climbs into the hundreds at the million-document tier. Self-hosting is free under MIT.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typesense Cloud&lt;/strong&gt; bills the cluster, not the usage: pick RAM and CPU, pay per hour it runs. The smallest configurations work out to roughly $7-25/month, and you can throw as many searches at it as the hardware handles. Self-hosting is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database driver&lt;/strong&gt; costs zero. Your database is already running.&lt;/p&gt;

&lt;p&gt;Now the worked example. Say your app does 500,000 searches a month across 250,000 records, a realistic mid-sized SaaS:&lt;/p&gt;

&lt;p&gt;Algolia Grow: 490,000 billable requests ($245) plus 150,000 extra records ($60) lands around $305/month. Every new user who searches makes next month's bill bigger. Meilisearch Cloud at that document count sits in its higher tiers, roughly the $200-300/month range. Typesense Cloud handles that load comfortably on a small dedicated cluster in the $25-70/month range, and the bill doesn't move when traffic doubles until the hardware runs out. Self-hosted Meilisearch or Typesense on a basic Hetzner or DigitalOcean instance: about €5-10/month for the server, and the real cost is you being the person who gets paged.&lt;/p&gt;

&lt;p&gt;That's a 30x spread for the same feature. The pricing model matters more than the sticker price: Algolia charges you for growth, Typesense charges you for capacity, self-hosting charges you in responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Database Driver Is Honestly Enough
&lt;/h2&gt;

&lt;p&gt;The advice you'll find everywhere is "just use Meilisearch," and it's not wrong. But I want to defend the boring option, because I keep seeing projects run a search server for workloads that don't need one.&lt;/p&gt;

&lt;p&gt;Use the database driver when all of these are true: your searchable dataset is under roughly 100,000 rows, your users are internal or forgiving (admin panels, back-office tools, MVPs), and you're searching short fields like names, emails, and titles. MySQL and Postgres full-text indexes are fast at this scale, and &lt;a href="https://hafiz.dev/blog/database-indexing-in-laravel-boost-mysql-performance-with-smart-indexes" rel="noopener noreferrer"&gt;a proper index&lt;/a&gt; is the only tuning you'll ever do. You skip a service, a sync pipeline, a deploy dependency, and a monthly bill.&lt;/p&gt;

&lt;p&gt;Move up when users start expecting typo tolerance, when you need facets and filters on the search page, or when search becomes part of the product rather than a utility. You'll feel the moment. And because Scout abstracts the engine, the migration is a config change plus &lt;code&gt;php artisan scout:import&lt;/code&gt;, not a rewrite. That's the quiet superpower of building on Scout even when you start on the database driver.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-scout-2026-meilisearch-vs-typesense-vs-algolia-vs-database" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  My Recommendation
&lt;/h2&gt;

&lt;p&gt;For most Laravel apps in 2026: &lt;strong&gt;self-hosted Meilisearch&lt;/strong&gt; if you already run a VPS, &lt;strong&gt;Typesense Cloud&lt;/strong&gt; if you don't want to.&lt;/p&gt;

&lt;p&gt;Meilisearch wins the self-host case because it's the least operational search server I've run: one binary, sane defaults, and it sits happily in 512MB of RAM for small datasets. If you deploy with Docker or manage a Hetzner box already, adding it costs you an evening. Typesense wins the managed case because resource-based pricing means your search bill is a known number, and known numbers are worth a lot to a bootstrapped product. A &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS charging monthly&lt;/a&gt; should not have a cost line that scales per-search.&lt;/p&gt;

&lt;p&gt;Pick Algolia when its extras are the point: merchandising tools, the InstantSearch frontend stack, AI ranking for an e-commerce catalog where relevance is revenue. It's the best product of the three and priced like it knows.&lt;/p&gt;

&lt;p&gt;And pick the database driver more often than the internet suggests. Half the Scout setups I've reviewed could have been a full-text index.&lt;/p&gt;

&lt;p&gt;One operational note whichever engine you choose: if you're running &lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;Octane&lt;/a&gt; or serious queue concurrency, keep Scout's queue on and give indexing jobs their own queue name. A bulk import re-indexing 100K models on the same queue as your password-reset emails is a bad afternoon.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can Laravel Scout do semantic or vector search?
&lt;/h3&gt;

&lt;p&gt;Scout itself is engine-agnostic, so it depends on the driver. Typesense has the most mature vector search of the three engines; Meilisearch's is newer and improving fast. If semantic search is central to your product, that's a different architecture conversation than a search box, and I've covered the options in my &lt;a href="https://hafiz.dev/blog/laravel-search-in-2026-full-text-semantic-and-vector-search-explained" rel="noopener noreferrer"&gt;guide to full-text, semantic, and vector search in Laravel&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Scout driver is fastest?
&lt;/h3&gt;

&lt;p&gt;For end-user perception, Meilisearch, Typesense, and Algolia are all effectively instant (tens of milliseconds) on typical datasets. The database driver is fast at small scale and degrades as tables grow and queries lean on LIKE. If you're choosing between the three engines on latency alone, you're optimizing the wrong variable; choose on cost model and features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does switching Scout drivers require code changes?
&lt;/h3&gt;

&lt;p&gt;Almost none, and that's Scout's best feature. Your models, &lt;code&gt;toSearchableArray()&lt;/code&gt;, and search calls stay the same. You change &lt;code&gt;SCOUT_DRIVER&lt;/code&gt;, add the new engine's credentials, and run &lt;code&gt;php artisan scout:import&lt;/code&gt; to build the index. The exceptions are engine-specific features like Typesense's schema definitions or Algolia-specific query options, which you'd need to recreate or remove.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the database driver production-ready?
&lt;/h3&gt;

&lt;p&gt;Yes, within its limits. It uses real full-text indexes on MySQL and PostgreSQL rather than naive LIKE scans, and it can't drift out of sync because it queries your actual tables. What it lacks is typo tolerance, ranked relevance, and facets. Internal tools and small catalogs: absolutely. Public product search where users type fast and expect Google-ish forgiveness: use a real engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Scout at all, or can I use Meilisearch directly?
&lt;/h3&gt;

&lt;p&gt;You can use any engine's SDK directly, and for complex multi-index setups some teams do. Scout earns its place through the automatic model syncing and the driver abstraction that makes engines swappable. For the common case of "make these models searchable," writing your own sync layer is reinventing a wheel Scout ships for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Scout's driver choice is a cost-model choice wearing a technical costume. The database driver is free and underrated for internal tools. Self-hosted Meilisearch is the sweet spot for developers who already run servers. Typesense Cloud buys you a predictable bill. Algolia buys you the most product for the most money, priced per unit of your own growth. Start boring, and let Scout's abstraction make the upgrade a config change instead of a project.&lt;/p&gt;

&lt;p&gt;Setting up search for a Laravel app, or trying to cut an Algolia bill that grew faster than revenue? I've done both and I'm happy to talk through your setup. &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelscout</category>
      <category>meilisearch</category>
      <category>search</category>
    </item>
    <item>
      <title>Laravel Telegram Bot: How I Cleared My YouTube Watch Later with AI Summaries</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 23 Jul 2026 12:28:54 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-telegram-bot-how-i-cleared-my-youtube-watch-later-with-ai-summaries-1840</link>
      <guid>https://dev.to/hafiz619/laravel-telegram-bot-how-i-cleared-my-youtube-watch-later-with-ai-summaries-1840</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-telegram-bot-ai-watch-later-summaries" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;My YouTube Watch Later had 42 videos in it. I'd watched maybe ten. X bookmarks were worse because I couldn't even remember why I'd saved half of them. Every platform has a save button, and every save button leads to the same place: a list nobody ever opens again.&lt;/p&gt;

&lt;p&gt;The obvious fix is "build something that syncs those lists and reminds you." I tried. That version is impossible, and knowing why shapes the whole design. So I built the opposite: a Telegram bot backed by a small Laravel app that replaces the save button instead of syncing it. I forward a link, it fetches the content, summarizes it with the AI SDK, and then, once a day, it forces me to decide: watch, snooze, or archive.&lt;/p&gt;

&lt;p&gt;I've been running it for ten days. Here's the build, including the security bug that almost shipped and the fallback path that fires way more often than I expected.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneenk8bka3sgvo71ft7c.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneenk8bka3sgvo71ft7c.webp" alt="A digest item marked as watched, with the video link unfurled below it in the chat" width="800" height="1377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Can't Sync Watch Later (So Stop Trying)
&lt;/h2&gt;

&lt;p&gt;The dream version of this tool connects to your accounts and pulls your saved items. Two hard walls and one soft one:&lt;/p&gt;

&lt;p&gt;YouTube's Data API deliberately excludes the Watch Later playlist. Access was removed years ago and never came back. You can read any playlist except that one.&lt;/p&gt;

&lt;p&gt;Facebook's saved items have no API at all.&lt;/p&gt;

&lt;p&gt;X bookmarks are the soft one. They do have an API, and since April 2026 reading your own data costs $0.001 per item, so pulling a thousand bookmarks runs about a dollar. But you still need a paid developer account with credits loaded and an OAuth flow to get there, and it only solves X. YouTube and Facebook stay shut either way.&lt;/p&gt;

&lt;p&gt;This is why no existing tool does real sync. The workaround is to own the capture step: don't read their list, become the list. On mobile that's the native share sheet, which already has Telegram in it. Share, tap the bot, done. On desktop it's a bookmarklet that POSTs the current URL to the app. Either way, the platform's save button never gets touched again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The stack is deliberately boring: Laravel 13, SQLite in WAL mode, the database queue driver, and the Telegram Bot API called through plain HTTP. No Telegram SDK package. The bot surface is four methods (sendMessage, editMessageText, answerCallbackQuery, setWebhook), and a thin service class wrapping Laravel's Http client covers them in about 60 lines. Zero extra infrastructure: no Redis, no Node process, no third-party queue.&lt;/p&gt;

&lt;p&gt;There's also no frontend. Telegram is the UI, which means the tool works on mobile, desktop, and web from day one without me writing a single screen.&lt;/p&gt;

&lt;p&gt;The flow from save to digest looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-telegram-bot-ai-watch-later-summaries" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The webhook does three jobs: it accepts new links, it answers commands like /stats, and it handles the button presses coming back from digest messages. Everything slow happens in &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;a queued job&lt;/a&gt;, so the bot always answers in under a second with "Saving..." before the real confirmation arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning URLs Is Half the Feature
&lt;/h2&gt;

&lt;p&gt;The first thing the SaveUrl action does is canonicalize. The same video arrives as youtube.com/watch, youtu.be, a Shorts URL, or any of those with tracking junk attached (si=, utm_*, the X s= and t= params). All of them collapse to one canonical form, so saving the same video twice gets caught as a duplicate instead of cluttering the inbox. Timestamps survive the cleaning because "watch from 18:30" is information I actually want.&lt;/p&gt;

&lt;p&gt;One small decision I like: re-saving something already watched or archived revives it back to the inbox. If I cared enough to save it twice, it's relevant again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching Content Without Scraping Anything
&lt;/h2&gt;

&lt;p&gt;For YouTube, oEmbed gives me the title and channel for free, no API key. Duration comes from the Data API's videos.list if a key is present, and gets skipped when it's not. Transcripts are best-effort against the caption endpoints.&lt;/p&gt;

&lt;p&gt;For X, the public oEmbed endpoint at publish.twitter.com returns the tweet text and author for any public tweet. No auth, no paid tier. That's the entire "X integration."&lt;/p&gt;

&lt;p&gt;Everything else falls through to an article fetcher that grabs the title tag and meta description. Not glamorous, works fine.&lt;/p&gt;

&lt;p&gt;Then the queue job hands whatever content it collected to the AI SDK with a schema-validated structured output request: a two-to-four sentence summary plus three to five lowercase tags, returned as strict JSON. If you haven't used structured outputs in the SDK yet, &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;the smart assistant tutorial&lt;/a&gt; covers the pattern; it's the same idea here with a smaller schema.&lt;/p&gt;

&lt;p&gt;The app runs DeepSeek by default because summaries are a cheap-model task, but the provider is configuration, not code. Two env vars switch it to Anthropic or OpenAI, and &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-ollama-local-llms-guide" rel="noopener noreferrer"&gt;the same swap works for local models through Ollama&lt;/a&gt; if you want summaries that cost nothing. Since &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-goes-stable-what-changed-what-to-check" rel="noopener noreferrer"&gt;the SDK went stable&lt;/a&gt;, this kind of provider independence is the main reason I keep reaching for it in side projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Probably:" Fallback Fires More Than You'd Think
&lt;/h2&gt;

&lt;p&gt;Here's the honest part. YouTube transcript fetching fails constantly. Captions are disabled, auto-captions aren't exposed, the endpoint shape shifts. I expected transcripts to be the normal path and the fallback to be rare. Ten days of real usage says it's closer to the other way around.&lt;/p&gt;

&lt;p&gt;So the fallback is a first-class feature, not an error state. When there's no transcript, the model summarizes from the title and channel alone, and the summary is prefixed with "Probably:" so I know it's an educated guess rather than a digest of the actual content. Treating low confidence as something to label instead of hide turned out to be the right call. A guessed summary still answers the only question that matters at digest time: do I still care about this?&lt;/p&gt;

&lt;p&gt;If I later want real transcripts, the escape hatch is downloading captions server-side per video. That's a v2 problem. For deciding watch-or-archive, "Probably:" is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Item a Day, Three Buttons
&lt;/h2&gt;

&lt;p&gt;Every evening at 19:00 the scheduler picks exactly one item and sends it with its summary and three inline buttons: Watch, Snooze 7d, Archive.&lt;/p&gt;

&lt;p&gt;The selection logic matters more than it looks. Due snoozes win first, oldest snooze first. Otherwise it picks from the inbox: never-surfaced items first, then whatever was surfaced longest ago. That rotation means ignoring a digest doesn't show you the same item every day forever, which is exactly the failure mode that makes reminder apps unbearable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwxzbn6gs2z98jilx17o.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwxzbn6gs2z98jilx17o.webp" alt="A digest item marked as watched, with the video link unfurled below it in the chat" width="800" height="1380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The buttons edit the original message in place. Tap Watch and the message updates to show the plain URL so it unfurls and I can tap through. Tap Archive and it becomes one line. No app, no tab, a two-second decision inside a chat I already have open.&lt;/p&gt;

&lt;p&gt;And because guilt is the actual product being fought here: anything untouched for 90 days gets auto-archived by a weekly job. The clock keys on updated_at, so snoozing counts as engagement and resets it. The list can only shrink.&lt;/p&gt;

&lt;p&gt;A /stats command replaces the dashboard I refused to build. Inbox count, watched, archived, watch rate, age of the oldest item. One message. That's the whole confession.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpzi72vtk8l2xs6lhfnm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpzi72vtk8l2xs6lhfnm.webp" alt="The /stats command output showing inbox count, watched, archived, watch rate and age of the oldest saved item" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug That Almost Shipped: hash_equals('', '')
&lt;/h2&gt;

&lt;p&gt;The first live smoke test caught something the 47 passing tests didn't. With a fresh .env where the secrets weren't filled in yet, every authenticated endpoint let everyone in.&lt;/p&gt;

&lt;p&gt;The webhook check compared the configured secret against the header Telegram sends. The ingest endpoint compared the configured token against the bearer token. Both used hash_equals(). And hash_equals('', '') returns true, because an empty string does equal an empty string. Blank config plus absent header equals open endpoint, on a public VPS.&lt;/p&gt;

&lt;p&gt;The tests never caught it because every test sets the secrets. Real config drift is exactly the case your test suite doesn't model.&lt;/p&gt;

&lt;p&gt;The fix is a rule worth stealing for any personal tool: fail closed when config is blank. Every auth check now short-circuits to a 403 if the configured secret is empty, before any comparison runs, with regression tests pinning it. If a required secret isn't set, the correct behavior is "nothing works," never "everything works."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Deliberately Didn't Build
&lt;/h2&gt;

&lt;p&gt;No user accounts, no dashboard, no Chrome extension, no Facebook support, no multi-tenancy. This is a single-user tool on my own VPS, and every one of those features would have turned a weekend build into a month of speculation about users who don't exist yet.&lt;/p&gt;

&lt;p&gt;That's also my honest recommendation if you build one: resist the SaaS reflex. The value here is that it's yours, it's small, and it costs nothing to run. SQLite plus the database queue on a box you already have is the whole bill. If strangers start asking to use it, that's a different project and a different decision, and it should be made then, with real people asking, not upfront.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why can't it just read my existing YouTube Watch Later list?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because the API doesn't allow it. YouTube removed Watch Later access from the Data API years ago, and no official way back exists. Any tool claiming to sync it is scraping, which breaks routinely. Replacing the save action is the reliable path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I don't configure an AI provider?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Links still get saved with full metadata (title, channel, duration). The bot just says no summary is configured. The AI layer is optional, not a dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Telegram instead of WhatsApp?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Telegram's Bot API is free, instant to set up, and supports inline buttons and message editing, which the digest depends on. WhatsApp's Business API costs money and requires approval. This isn't close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it handle X bookmarks?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It replaces them rather than reading them. The bookmarks API is affordable now (roughly a dollar per thousand of your own items since April 2026), but it needs a funded developer account and an OAuth flow, and it only covers X. The public oEmbed endpoint returns any public tweet's text for free, so forwarding a tweet link gets you the same summarize-and-resurface treatment with no account at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does it decide which item to resurface?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Due snoozes first, then never-surfaced inbox items oldest-first, then the item that's gone longest without being shown. Ignoring the digest rotates the backlog instead of repeating one item, and anything untouched for 90 days archives itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch Rate Over Library Size
&lt;/h2&gt;

&lt;p&gt;Ten days in: 12 sitting in the inbox, 7 watched, 3 archived, one snoozed. That's a 70% watch rate on the things I've actually decided about, and I'll be honest that the number flatters me. These are still the early days and the sample is ten decisions. Ask again in a month when the novelty has worn off.&lt;/p&gt;

&lt;p&gt;The whole thing is on GitHub at &lt;a href="https://github.com/hzeeshan/watch-later" rel="noopener noreferrer"&gt;hzeeshan/watch-later&lt;/a&gt; if you want to run your own. It's a single-user tool by design, so expect to spend ten minutes on a bot token and a cron entry rather than a signup form.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>telegram</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Laravel PostgreSQL Connection Pooling: Native PgBouncer Support Is Here</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:31:23 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-postgresql-connection-pooling-native-pgbouncer-support-is-here-4ig7</link>
      <guid>https://dev.to/hafiz619/laravel-postgresql-connection-pooling-native-pgbouncer-support-is-here-4ig7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-postgresql-connection-pooling-native-pgbouncer-support" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel PostgreSQL connection pooling used to mean one thing: workarounds. If you've ever run Laravel against Supabase, Neon, or a self-hosted PgBouncer, you know the ritual. Set &lt;code&gt;PDO::ATTR_EMULATE_PREPARES&lt;/code&gt;, install a community package to patch boolean bindings, keep a second connection config for migrations, and hope nobody on the team forgets which one is which.&lt;/p&gt;

&lt;p&gt;That ritual is over. Laravel 13.17 shipped framework-level support for Postgres transaction poolers. One config flag, an optional &lt;code&gt;direct&lt;/code&gt; block, and the framework handles everything the pooler requires. Emulated prepares, boolean binding, routing migrations around the pooler. All of it.&lt;/p&gt;

&lt;p&gt;This post covers what the feature does, why the problem existed in the first place, the exact setup for Supabase, Neon, and self-hosted PgBouncer, and which of your old hacks you can now delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Postgres Connections Are Expensive
&lt;/h2&gt;

&lt;p&gt;If you come from MySQL (like most Laravel developers), this problem probably never bit you. MySQL handles connections with lightweight threads. Postgres forks an entire OS process per connection, and each one eats roughly 5-10MB of RAM on the server. The default &lt;code&gt;max_connections&lt;/code&gt; on most Postgres setups sits around 100.&lt;/p&gt;

&lt;p&gt;Now do the math for a typical Laravel deployment. PHP-FPM opens a fresh database connection for every request. A moderately busy app with 50 concurrent requests is already holding 50 Postgres processes. Add a queue worker or three, a scheduler, and a second app server, and you're staring at &lt;code&gt;FATAL: too many connections&lt;/code&gt; in your logs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;Octane&lt;/a&gt; makes this worse, not better. Persistent workers hold their database connection for their entire lifetime. 32 Swoole workers means 32 permanent Postgres processes, most of them idle at any given moment. There's a years-old GitHub discussion of Octane users hitting exactly this wall and hand-rolling PgBouncer setups to survive it.&lt;/p&gt;

&lt;p&gt;The fix isn't more connections. It's a pooler.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Transaction Pooler Actually Does
&lt;/h2&gt;

&lt;p&gt;A pooler like PgBouncer is a lightweight middleman between your app and Postgres. Your app opens hundreds of cheap connections to the pooler. The pooler multiplexes them over a small set of real Postgres connections, maybe 20.&lt;/p&gt;

&lt;p&gt;In transaction mode (the mode that matters here), a real connection is borrowed only for the duration of a single transaction. The moment your transaction commits, that connection goes straight back into the pool for the next request. This is how Supabase's Supavisor serves thousands of clients, how Neon's pooler endpoints work, and how AWS RDS Proxy keeps Lambda functions from melting a small RDS instance.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-postgresql-connection-pooling-native-pgbouncer-support" 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;Transaction mode has one big catch: it breaks server-side prepared statements. Your next query might execute on a different real connection, one that never saw the &lt;code&gt;PREPARE&lt;/code&gt;. The same goes for session-level features like &lt;code&gt;SET&lt;/code&gt; commands and &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;. That catch is the entire reason Laravel needed framework support.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old Pain: What We All Had to Do Before 13.17
&lt;/h2&gt;

&lt;p&gt;Before this release, running Laravel through a transaction pooler meant assembling your own solution from three parts.&lt;/p&gt;

&lt;p&gt;First, you disabled server-side prepares by adding &lt;code&gt;PDO::ATTR_EMULATE_PREPARES =&amp;gt; true&lt;/code&gt; to your connection's &lt;code&gt;options&lt;/code&gt; array. That made queries pooler-safe.&lt;/p&gt;

&lt;p&gt;But it introduced a second bug: with emulated prepares, Laravel sends PHP booleans to Postgres as &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;0&lt;/code&gt; instead of &lt;code&gt;'true'&lt;/code&gt; and &lt;code&gt;'false'&lt;/code&gt;, and Postgres rejects them. Entire community packages exist purely to patch this. Their whole job is a custom &lt;code&gt;PostgresConnection&lt;/code&gt; class that reformats boolean bindings. If you have one of those in your &lt;code&gt;composer.json&lt;/code&gt;, you already know.&lt;/p&gt;

&lt;p&gt;Third, migrations and DDL can't run through a transaction pooler at all. So you kept a second connection config pointing at the real database host and remembered to pass &lt;code&gt;--database=pgsql_direct&lt;/code&gt; to every &lt;code&gt;migrate&lt;/code&gt; call. Every deployment script grew a little ritual around it. Forget once and your migration hangs or fails in a way that takes twenty minutes to diagnose.&lt;/p&gt;

&lt;p&gt;None of this was hard, exactly. It was just fragile, undocumented tribal knowledge that every team rediscovered the painful way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel 13.17 Ships
&lt;/h2&gt;

&lt;p&gt;PR #60425 (merged June 21, shipped in v13.17.0) moves all of that into the framework. Yes, the same release that gave us &lt;a href="https://hafiz.dev/blog/laravel-route-metadata-5-real-problems-it-finally-solves" rel="noopener noreferrer"&gt;route metadata&lt;/a&gt;. 13.17 was a good week. Interesting detail: Laravel Cloud has had internal pooled-connection handling since 2025, but it was private plumbing. This PR is the first time it's a public, documented API that any Laravel app can use.&lt;/p&gt;

&lt;p&gt;Here's the whole 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="s1"&gt;'pgsql'&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;'pgsql'&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;'port'&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_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5432'&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;'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_USERNAME'&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_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="s1"&gt;'pooled'&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_POOLED'&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="s1"&gt;'direct'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="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_DIRECT_HOST'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'port'&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_DIRECT_PORT'&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_DIRECT_USERNAME'&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_DIRECT_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'sslmode'&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_DIRECT_SSLMODE'&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 main connection points at your pooler. The &lt;code&gt;direct&lt;/code&gt; block points at the real database. With &lt;code&gt;pooled =&amp;gt; true&lt;/code&gt;, the framework does four things automatically:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emulated prepares turn on for the pooled connection.&lt;/strong&gt; Direct connections keep native prepares. You never touch &lt;code&gt;PDO::ATTR_EMULATE_PREPARES&lt;/code&gt; yourself again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boolean bindings get fixed.&lt;/strong&gt; Under emulated prepares, Postgres now receives &lt;code&gt;'true'&lt;/code&gt; and &lt;code&gt;'false'&lt;/code&gt; strings instead of integer literals. Delete the community patch package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema operations route around the pooler.&lt;/strong&gt; Migrations, &lt;code&gt;schema:dump&lt;/code&gt;, schema load, &lt;code&gt;db:wipe&lt;/code&gt;, &lt;code&gt;db:show&lt;/code&gt;, and &lt;code&gt;db:table&lt;/code&gt; all use the direct connection without you passing any flags. Your deployment script gets simpler, not more complicated. The full command list lives in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt; if you want to check what else touches the schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;php artisan db&lt;/code&gt; defaults to direct.&lt;/strong&gt; When pooled mode is on and a direct endpoint exists, the interactive database CLI connects directly, which is what you want for poking at the schema. Pass &lt;code&gt;--pooled&lt;/code&gt; if you specifically want to inspect behavior through the pooler.&lt;/p&gt;

&lt;p&gt;And when your own application code needs the real connection (say, a &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; listener or a long-running cursor), append the &lt;code&gt;::direct&lt;/code&gt; suffix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pgsql::direct'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LISTEN order_events'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backward compatibility is clean. Without &lt;code&gt;pooled =&amp;gt; true&lt;/code&gt;, nothing changes. Existing &lt;code&gt;::read&lt;/code&gt; and &lt;code&gt;::write&lt;/code&gt; suffixes keep their meaning. Adding a &lt;code&gt;direct&lt;/code&gt; block alone doesn't reroute anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up With Real Providers
&lt;/h2&gt;

&lt;p&gt;The config shape is identical everywhere. The only thing that changes is where the pooled and direct hosts come from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase
&lt;/h3&gt;

&lt;p&gt;Supabase gives you two connection paths. The direct connection runs on port 5432 at your project's database host. The transaction pooler (Supavisor) runs on port 6543 at a regional pooler host. Both are on your project's Connect page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_HOST=aws-0-eu-central-1.pooler.supabase.com
DB_PORT=6543
DB_POOLED=true
DB_DIRECT_HOST=db.yourproject.supabase.co
DB_DIRECT_PORT=5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your app traffic flows through Supavisor. Your migrations hit the database directly. No custom connection classes, no session-mode compromises.&lt;/p&gt;

&lt;h3&gt;
  
  
  Neon
&lt;/h3&gt;

&lt;p&gt;Neon exposes the pooler through a hostname suffix: take your endpoint host and add &lt;code&gt;-pooler&lt;/code&gt; to it. Same credentials for both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_HOST=ep-cool-name-123456-pooler.eu-central-1.aws.neon.tech
DB_POOLED=true
DB_DIRECT_HOST=ep-cool-name-123456.eu-central-1.aws.neon.tech
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on Neon specifically for the scale-to-zero pricing, this pairing matters more than it looks. Pooled connections are what let a tiny compute instance handle bursty traffic without connection churn waking it into a bigger bill.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-Hosted PgBouncer
&lt;/h3&gt;

&lt;p&gt;Run PgBouncer with &lt;code&gt;pool_mode = transaction&lt;/code&gt; in &lt;code&gt;pgbouncer.ini&lt;/code&gt;, point &lt;code&gt;DB_HOST&lt;/code&gt; at the PgBouncer port (usually 6432), and point the &lt;code&gt;direct&lt;/code&gt; block at Postgres itself on 5432. That's it. All the &lt;code&gt;ignore_startup_parameters&lt;/code&gt; and prepared-statement tuning advice you'll find in old tutorials was written for the pre-13.17 world. The framework handles the client side now.&lt;/p&gt;

&lt;p&gt;One honest note for Octane users: a pooler doesn't reduce how many connections your workers open. It makes those connections cheap. 32 workers still hold 32 client connections, but they multiplex over a fraction of the real Postgres processes, which is the resource that actually runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Verify It's Actually Working
&lt;/h2&gt;

&lt;p&gt;Don't trust the config, check the behavior. Three quick tests after enabling &lt;code&gt;pooled&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;First, confirm your app traffic goes through the pooler. Run a request, then check active server connections from a direct session:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_database'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under load, that number should stay small and stable (your pool size) while your app happily serves far more concurrent requests. If it climbs with concurrency, you're not actually connecting to the pooler endpoint.&lt;/p&gt;

&lt;p&gt;Second, confirm schema routing. Run &lt;code&gt;php artisan db:show&lt;/code&gt;. It should report the direct host, not the pooler host. Then run a throwaway migration on staging and watch it complete without hanging. That's the automatic routing doing its job.&lt;/p&gt;

&lt;p&gt;Third, if you had the boolean problem before, hit a code path that writes a boolean column. On 13.17 with &lt;code&gt;pooled =&amp;gt; true&lt;/code&gt; it just works. No exception about invalid input syntax for type boolean, no patch package in sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Turn This On?
&lt;/h2&gt;

&lt;p&gt;If you're on Supabase, Neon, RDS Proxy, or any managed Postgres with a pooler endpoint: yes, and this feature removes the last good excuse not to. The old objection was that pooler setups in Laravel were fragile hand-rolled things. That objection died in 13.17.&lt;/p&gt;

&lt;p&gt;If you're self-hosting Postgres for a small app with steady traffic, you don't need a pooler yet. A single server with PHP-FPM and modest concurrency lives comfortably inside default connection limits. Add PgBouncer when you see connection pressure, not before. &lt;a href="https://hafiz.dev/blog/database-indexing-in-laravel-boost-mysql-performance-with-smart-indexes" rel="noopener noreferrer"&gt;Database indexing&lt;/a&gt; will buy you more performance per hour invested until then.&lt;/p&gt;

&lt;p&gt;The setup where I'd call it non-negotiable: Octane on Postgres, or anything serverless. Persistent workers and scale-to-zero databases are both connection-hungry patterns, and transaction pooling is the standard answer in every ecosystem, ours included.&lt;/p&gt;

&lt;p&gt;One thing to check before enabling it on an existing app: emulated prepares change how query plans are cached server-side, and code that relied on session state between queries (temporary tables, &lt;code&gt;SET&lt;/code&gt; commands, advisory locks held across requests) will behave differently through a pooler. That's pooler physics, not a Laravel limitation. Audit for those patterns first. Multi-tenant apps that switch databases per tenant should test carefully too, since &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;tenant connection strategies&lt;/a&gt; often assume dedicated connections.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Which Laravel version do I need for native pooler support?
&lt;/h3&gt;

&lt;p&gt;Laravel 13.17 or newer. The feature landed via PR #60425 and shipped in v13.17.0 in late June 2026. If you're still on Laravel 12, the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;upgrade to 13&lt;/a&gt; is worth it for this alone if you run Postgres in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with MySQL or MariaDB?
&lt;/h3&gt;

&lt;p&gt;No. The &lt;code&gt;pooled&lt;/code&gt; option is specific to the PostgreSQL driver, because the problem it solves (process-per-connection cost plus prepared statements breaking under transaction pooling) is specific to Postgres. MySQL's threaded connection model doesn't need this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need packages that patch PgBouncer boolean bindings?
&lt;/h3&gt;

&lt;p&gt;Not on 13.17+. Correct boolean binding under emulated prepares is now framework behavior. Remove the package, remove any custom &lt;code&gt;PostgresConnection&lt;/code&gt; class you copied from a gist, and remove &lt;code&gt;PDO::ATTR_EMULATE_PREPARES&lt;/code&gt; from your connection options. The framework sets it for pooled connections automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to migrations if I don't configure a direct block?
&lt;/h3&gt;

&lt;p&gt;Then there's nothing to route to, and schema operations go through your main connection like before. If that connection is a transaction pooler, DDL can fail or hang, exactly as it always did. The direct block is what makes the automatic routing possible, so treat it as required whenever &lt;code&gt;pooled&lt;/code&gt; is true.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does pooling change anything for read/write connection splits?
&lt;/h3&gt;

&lt;p&gt;No. Explicit &lt;code&gt;::read&lt;/code&gt; and &lt;code&gt;::write&lt;/code&gt; suffixes and the &lt;code&gt;--read&lt;/code&gt;/&lt;code&gt;--write&lt;/code&gt; CLI flags keep working exactly as before and aren't rerouted to the direct connection. The &lt;code&gt;::direct&lt;/code&gt; suffix is a separate, additional routing option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This is one of those features where the code change in your app is five lines of config and the actual win is everything you get to delete: the options array hack, the patch package, the second migration config, the deploy-script flags, and the tribal knowledge explaining why they all exist. Laravel apps on Supabase and Neon just got noticeably simpler to run, and Octane on Postgres finally has a first-party answer to connection exhaustion.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Filament Billing: The Missing Stripe Admin Layer for Your Panel</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 06:02:55 +0000</pubDate>
      <link>https://dev.to/hafiz619/filament-billing-the-missing-stripe-admin-layer-for-your-panel-4lfp</link>
      <guid>https://dev.to/hafiz619/filament-billing-the-missing-stripe-admin-layer-for-your-panel-4lfp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/filament-billing-the-missing-stripe-admin-layer-for-your-panel" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You wired up Laravel Cashier, connected Stripe, and your app takes payments. Subscriptions get created, webhooks fire, the &lt;code&gt;subscriptions&lt;/code&gt; table fills up. Everything works.&lt;/p&gt;

&lt;p&gt;Then a customer emails asking to cancel. Or you want to know what your MRR actually is this month. Or someone's payment failed and you need to see who's past due.&lt;/p&gt;

&lt;p&gt;Where do you go? Not your admin panel. Cashier doesn't put anything there. You open the Stripe dashboard, or you write a tinker one-liner, or you build a one-off resource every single time. The billing runs fine, but managing it lives outside the tool you built everything else in.&lt;/p&gt;

&lt;p&gt;That gap is the whole reason I built Filament Billing. It's a commercial plugin that adds the operator side of billing to your Filament panel: plans, subscriptions, and an MRR widget, all reading from the Cashier tables you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cashier gives you, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Cashier is really good at what it does. It handles the Stripe API calls, the subscription lifecycle, the webhook parsing, the proration math. If you've set up billing with it before (and if you haven't, my &lt;a href="https://hafiz.dev/blog/stripe-integration-in-laravel-complete-guide-to-subscriptions-one-time-payments" rel="noopener noreferrer"&gt;Stripe integration guide&lt;/a&gt; walks through the whole thing), you know it takes care of the hard parts.&lt;/p&gt;

&lt;p&gt;But Cashier is a library, not an interface. It writes &lt;code&gt;stripe_status&lt;/code&gt; to your database when a webhook arrives. It does not give you a screen to see that status. It cancels a subscription when you call &lt;code&gt;-&amp;gt;cancel()&lt;/code&gt; in code. It does not give your support person a button to do it.&lt;/p&gt;

&lt;p&gt;So most teams end up in one of three places. They pop open the Stripe dashboard for everything, which means context-switching out of their own app and giving more people Stripe access than they'd like. They write throwaway tinker commands, which works until you need it at 11pm and can't remember the syntax. Or they build custom Filament resources by hand, which is fine the first time and annoying the fifth.&lt;/p&gt;

&lt;p&gt;I've done all three. The third one is what this plugin is, except built once, properly, instead of half-built on every new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Filament Billing actually adds
&lt;/h2&gt;

&lt;p&gt;Three things, all operator-facing.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Plans resource&lt;/strong&gt; where you define your billing plans inside the panel. Name, price, currency, the policy behind it. This is your reference layer for what you sell.&lt;/p&gt;

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

&lt;p&gt;A &lt;strong&gt;Subscriptions resource&lt;/strong&gt; that reads your Cashier subscription tables and shows them as a proper Filament list. Each row has the actions you'd expect: cancel at period end, cancel now, resume, swap. No tinker, no Stripe dashboard. Your support person can handle a cancellation without touching code or getting a Stripe login.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizp5d5e9pfblwwac70yl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizp5d5e9pfblwwac70yl.webp" alt="Filament Billing subscriptions list showing customer plans, statuses, and cancel, resume, and swap row actions" width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;MRR widget&lt;/strong&gt; for your dashboard. It shows monthly recurring revenue, active subscriber count, and how many people are in trial. It derives the number from the plans it sums, so the currency matches your data instead of a hardcoded symbol. If you've read my &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;admin dashboards guide&lt;/a&gt;, this is the metrics-at-a-glance idea applied to billing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6pjyhm5zzzs19yqrfjut.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6pjyhm5zzzs19yqrfjut.webp" alt="Filament Billing MRR dashboard widget showing monthly recurring revenue, active subscribers, and in-trial counts" width="799" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's also Stripe webhook status sync that keeps subscription statuses current as Stripe events arrive. That part builds on Cashier's own webhook handling, so it works as long as you've got Stripe webhooks wired to your app the normal way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this is for, and who it isn't
&lt;/h2&gt;

&lt;p&gt;I'd rather you know before you buy than ask for a refund after, so here's the honest scope.&lt;/p&gt;

&lt;p&gt;This is an &lt;strong&gt;operator-side&lt;/strong&gt; tool. It's for you and your team to manage billing from the admin panel. It is not a customer-facing checkout. Your customers still create subscriptions the way they already do through Cashier. Filament Billing gives you the layer to manage what those flows produce, not a replacement for them.&lt;/p&gt;

&lt;p&gt;It's &lt;strong&gt;Stripe only&lt;/strong&gt;, through Cashier. If you're on Paddle, this isn't for you yet.&lt;/p&gt;

&lt;p&gt;You &lt;strong&gt;define your plans in the panel&lt;/strong&gt;. It doesn't auto-sync from Stripe on install. You enter what you sell once, and manage from there. (Pulling plans straight from the Stripe API is the feature I'm most likely to add next, but I'd rather ship the honest version now than promise sync that isn't there.)&lt;/p&gt;

&lt;p&gt;And it needs &lt;strong&gt;Filament v5 and Laravel 11 or 12&lt;/strong&gt;, with Cashier for Stripe. Filament v5 shipped mainly to support Livewire v4, with no functional changes from v4, so if you're on a recent v4 app the upgrade is quick. If you want to know exactly what changed, I covered that in my &lt;a href="https://hafiz.dev/blog/filament-v5-released-whats-new-what-changed-and-should-you-upgrade" rel="noopener noreferrer"&gt;Filament v5 breakdown&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building a Laravel SaaS on Filament and you've already got Cashier taking payments, you're the exact person I built this for. If you're still deciding between billing providers, my &lt;a href="https://hafiz.dev/blog/laravel-cashier-stripe-vs-paddle-real-cost-comparison" rel="noopener noreferrer"&gt;Cashier Stripe vs Paddle cost comparison&lt;/a&gt; is worth a read first, because this plugin commits you to the Stripe side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I shipped it narrow
&lt;/h2&gt;

&lt;p&gt;I've made the mistake before of building a big, clever thing nobody used. So this one is deliberately small. Operator-side only. Stripe only. No customer checkout, no dunning flows, no churn analytics, no Paddle. Just the three pieces that solve the actual daily pain: seeing subscriptions, acting on them, and knowing your MRR.&lt;/p&gt;

&lt;p&gt;That narrowness is a feature. It means the plugin does what it says, installs cleanly, and doesn't drag half-finished functionality into your app. If it turns out people want plan-sync or Paddle or a customer portal, those come later, driven by what buyers actually ask for, not by what I imagined in advance. This is the same ship-narrow-to-validate approach I wrote about in my &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;Building a SaaS with Laravel and Filament guide&lt;/a&gt;, where I built this billing layer by hand the first time. I've since packaged that admin side into this plugin so you don't have to rebuild it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing and how to get it
&lt;/h2&gt;

&lt;p&gt;Two one-time tiers: €69 for a single project, €149 for unlimited projects. No subscription, no recurring fee for the plugin itself. You buy it, you get the private Composer repository access, you install it with your license key. You can &lt;a href="https://checkout.anystack.sh/filament-billing" rel="noopener noreferrer"&gt;grab it on Anystack here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Installation is the standard Anystack flow: configure the repository, add your license credentials, &lt;code&gt;composer require hafizdev/filament-billing&lt;/code&gt;, then run the install command. It reads your existing Cashier setup and adds the resources and widget to your panel.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this replace Laravel Cashier?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. It sits on top of Cashier and reads its tables. You still need Cashier installed and configured for Stripe. Think of this as the admin interface Cashier never shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can my customers use it to subscribe or manage their own plans?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not in this version. It's operator-side only. Your customers create subscriptions through your existing Cashier flows; this is for you and your team to manage them from the panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it work with Paddle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, Stripe only for now. Paddle support depends on demand, so if that's a blocker for you, let me know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to be on Filament v5?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, v5 with Laravel 11 or 12. Since v5 exists mainly for Livewire v4 support with no functional changes from v4, upgrading a recent v4 app is quick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will it pull my plans from Stripe automatically?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not yet. You define your plans in the panel once. Automatic sync from the Stripe API is the most likely next addition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it or ask me
&lt;/h2&gt;

&lt;p&gt;If you're running Cashier on a Filament panel and you're tired of opening Stripe to cancel a subscription, this is the layer that fixes it. It's &lt;a href="https://checkout.anystack.sh/filament-billing" rel="noopener noreferrer"&gt;live now on Anystack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Building something with Filament and Stripe and want to talk through whether this fits, or what's missing for your case? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;. I read every message, and buyer questions are what shape where this goes next.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>stripe</category>
      <category>laravelcashier</category>
    </item>
  </channel>
</rss>
