<?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>Laravel Octane in 2026: FrankenPHP vs Swoole vs RoadRunner</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 08 Jul 2026 12:07:04 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-octane-in-2026-frankenphp-vs-swoole-vs-roadrunner-3cme</link>
      <guid>https://dev.to/hafiz619/laravel-octane-in-2026-frankenphp-vs-swoole-vs-roadrunner-3cme</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;In April 2026, a carefully controlled benchmark put all three Laravel Octane servers through a real Laravel workload. RoadRunner more than doubled PHP-FPM's throughput. Swoole, the server with a decade-long reputation as PHP's performance monster, finished dead last. Below plain FPM.&lt;/p&gt;

&lt;p&gt;A month later, a hosting company that has migrated dozens of Laravel apps to Octane published their production numbers. Their verdict on Swoole: it's their fastest option for API-heavy workloads, and they deploy it deliberately for exactly those customers.&lt;/p&gt;

&lt;p&gt;Both sources are competent. Both published their methodology. Both are right.&lt;/p&gt;

&lt;p&gt;That contradiction is the most useful thing you can learn about choosing an Octane server, because it tells you the question "which server is fastest?" has no universal answer. The useful question is which server matches your workload, your deployment model, and your team's appetite for operational complexity. This post covers what Octane actually changes, how the three servers differ architecturally, why the benchmarks disagree, and a decision framework you can apply to your own app. I'll also tell you when the honest answer is to not use Octane at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Octane Actually Changes
&lt;/h2&gt;

&lt;p&gt;A standard Laravel request on PHP-FPM boots the entire framework from zero. Service providers register, config loads, the container wires up, and then your code finally runs. For a typical app that boot costs 10 to 30ms per request, more if you're carrying Filament, Nova, or a heavy stack of packages. Then the request ends and everything is thrown away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/13.x/octane" rel="noopener noreferrer"&gt;Octane&lt;/a&gt; inverts that. The application boots once per worker, stays resident in memory, and each incoming request reuses the already-built framework. The boot tax disappears from every request except the first.&lt;/p&gt;

&lt;p&gt;That's the whole trick. Octane doesn't make your database queries faster, doesn't cache your responses, and doesn't parallelize your code (with one Swoole-shaped exception we'll get to). If your endpoint spends 200ms in MySQL, Octane saves you the 20ms boot and leaves the 200ms untouched. This is why I tell people to read their profiler before reading Octane benchmarks: if framework bootstrap isn't a meaningful slice of your response time, start with &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;query optimization&lt;/a&gt; instead. It's usually the bigger win and it carries zero migration risk.&lt;/p&gt;

&lt;p&gt;Installation is the same regardless of server:&lt;br&gt;
&lt;/p&gt;

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

php artisan octane:install &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;frankenphp
&lt;span class="c"&gt;# or --server=swoole, --server=roadrunner&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Octane supports four servers: FrankenPHP, Swoole, Open Swoole, and RoadRunner. Open Swoole is a community fork with the same Octane feature set as Swoole, so practically the choice is between three architectures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Servers, Three Architectures
&lt;/h2&gt;

&lt;p&gt;The performance and failure characteristics of each server come directly from how a request physically reaches your PHP code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FrankenPHP&lt;/strong&gt; is a Go binary built on the Caddy web server, with the official PHP interpreter embedded directly into the process via cgo. There's no proxy hop and no inter-process communication: Caddy accepts the request and hands it to a PHP thread inside the same process, where your booted Laravel app is waiting. Because Caddy is the web server, you get HTTP/2, HTTP/3, and automatic HTTPS without putting Nginx in front. Octane downloads the FrankenPHP binary for you when you pick it during install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RoadRunner&lt;/strong&gt; is also written in Go, but it takes the opposite approach to integration. It runs as a standalone server binary that manages a pool of ordinary PHP processes, forwarding requests to them over a fast RPC protocol. Your PHP workers are regular processes with real isolation: if one worker corrupts its state or dies, RoadRunner respawns it and the others never notice. It's been doing this job since 2018 and has the most mature plugin ecosystem of the three (queues, gRPC, key-value storage).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swoole&lt;/strong&gt; is different in kind, and the difference goes deeper than plumbing. It's a C extension installed via PECL that replaces PHP's execution model with an event loop and coroutines, closer to Node.js than to classic PHP. When your code hits blocking I/O, Swoole can suspend that coroutine and run another request on the same worker. That's a capability the other two simply don't have, and it's also why Swoole demands the most from your code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" 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 architectural differences cascade into everything else, so let's take each server on its own terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  FrankenPHP: The Deployment Story
&lt;/h2&gt;

&lt;p&gt;FrankenPHP is the youngest of the three, released in late 2023 and hardened through 2024 and 2025. What it changed isn't raw speed. It's how little infrastructure you need.&lt;/p&gt;

&lt;p&gt;One binary is your web server, TLS manager, static file server, and PHP runtime. A production Dockerfile is this small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; dunglas/frankenphp&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . /app&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["php", "artisan", "octane:frankenphp"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One container, one port, no Nginx, no supervisor config. If you deploy to Cloud Run, Kubernetes, or any autoscaling container platform, that shape maps perfectly. And on classic VPS setups, HTTPS with automatic certificate renewal comes from Caddy for free. When you need to go beyond the defaults, &lt;code&gt;php artisan octane:start --server=frankenphp --caddyfile=/path/to/Caddyfile&lt;/code&gt; gives you full Caddy configuration.&lt;/p&gt;

&lt;p&gt;The honest trade-offs: it's the youngest option, and youth shows. One hosting provider that runs it in production reports hitting two memory-leak regressions over 18 months, both fixed promptly upstream, but that's a different stability track record than RoadRunner's. Configuration is Caddyfile syntax rather than the Nginx configs most Laravel developers have committed to muscle memory. And in benchmarks driven by HTTP/1.0 tools, FrankenPHP looks unremarkable, for reasons we'll get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Swoole: The Ceiling and the Cliff
&lt;/h2&gt;

&lt;p&gt;Swoole has the highest performance ceiling of the three, and the steepest cliff next to it.&lt;/p&gt;

&lt;p&gt;The ceiling comes from coroutines. While FrankenPHP and RoadRunner workers handle one request at a time, Swoole's runtime can juggle many, suspending whichever one is waiting on the database. For endpoints that fan out to multiple upstream services, Octane exposes this directly:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$flags&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Octane&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;concurrently&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard:stats'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three operations, dispatched to Swoole's task workers and executed concurrently while your request waits for the results. You size that pool with --task-workers on octane:start. &lt;code&gt;Octane::concurrently()&lt;/code&gt;, ticks, intervals, and the Swoole-backed Octane cache and tables are exclusive to Swoole and Open Swoole. Neither FrankenPHP nor RoadRunner can offer them because their workers don't have a coroutine scheduler.&lt;/p&gt;

&lt;p&gt;The cliff: Swoole changes PHP's execution semantics, and code written for classic synchronous PHP doesn't always survive the change. Extensions can conflict, some packages misbehave inside coroutines, and Xdebug famously doesn't cooperate, so production profiling means Blackfire or Tideways. It's also an extension you compile via PECL rather than a binary Octane downloads for you, which makes your Docker images and CI more bespoke. The production shops that get the most from Swoole recycle workers aggressively (max_requests around 250 versus 500 on RoadRunner) because memory grows faster.&lt;/p&gt;

&lt;p&gt;My blunt read: Swoole is a specialist's tool. If you're not going to use &lt;code&gt;concurrently()&lt;/code&gt; or your workloads don't have real I/O concurrency, you're paying Swoole's complexity tax for a capability you never invoke.&lt;/p&gt;

&lt;h2&gt;
  
  
  RoadRunner: The Boring Choice, Compliment Intended
&lt;/h2&gt;

&lt;p&gt;RoadRunner wins on exactly one axis, and it happens to be the axis that matters most for a lot of teams: predictability.&lt;/p&gt;

&lt;p&gt;Its worker isolation model means a leak or crash in one PHP process can't poison its siblings. The Go server respawns dead workers and traffic continues. It has seven-plus years of production history, and its configuration lives in a plain YAML file that does what it says. In the April 2026 benchmark that embarrassed Swoole, RoadRunner delivered 111.6% more throughput than PHP-FPM on the same hardware with a 41% lower p99. Not because it's exotic, but because boot-once plus process isolation is a simple model that performs consistently across workload shapes.&lt;/p&gt;

&lt;p&gt;Setup needs two extra packages alongside Octane, and the &lt;code&gt;rr&lt;/code&gt; binary is offered for download on first start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/octane spiral/roadrunner-cli spiral/roadrunner-http

php artisan octane:start &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;roadrunner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you give up: no HTTP/3 or automatic HTTPS (you'll keep Nginx or Caddy in front), no coroutines, and a plugin ecosystem (gRPC, queues) that most Laravel apps never touch because Laravel's own &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue system&lt;/a&gt; already covers that ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Benchmarks Disagree
&lt;/h2&gt;

&lt;p&gt;Back to the contradiction from the intro, because resolving it is what makes you dangerous in this decision.&lt;/p&gt;

&lt;p&gt;The April 2026 benchmark that placed Swoole below PHP-FPM used &lt;code&gt;ab&lt;/code&gt;, a load tool that speaks HTTP/1.0 with short-lived connections, against a light-I/O Laravel route. Two things follow. First, Swoole's entire advantage is concurrent I/O scheduling; on a workload with barely any I/O wait, its coroutine scheduler is pure overhead, so it loses. Second, FrankenPHP's HTTP/2 multiplexing never gets exercised over HTTP/1.0, so it benchmarks like plain FPM while its real-world strength stays invisible.&lt;/p&gt;

&lt;p&gt;Meanwhile, the production shop reporting Swoole as their fastest option runs it on API workloads full of upstream calls and database fan-out, with &lt;code&gt;concurrently()&lt;/code&gt; in active use. That's precisely the workload where coroutines pay. Same server, opposite conclusion, and both measurements are honest.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: every Octane benchmark measures its own workload and tooling as much as it measures the server. A hosting provider's before-and-after on a real authenticated endpoint (4 to 5x throughput moving FPM to FrankenPHP with Octane) is more predictive for a typical app than any synthetic table. And your own app benchmarked for an afternoon beats both. So treat published numbers as bounds, not rankings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Decision Framework
&lt;/h2&gt;

&lt;p&gt;Here's how I'd actually choose in 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick FrankenPHP if you deploy in containers or want the simplest possible stack.&lt;/strong&gt; Cloud Run, Kubernetes, Docker-based platforms, or a VPS where you'd rather not maintain Nginx. It's also the right default if you're new to Octane: the binary auto-installs, and the single-process model has the fewest moving parts to reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick RoadRunner if you're migrating a large existing app and stability is the priority.&lt;/strong&gt; The process isolation gives you the softest failure modes while you shake out the state bugs every migration surfaces. It's the conservative choice, and for a revenue-carrying monolith, conservative is correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Swoole only if you'll use what makes it Swoole.&lt;/strong&gt; Many concurrent upstream calls per request, real fan-out, a team comfortable auditing packages for coroutine safety. Then the ceiling is real and nothing else reaches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick none of them if your profiler says boot time isn't your problem.&lt;/strong&gt; A 300ms endpoint that spends 260ms in queries gets nothing meaningful from Octane. The &lt;a href="https://hafiz.dev/blog/transform-your-laravel-app-performance-7-quick-wins-that-actually-work" rel="noopener noreferrer"&gt;cheap performance wins&lt;/a&gt; come first; Octane is what you reach for after them, when traffic and infrastructure cost make the boot tax worth eliminating. My own apps still run plain FPM on small droplets, and that's deliberate: at their traffic, Octane would add operational surface without moving anything a user can feel. The &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;infrastructure cost math&lt;/a&gt; only tips toward Octane when you're scaling servers to handle load that's mostly boot overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotchas That Apply to All Three
&lt;/h2&gt;

&lt;p&gt;Whichever server you pick, the migration risk is identical, because it comes from Octane's model rather than the server underneath.&lt;/p&gt;

&lt;p&gt;Your app now lives in memory across requests. Anything static, any singleton, any memoized value persists into the next request. The classic bug is a package caching the authenticated user in a singleton, so request N+1 sees request N's user. Audit your singletons, use Octane's flush hooks for anything request-scoped, and test with two different logged-in users hitting the same worker.&lt;/p&gt;

&lt;p&gt;Memory grows. Every long-running PHP process leaks a little, so set &lt;code&gt;--max-requests&lt;/code&gt; and let workers recycle themselves. Deploys change too: &lt;code&gt;php artisan octane:reload&lt;/code&gt; swaps workers with zero downtime, and it belongs in your deploy script (the full 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;). And remember each worker handles one request at a time (Swoole's coroutines aside), so a slow request blocks a worker slot. Anything slow belongs on a queue, same as always.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is Laravel Octane faster than PHP-FPM for every application?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Octane eliminates the framework boot cost, roughly 10 to 30ms per request on a typical app. If your response time is dominated by database queries or external APIs, Octane changes almost nothing. Profile first: it pays off when boot time is a meaningful fraction of your responses and traffic is high enough that the saved milliseconds compound into fewer servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which Octane server is easiest to set up?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;FrankenPHP. Octane downloads the binary for you during &lt;code&gt;octane:install&lt;/code&gt;, and because it bundles Caddy you don't need a separate web server for HTTPS, HTTP/2, or HTTP/3. RoadRunner is close behind (two extra Composer packages, auto-downloaded binary). Swoole requires compiling a PECL extension, which makes it the most involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to rewrite my code for Octane?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rewrite, no. Audit, yes. Static properties, singletons, and anything memoized will persist between requests, which is the source of nearly all Octane bugs. Most well-structured apps need a handful of fixes, not an architectural change. Third-party packages are the bigger unknown, so test the critical paths under a single worker with &lt;code&gt;--max-requests=1&lt;/code&gt; to surface state leaks quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Open Swoole?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fully supported by Octane and functionally equivalent for Octane's purposes: you get the same concurrent tasks, ticks, and intervals as Swoole. It's a community fork, so the choice between them usually comes down to which extension your platform and PHP version support more cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Octane work with Laravel 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Octane is a first-party package and the Laravel 13 documentation covers all four servers. Install with &lt;code&gt;composer require laravel/octane&lt;/code&gt; and pick your server during &lt;code&gt;octane:install&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The 2026 Octane question isn't "which server wins the benchmark". It's a matching problem. FrankenPHP matches container-native deployments and small teams that want one binary doing everything. RoadRunner matches big migrations where boring is a feature. Swoole matches I/O-heavy APIs run by teams who'll actually use its concurrency. And plain PHP-FPM still matches every app whose profiler says boot time isn't the bottleneck, which is more apps than the benchmark posts admit.&lt;/p&gt;

&lt;p&gt;Pick by workload, verify with an afternoon of load testing on your own endpoints, and be suspicious of any comparison (including this one) that hands you a single winner.&lt;/p&gt;

&lt;p&gt;Planning an Octane migration for a production Laravel app and want a second pair of eyes on the state-leak audit and server choice? &lt;a href="mailto:contact@hafiz.dev"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>performance</category>
      <category>octane</category>
    </item>
    <item>
      <title>Laravel Route Metadata: 5 Real Problems It Finally Solves</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 06 Jul 2026 07:08:27 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-route-metadata-5-real-problems-it-finally-solves-7gn</link>
      <guid>https://dev.to/hafiz619/laravel-route-metadata-5-real-problems-it-finally-solves-7gn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-route-metadata-5-real-problems-it-finally-solves" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;When route metadata got merged into Laravel 13.17, the first comment on the &lt;a href="https://github.com/laravel/framework/pull/60530" rel="noopener noreferrer"&gt;pull request&lt;/a&gt; was a long-time framework contributor asking what it's actually for. His follow-up was blunter: "IMO none of this behavior belongs on the Route, but oh well."&lt;/p&gt;

&lt;p&gt;I get the skepticism. On paper, "attach arbitrary data to routes" sounds like a solution looking for a problem. But here's the thing: Laravel developers have been attaching arbitrary data to routes for years. We just did it badly. Config files keyed by route name. Abused &lt;code&gt;-&amp;gt;defaults()&lt;/code&gt; calls that leak into controller parameters. Naming conventions like &lt;code&gt;admin.reports.legacy.v1&lt;/code&gt; that encode three different facts into a string and pray nobody renames the route. A GitHub discussion from 2022 asked for exactly this feature and got zero answers.&lt;/p&gt;

&lt;p&gt;So the question isn't whether attaching data to routes is a good idea. You're already doing it. The question is whether you keep doing it through hacks or use the supported, cache-safe API that ships in every Laravel app from 13.17 onwards.&lt;/p&gt;

&lt;p&gt;This post covers what shipped, the merge rules that make groups powerful, and five concrete problems where route metadata replaces something uglier. Working code for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel 13.17 Actually Shipped
&lt;/h2&gt;

&lt;p&gt;Three methods. That's the whole API.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;metadata()&lt;/code&gt; attaches an array of data to a route or a group:&lt;br&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\Http\Controllers\UserController&lt;/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\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Users'&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;getMetadata()&lt;/code&gt; reads it back from the resolved route, with dot notation and an optional default:&lt;br&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'head.title'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// 'Users'&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'head.author'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Hafiz'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'Hafiz' (fallback)&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                       &lt;span class="c1"&gt;// the full array&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;setMetadata()&lt;/code&gt; replaces everything instead of merging, for the rare case where you want a route to opt out of whatever its group defined.&lt;/p&gt;

&lt;p&gt;The part that makes this more than a glorified array property: metadata is stored on the route action under a dedicated &lt;code&gt;metadata&lt;/code&gt; key. That means it flows through the existing route group logic and gets serialized by &lt;code&gt;route:cache&lt;/code&gt;. Your metadata survives production route caching with zero extra work. Anything you'd find with &lt;code&gt;php artisan route:list&lt;/code&gt; still resolves the same way (the full command reference is in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands guide&lt;/a&gt; if you need it).&lt;/p&gt;

&lt;p&gt;Resources and singletons work too:&lt;br&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;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserController&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;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every route the resource generates carries the metadata. Before 13.17 there was no clean way to do this, because resource routes are registered in a pending state and you couldn't hook data onto them mid-registration.&lt;/p&gt;

&lt;h3&gt;
  
  
  The merge rules worth memorizing
&lt;/h3&gt;

&lt;p&gt;Group metadata cascades down to every route inside the group. The merge follows two rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Associative arrays merge recursively. Nested groups layer values on top of each other.&lt;/li&gt;
&lt;li&gt;Lists and scalar values replace what they inherit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's both rules in one example:&lt;br&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;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&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;'robots'&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;'noindex'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Hafiz'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// $request-&amp;gt;route()-&amp;gt;getMetadata('head') returns:&lt;/span&gt;
&lt;span class="c1"&gt;// [&lt;/span&gt;
&lt;span class="c1"&gt;//     'robots' =&amp;gt; ['noindex'],  // inherited from the group&lt;/span&gt;
&lt;span class="c1"&gt;//     'author' =&amp;gt; 'Hafiz',      // inherited from the group&lt;/span&gt;
&lt;span class="c1"&gt;//     'title'  =&amp;gt; 'Users',      // added by the route&lt;/span&gt;
&lt;span class="c1"&gt;// ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route added &lt;code&gt;title&lt;/code&gt; without losing &lt;code&gt;robots&lt;/code&gt; or &lt;code&gt;author&lt;/code&gt;. But if the route had defined its own &lt;code&gt;robots&lt;/code&gt; list, it would have replaced the group's list entirely, not merged with it. Lists replace. Associative keys merge. Once that clicks, the whole feature is predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Hacked This Before
&lt;/h2&gt;

&lt;p&gt;Every workaround for this problem had a real cost. Quick tour, because recognizing your own code in this list is the fastest way to see why the feature matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom keys in the action array.&lt;/strong&gt; You could always smuggle extra keys into the route action and read them back with &lt;code&gt;getAction()&lt;/code&gt;. It worked for single routes. It fell apart for groups, resources, and singletons, because there was no supported way to carry the data through the route-building pipeline. That's the exact gap the PR closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Abusing &lt;code&gt;-&amp;gt;defaults()&lt;/code&gt;.&lt;/strong&gt; Route defaults exist to provide default values for route parameters. Stuffing page titles or permission strings in there technically works, until a controller method with a matching parameter name silently receives your metadata as an argument. I've debugged that one. Not fun.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config maps keyed by route name.&lt;/strong&gt; A &lt;code&gt;config/seo.php&lt;/code&gt; file with a hundred route names mapped to titles and descriptions. Now every route rename is a two-file change, nothing enforces the mapping stays in sync, and the data lives nowhere near the route it describes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encoding facts into route names.&lt;/strong&gt; Names like &lt;code&gt;api.v1.legacy.reports.index&lt;/code&gt; where "v1" and "legacy" are load-bearing. String parsing as architecture.&lt;/p&gt;

&lt;p&gt;Route metadata replaces all four with data that lives on the route definition itself, cascades through groups, and survives caching. Now the five problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Per-Route SEO Tags Without a Config Map
&lt;/h2&gt;

&lt;p&gt;The classic case, and the one the PR uses in its own examples. Marketing pages need distinct titles, meta descriptions, and robots directives. Most Laravel apps solve this with view-level variables passed from every controller method, which means every controller method has to remember to pass them.&lt;/p&gt;

&lt;p&gt;With metadata, the route definition carries the SEO data and one middleware shares it with every view:&lt;br&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;// routes/web.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'seo'&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;'robots'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'index,follow'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/pricing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pages.pricing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'seo'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Pricing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Plans from 9 euro a month.'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;

        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/about'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pages.about'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'seo'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'About us'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/ShareSeoMetadata.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;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;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&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="nc"&gt;View&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'seo'&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'seo'&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;$next&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your layout reads &lt;code&gt;$seo['title']&lt;/code&gt; with a sensible fallback and you're done. Controllers stop caring about SEO entirely. The &lt;code&gt;/about&lt;/code&gt; page didn't define a description, so &lt;code&gt;getMetadata()&lt;/code&gt; just won't return one, and your Blade fallback handles it.&lt;/p&gt;

&lt;p&gt;The win over a config map: renaming a route can't break anything, because nothing is keyed by route name anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Feature Flag Gating for Whole Route Groups
&lt;/h2&gt;

&lt;p&gt;You're rolling out a new billing section behind a flag. The old approach is a middleware that takes the flag name as a parameter, &lt;code&gt;-&amp;gt;middleware('feature:new-billing')&lt;/code&gt;, repeated on every route or group. Workable, but middleware parameters are strings, they don't cascade with any structure, and stacking more than one gets noisy.&lt;/p&gt;

&lt;p&gt;Metadata handles the cascade for you:&lt;br&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;// routes/web.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'new-billing'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing-v2'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;BillingController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/invoices'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;BillingController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'invoices'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment-methods'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethodController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/EnforceFeatureFlags.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Pennant\Feature&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;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&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="nv"&gt;$flag&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&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;$next&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the middleware globally on the web group. Routes without a &lt;code&gt;feature&lt;/code&gt; key pass straight through. The resource inside the group is covered automatically, which is exactly the pending-registration case the old action-array hack couldn't handle.&lt;/p&gt;

&lt;p&gt;And when the rollout finishes, you delete one metadata line instead of hunting middleware strings across three files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Declarative Permission Tags in Middleware
&lt;/h2&gt;

&lt;p&gt;To be clear about scope first: this doesn't replace &lt;a href="https://hafiz.dev/blog/laravel-policies-vs-gates-authorization-guide" rel="noopener noreferrer"&gt;policies and gates&lt;/a&gt;. Model-level authorization belongs in policies. But plenty of apps also have coarse route-level access rules ("everything under /admin/reports needs the reports.view permission") that end up scattered across &lt;code&gt;can:&lt;/code&gt; middleware strings.&lt;/p&gt;

&lt;p&gt;Metadata gives you a single declarative 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="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&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;'admin.access'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;DashboardController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&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;'admin.access'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reports.view'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reports'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReportController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/export'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReportController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'export'&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;Note the nested group repeats &lt;code&gt;admin.access&lt;/code&gt; in its list. That's deliberate: &lt;code&gt;permissions&lt;/code&gt; is a list, and lists replace inherited values rather than merging. If you want additive permissions, either repeat the inherited ones like I did here, or structure them as an associative array (&lt;code&gt;['admin.access' =&amp;gt; true, 'reports.view' =&amp;gt; true]&lt;/code&gt;) so the recursive merge works in your favor. This is the sharpest edge in the whole feature, so it's worth internalizing early.&lt;/p&gt;

&lt;p&gt;The enforcement middleware is five lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&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;foreach&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$permission&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$permission&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One place to audit route-level access. &lt;code&gt;php artisan route:list&lt;/code&gt; shows you the routes, the metadata shows you the rules, and nothing is hidden in controller constructors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Breadcrumbs That Build Themselves
&lt;/h2&gt;

&lt;p&gt;This is the use case Ben Bjurstrom, the PR's author, gave when asked what the feature is for: package authors need a place to attach data to routes while group and resource registration is still pending. Breadcrumbs are the perfect example because they're hierarchical, exactly like nested route groups.&lt;br&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;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'breadcrumb'&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;'Admin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'breadcrumb'&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;'Admin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin/users'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/{user}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'show'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'breadcrumb'&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;'Admin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin/users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Detail'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Blade component reads &lt;code&gt;$request-&amp;gt;route()-&amp;gt;getMetadata('breadcrumb', [])&lt;/code&gt; and renders the trail. No third-party breadcrumb package, no separate breadcrumb definition file that drifts out of sync with your routes, no reflection tricks.&lt;/p&gt;

&lt;p&gt;Because breadcrumb maps are associative arrays, nested groups merge cleanly and you could even skip repeating the parent crumbs. I repeat them anyway for explicitness, since key order in merged arrays is something I'd rather not depend on for UI. Expect packages to build nicer APIs on top of this primitive soon; error trackers are already reading it, and Flare 3.1.0 displays route metadata next to stack traces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: API Deprecation Headers From One Middleware
&lt;/h2&gt;

&lt;p&gt;If you version APIs, you eventually deprecate endpoints. Communicating that properly means &lt;code&gt;Deprecation&lt;/code&gt; and &lt;code&gt;Sunset&lt;/code&gt; headers on every affected response, and most teams either skip it or hardcode headers in a dozen controllers.&lt;/p&gt;

&lt;p&gt;Tag the routes instead:&lt;br&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;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api/v1'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'api'&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;'version'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Api\V1\OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'api'&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;'deprecated_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-01T00:00:00Z'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sunset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-12-31T23:59:59Z'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'successor'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/api/v2/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]]);&lt;/span&gt;

        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/customers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Api\V1\CustomerController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/AddDeprecationHeaders.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Carbon&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;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&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="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$next&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deprecatedAt&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api.deprecated_at'&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deprecation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'@'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Carbon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deprecatedAt&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;timestamp&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;$sunset&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api.sunset'&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Sunset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Carbon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sunset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toRfc7231String&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$successor&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;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api.successor'&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Link'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$successor&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'&amp;gt;; rel="successor-version"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The header formats matter here. RFC 9745, published March 2025, standardized the Deprecation header as a structured field date, meaning &lt;code&gt;@&lt;/code&gt; followed by a unix timestamp, and the older Sunset header from RFC 8594 takes a classic HTTP-date. Storing ISO strings in metadata and converting with Carbon at the edge keeps the route file readable while the wire format stays spec-compliant. Plenty of APIs still ship &lt;code&gt;Deprecation: true&lt;/code&gt; from the pre-RFC draft days, but new code has no excuse.&lt;/p&gt;

&lt;p&gt;Notice the dot notation doing real work too: &lt;code&gt;api.deprecated_at&lt;/code&gt; reaches into the merged array, and the group's &lt;code&gt;version&lt;/code&gt; key merged with the route's keys because both live under the associative &lt;code&gt;api&lt;/code&gt; key.&lt;/p&gt;

&lt;p&gt;The same metadata doubles as input for documentation tooling. Iterate the route collection in a command, read &lt;code&gt;getMetadata('api')&lt;/code&gt;, and generate a deprecation report for your changelog. The data has one home and two consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd Draw the Line
&lt;/h2&gt;

&lt;p&gt;The contributor who questioned this feature on the PR wasn't entirely wrong. There's a version of route metadata that makes your app worse, and it looks like this: a junk drawer of loosely related keys that grows forever because "just add it to metadata" became the answer to everything.&lt;/p&gt;

&lt;p&gt;My rules so far:&lt;/p&gt;

&lt;p&gt;Metadata should describe the route, not configure behavior that belongs elsewhere. "This route is deprecated" describes. "Retry this route's queue jobs three times" configures, and belongs where queue configuration lives.&lt;/p&gt;

&lt;p&gt;Keep values serializable. Everything goes through &lt;code&gt;route:cache&lt;/code&gt; in production, so closures and objects are out. Strings, numbers, booleans, arrays. If you're tempted to put a closure in metadata, you actually want middleware.&lt;/p&gt;

&lt;p&gt;Prefer associative structures over lists when groups are involved. You saw why in the permissions example. Lists replace on inheritance, and that surprises people.&lt;/p&gt;

&lt;p&gt;Test the metadata directly. Since metadata is now load-bearing (a missing &lt;code&gt;feature&lt;/code&gt; key means an unguarded route), a cheap test that iterates the route collection and asserts every route under a prefix carries the expected keys catches drift before production does:&lt;br&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;'guards every billing-v2 route with a feature flag'&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;$routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'router'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRoutes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&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;$route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'billing-v2'&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;$routes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$routes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&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;$route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-billing'&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;Five lines of Pest, and nobody can add a route to that group that silently skips the gate. This kind of route-collection assertion was always possible, but metadata finally gives it something structured to assert against.&lt;/p&gt;

&lt;p&gt;And know when the old tools are still right. A one-off route with one middleware doesn't need a metadata layer plus a global middleware reading it. Metadata earns its place when the same kind of data applies across many routes, especially through groups and resources. For single-route concerns, &lt;a href="https://hafiz.dev/blog/laravel-route-model-binding-best-practices-for-cleaner-code" rel="noopener noreferrer"&gt;route model binding&lt;/a&gt; and plain middleware parameters are still the simpler answer.&lt;/p&gt;

&lt;p&gt;If you're not on Laravel 13 yet, this feature alone won't justify the jump, but combined with everything else in the release it's another reason to schedule it. The &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade guide&lt;/a&gt; covers the path, and the upgrade itself is one of the smallest major-version jumps Laravel has shipped.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Which Laravel version do I need for route metadata?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Laravel 13.17.0 or later. The PR was merged on June 19, 2026 and shipped in the 13.17.0 release. It's a minor release, so if you're on any 13.x version, &lt;code&gt;composer update&lt;/code&gt; gets you there with no breaking changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does route metadata work with route:cache?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, and this is the main advantage over rolling your own. Metadata is stored on the route action under a dedicated key, so &lt;code&gt;php artisan route:cache&lt;/code&gt; serializes it and &lt;code&gt;getMetadata()&lt;/code&gt; reads it back identically in cached and uncached environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this in Laravel 12 or 11?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The feature landed in 13.x only and there's no official backport. On older versions you're back to config maps or action-array workarounds, which is a decent nudge toward upgrading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is metadata() different from defaults()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;defaults()&lt;/code&gt; provides default values for route parameters, which means the values can be injected into controller method arguments. &lt;code&gt;metadata()&lt;/code&gt; is inert data that never touches parameter binding. If you've been using &lt;code&gt;defaults()&lt;/code&gt; to store non-parameter data, metadata is the correct home for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between metadata() and setMetadata()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;metadata()&lt;/code&gt; merges with whatever the route already has, including values inherited from groups. &lt;code&gt;setMetadata()&lt;/code&gt; replaces the entire metadata array, which is how a route opts out of its group's metadata completely.&lt;/p&gt;

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

&lt;p&gt;Route metadata is a small feature with a long tail. Nothing here was impossible before. All of it was awkward before. The difference between "possible with hacks" and "supported with clean cascade semantics and cache safety" is the difference between a pattern you avoid and a pattern you reach for.&lt;/p&gt;

&lt;p&gt;Start with one use case. The SEO middleware is the lowest-risk entry point, and once the &lt;code&gt;getMetadata()&lt;/code&gt; pattern is in your muscle memory, you'll spot the other four in your own codebase within a week.&lt;/p&gt;

&lt;p&gt;Running a Laravel app that's accumulated years of route-name conventions and config maps, and want help untangling it during a 13.x upgrade? &lt;a href="mailto:contact@hafiz.dev"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>routing</category>
      <category>laravel13</category>
    </item>
    <item>
      <title>Laravel Reverb vs Pusher vs Soketi: Which WebSocket Server in 2026?</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 01 Jul 2026 05:56:05 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-reverb-vs-pusher-vs-soketi-which-websocket-server-in-2026-4h7</link>
      <guid>https://dev.to/hafiz619/laravel-reverb-vs-pusher-vs-soketi-which-websocket-server-in-2026-4h7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-reverb-vs-pusher-vs-soketi-websocket-comparison" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You're adding real-time features to a Laravel app. Live notifications, a chat panel, a collaborative cursor, a dashboard that updates without a refresh. The broadcasting code is the easy part because Laravel's API is the same no matter what's underneath. The actual decision is which WebSocket server sits behind it.&lt;/p&gt;

&lt;p&gt;Three options dominate the Laravel world: Reverb (first-party, self-hosted), Pusher (hosted, pay per connection), and Soketi (open-source, self-hosted, Pusher-compatible). They all speak the Pusher protocol, so your &lt;a href="https://hafiz.dev/blog/implementing-real-time-notifications-with-laravel-a-complete-guide" rel="noopener noreferrer"&gt;broadcasting and Echo code&lt;/a&gt; barely changes between them. What changes is cost, scaling ceiling, and how much operational work lands on you.&lt;/p&gt;

&lt;p&gt;I'll compare all three on the things that actually decide it, then give you a clear pick for each stage. There's also one fact about Soketi that should change how you think about it in 2026, and most comparisons skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Quick Version
&lt;/h2&gt;

&lt;p&gt;Pusher is the hosted option: zero servers to run, you pay per concurrent connection, and it gets expensive as you grow. Reverb is Laravel's first-party server: free software, you host it (or let Laravel Cloud host it), and it scales with Redis. Soketi is the open-source Pusher-compatible server: also free and self-hosted, historically the budget favorite, but its maintenance situation in 2026 is the catch.&lt;/p&gt;

&lt;p&gt;All three use the Pusher protocol. That's the thing that makes this a real choice rather than a lock-in: you can move between them with config changes, not rewrites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pusher: Pay to Not Think About It
&lt;/h2&gt;

&lt;p&gt;Pusher is the original hosted WebSocket service, and the pitch hasn't changed. You never run a server. You never tune file descriptors or restart a daemon. You create an app, get credentials, and broadcast.&lt;/p&gt;

&lt;p&gt;The pricing is connection-based and it's the whole story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free Sandbox: 100 concurrent connections, 200,000 messages/day&lt;/li&gt;
&lt;li&gt;Startup: $49/month, 500 concurrent connections&lt;/li&gt;
&lt;li&gt;Business: $299/month, 2,000 concurrent connections&lt;/li&gt;
&lt;li&gt;Higher tiers climb past $499 to $1,199/month for 15,000 to 30,000 connections&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A message counts both ways. Publish one event to 50 subscribers and that's 51 messages against your quota. For a chatty app (presence channels, typing indicators, live cursors) the message count climbs faster than you'd expect, and the daily cap on the free tier makes it unsuitable for production testing.&lt;/p&gt;

&lt;p&gt;Pusher's real value is the zero-ops promise and the SDK breadth. If you want real-time working in 15 minutes and you'd rather pay than provision, it delivers. The problem is purely cost at scale: 2,000 connections for $299/month is the kind of bill that makes self-hosting look attractive the moment you have the ops capacity to handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverb: The First-Party Default
&lt;/h2&gt;

&lt;p&gt;Reverb is Laravel's own WebSocket server, built on ReactPHP, shipped in 2024, and by 2026 it's the default choice for most Laravel teams. It speaks the Pusher protocol, so it's a drop-in for Laravel broadcasting and Echo. A single server handles thousands of concurrent connections, and it's free software: you only pay for the box it runs on.&lt;/p&gt;

&lt;p&gt;That last point is the headline. A Reverb instance on a $10 DigitalOcean droplet or a &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;cheap Hetzner VPS&lt;/a&gt; handles early-stage real-time features for the cost of the server, not per connection. Compared to Pusher's $49 to $299 tiers, the savings start immediately and widen as you grow.&lt;/p&gt;

&lt;p&gt;Scaling past one server uses Redis. You set &lt;code&gt;REVERB_SCALING_ENABLED=true&lt;/code&gt;, point your Reverb instances at a shared Redis server, and put them behind a load balancer. When a message hits one server, Redis publishes it to the others via pub/sub, so connections spread across machines stay in sync. It's the same horizontal-scaling pattern you'd use for any stateful service.&lt;/p&gt;

&lt;p&gt;Two things make Reverb in 2026 stronger than it was at launch.&lt;/p&gt;

&lt;p&gt;First, the database driver. You no longer need Redis for small-to-medium apps. Reverb can use your existing MySQL or Postgres database to coordinate, which means you can ship live notifications, dashboards, and collaborative features without provisioning, securing, and paying for a Redis cluster. For early-stage products that's a real infrastructure cost cut. For high-throughput apps (10,000+ concurrent connections), Redis is still the recommended backend.&lt;/p&gt;

&lt;p&gt;Second, managed Reverb on Laravel Cloud. If you want Reverb's economics without running the server yourself, Laravel Cloud offers fully managed Reverb clusters. You pick the number of concurrent connections, and Laravel claims up to 50% less than third-party tools like Pusher. It became the single most requested Cloud feature for a reason: it's the middle ground between self-hosting and paying Pusher's per-connection rates. It's the same managed-versus-self-hosted trade I walked through for &lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;Cloud's managed queues versus Horizon&lt;/a&gt;, just applied to WebSockets.&lt;/p&gt;

&lt;p&gt;The cost of Reverb shows up as operations, not dollars. A self-hosted Reverb server is a long-running process you have to keep alive with Supervisor, tune for open file limits, restart on deploy with &lt;code&gt;reverb:restart&lt;/code&gt;, and monitor. If your site is on Forge, the Application panel has a Reverb integration that handles the server tuning for you. But it's still infrastructure you own. That's the trade against Pusher: you swap a monthly bill for operational responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Soketi: The Catch in 2026
&lt;/h2&gt;

&lt;p&gt;Soketi is an open-source, Pusher-compatible WebSocket server built on uWebSockets.js (a C library ported to Node.js, which makes it very fast). For years it was the go-to budget pick: run it on a $5 to $10 instance and get effectively unlimited connections and messages, where the equivalent Pusher plan would cost $49 for just 500 connections. On raw price-per-connection, Soketi has always won.&lt;/p&gt;

&lt;p&gt;Here's the part most comparisons won't tell you, and it's the most important fact in this whole post. As of 2026, Soketi's maintenance is a concern, and that's not speculation. The maintainers say so directly. Their own documentation describes release and maintenance frequency as based on available time that's "tight as hell," and openly notes that maintenance issues have caused infrequent updates and infrequent support.&lt;/p&gt;

&lt;p&gt;That changes the calculation. A Pusher-compatible server that's blazing fast and free but slowly maintained is fine for a side project where you can absorb the risk. For something you're betting a business on, leaning on a server whose maintainers have flagged their own limited bandwidth is a different proposition. And the kicker: Reverb now does almost everything Soketi did (Pusher protocol, self-hosted, free, fast), but with the entire Laravel core team behind it and first-party integration. The main reason to pick Soketi over Reverb in 2026 is if you specifically want a Node-based server in a non-Laravel-centric stack. Inside a Laravel app, Reverb has quietly taken Soketi's lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Head to Head
&lt;/h2&gt;

&lt;p&gt;Cost at a small scale (say 500 concurrent connections): Pusher is $49/month. Reverb is the price of a $10 VPS. Soketi is the price of a $5 to $10 VPS. Self-hosting wins by a wide margin the moment you can run a server.&lt;/p&gt;

&lt;p&gt;Cost at a larger scale (5,000+ connections): Pusher runs into the hundreds per month and keeps climbing. Reverb and Soketi cost the same VPS plus a Redis instance, a fraction of the hosted price, but now you're managing horizontal scaling yourself. Managed Reverb on Laravel Cloud sits in between: more than a raw VPS, less than Pusher, and if you're already eyeing Cloud, the &lt;a href="https://hafiz.dev/blog/laravel-cloud-5-dollar-plan-spend-caps-scale-to-zero" rel="noopener noreferrer"&gt;$5 plan with spend caps and scale-to-zero&lt;/a&gt; keeps the floor low.&lt;/p&gt;

&lt;p&gt;Operational burden: Pusher is zero. Managed Reverb (Cloud) is near zero. Self-hosted Reverb and Soketi both require you to run, tune, monitor, and scale a long-running process.&lt;/p&gt;

&lt;p&gt;Maintenance confidence: Reverb and Pusher are actively, professionally maintained. Soketi has the open maintenance concern above.&lt;/p&gt;

&lt;p&gt;Laravel integration: Reverb is first-party and tightest. Pusher and Soketi are Pusher-protocol compatible, which is close, but Reverb is the one the framework ships with and the docs assume.&lt;/p&gt;

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

&lt;p&gt;For most Laravel apps in 2026, start with Reverb. It's first-party, it's free, the database driver means you don't even need Redis early on, and a single instance handles thousands of connections on a cheap server. You'd have to have a specific reason not to use it.&lt;/p&gt;

&lt;p&gt;If you don't want to run any infrastructure and you'd rather pay for it, you've got two clean options. Managed Reverb on Laravel Cloud keeps you in the Reverb ecosystem with no ops. Pusher makes sense if you want a battle-tested hosted service with the broadest SDK support and you're early enough that the connection count keeps you in the cheaper tiers.&lt;/p&gt;

&lt;p&gt;Pick Pusher over managed Reverb when you need the SDK breadth for non-web clients, or you want a vendor with a long track record and enterprise SLAs. Pick managed Reverb when you're a Laravel shop that wants the lowest hosted cost and tightest framework fit.&lt;/p&gt;

&lt;p&gt;I'd be cautious about starting a new production project on Soketi in 2026. The technology is solid and fast, but the maintainers' own note about limited upkeep, combined with Reverb now covering the same ground with first-party backing, makes it hard to recommend over Reverb for new Laravel work. If you're already running Soketi happily, there's no fire drill. But for a fresh build, Reverb is the safer version of the same idea.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I switch between these later without rewriting?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mostly yes, and that's the upside of all three speaking the Pusher protocol. Your Laravel broadcasting code and Echo config stay the same; you change connection credentials and host settings. Moving from Pusher to Reverb, or Soketi to Reverb, is a config and infrastructure task, not an application rewrite. That makes "start simple, move later" a viable strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Redis for Reverb?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not anymore, for small-to-medium apps. Reverb's database driver lets it coordinate through your existing MySQL or Postgres, so you can skip Redis entirely early on. You'll want Redis once you scale horizontally across multiple Reverb servers or push into very high connection counts (10,000+), where it's the recommended backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many connections can one Reverb server handle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single Reverb instance handles thousands of concurrent connections, with the exact ceiling depending on your server's resources and tuning (open file limits matter a lot here). Past what one box can hold, you scale horizontally with Redis pub/sub and a load balancer. For context, Soketi reports holding 500 active high-traffic connections on a 1GB, 1-CPU machine, and Reverb is in the same class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Pusher still worth paying for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you value zero operations and broad SDK support over cost, yes. Pusher's advantage is that you never run anything and it works across many platforms and languages. The downside is purely price: connection-based tiers get expensive fast, and a chatty app burns message quota quickly. If you have the ops capacity to run Reverb, the math usually favors self-hosting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Ably or other alternatives?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ably is another hosted option with stronger delivery guarantees (exactly-once, message history, state recovery) and is worth a look for mission-critical messaging where dropped messages aren't acceptable. But it's not Pusher-protocol native in the way these three are, so it's a bigger integration lift inside Laravel. For most Laravel real-time features, the Reverb-Pusher-Soketi triangle covers the decision.&lt;/p&gt;

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

&lt;p&gt;The honest 2026 answer is that Reverb won the default-choice slot, and for good reasons: first-party, free, fast, and no longer even requiring Redis for smaller apps. Pusher remains the right call when you'll pay to never touch infrastructure, especially via managed Reverb if you want to stay in the Laravel ecosystem. And Soketi, once the obvious budget pick, now carries a maintenance question that Reverb sidesteps entirely. Start with Reverb, reach for a hosted option when ops isn't where you want to spend time, and treat the Pusher protocol compatibility as your insurance policy to change your mind later.&lt;/p&gt;

&lt;p&gt;If you're planning real-time features for a Laravel app and want help choosing and setting up the right WebSocket layer, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>reverb</category>
      <category>websockets</category>
      <category>realtime</category>
    </item>
    <item>
      <title>Connect a Laravel AI Agent to Any MCP Server: A Hands-On Guide</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 29 Jun 2026 07:01:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/connect-a-laravel-ai-agent-to-any-mcp-server-a-hands-on-guide-128h</link>
      <guid>https://dev.to/hafiz619/connect-a-laravel-ai-agent-to-any-mcp-server-a-hands-on-guide-128h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/connect-laravel-ai-agent-to-mcp-server" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most Laravel MCP tutorials teach you to build a server. You expose your app's tools, an AI client connects, and Claude or Cursor can query your orders table. Useful, and I've covered &lt;a href="https://hafiz.dev/blog/how-to-build-a-laravel-mcp-server-with-filament" rel="noopener noreferrer"&gt;building an MCP server with Filament&lt;/a&gt; before.&lt;/p&gt;

&lt;p&gt;This post goes the other direction. Your Laravel AI agent becomes the client. It connects out to any MCP server, GitHub, Notion, Laravel Nightwatch, or one you run locally, and uses that server's tools as if you'd written them by hand. If you've built &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;an agent with the Laravel AI SDK&lt;/a&gt; already, this slots straight into the agent you have. Ask your agent to "look into the latest exception in my app" and it browses your Nightwatch issues, pulls the stack trace, and reports back. You wrote none of that integration code.&lt;/p&gt;

&lt;p&gt;Laravel shipped this on June 9, 2026. It's the piece that turns an agent from something that only knows your code into something that can reach into every tool your team already uses. If you've been giving your agent context with &lt;a href="https://hafiz.dev/blog/laravel-boost-and-mcp-servers-the-context-your-ai-agent-is-missing" rel="noopener noreferrer"&gt;Laravel Boost and MCP&lt;/a&gt;, this is the reverse: your agent reaching out instead of a tool reaching in. Here's how to wire it up, from a no-auth local server to a full OAuth flow, plus the gotchas that'll bite you in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Ships in the Box
&lt;/h2&gt;

&lt;p&gt;The feature is two packages working together, and the split matters.&lt;/p&gt;

&lt;p&gt;The MCP client lives in &lt;code&gt;laravel/mcp&lt;/code&gt;. It knows how to connect to a server, negotiate the handshake, authenticate, and call tools. It works on its own, from a queued job or a console command, with no agent anywhere in sight.&lt;/p&gt;

&lt;p&gt;The thin integration lives in &lt;code&gt;laravel/ai&lt;/code&gt;. It lets an agent consume that client without changing how agents already work. You drop MCP tools into the same &lt;code&gt;tools()&lt;/code&gt; array you already use, and the SDK wraps each one to fit the agent's tool contract.&lt;/p&gt;

&lt;p&gt;Before any code, pin your versions. As of late June 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;laravel/mcp&lt;/code&gt; v0.8.1 (the 0.8 line introduced the client; earlier versions were server-only)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;laravel/ai&lt;/code&gt; v0.8.1&lt;/li&gt;
&lt;li&gt;Laravel 12 or 13&lt;/li&gt;
&lt;li&gt;PHP 8.3 or higher&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That PHP floor matters. &lt;code&gt;laravel/mcp&lt;/code&gt; itself tolerates PHP 8.2, but &lt;code&gt;laravel/ai&lt;/code&gt; requires 8.3, and you need both for this. If you're on 8.2, Composer will refuse to resolve. Check what you've got:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Client Surface
&lt;/h2&gt;

&lt;p&gt;The whole client is three verbs: connect, list tools, call a tool. Everything else stays out of your way.&lt;br&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\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// A remote server over HTTP&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&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;'https://mcp.example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// A local server you run as a process (STDIO)&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'artisan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mcp:start'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two constructors are the two transports. &lt;code&gt;web()&lt;/code&gt; uses streamable HTTP for remote servers you reach over the network. &lt;code&gt;local()&lt;/code&gt; uses STDIO (standard input/output) for servers you run as a child process on the same machine. You pick by where the server lives, not by preference.&lt;/p&gt;

&lt;p&gt;Listing and calling tools looks like this:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'search-issues'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'payment failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;             &lt;span class="c1"&gt;// the text content&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// did the tool report a failure&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// structured payload, if the tool returned one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection happens lazily on the first real call, so you don't have to manage it manually. But you can if you want:&lt;br&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;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During that connection, the client and server negotiate a protocol version and exchange capabilities, so both sides agree on the rules before any work happens. You don't write any of that. It's the handshake, and when it fails (more on that later) it's usually an auth or version mismatch underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: A Local Server With No Auth
&lt;/h2&gt;

&lt;p&gt;Start with the simplest possible case so you see the wiring before the complexity. Point an agent at a local MCP server (yours, or any STDIO server) with no authentication at all.&lt;/p&gt;

&lt;p&gt;Inside your agent, the MCP tools go straight into the &lt;code&gt;tools()&lt;/code&gt; method, right next to your hand-written tools:&lt;br&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\Ai\Tools\SendSlackMessage&lt;/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\Client&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="mf"&gt;...&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'artisan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mcp:start'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&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;SendSlackMessage&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's the entire integration. The spread operator drops every tool from the MCP server into the array. The SDK notices they're MCP tools and wraps each one: it translates the MCP input schema into Laravel's JSON schema, calls the remote tool when the model asks for it, and normalizes whatever comes back into a result the model can read. The model has no idea which tools came from MCP and which you wrote. They all look the same to it.&lt;/p&gt;

&lt;p&gt;This is the mental model for the whole feature. If you can get one local server connected, every other server is just a different transport and an auth layer on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Bearer Token Auth
&lt;/h2&gt;

&lt;p&gt;Most useful servers want to know who you are. The simpler auth path is a bearer token, a string you already hold. A GitHub personal access token is the classic example.&lt;br&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\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&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;'https://api.githubcopilot.com/mcp/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can pass a closure instead of a raw string, which is what you want when the token belongs to the currently authenticated user:&lt;br&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;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&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;'https://api.githubcopilot.com/mcp/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;github_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop it into the agent the same way:&lt;br&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="mf"&gt;...&lt;/span&gt;&lt;span class="nc"&gt;Client&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;'https://api.githubcopilot.com/mcp/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;github_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your agent can search repositories, read CI logs, triage issues, and check Dependabot alerts. GitHub's server even supports a read-only mode through a request header if you'd rather the agent not change anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Full OAuth (The Real-World Case)
&lt;/h2&gt;

&lt;p&gt;Bearer tokens are fine when you already have a token. But hosted servers like Laravel Nightwatch use OAuth, where the user clicks a button, approves access on the provider's site, and comes back with a token your app stores. (If you're weighing Nightwatch against the alternatives, I compared &lt;a href="https://hafiz.dev/blog/laravel-telescope-vs-pulse-vs-nightwatch" rel="noopener noreferrer"&gt;Telescope vs Pulse vs Nightwatch&lt;/a&gt; separately.) This is the part the official announcement glosses over, so here's the whole flow.&lt;/p&gt;

&lt;p&gt;Register a named client, usually in a service provider's &lt;code&gt;boot()&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Client&lt;/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;registerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Client&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;'https://nightwatch.laravel.com/mcp'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withOAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.nightwatch_mcp.client_id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.nightwatch_mcp.client_secret'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the server supports dynamic client registration, you can omit the ID and secret entirely and the client registers itself. Whether you can do that depends on the server, which becomes important when things break.&lt;/p&gt;

&lt;p&gt;Next, wire the connect and callback routes in &lt;code&gt;routes/ai.php&lt;/code&gt;. The package gives you a helper that registers both:&lt;br&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\Client\OAuth\TokenSet&lt;/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;oAuthRoutesFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&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;string&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TokenSet&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'nightwatch_mcp_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accessToken&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;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This registers two named routes: &lt;code&gt;mcp.oauth.nightwatch.connect&lt;/code&gt; and &lt;code&gt;mcp.oauth.nightwatch.callback&lt;/code&gt;. The connect route redirects the user to the authorization server. The callback route exchanges the authorization code and runs your closure with a fresh token. You never touch the redirect URLs or the PKCE details.&lt;/p&gt;

&lt;p&gt;Then a button in your Blade view kicks it off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ route('mcp.oauth.nightwatch.connect') }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Connect Nightwatch
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow runs in order: the user clicks the button, the package redirects them to Nightwatch, they sign in and approve, Nightwatch sends them back to your callback route, your closure runs with the token, and you store it however you like. The package owns the OAuth choreography. Your app owns where the token lives.&lt;/p&gt;

&lt;p&gt;One thing to be clear about: there's no built-in token store. You persist &lt;code&gt;$token-&amp;gt;accessToken&lt;/code&gt; yourself, and you read it back when you build the client. The package handles refreshing expired tokens, but storage is on you.&lt;/p&gt;

&lt;p&gt;Once the token's stored, the agent uses the named client:&lt;br&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="mf"&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;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now ask the agent: "What are the most recent exceptions in production? Help me prioritize which to fix first." It browses your issues, reads stack traces, and answers. Tell it "mark issue 123 as resolved with a comment summarizing the fix," and it does that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Agent Sees MCP Tools
&lt;/h2&gt;

&lt;p&gt;Here's the flow from prompt to remote tool call and back, so you know what's happening when the model decides to use a Nightwatch tool.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/connect-laravel-ai-agent-to-mcp-server" 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 key insight is in the middle. By the time the model is choosing, native and MCP tools sit in the same list and look identical. The wrapping, the schema translation, the network call, the result normalization, all of it happens below the model's awareness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching the Tool List
&lt;/h2&gt;

&lt;p&gt;Listing tools is a network round trip on a remote server, and the tool list rarely changes. So cache it. Because tools come back as plain data, you wrap the call in Laravel's cache and they rehydrate cleanly:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mcp.nightwatch.tools'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addHour&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&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;Worth knowing: this isn't a dedicated client feature, despite what some descriptions imply. It's standard Laravel caching wrapped around the &lt;code&gt;tools()&lt;/code&gt; call. The win is real though: you skip a round trip on every single prompt, which matters most for OAuth-secured remote servers where each call carries auth overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Without a Live Server
&lt;/h2&gt;

&lt;p&gt;You don't want your test suite hitting a real MCP server over the network. Laravel AI's faking layer lets you script the model's responses while the tool call still flows through the MCP layer.&lt;/p&gt;

&lt;p&gt;MCP tool names follow a &lt;code&gt;mcp_tools_&amp;lt;name&amp;gt;&lt;/code&gt; pattern. A server tool called &lt;code&gt;search&lt;/code&gt; shows up to the agent as &lt;code&gt;mcp_tools_search&lt;/code&gt;. So you can fake a model response that calls the tool, then a final answer, and assert on the result:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'investigates exceptions through the nightwatch agent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Ai&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;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mcp_tools_search-issues'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'payment'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="nc"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Found 3 payment exceptions. The most frequent is a Stripe timeout.'&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;'Any payment errors lately?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Stripe timeout'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You test the production path (the agent calling a tool, reading the result, responding) without a single network call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotchas That'll Actually Bite You
&lt;/h2&gt;

&lt;p&gt;The happy path is clean. Here's what isn't, drawn from real issues developers have hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth handshake failures are the big one.&lt;/strong&gt; There's a documented case (laravel/boost issue #584) where connecting Nightwatch through certain agents threw &lt;code&gt;403 Forbidden ... when send initialize request&lt;/code&gt; before auth, and handshake decoding errors after. The reported cause: the client didn't detect that auth was required and never triggered the login flow. Two fixes came out of that thread. Run an explicit login for your agent if it supports one. Or route through the &lt;code&gt;mcp-remote&lt;/code&gt; bridge instead of pointing directly at the URL, which is what Nightwatch's own docs recommend for several clients. If a direct connection fails the handshake, try the bridge before assuming your code is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic client registration isn't universal.&lt;/strong&gt; Remember the OAuth step where you could omit the client ID and secret? That only works if the server supports dynamic client registration. Several servers require a pre-registered OAuth client, and trying to auto-register against them fails, often as a 403 that looks like an auth bug. Know which mode your target expects: if it doesn't do dynamic registration, you must supply &lt;code&gt;clientId&lt;/code&gt; and &lt;code&gt;clientSecret&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token storage is your job, and it's easy to get wrong.&lt;/strong&gt; The package refreshes tokens but doesn't store them. The common mistake is persisting the token somewhere your &lt;code&gt;withToken()&lt;/code&gt; closure can't read it back, or not persisting it at all and wondering why every request re-prompts for auth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protocol version mismatches surface as handshake failures.&lt;/strong&gt; Servers advertise different MCP spec versions. If a server only speaks a version the client doesn't negotiate, the connection fails at the handshake. The error won't always say "version," so keep it on your list of suspects when a connection won't establish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remote tool errors don't throw into your code.&lt;/strong&gt; When a remote tool fails, the result carries &lt;code&gt;isError&lt;/code&gt; rather than raising an exception in your agent loop. The wrapper normalizes the failure into something the model can read and react to. Check &lt;code&gt;isError&lt;/code&gt; if you're handling results manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use This (and When Not To)
&lt;/h2&gt;

&lt;p&gt;Reach for an MCP client connection when the tool already exists as an MCP server and you'd otherwise be writing an API integration by hand. Nightwatch, GitHub, Notion, and a growing list of services expose MCP servers. Consuming one is a few lines; rebuilding its integration is a few days.&lt;/p&gt;

&lt;p&gt;Skip it when a plain API call or a tool class you write yourself is simpler. If you only need one endpoint from a service, a small hand-written tool beats pulling in an entire MCP connection and its auth flow. MCP earns its place when you want many of a server's capabilities, or when the server is something like Nightwatch where the tools are rich enough to be worth the wiring.&lt;/p&gt;

&lt;p&gt;And if you're building tools for your own app, remember you can reuse your &lt;code&gt;laravel/mcp&lt;/code&gt; server tool classes directly in an agent with no client at all. Write the tool once, use it as a server tool for external clients and as a native agent tool internally. It's the same instinct behind making &lt;a href="https://hafiz.dev/blog/how-to-make-your-laravel-app-ai-agent-friendly-the-complete-2026-guide" rel="noopener noreferrer"&gt;your whole Laravel app AI-agent friendly&lt;/a&gt;: build the capability once, expose it everywhere.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need to build an MCP server to use this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. This is the opposite direction. You're consuming external servers as a client. You don't expose anything. If you also want to expose your own app's tools to outside AI clients, that's the server side, which is a separate setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which transports does the client support?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two: STDIO for local servers you run as a process (&lt;code&gt;Client::local(...)&lt;/code&gt;), and streamable HTTP for remote servers you reach over the network (&lt;code&gt;Client::web(...)&lt;/code&gt;). You choose based on where the server runs, not preference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I connect to multiple MCP servers in one agent?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Spread multiple clients into the same &lt;code&gt;tools()&lt;/code&gt; array, mixing transports and auth freely. A local STDIO server and a remote OAuth server can both feed one agent, alongside your hand-written tools. The model treats them all identically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle a server that needs OAuth but my client runs headless?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OAuth assumes a human approves access in a browser. For background or headless work, some servers support a client-credentials grant (no user in the loop), which the package handles. Others, like Notion's remote server, explicitly aren't built for headless agents, and you'd need a different auth path or a token issued ahead of time.&lt;/p&gt;

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

&lt;p&gt;The &lt;code&gt;laravel/mcp&lt;/code&gt; client supports Laravel 11, 12, and 13, but &lt;code&gt;laravel/ai&lt;/code&gt; requires Laravel 12 or 13 and PHP 8.3+. Since the agent integration needs both, you effectively need Laravel 12 or 13 to use an agent as an MCP client.&lt;/p&gt;

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

&lt;p&gt;The agent-as-client direction is the quieter half of Laravel's MCP story, and the more useful one for most apps. Building a server exposes your app to AI tools. Consuming servers gives your agent reach into every tool your team already runs, with a few lines instead of a custom integration per service. Start with a local no-auth server to internalize the &lt;code&gt;tools()&lt;/code&gt; pattern, graduate to a bearer token, then wire the full OAuth flow when you connect something like Nightwatch. And keep the troubleshooting section handy, because the handshake and OAuth edge cases are where the real time goes.&lt;/p&gt;

&lt;p&gt;If you're building agent features into a Laravel app and want help getting the MCP layer right, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>ai</category>
      <category>mcp</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>PHP 8.5's Pipe Operator vs Laravel Collections: Where Piper Fits (And Where It Doesn't)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:55:49 +0000</pubDate>
      <link>https://dev.to/hafiz619/php-85s-pipe-operator-vs-laravel-collections-where-piper-fits-and-where-it-doesnt-1bkl</link>
      <guid>https://dev.to/hafiz619/php-85s-pipe-operator-vs-laravel-collections-where-piper-fits-and-where-it-doesnt-1bkl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/php-pipe-operator-vs-laravel-collections-piper" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;PHP 8.5 shipped the pipe operator in November, and most Laravel developers shrugged. Why would we care about &lt;code&gt;|&amp;gt;&lt;/code&gt; when &lt;code&gt;collect()-&amp;gt;filter()-&amp;gt;map()&lt;/code&gt; has read top-to-bottom for a decade? Chained transformations are a solved problem in Laravel.&lt;/p&gt;

&lt;p&gt;Then Spatie released Piper last month: a library that ports Laravel's collection and string helpers to standalone functions built specifically for the pipe operator. When the team that maintains 300+ Laravel packages decides the pipe operator needs a Laravel-flavored toolkit, the question stops being academic. Is there an actual reason to write &lt;code&gt;$array |&amp;gt; filter(...) |&amp;gt; map(...)&lt;/code&gt; instead of reaching for Collections?&lt;/p&gt;

&lt;p&gt;I dug into the package, the design decisions behind it, and where each approach actually wins. Short answer: Piper isn't a Collections replacement and doesn't try to be. But there are two specific situations where it's the better tool, and one hard constraint that decides everything before style ever enters the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 60-Second Pipe Operator Refresher
&lt;/h2&gt;

&lt;p&gt;The pipe operator takes the result of the left expression and passes it as the single argument to the callable on the right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'  laravel  '&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtoupper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// "LARAVEL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step reads in execution order, top to bottom, no nesting, no intermediate variables. The &lt;code&gt;(...)&lt;/code&gt; syntax is PHP 8.1's first-class callable notation.&lt;/p&gt;

&lt;p&gt;The catch: the right side must be a callable that accepts exactly one argument. And PHP's standard library was not designed for that. Argument orders are famously inconsistent (&lt;code&gt;array_map($callback, $array)&lt;/code&gt; but &lt;code&gt;array_filter($array, $callback)&lt;/code&gt;), so half the stdlib needs wrapping in arrow functions before it pipes. That friction is exactly the gap Piper fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Piper Actually Does
&lt;/h2&gt;

&lt;p&gt;Piper (v1.0, requires PHP 8.5) ports Laravel's array and string helpers to namespaced functions under &lt;code&gt;Spatie\Piper\Arr&lt;/code&gt; and &lt;code&gt;Spatie\Piper\Str&lt;/code&gt;. The design trick: every function is a higher-order function. Calling &lt;code&gt;filter(fn ($i) =&amp;gt; $i % 2 === 0)&lt;/code&gt; doesn't filter anything. It returns a closure waiting for the one value the pipe will feed 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="k"&gt;function&lt;/span&gt; &lt;span class="nn"&gt;Spatie\Piper\Arr\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nn"&gt;Spatie\Piper\Str\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;filter&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;int&lt;/span&gt; &lt;span class="nv"&gt;$i&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;$i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="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;int&lt;/span&gt; &lt;span class="nv"&gt;$i&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;$i&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', and '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The winning numbers are '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// "The winning numbers are 4, 16, and 36."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's absent: no &lt;code&gt;collect()&lt;/code&gt; going in, no &lt;code&gt;-&amp;gt;all()&lt;/code&gt; or &lt;code&gt;-&amp;gt;toString()&lt;/code&gt; coming out. Plain arrays and strings flow through plain functions. That's the entire philosophy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Piper Beats Collections
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Native values in, native values out.&lt;/strong&gt; Collections are a wrapper object. You pay a conversion at each boundary: &lt;code&gt;collect($array)&lt;/code&gt; to enter, &lt;code&gt;-&amp;gt;all()&lt;/code&gt; to leave, and every method returns a new Collection instance along the way. In application code you barely notice. In code that interfaces with anything expecting plain arrays (third-party SDKs, array-typed signatures, JSON boundaries), the wrapping and unwrapping is pure ceremony. Piper skips it entirely: each function takes an array, returns an array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixing your own functions into the chain.&lt;/strong&gt; This is the pipe operator's structural advantage, and it's something Collections can't do cleanly. A Collection chain can only call methods that exist on the Collection class (or macros you register globally). A pipe chain accepts &lt;em&gt;any&lt;/em&gt; callable: Piper helpers, native functions, your own domain functions, all in one flow:&lt;br&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="k"&gt;function&lt;/span&gt; &lt;span class="nn"&gt;Spatie\Piper\Arr\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;filter&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;Order&lt;/span&gt; &lt;span class="nv"&gt;$o&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;$o&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPaid&lt;/span&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;Order&lt;/span&gt; &lt;span class="nv"&gt;$o&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;$o&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="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="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Collections, &lt;code&gt;applyDiscount()&lt;/code&gt; either becomes a macro, a &lt;code&gt;-&amp;gt;pipe()&lt;/code&gt; call, or a break out of the chain into a variable. The pipe operator treats your functions as first-class chain citizens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No framework dependency.&lt;/strong&gt; Piper's functions are standalone with zero Illuminate packages required. For package authors who want Laravel-style ergonomics without pulling &lt;code&gt;illuminate/collections&lt;/code&gt; into their dependency tree, that's a real selling point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Collections Still Win
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;API surface.&lt;/strong&gt; Piper v1 ports the most-used helpers. Collections have 100+ methods refined over a decade: &lt;code&gt;groupBy()&lt;/code&gt;, &lt;code&gt;pluck()&lt;/code&gt; with dot notation, &lt;code&gt;chunk()&lt;/code&gt;, &lt;code&gt;zip()&lt;/code&gt;, &lt;code&gt;mapWithKeys()&lt;/code&gt;, higher-order messages like &lt;code&gt;-&amp;gt;map-&amp;gt;title&lt;/code&gt;, and on and on. The moment your transformation needs one of the deeper methods, you're back in &lt;code&gt;collect()&lt;/code&gt; territory, and splitting one pipeline across both styles is worse than picking one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lazy evaluation.&lt;/strong&gt; &lt;code&gt;LazyCollection&lt;/code&gt; processes large datasets (cursor results, big files) one item at a time with constant memory. Piper has no lazy story: each function in the chain materializes a full new array. For &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;query results you've already optimized&lt;/a&gt;, chaining five Piper functions over 100,000 rows allocates five intermediate arrays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eloquent integration.&lt;/strong&gt; Query builders return Collections. Relations return Collections. Every Laravel API hands you a Collection already. Converting to a plain array just to pipe it through Piper means swimming upstream against the entire framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discoverability.&lt;/strong&gt; Type &lt;code&gt;-&amp;gt;&lt;/code&gt; after a Collection and your IDE lists every available method. With Piper you need to know the function exists, import it, and keep your &lt;code&gt;use function&lt;/code&gt; block tidy. It's a small tax, but it's paid on every file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint That Decides Everything
&lt;/h2&gt;

&lt;p&gt;Piper requires PHP 8.5. That's not a style consideration, it's a hard gate. PHP 8.5 shipped in November 2025, and most production fleets I see are still on 8.2 through 8.4. If your servers aren't on 8.5, this entire discussion is theoretical, and I'd guess that covers the majority of Laravel apps in production today. I wrote about this lag pattern before: &lt;a href="https://hafiz.dev/blog/php-8-4-features-not-using-yet-laravel-app" rel="noopener noreferrer"&gt;PHP 8.4 features were still "new" to most codebases&lt;/a&gt; a full year after release.&lt;/p&gt;

&lt;p&gt;There's a second adoption headwind worth naming: AI coding assistants. Sebastian De Deyne (who built Piper) observed that coding agents essentially never produce pipe operator code unless explicitly asked. Training data has a decade of &lt;code&gt;collect()&lt;/code&gt; chains and almost no &lt;code&gt;|&amp;gt;&lt;/code&gt;. In 2026, syntax that your tooling doesn't reach for by default spreads slower, regardless of merit.&lt;/p&gt;

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

&lt;p&gt;Use Piper when you're writing framework-agnostic code (packages, shared libraries) or transformation-heavy code on native arrays and strings where pulling in Collections feels like overkill. The function-mixing capability is the one place the pipe operator is structurally better than method chaining, not just different. It's the same kind of &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;decision-tree thinking I apply to services versus actions&lt;/a&gt;: pick by code context, not by what's newest.&lt;/p&gt;

&lt;p&gt;Inside a Laravel app, keep using Collections. They're everywhere, your team knows them, Eloquent hands them to you, and the lazy variant handles scale. Piper isn't trying to dethrone them; it's filling the gap for the code that lives &lt;em&gt;between&lt;/em&gt; Laravel projects. Spatie has a habit of shipping packages a few years before the ecosystem catches up (&lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;Scotty did this for deployment&lt;/a&gt;), and Piper reads like a bet on where PHP code style lands by 2028, not a tool you must adopt this sprint.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does Piper replace Laravel Collections?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, and Spatie doesn't position it that way. Piper covers array and string transformations on native values. Collections remain the richer, framework-integrated tool. Think of Piper as a companion for the places Collections are awkward, not a successor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the pipe operator with Collections directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not meaningfully. Collections are objects with chained methods, and the pipe operator wants single-argument callables. You could pipe into &lt;code&gt;collect(...)&lt;/code&gt; to enter Collection-land, but at that point just use the Collection chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the pipe operator perform better than Collection chaining?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Marginally, in theory: plain function calls skip the object method dispatch and per-step Collection instantiation. In practice the difference is noise compared to your database queries. Don't pick either approach for performance; pick for readability and context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Piper in Laravel 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, as long as your runtime is PHP 8.5 or higher. The package has no framework dependency, so it works in any PHP 8.5 project, Laravel or not. The version check that matters is your PHP binary, not your Laravel version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Stringable for string chains?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Laravel's &lt;code&gt;Str::of()&lt;/code&gt; fluent strings cover the same ground as &lt;code&gt;Spatie\Piper\Str&lt;/code&gt; inside a Laravel app, with the same trade-offs as Collections: wrapper object, framework dependency, huge API. The same logic applies: Stringable in app code, Piper in framework-agnostic code.&lt;/p&gt;

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

&lt;p&gt;The pipe operator is the most interesting thing to happen to PHP syntax in years, and Piper is the first serious attempt to make it ergonomic for the Laravel crowd. But interesting and necessary aren't the same thing. If your production PHP is 8.5 and you write package code or work with native arrays at framework boundaries, Piper earns a spot in your composer.json. For everything else, &lt;code&gt;collect()&lt;/code&gt; isn't going anywhere.&lt;/p&gt;

&lt;p&gt;If you're modernizing a Laravel codebase and weighing which PHP 8.5 features are actually worth adopting, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>spatie</category>
      <category>php85</category>
    </item>
    <item>
      <title>Laravel Cloud Managed Queues vs Horizon: What You Give Up and What You Get</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 22 Jun 2026 06:17:23 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-cloud-managed-queues-vs-horizon-what-you-give-up-and-what-you-get-414o</link>
      <guid>https://dev.to/hafiz619/laravel-cloud-managed-queues-vs-horizon-what-you-give-up-and-what-you-get-414o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel Cloud shipped Managed Queues on May 26, and the pitch is hard to ignore: queue workers that scale up when jobs pile up, scale to zero when there's nothing to do, and a built-in dashboard for failed jobs. No Supervisor configs, no Redis tuning, no &lt;code&gt;horizon:terminate&lt;/code&gt; in your deploy script.&lt;/p&gt;

&lt;p&gt;If you're running Horizon today, the obvious question is: should you care? I went through the docs, the launch post, and the pricing model to answer that properly. The short version: Managed Queues solve real problems, but Horizon users give up more than the announcement suggests. There's a 15-minute delay cap that will silently break a lot of apps, and the Redis features Horizon users lean on don't exist in the SQS world.&lt;/p&gt;

&lt;p&gt;Here's the honest breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Managed Queues Actually Are
&lt;/h2&gt;

&lt;p&gt;First, the architecture, because it explains everything else. With Managed Queues, Laravel Cloud becomes your queue driver. Under the hood it's SQS: your app needs &lt;code&gt;aws/aws-sdk-php&lt;/code&gt; in its &lt;code&gt;composer.json&lt;/code&gt;, and Cloud provisions the queue, configures access, and reads queue depth directly from SQS without going through your application.&lt;/p&gt;

&lt;p&gt;Every worker runs in its own isolated container with guaranteed memory. You pick a tier from 256 MiB up to 8 GiB, and CPU scales with it. When jobs arrive, Cloud spins up workers based on queue pressure (depth plus message age, not just depth). When the queue drains, workers scale back to zero and billing stops.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" 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;Compare that to Horizon (v5.47 as of June 2026): a free package, Redis only, running on infrastructure you provision. You define worker pools and balancing strategies in &lt;code&gt;config/horizon.php&lt;/code&gt;, keep the master process alive with Supervisor, and remember to call &lt;code&gt;horizon:terminate&lt;/code&gt; on every deploy. Horizon gives you enormous control. It also gives you all the operational responsibility.&lt;/p&gt;

&lt;p&gt;That's the actual trade: control and Redis features versus zero infrastructure and pay-per-second billing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scale to zero.&lt;/strong&gt; This is the headline. A Horizon setup on a &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;Forge-managed VPS or Hetzner box&lt;/a&gt; costs the same whether it processes a million jobs or none. Managed queue workers cost nothing while idle. For side projects, staging environments, and apps with bursty workloads, that's a real difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;True worker isolation.&lt;/strong&gt; Each worker gets its own container with guaranteed memory. One job blowing past its memory limit kills that one worker, not its neighbors. With Horizon, workers share the box: one memory-hungry job can take down everything on the same server, which is exactly the failure mode I covered when the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" rel="noopener noreferrer"&gt;AI SDK's default config starved a Horizon queue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Burst absorption without capacity planning.&lt;/strong&gt; Cloud scales from zero to your max worker cap automatically. With Horizon, &lt;code&gt;maxProcesses&lt;/code&gt; is bounded by the RAM you bought. Dispatching 10,000 jobs at once means either provisioning headroom you pay for all month, or watching the backlog drain slowly. &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Processing 10,000 tasks&lt;/a&gt; is a sizing exercise on Horizon. On Managed Queues it's a config field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed jobs dashboard your support team can use.&lt;/strong&gt; Failed jobs surface with full payload, exception, and stack trace, and anyone with environment access can retry with one click. No &lt;code&gt;php artisan queue:retry&lt;/code&gt; over SSH. For teams where non-developers field "my export never arrived" tickets, this matters more than it sounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pause and purge from the UI.&lt;/strong&gt; Pause terminates workers immediately and stops billing while jobs accumulate safely. Note the difference from Horizon's &lt;code&gt;queue:pause&lt;/code&gt;: Horizon's version keeps workers alive (and on your server, still costing you), Cloud's version actually stops the meter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Give Up
&lt;/h2&gt;

&lt;p&gt;This is the part the launch post doesn't dwell on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delayed jobs are capped at 15 minutes.&lt;/strong&gt; This is the biggest gotcha and it deserves the bold. SQS has a hard 15-minute delay limit, and Managed Queues inherit it. If your app does &lt;code&gt;-&amp;gt;delay(now()-&amp;gt;addHour())&lt;/code&gt; for a follow-up email, or schedules a retry for tomorrow morning, that breaks. Horizon on Redis delays jobs for as long as you want. Plenty of Laravel apps use long delays without thinking about it, and nothing in your code will warn you before you migrate. Audit every &lt;code&gt;delay()&lt;/code&gt; and &lt;code&gt;release()&lt;/code&gt; call before considering a move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At-least-once delivery only.&lt;/strong&gt; No strict ordering, no exactly-once processing. If a worker hits the visibility timeout, gets terminated during a deploy, or a queue is paused mid-flight, jobs can be redelivered. Your jobs must be idempotent. Good queue code should be idempotent anyway, but with Horizon on Redis many apps quietly get away with assuming a job runs once. On Managed Queues that assumption will eventually bite you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizon's Redis toolbox doesn't come along.&lt;/strong&gt; Tags for monitoring specific models or job types. &lt;code&gt;Redis::funnel()&lt;/code&gt; and &lt;code&gt;Redis::throttle()&lt;/code&gt; for rate limiting third-party API calls. Balancing strategies that shift workers between queues based on load. Per-queue wait time alerts via Slack or SMS. None of this exists on Managed Queues. The dashboard shows volume, duration, memory, and worker counts per queue, which is good operational visibility, but it's not Horizon's per-tag, per-job granularity. (For deeper tracing, Laravel's answer is pairing Cloud with Nightwatch, which is another subscription.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One queue name per managed queue.&lt;/strong&gt; Horizon lets one supervisor drain &lt;code&gt;default,emails,exports&lt;/code&gt; with priorities. On Cloud, each queue name is its own managed queue with its own config. The Starter plan allows exactly 1 queue and 3 max workers, Growth allows 10 queues and 25 workers each, Business is uncapped (50 workers soft cap, raisable). If your app uses queue names for priority lanes, you'll restructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold starts.&lt;/strong&gt; A queue that scaled to zero takes several seconds to spin up its first worker (around 10 seconds per the engineering team). For background reports nobody notices. For user-facing jobs like "your download is ready," a 10-second floor on top of job runtime is noticeable. Workers can't currently be kept warm; a configurable warm minimum is planned but not shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard-only management, for now.&lt;/strong&gt; No API or CLI for creating, pausing, or configuring queues at launch. If you manage infrastructure as code, that's a regression from a &lt;code&gt;horizon.php&lt;/code&gt; file in version control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're on Laravel Cloud.&lt;/strong&gt; Obvious, but worth saying: Managed Queues only exist as a platform feature of Laravel Cloud. Horizon runs anywhere Redis does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pricing Math
&lt;/h2&gt;

&lt;p&gt;Managed Queues bill two meters: worker compute per second (by memory tier) and queue operations at $1 per million. An operation is any API call against the queue: every dispatch, receive, delete, and every polling check at your configured interval (default 5 seconds). Payloads are measured in 64 KB chunks, with a 1 MiB max per job.&lt;/p&gt;

&lt;p&gt;Run the numbers on a typical small production app: say 300,000 jobs a month, averaging 2 seconds each on a 256 MiB worker. That's roughly 600,000 worker-seconds, plus around a million operations (three per job, plus polling). You're looking at single-digit dollars per month, and a dev or staging environment that sits idle most of the day costs close to nothing.&lt;/p&gt;

&lt;p&gt;Now the Horizon side: a €4.49/month Hetzner CX22 runs Horizon comfortably for that workload, flat rate, with the rest of the box free for your app. At steady, predictable volume, a fixed server stays hard to beat, and at high sustained volume the per-second meter can climb past a beefier fixed server. The crossover isn't a single number because it depends on job duration and memory tier, but the shape is clear: spiky and idle-heavy favors Managed Queues, flat and sustained favors a fixed worker.&lt;/p&gt;

&lt;p&gt;That's also why Cloud itself still offers worker clusters (fixed workers running &lt;code&gt;queue:work&lt;/code&gt; continuously) and recommends them for sustained, predictable throughput. Even inside Laravel's own platform, autoscaling isn't always the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  So Who Should Switch?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stay on Horizon if:&lt;/strong&gt; you use delays longer than 15 minutes anywhere, you depend on tags, &lt;code&gt;Redis::throttle()&lt;/code&gt;, or balancing strategies, your throughput is steady enough that a fixed server is cheaper, or you simply aren't on Laravel Cloud and have no other reason to move. Horizon is mature, free, and v5.47 shows it's still actively maintained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Managed Queues if:&lt;/strong&gt; you're already on Cloud (it should be your default there over app cluster background processes), your workload is bursty or idle-heavy, you want support staff retrying failed jobs from a UI, or you're starting a new project and would rather never write a Supervisor config. Start with a single queue at 256 MiB, watch the memory chart, and adjust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't switch mid-flight without an audit.&lt;/strong&gt; The delay cap and at-least-once semantics are silent breaking changes. Grep for &lt;code&gt;delay(&lt;/code&gt;, &lt;code&gt;release(&lt;/code&gt;, and &lt;code&gt;WithoutOverlapping&lt;/code&gt; and check every result before moving a production queue.&lt;/p&gt;

&lt;p&gt;My take: Managed Queues are the right default for new apps on Cloud, and the failed jobs dashboard alone will sell it to teams. But Horizon is not obsolete. It's the more capable tool that costs you operational attention instead of per-second billing. The 15-minute delay cap is the dealbreaker to check first, before price, before features.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I run Horizon on Laravel Cloud instead of Managed Queues?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Cloud's worker clusters let you self-manage &lt;code&gt;queue:work&lt;/code&gt; processes with your own driver, and you can run Horizon against a Redis instance there. You give up scale-to-zero and the built-in failed jobs dashboard, but you keep Redis features and long delays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do Managed Queues work with Laravel 10?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The supported minimums are Laravel 11.53.1, 12.60.2, and 13.11.2, and your app must include &lt;code&gt;aws/aws-sdk-php&lt;/code&gt;. Deploys that create a managed queue on an unsupported version fail with a clear error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to my &lt;code&gt;failed_jobs&lt;/code&gt; table?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Managed Queues surface failed jobs in the Cloud dashboard automatically, with no failed jobs driver to configure. Failures are visible cross-queue with payload, exception, and stack trace, and you can retry or delete from the UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do scheduled jobs with long delays work if there's a 15-minute cap?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They don't, directly. The workaround is restructuring: instead of dispatching with a long delay, store the intended run time and let the scheduler dispatch the job when it's due. It's a known SQS pattern, but it's code you have to write and test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Horizon being deprecated?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No sign of that. Horizon v5.47.2 shipped in early June 2026 with support through Laravel 13. Cloud's own queue clusters were deprecated in favor of Managed Queues, but Horizon is a framework package, not a Cloud feature, and it remains the standard for self-hosted Redis queues.&lt;/p&gt;

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

&lt;p&gt;Managed Queues are Laravel Cloud's strongest argument yet for teams that hate running queue infrastructure: real isolation, honest pay-for-what-runs billing, and a failed jobs story Horizon never had. Horizon remains the choice when you need Redis semantics, long delays, fine-grained control, or predictable flat costs. Know which list describes your app before you touch anything, starting with that 15-minute delay cap.&lt;/p&gt;

&lt;p&gt;If you're weighing a move to Laravel Cloud or untangling a queue setup that's outgrown its config, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelcloud</category>
      <category>horizon</category>
      <category>queues</category>
    </item>
    <item>
      <title>Cashier (Stripe) vs Cashier (Paddle) for a Bootstrapped Laravel SaaS: The Numbers Nobody Shows You</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 17 Jun 2026 11:08:05 +0000</pubDate>
      <link>https://dev.to/hafiz619/cashier-stripe-vs-cashier-paddle-for-a-bootstrapped-laravel-saas-the-numbers-nobody-shows-you-7oj</link>
      <guid>https://dev.to/hafiz619/cashier-stripe-vs-cashier-paddle-for-a-bootstrapped-laravel-saas-the-numbers-nobody-shows-you-7oj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cashier-stripe-vs-paddle-real-cost-comparison" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every "Stripe vs Paddle" comparison I've read compares features. Webhook flexibility. Checkout customization. API surface area. That's useful if you're an engineer evaluating architecture, but it's useless if you're a bootstrapped founder trying to figure out how much money you'll actually keep.&lt;/p&gt;

&lt;p&gt;So let's do the math instead. I'm going to take a $100/month subscription and trace it through both platforms, including the fees that most comparisons conveniently leave out. Then I'll tell you which one I'd pick at different stages, and why.&lt;/p&gt;

&lt;p&gt;Both platforms have official Laravel packages: &lt;code&gt;laravel/cashier&lt;/code&gt; (v16.5, Stripe) and &lt;code&gt;laravel/cashier-paddle&lt;/code&gt; (v2.8, Paddle). Both support Laravel 10 through 13. The integration quality is comparable. So this really comes down to money and operational cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stripe Actually Costs
&lt;/h2&gt;

&lt;p&gt;Stripe's headline rate is 2.9% + $0.30 per transaction in the US, or 1.5% + €0.25 in most of Europe. On a $100 subscription, that's $3.20 in the US. Simple enough.&lt;/p&gt;

&lt;p&gt;But if you're running a &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS with recurring billing&lt;/a&gt;, that's not the full picture. You'll probably want Stripe Tax to handle VAT and sales tax automatically. That adds 0.5% per transaction where tax is collected. On our $100 charge, that's another $0.50.&lt;/p&gt;

&lt;p&gt;If your customers pay with international cards, add another 1.5%. Currency conversion adds 1-2% on top of that. And chargebacks cost $15 each, win or lose.&lt;/p&gt;

&lt;p&gt;Here's the realistic breakdown for a $100 US subscription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Base processing:        $3.20  (2.9% + $0.30)
Stripe Tax:             $0.50  (0.5%)
────────────────────────────────
Total Stripe fees:      $3.70
You keep:               $96.30
Effective rate:         3.7%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an EU seller charging €100 to an EU customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Base processing:        €1.75  (1.5% + €0.25)
Stripe Tax:             €0.50  (0.5%)
────────────────────────────────
Total Stripe fees:      €2.25
You keep:               €97.75
Effective rate:         2.25%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That EU rate looks great. But there's a catch. Stripe Tax calculates and collects the tax. It doesn't file your returns everywhere automatically. You'll still need to register for OSS (One-Stop Shop) in the EU and handle filing, either yourself or through a service. Some of that is included in Stripe's Tax Complete tier, but you'll need to verify coverage for your specific countries.&lt;/p&gt;

&lt;p&gt;And none of this includes the time you spend managing it. Configuring &lt;a href="https://hafiz.dev/blog/stripe-integration-in-laravel-complete-guide-to-subscriptions-one-time-payments" rel="noopener noreferrer"&gt;Stripe webhooks&lt;/a&gt;, handling failed payments, generating compliant invoices, dealing with chargebacks. That's your time or your developer's time, and it has a cost even if it doesn't show up on an invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Paddle Actually Costs
&lt;/h2&gt;

&lt;p&gt;Paddle's rate is 5% + $0.50 per transaction. Full stop. On a $100 subscription, you pay $5.50. You keep $94.50.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;All-inclusive fee:      $5.50  (5% + $0.50)
────────────────────────────────
Total Paddle fees:      $5.50
You keep:               $94.50
Effective rate:         5.5%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 5.5% looks painful compared to Stripe's 3.7%. But here's what's included in Paddle's fee that isn't included in Stripe's:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tax compliance in 200+ countries.&lt;/strong&gt; Paddle registers, calculates, collects, and files sales tax, VAT, and GST everywhere. You don't touch it. You don't even think about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merchant of Record status.&lt;/strong&gt; Paddle is the legal seller. Your customer's credit card statement shows "Paddle" (or a Paddle subsidiary), not your company. This means Paddle handles refunds, chargebacks, and compliance obligations. If a customer disputes a charge, Paddle deals with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chargeback protection.&lt;/strong&gt; Included in the 5%. No $15 per dispute fee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoicing.&lt;/strong&gt; Paddle generates compliant invoices automatically for every jurisdiction.&lt;/p&gt;

&lt;p&gt;The trade-off is clear: Paddle costs more per transaction, but it removes an entire category of operational work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Number That Actually Matters
&lt;/h2&gt;

&lt;p&gt;The fee difference on a $100 transaction is $1.80 ($5.50 Paddle vs $3.70 Stripe). On $1,000 MRR, that's $18/month. On $5,000 MRR, it's $90/month. On $10,000 MRR, it's $180/month.&lt;/p&gt;

&lt;p&gt;Now compare that to the cost of handling tax compliance yourself with Stripe:&lt;/p&gt;

&lt;p&gt;If you're an EU-based solo developer (like me, based in Italy), selling to customers across the EU, you need to handle VAT via OSS. Stripe Tax helps with calculation, but the filing, the registration, the record-keeping: that takes time. Conservative estimate: 2-4 hours per month at the start. If your time is worth $50/hour, that's $100-200/month in opportunity cost.&lt;/p&gt;

&lt;p&gt;Add in the occasional chargeback investigation ($15 each plus your time), invoice formatting issues, and the mental load of being legally responsible for tax compliance in dozens of countries. The $90/month difference at $5,000 MRR starts looking like a bargain for Paddle.&lt;/p&gt;

&lt;p&gt;But. At $10,000+ MRR, the math shifts. By then you probably have a bookkeeper or accountant. Tax filing is systematized. The $180/month gap becomes real money over a year ($2,160). And Stripe gives you more control: more checkout customization, more webhook flexibility, deeper integration with your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Laravel Integration Side
&lt;/h2&gt;

&lt;p&gt;Both packages follow similar patterns. Install, configure, add the &lt;code&gt;Billable&lt;/code&gt; trait to your User model. Both handle subscriptions, plan swapping, cancellation grace periods.&lt;/p&gt;

&lt;p&gt;The practical differences:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkout flow.&lt;/strong&gt; Stripe lets you build a fully custom checkout within your app. Paddle uses an overlay or redirect checkout hosted by Paddle. If you want the payment form embedded directly in your Blade views, Stripe gives you that control. With Paddle, the customer briefly leaves your UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks.&lt;/strong&gt; Both packages register webhook routes automatically. Stripe sends more granular &lt;a href="https://hafiz.dev/blog/how-laravel-events-listeners-observers-actually-work" rel="noopener noreferrer"&gt;events&lt;/a&gt; (payment_intent.succeeded, invoice.payment_failed, customer.subscription.updated). Paddle sends fewer, broader events (transaction.completed, subscription.activated). For most SaaS apps, both provide everything you need. Stripe's granularity matters more if you're building complex billing flows with metered usage or prorations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refunds.&lt;/strong&gt; With Stripe (via &lt;code&gt;laravel/cashier&lt;/code&gt;), you call &lt;code&gt;$user-&amp;gt;refund($paymentId)&lt;/code&gt; and handle the money movement yourself. With Paddle (via &lt;code&gt;laravel/cashier-paddle&lt;/code&gt;), you call &lt;code&gt;$transaction-&amp;gt;refund()&lt;/code&gt; and Paddle handles everything including the tax adjustment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing.&lt;/strong&gt; Both have sandbox/test modes. Stripe's test mode is more mature with more test card numbers and edge case simulation. Paddle's sandbox works but has fewer testing tools.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Under $5,000 MRR, especially if you sell to EU customers: use Paddle.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The time you save on tax compliance, chargeback handling, and invoice generation is worth more than the fee difference. You should be building features and acquiring customers, not debugging VAT edge cases in Slovenia. Paddle lets you ship billing in an afternoon and forget about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Above $10,000 MRR, or if you need deep checkout customization: use Stripe.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this scale, you can afford to systematize the tax compliance work. The per-transaction savings add up. And Stripe's flexibility lets you build billing experiences that Paddle's checkout overlay can't match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Between $5,000 and $10,000 MRR:&lt;/strong&gt; This is the gray zone. If you're still a solo dev or a tiny team, stay on Paddle. If you've hired help and your accountant has capacity, the Stripe migration makes sense.&lt;/p&gt;

&lt;p&gt;One more thing to consider. Switching from Paddle to Stripe (or vice versa) mid-flight is painful. Active subscriptions can't be cleanly migrated. Customers may need to re-enter payment details. You could lose 10-20% of subscribers in the churn. So pick the one that fits your next 12-18 months, not just today.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I start with Paddle and switch to Stripe later?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can, but it's not seamless. Paddle is the Merchant of Record, so they own the customer billing relationship. When you migrate, each customer needs to set up a new subscription with Stripe. There's no automatic migration path. Plan for some churn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Paddle's checkout hurt conversion rates?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Paddle's overlay checkout is polished and trusted, but it does briefly take the customer out of your UI. Some developers report no measurable difference. Others see a small drop. If your audience is technical (developers, designers), they're less likely to be spooked by a Paddle checkout. If you're selling to enterprise buyers, a branded Stripe checkout embedded in your app may convert better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Lemon Squeezy as an alternative?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lemon Squeezy is another Merchant of Record platform (now owned by Stripe). It's popular with indie developers and often compared to Paddle. However, there's no official Laravel Cashier package for Lemon Squeezy. You'd need a third-party integration or build your own. If Laravel-native billing is important to you, it's Stripe or Paddle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I still need an accountant with Paddle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but your accountant's job gets much simpler. Paddle sends you a monthly payout with a single invoice for their services. Your accountant records one line item of income instead of hundreds of individual transactions with varying tax treatments across jurisdictions. It's the difference between a 30-minute task and a multi-hour one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the 5% + $0.50 negotiable with Paddle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Paddle offers custom pricing for businesses processing over $50,000/month. If you're approaching that volume, reach out to their sales team. At scale, the negotiated rate can close much of the gap with Stripe.&lt;/p&gt;

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

&lt;p&gt;The right choice depends on where you are, not where you want to be. If you're in the first year of a bootstrapped SaaS, every hour you spend on billing infrastructure is an hour you're not spending on the product. Paddle's higher fee buys you that time back. If you're past product-market fit and optimizing unit economics, Stripe's lower fees compound into real savings.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;laravel/cashier&lt;/code&gt; and &lt;code&gt;laravel/cashier-paddle&lt;/code&gt; are well-maintained, first-party packages with official Laravel documentation. The integration quality won't be your bottleneck either way. If you're building a Laravel SaaS and want help picking the right billing setup for your stage, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>saas</category>
      <category>stripe</category>
      <category>paddle</category>
    </item>
    <item>
      <title>Multi-Tenancy + Queues: The Three Bugs Every Laravel SaaS Hits in Its First Year</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 15 Jun 2026 05:54:01 +0000</pubDate>
      <link>https://dev.to/hafiz619/multi-tenancy-queues-the-three-bugs-every-laravel-saas-hits-in-its-first-year-al1</link>
      <guid>https://dev.to/hafiz619/multi-tenancy-queues-the-three-bugs-every-laravel-saas-hits-in-its-first-year-al1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/multi-tenancy-queues-three-bugs-laravel-saas" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Your multi-tenant Laravel app works perfectly in development. Every test passes. You push to production, onboard your first ten customers, and everything looks fine. Then one morning a customer emails you a screenshot of someone else's dashboard data.&lt;/p&gt;

&lt;p&gt;That's not a hypothetical. It's what happens when multi-tenancy and queues collide without the right safeguards. The combination is tricky because queue workers are long-running daemons. Unlike HTTP requests (which boot fresh for every visitor), a queue worker starts once and processes hundreds of jobs sequentially. Any tenant state left over from one job bleeds into the next.&lt;/p&gt;

&lt;p&gt;I've seen three specific bugs come up again and again in &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;multi-tenant Laravel SaaS apps&lt;/a&gt;. They're all silent. They don't throw exceptions in local development. They don't fail your test suite. And they can leak data between tenants in production. Here's what they are and how to fix each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: Tenant Context Evaporates in Queued Jobs
&lt;/h2&gt;

&lt;p&gt;This is the classic one, and it's the scariest because it fails silently.&lt;/p&gt;

&lt;p&gt;You dispatch a job from a controller while Tenant A is active. The job gets serialized to the queue. A worker picks it up seconds later. But the worker process has no idea which tenant dispatched that job. It's running in the central (landlord) context, or worse, it's still holding stale context from the previous job it processed for Tenant B.&lt;/p&gt;

&lt;p&gt;Here's the flow that causes the data leak:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/multi-tenancy-queues-three-bugs-laravel-saas" 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 problem gets worse if you're using &lt;code&gt;SerializesModels&lt;/code&gt;. Laravel's model serialization stores the model's ID and class name, then rehydrates the model when the job runs. But rehydration calls &lt;code&gt;newQueryForRestoration()&lt;/code&gt;, which applies any global scopes, including your &lt;code&gt;BelongsToTenant&lt;/code&gt; scope. That scope tries to filter by &lt;code&gt;tenant_id&lt;/code&gt;, but &lt;code&gt;tenant_id&lt;/code&gt; is null because no tenant is active yet. The query returns nothing. &lt;code&gt;ModelNotFoundException&lt;/code&gt;. Your job fails, but the real cause is buried under a misleading error message.&lt;/p&gt;

&lt;p&gt;The worst part? This never shows up in tests. Most test suites use &lt;code&gt;Queue::fake()&lt;/code&gt; or the &lt;code&gt;sync&lt;/code&gt; driver, which processes jobs inline with no serialization round-trip. The bug only appears with a real queue worker.&lt;/p&gt;

&lt;p&gt;And it's hard to spot in production too. If your models don't use tenant-scoped global scopes, you won't even get exceptions. The job will happily query the central database, find nothing (or find the wrong tenant's data), and complete "successfully." You'll only discover the problem when a customer reports seeing data that isn't theirs. Or worse, when they don't report it because they didn't notice.&lt;/p&gt;

&lt;p&gt;Here's how to detect it before a customer does. Add a sanity check at the start of every tenant job's &lt;code&gt;handle()&lt;/code&gt; method during your early production phase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;report&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"Job "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;" ran without tenant context. "&lt;/span&gt;
            &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"Expected tenant: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantId&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fail&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="c1"&gt;// Proceed with actual job logic...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you an early warning in your error tracker instead of a silent data leak. Remove it once you've confirmed your bootstrapper is working correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Both major tenancy packages handle this, but you have to explicitly enable it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With stancl/tenancy (v3.10):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enable the &lt;code&gt;QueueTenancyBootstrapper&lt;/code&gt; in your &lt;code&gt;config/tenancy.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'bootstrappers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Add this&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This serializes the current tenant's ID into the job payload and restores tenant context before the job runs. But there's a catch. The bootstrapper fires &lt;em&gt;after&lt;/em&gt; &lt;code&gt;SerializesModels&lt;/code&gt; tries to rehydrate your models. So don't pass tenant-scoped Eloquent models directly into job constructors. Pass the ID instead:&lt;br&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;// Bad: model rehydration runs before tenant context is restored&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Invoice&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Good: pass the ID, query inside handle()&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$invoiceId&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Tenant context is active by this point&lt;/span&gt;
        &lt;span class="nv"&gt;$invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Process it...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With spatie/laravel-multitenancy (v4.1):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;queues_are_tenant_aware_by_default&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; in &lt;code&gt;config/multitenancy.php&lt;/code&gt;. Or implement the &lt;code&gt;TenantAware&lt;/code&gt; marker interface on individual jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Multitenancy\Jobs\TenantAware&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;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TenantAware&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This job will automatically run in the correct tenant context&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jobs that should explicitly run in the central context can implement &lt;code&gt;NotTenantAware&lt;/code&gt; instead. The same &lt;code&gt;SerializesModels&lt;/code&gt; warning applies here: pass IDs, not models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: Cache Key Collisions Across Tenants
&lt;/h2&gt;

&lt;p&gt;This bug is quieter than the first one. No exceptions, no failed jobs. Just wrong numbers on a dashboard that nobody notices for weeks.&lt;/p&gt;

&lt;p&gt;Imagine you cache a dashboard stat:&lt;br&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;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'monthly_revenue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addHour&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That key, &lt;code&gt;monthly_revenue&lt;/code&gt;, is the same string for every tenant. If Tenant A's &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue job processes a report&lt;/a&gt; and caches the result, Tenant B reads the same cache key and sees Tenant A's revenue figure.&lt;/p&gt;

&lt;p&gt;The obvious fix is to prefix every cache key with the tenant ID manually. But that's error-prone because you'll forget it in at least one place. The better fix is to let the tenancy package handle prefixing globally.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;With stancl/tenancy:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;CacheTenancyBootstrapper&lt;/code&gt; (shown in the Bug 1 config above) automatically prefixes all cache keys with the tenant's identifier. Every &lt;code&gt;Cache::get()&lt;/code&gt; and &lt;code&gt;Cache::put()&lt;/code&gt; call is transparently scoped. You don't change your application code at all.&lt;/p&gt;

&lt;p&gt;But watch for these edge cases that the bootstrapper doesn't catch automatically:&lt;br&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;// These need manual tenant scoping:&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Redis locks&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"report_generation_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$tenant&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="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Rate limiting keys&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"api_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$tenant&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="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&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="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Laravel Scout search indexes&lt;/span&gt;
&lt;span class="c1"&gt;// Use tenant-prefixed index names or a filterable tenant_id attribute&lt;/span&gt;

&lt;span class="c1"&gt;// 4. spatie/laravel-permission cache&lt;/span&gt;
&lt;span class="c1"&gt;// Set a tenant-specific cache key in config/permission.php&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With spatie/laravel-multitenancy:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add the &lt;code&gt;PrefixCacheTask&lt;/code&gt; to your switch tenant tasks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/multitenancy.php&lt;/span&gt;
&lt;span class="s1"&gt;'switch_tenant_tasks'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;\Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;\Spatie\Multitenancy\Tasks\PrefixCacheTask&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Add this&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prefixes cache keys for memory-based stores like Redis and APC. The same edge cases apply: locks, rate limiters, and third-party package caches all need manual attention.&lt;/p&gt;

&lt;p&gt;One more thing. If you're running Laravel Octane in a multi-tenant setup, you have an additional risk. Octane reuses the same application instance across requests. A stale cache prefix from a previous request's tenant can bleed into the next request if the bootstrapper doesn't reset properly between requests.&lt;/p&gt;

&lt;p&gt;And cache isn't the only place where unprefixed keys cause cross-tenant leaks. Session cookies on wildcard domains (&lt;code&gt;*.yourapp.com&lt;/code&gt;) can let a session from one tenant's subdomain work on another tenant's subdomain. Validation rules like &lt;code&gt;Rule::unique('users', 'email')&lt;/code&gt; check the full table unless you scope them with a &lt;code&gt;where&lt;/code&gt; clause. Rate limiting keys based on IP addresses mean tenants behind the same corporate proxy share rate counters. These aren't queue-specific bugs, but they compound when a queued job reads from a session or applies a validation rule without tenant scoping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: Failed Job Retries Run in the Wrong Tenant
&lt;/h2&gt;

&lt;p&gt;Your tenancy bootstrapper serializes the tenant ID into the job payload. Jobs dispatch and process correctly. You think you're covered. Then a job fails.&lt;/p&gt;

&lt;p&gt;This one is particularly nasty because it takes time to appear. Bugs 1 and 2 can show up on day one if you're paying attention. But Bug 3 only triggers when a job actually fails, and then only when someone retries it. Most SaaS apps don't have a robust retry workflow in their first few months. They're focused on getting things working, not recovering from failures. So this bug sits dormant, waiting for the first time an external API times out or a database connection hiccups.&lt;/p&gt;

&lt;p&gt;Laravel stores failed jobs in the &lt;code&gt;failed_jobs&lt;/code&gt; table. When you run &lt;code&gt;php artisan queue:retry 5&lt;/code&gt; three days later, the framework pulls the serialized payload, reconstructs the job, and dispatches it again. But here's the problem: the retry mechanism doesn't fire your tenancy bootstrapper the same way the original dispatch did.&lt;/p&gt;

&lt;p&gt;With spatie/laravel-multitenancy, this was &lt;a href="https://github.com/spatie/laravel-multitenancy/issues/259" rel="noopener noreferrer"&gt;a confirmed bug&lt;/a&gt; in earlier versions. The tenant ID was embedded in the payload, but the retry path didn't restore tenant context before the job started processing. The job would run in whatever tenant context the worker happened to have at that moment, which could be the central database or a completely different tenant.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;The fix depends on your package version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With stancl/tenancy v3.10:&lt;/strong&gt; The &lt;code&gt;QueueTenancyBootstrapper&lt;/code&gt; handles retries correctly in recent versions. The tenant ID is stored at the top level of the job payload and restored on retry. Verify this by inspecting a failed job's payload:&lt;br&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;$failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'failed_jobs'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// You should see a tenant_id key at the top level&lt;/span&gt;
&lt;span class="nf"&gt;dd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// Should not be null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;tenant_id&lt;/code&gt; is missing from the payload, your bootstrapper isn't configured correctly. Go back to Bug 1 and ensure &lt;code&gt;QueueTenancyBootstrapper&lt;/code&gt; is in your bootstrappers array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With spatie/laravel-multitenancy v4.1:&lt;/strong&gt; The &lt;code&gt;MakeQueueTenantAwareAction&lt;/code&gt; in v4 handles this correctly. But if you're on an older version, you can listen for the retry event manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In a service provider&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Events\JobRetryRequested&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JobRetryRequested&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;makeCurrent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Testing retries properly:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part most teams skip. You can't test retries with &lt;code&gt;Queue::fake()&lt;/code&gt;. You need an integration test that uses a real queue driver, dispatches a job that deliberately fails, then retries it and verifies the tenant context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'retries failed jobs in the correct tenant context'&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;$tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;makeCurrent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Dispatch a job that will fail on first attempt&lt;/span&gt;
    &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FailOnceJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&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="c1"&gt;// Process the queue (job fails)&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:work'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--once'&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="c1"&gt;// Retry the failed job&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:retry'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'all'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Process again (should succeed in correct tenant context)&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:work'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--once'&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="c1"&gt;// Assert the job ran in the correct tenant&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'job_results'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Audit Checklist
&lt;/h2&gt;

&lt;p&gt;If you're running a &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;multi-tenant Laravel application&lt;/a&gt;, here's a quick audit you can run right now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenant context in jobs:&lt;/strong&gt; Dispatch a job from a tenant context with the &lt;code&gt;database&lt;/code&gt; or &lt;code&gt;redis&lt;/code&gt; driver (not &lt;code&gt;sync&lt;/code&gt;). Check whether the job runs against the correct tenant's data. If you're passing Eloquent models to job constructors, switch to passing IDs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache isolation:&lt;/strong&gt; Open &lt;code&gt;tinker&lt;/code&gt; as Tenant A, run &lt;code&gt;Cache::put('test', 'tenant-a')&lt;/code&gt;. Switch to Tenant B, run &lt;code&gt;Cache::get('test')&lt;/code&gt;. If you get &lt;code&gt;tenant-a&lt;/code&gt; back, your cache isn't scoped. Enable the cache bootstrapper or prefix task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failed job retries:&lt;/strong&gt; Deliberately fail a tenant job, wait a minute, run &lt;code&gt;queue:retry all&lt;/code&gt;. Check which tenant's database the retried job hit. If it's wrong, check your package version and bootstrapper configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue topology:&lt;/strong&gt; If one tenant dispatches a massive CSV export job, does it block jobs for every other tenant? Consider &lt;a href="https://hafiz.dev/blog/laravel-queue-route-centralize-queue-topology" rel="noopener noreferrer"&gt;dedicating separate queues&lt;/a&gt; for heavy operations so one tenant's workload doesn't starve the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker restart cadence:&lt;/strong&gt; Queue workers hold state in memory. If you deploy a tenancy config change but don't restart workers, the old configuration stays active. Always run &lt;code&gt;php artisan queue:restart&lt;/code&gt; after deploying changes to your tenancy bootstrappers or cache prefix configuration. In production, use a process manager like Supervisor that can gracefully restart workers on deploy.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need stancl/tenancy or spatie/laravel-multitenancy for this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not strictly. You can build tenant-aware queues manually by storing the tenant ID in every job and restoring context in &lt;code&gt;handle()&lt;/code&gt;. But the packages automate the serialization, context restoration, and cache scoping. If you're already using one of them for your &lt;a href="https://hafiz.dev/blog/filament-v5-multi-tenancy-complete-implementation-guide" rel="noopener noreferrer"&gt;multi-tenancy implementation&lt;/a&gt;, enabling queue support is a few lines of config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the sync queue driver to avoid these bugs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Technically yes, because the sync driver processes jobs inline in the same request, so tenant context is always present. But the sync driver blocks the request until the job finishes, which defeats the purpose of using queues. These bugs only surface with async drivers (database, Redis, SQS), and that's exactly what you'll use in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Laravel Horizon help with tenant-scoped queues?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Horizon gives you visibility into what's happening on your queues, but it doesn't handle tenant context itself. You still need the tenancy package's queue bootstrapper for context restoration. That said, Horizon's queue balancing and monitoring are useful for spotting when one tenant's jobs dominate the queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about database-per-tenant setups? Is the jobs table per tenant or central?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep the &lt;code&gt;jobs&lt;/code&gt; and &lt;code&gt;failed_jobs&lt;/code&gt; tables in the central (landlord) database. If you put them in tenant databases, the queue worker won't know which tenant database to connect to before it even picks up a job. Both stancl/tenancy and spatie/laravel-multitenancy recommend central job storage with tenant context serialized into the payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I run separate queue workers per tenant?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For most apps, no. A shared worker pool with tenant context in the payload works fine. Separate workers per tenant only makes sense if you have strict resource isolation requirements or tenants with wildly different job volumes. The complexity of managing dozens of worker processes usually isn't worth it until you're well past your first year.&lt;/p&gt;

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

&lt;p&gt;Multi-tenancy and queues are both solved problems individually. The bugs live at the intersection, in the gap between "tenant context exists during the HTTP request" and "tenant context needs to exist in a background worker too." All three bugs share the same root cause: queue workers are long-lived processes that don't get fresh context the way web requests do.&lt;/p&gt;

&lt;p&gt;The good news is that both stancl/tenancy (v3.10) and spatie/laravel-multitenancy (v4.1) have solid solutions for all three. But you have to enable them, test them with a real queue driver, and audit your cache keys. If you're building a multi-tenant SaaS on Laravel and want help getting the queue layer right, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>multitenancy</category>
      <category>queues</category>
      <category>saas</category>
    </item>
    <item>
      <title>Every Livewire Public Property Is a Form Field: The Security Audit Every Laravel App Needs</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:18:27 +0000</pubDate>
      <link>https://dev.to/hafiz619/every-livewire-public-property-is-a-form-field-the-security-audit-every-laravel-app-needs-145i</link>
      <guid>https://dev.to/hafiz619/every-livewire-public-property-is-a-form-field-the-security-audit-every-laravel-app-needs-145i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/livewire-public-properties-security-audit" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every public property in your Livewire component is sent to the browser. Every single one. The snapshot that Livewire uses to maintain state between requests includes every public property value in plain JSON. Your users can see them, modify them, and send them back to your server.&lt;/p&gt;

&lt;p&gt;Most Laravel developers don't think about this. They write &lt;code&gt;public $userId&lt;/code&gt; the same way they'd write a protected property on any other PHP class. The difference is that a regular PHP property lives only on the server. A Livewire public property lives on both sides, and the client side isn't yours.&lt;/p&gt;

&lt;p&gt;This post covers what can go wrong, what already went wrong in production for thousands of apps, and how to audit your own components in about 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the snapshot works
&lt;/h2&gt;

&lt;p&gt;When Livewire renders a component, it serializes all public properties into a JSON snapshot that gets embedded in the page. On every subsequent request (a button click, a form submission, a &lt;code&gt;wire:model&lt;/code&gt; update), the browser sends that snapshot back to the server. Livewire hydrates the component from the snapshot, applies the update, and sends a new snapshot back.&lt;/p&gt;

&lt;p&gt;The attack vector is simple: the snapshot travels through the browser, and the browser is controlled by the user.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/livewire-public-properties-security-audit" 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;Anything in that snapshot can be changed before it comes back. IDs, status flags, prices, permissions, whatever your public properties hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The concrete attack
&lt;/h2&gt;

&lt;p&gt;Here's a component that looks normal but is vulnerable:&lt;br&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;Livewire\Component&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;EditProfile&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$email&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;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$userId&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;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&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;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&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;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: &lt;code&gt;$userId&lt;/code&gt; is public. A user loading their own profile page sees their own ID in the snapshot. They open DevTools, change the ID to another user's ID, and the next &lt;code&gt;save()&lt;/code&gt; call updates someone else's profile. No authentication bypass needed. No SQL injection. Just modifying a JSON value the server trusts implicitly.&lt;/p&gt;

&lt;p&gt;This pattern shows up constantly in real codebases. Any component where a public property determines &lt;em&gt;whose&lt;/em&gt; data gets read or written is vulnerable if the property isn't locked or the action doesn't re-authorize.&lt;/p&gt;

&lt;h2&gt;
  
  
  CVE-2025-54068: proof this matters
&lt;/h2&gt;

&lt;p&gt;In July 2025, a critical vulnerability (CVE-2025-54068) was disclosed in Livewire v3 versions 3.0.0-beta.1 through 3.6.3. The flaw was in the property hydration mechanism itself, allowing unauthenticated attackers to achieve remote code execution by crafting malicious property updates. No authentication required. No user interaction needed.&lt;/p&gt;

&lt;p&gt;The vulnerability was patched in v3.6.4. If you're running anything older, update immediately.&lt;/p&gt;

&lt;p&gt;The broader lesson from this CVE is that the property hydration pipeline is a real attack surface. The RCE was the most severe example, but garden-variety property manipulation (changing IDs, toggling flags, modifying amounts) is something any developer with browser DevTools can do right now against your components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to protect your components
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Lock properties that shouldn't change
&lt;/h3&gt;

&lt;p&gt;Livewire v3 introduced the &lt;code&gt;#[Locked]&lt;/code&gt; attribute for exactly this problem. When a property is locked, any attempt to modify it from the client throws an exception.&lt;br&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;Livewire\Attributes\Locked&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Livewire\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditProfile&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Locked]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$email&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;Now if someone modifies &lt;code&gt;$userId&lt;/code&gt; in the snapshot, Livewire rejects the request before your code even runs. This is the simplest fix for any property that gets set during &lt;code&gt;mount()&lt;/code&gt; and should never change after that.&lt;/p&gt;

&lt;p&gt;Don't forget the import: &lt;code&gt;use Livewire\Attributes\Locked;&lt;/code&gt;. Missing it is a silent failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use Eloquent models instead of IDs
&lt;/h3&gt;

&lt;p&gt;Livewire automatically protects Eloquent model IDs when you store the full model as a property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditProfile&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&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;email&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;Livewire ensures the model's ID can't be tampered with. No &lt;code&gt;#[Locked]&lt;/code&gt; needed. This is the recommended pattern for most components that operate on a single model. If you're storing &lt;code&gt;$postId&lt;/code&gt; or &lt;code&gt;$userId&lt;/code&gt; as a plain integer, ask yourself why you're not storing the model instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Authorize in every action method
&lt;/h3&gt;

&lt;p&gt;Even with locked properties, action parameters are still modifiable. The &lt;code&gt;wire:click="delete({{ $post-&amp;gt;id }})"&lt;/code&gt; in your Blade template sends the post ID as an argument, and that argument can be changed in the browser.&lt;/p&gt;

&lt;p&gt;Always authorize:&lt;br&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$postId&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;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never trust that the value coming from the browser is the same value you rendered. Treat every Livewire action parameter exactly like you'd treat a POST request parameter. Validate and authorize every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-minute audit
&lt;/h2&gt;

&lt;p&gt;Run these searches against your Livewire components. Each one finds a potential vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find unlocked ID properties:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'public \$.*[Ii]d'&lt;/span&gt; app/Livewire/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'#\[Locked\]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any result that stores an ID without &lt;code&gt;#[Locked]&lt;/code&gt; or without being a full Eloquent model binding is a candidate for fixing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find action methods that trust their parameters:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'function delete\|function update\|function remove\|function approve'&lt;/span&gt; app/Livewire/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check each result: does the method verify that the authenticated user has permission to perform the action on the specific resource? If it goes straight from parameter to database query without authorization, it's vulnerable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find components that use &lt;code&gt;$this-&amp;gt;someId&lt;/code&gt; in queries without authorization:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'find(\$this-&amp;gt;'&lt;/span&gt; app/Livewire/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;find($this-&amp;gt;someId)&lt;/code&gt; should be followed by an authorization check, or the property should be &lt;code&gt;#[Locked]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once you've fixed the patterns, write &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest tests&lt;/a&gt; that attempt to tamper with locked properties and verify the exceptions fire. Automated tests catch regressions when someone removes a &lt;code&gt;#[Locked]&lt;/code&gt; attribute without realizing what it protects.&lt;/p&gt;

&lt;p&gt;For a broader security audit covering Composer dependencies and supply chain risks, the &lt;a href="https://hafiz.dev/blog/fake-laravel-packages-targeting-your-env-how-to-audit-composer-dependencies" rel="noopener noreferrer"&gt;Composer audit guide&lt;/a&gt; walks through the process of checking what you've actually installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Livewire does and doesn't protect
&lt;/h2&gt;

&lt;p&gt;A few things Livewire handles for you that are worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Middleware re-application.&lt;/strong&gt; If your Livewire component is loaded via a route with authorization middleware (like &lt;code&gt;can:update,post&lt;/code&gt;), Livewire re-applies that middleware on every subsequent request. So a user who loads the page but then loses permission will be blocked on the next interaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model property IDs.&lt;/strong&gt; As mentioned, storing a full Eloquent model as &lt;code&gt;public User $user&lt;/code&gt; protects the model ID automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checksum validation.&lt;/strong&gt; Livewire signs its snapshots with the application key. This prevents wholesale snapshot forgery. But it doesn't prevent modification of individual property values, the checksum covers the snapshot's structure, not the content of mutable properties.&lt;/p&gt;

&lt;p&gt;What Livewire does NOT protect: plain public property values (integers, strings, booleans), action method parameters, and any property you don't explicitly lock.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Should I lock every public property?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Properties bound to &lt;code&gt;wire:model&lt;/code&gt; need to be mutable. Lock the properties that get set in &lt;code&gt;mount()&lt;/code&gt; and should never change: IDs, user references, permission flags, anything that determines &lt;em&gt;whose&lt;/em&gt; data the component operates on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;#[Locked]&lt;/code&gt; work on Livewire 4?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The attribute exists in both Livewire v3 and v4. The import is &lt;code&gt;use Livewire\Attributes\Locked;&lt;/code&gt; in both versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use protected properties instead?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Protected properties don't persist between Livewire requests. They're fine for static values you set once and never need again, but any runtime data that must survive between user interactions has to be a public property. That's why &lt;code&gt;#[Locked]&lt;/code&gt; exists: it gives you the persistence of a public property with the safety of a protected one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this only a problem with Livewire, or does Inertia have the same issue?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inertia sends props to the frontend too, but Inertia props are read-only on the client. The client doesn't send them back on subsequent requests. Livewire's two-way sync is what creates the attack surface. Inertia forms use explicit POST requests with validated data, so the pattern is fundamentally different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My app is behind authentication. Am I still at risk?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The attack doesn't require being unauthenticated. A logged-in user can modify their own component's snapshot to access or modify another user's data. Authentication proves who someone is, authorization proves what they're allowed to do. You need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Every time you write &lt;code&gt;public $something&lt;/code&gt; in a Livewire component, ask yourself one question: what happens if the user changes this value? If the answer is "something bad," lock it with &lt;code&gt;#[Locked]&lt;/code&gt; or store the full Eloquent model instead.&lt;/p&gt;

&lt;p&gt;The 30-minute audit above catches the most common patterns. Run it once, fix what you find, and add &lt;code&gt;#[Locked]&lt;/code&gt; to your mental default for any property that determines data ownership.&lt;/p&gt;

&lt;p&gt;Security at the application layer and security at the server layer are different problems. For the infrastructure side, the &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; covers closing ports, hiding your IP, and locking SSH. Both layers matter.&lt;/p&gt;

&lt;p&gt;Got a Livewire app you want audited? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>security</category>
      <category>php</category>
    </item>
    <item>
      <title>How I Refactored a 1,200-Line Filament Resource Into Something I Could Actually Maintain</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 10 Jun 2026 07:20:19 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-i-refactored-a-1200-line-filament-resource-into-something-i-could-actually-maintain-31ll</link>
      <guid>https://dev.to/hafiz619/how-i-refactored-a-1200-line-filament-resource-into-something-i-could-actually-maintain-31ll</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/refactoring-large-filament-resource" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You know the resource is in trouble when scrolling to &lt;code&gt;table()&lt;/code&gt; takes three full page-downs past the form definition. When every pull request touches the same file because everything lives in one place. When adding a column to the table means scrolling past 400 lines of form fields to find where the table starts.&lt;/p&gt;

&lt;p&gt;Filament resources grow faster than almost any other file in a Laravel project. The framework makes it easy to define forms, tables, actions, filters, and relations in one class, which is great for small models. But for anything complex, that single class becomes the most dangerous file in your codebase: too long to navigate, too coupled to test, too fragile to change.&lt;/p&gt;

&lt;p&gt;I hit this with a resource that managed a multi-step onboarding flow. The form had tabs, conditional fields, repeater components, and custom validation. The table had 15 columns, 6 filters, and 4 custom actions. The resource file was 1,247 lines. Every time I opened it, I spent more time scrolling than coding.&lt;/p&gt;

&lt;p&gt;This post walks through four levels of extraction I used to bring it back under control. Each level is independent. You can apply one, two, or all four depending on how large your resource is and how much refactoring time you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 1: Split form and table into methods
&lt;/h2&gt;

&lt;p&gt;This takes five minutes and immediately makes the file navigable.&lt;/p&gt;

&lt;p&gt;Instead of defining everything inline in &lt;code&gt;form()&lt;/code&gt; and &lt;code&gt;table()&lt;/code&gt;, extract logical sections into static methods within the same class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Resource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Order'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                    &lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Details'&lt;/span&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="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;detailsFormSchema&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
                    &lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Shipping'&lt;/span&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="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;shippingFormSchema&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
                    &lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Payment'&lt;/span&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="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;paymentFormSchema&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="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;detailsFormSchema&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="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order_number'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="c1"&gt;// ... 15 more fields&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;shippingFormSchema&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="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_address'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_city'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_country'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Country&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;paymentFormSchema&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="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the same for the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Table&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Table&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableColumns&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableFilters&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableActions&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bulkActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableBulkActions&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tableColumns&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="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order_number'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer.name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;BadgeColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'eur'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't reduce line count, but it makes the file navigable. You can collapse methods in your IDE. The &lt;code&gt;form()&lt;/code&gt; method becomes 10 lines instead of 200. You can jump directly to &lt;code&gt;shippingFormSchema()&lt;/code&gt; when that's the section you need to edit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; Always. Even for small resources, this is good practice. It costs nothing and pays off immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 2: Extract form sections into separate classes
&lt;/h2&gt;

&lt;p&gt;When a form section has 20+ fields or complex conditional logic, extract it into its own class. This is where the real line count reduction happens.&lt;/p&gt;

&lt;p&gt;Create a class that extends &lt;code&gt;Filament\Forms\Components\Section&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Filament\Forms\Sections\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;Filament\Forms\Components\Section&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Components\TextInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Components\Select&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;ShippingSection&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Section&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;make&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;$heading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Shipping'&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="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$heading&lt;/span&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;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_address'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_city'&lt;/span&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="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_country'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Country&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_postal_code'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipping_phone'&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;columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;collapsible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the resource references it with one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Filament\Forms\Sections\Order\ShippingSection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Order'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Details'&lt;/span&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="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;detailsFormSchema&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Shipping'&lt;/span&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;ShippingSection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="nc"&gt;Tab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Payment'&lt;/span&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;PaymentSection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each section class owns its fields, validation, and layout. The resource file drops from 1,200 lines to maybe 200. And if the same shipping form is needed in another resource (like a &lt;code&gt;CustomerResource&lt;/code&gt; or a &lt;code&gt;ReturnResource&lt;/code&gt;), you reuse the section class without duplicating a thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Directory structure I use:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/Filament/Forms/Sections/
    Order/
        ShippingSection.php
        PaymentSection.php
        DetailsSection.php
    Customer/
        PersonalInfoSection.php
        AddressSection.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; When a form section has more than 15 fields, when the same fields appear in multiple resources, or when a section has complex conditional logic that makes the resource file hard to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 3: Extract table actions into Action classes
&lt;/h2&gt;

&lt;p&gt;Filament table actions defined inline add up fast. A single action with a confirmation modal, form fields, and business logic can be 30-50 lines. Four actions and you're at 200 lines just for the actions array.&lt;/p&gt;

&lt;p&gt;The solution: create dedicated Action classes. Here's a pattern adapted from Johannes Pichler's approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Filament\Actions\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;Filament\Tables\Actions\Action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Notifications\Notification&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;MarkAsShippedAction&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;make&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Action&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;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mark-as-shipped'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Mark Shipped'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'heroicon-o-truck'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;requiresConfirmation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;notify&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;OrderShippedNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

                &lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Order marked as shipped'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;visible&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;$record&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;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'processing'&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;In the resource, the action registration becomes one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tableActions&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="nc"&gt;MarkAsShippedAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;SendInvoiceAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;RefundAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;Tables\Actions\EditAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each action is testable independently. Each action can be reused across resources if needed. And the resource file doesn't grow every time you add a new action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; When actions contain business logic beyond simple CRUD. If the action is just &lt;code&gt;EditAction::make()&lt;/code&gt; or &lt;code&gt;DeleteAction::make()&lt;/code&gt;, leave it inline. Extract when the action has custom logic, confirmations, form fields, or notifications.&lt;/p&gt;

&lt;p&gt;For a broader framework on when to extract logic into Actions versus Services, the &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;Service vs Action vs Job decision tree&lt;/a&gt; covers the general principles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 4: Use page-level overrides
&lt;/h2&gt;

&lt;p&gt;Filament resources delegate to page classes for Create, Edit, View, and List. Each page can override the form or table configuration from the resource. This is useful when your create and edit forms are significantly different.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Filament\Resources\OrderResource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Resources\Pages\CreateRecord&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filament\Forms\Form&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateOrder&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;CreateRecord&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="c1"&gt;// Simplified create form: only essential fields&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer_name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'product_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'quantity'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The create form has 3 fields while the edit form (defined on the resource) has 40. Without this separation, you'd have conditional &lt;code&gt;-&amp;gt;hidden()&lt;/code&gt; calls everywhere, making the form definition harder to follow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; When create and edit forms differ significantly, when the list page needs a different table configuration than what the resource defines, or when a specific page has unique header actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How far to go
&lt;/h2&gt;

&lt;p&gt;Not every resource needs all four levels. Here's the simple version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Under 200 lines:&lt;/strong&gt; Don't refactor. The resource is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;200-500 lines:&lt;/strong&gt; Level 1 (method extraction). Takes five minutes. Do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;500-1,000 lines:&lt;/strong&gt; Levels 1 + 2 (method extraction + section classes). This handles most cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over 1,000 lines:&lt;/strong&gt; All four levels. Your resource is complex enough to justify the structure. The time spent refactoring pays back within a week of not scrolling past 400 lines of form fields to find the table definition.&lt;/p&gt;

&lt;p&gt;The goal isn't to have the fewest lines in the resource. It's to make the resource navigable, testable, and safe to change. If you can open the file, find what you need in under 5 seconds, and change it without worrying about side effects, the refactoring worked.&lt;/p&gt;

&lt;p&gt;For the broader architecture of a Filament-based SaaS, the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;full SaaS guide&lt;/a&gt; covers the patterns beyond individual resources. And for the &lt;a href="https://hafiz.dev/blog/mastering-design-patterns-in-laravel" rel="noopener noreferrer"&gt;design patterns&lt;/a&gt; that underpin these extraction techniques, Strategy and Composition are the two that matter most here.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does extracting form sections affect Filament's reactivity (wire:model, conditional visibility)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Extracted sections are just PHP classes that return the same component arrays. Livewire's reactivity is based on the rendered component tree, not on which PHP class defined the components. &lt;code&gt;-&amp;gt;visible(fn (Get $get) =&amp;gt; $get('status') === 'active')&lt;/code&gt; works identically whether it's defined in the resource or in a section class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use Traits instead of separate classes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Traits work for small extractions (a shared set of filters, a common set of bulk actions). For form sections and table configurations, separate classes are better because they're independently testable and don't pollute the resource's namespace. A resource with 6 traits is just as hard to navigate as a resource with 1,200 lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the &lt;code&gt;rmitesh/filament-action&lt;/code&gt; package?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It provides an artisan command to generate Filament action classes: &lt;code&gt;php artisan make:filament-action CommentAction --resource=UserResource&lt;/code&gt;. If you're extracting many actions across multiple resources, it saves boilerplate. For a few extractions, the manual approach in Level 3 is lighter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test extracted section classes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instantiate the section, call the static &lt;code&gt;make()&lt;/code&gt; method, and assert the schema contains the expected components. You can also use Filament's testing utilities to render a full form with the section and assert field values. The key benefit: you test the section without loading the entire resource.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this approach with Filament v4/v5?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The section and action extraction patterns work across Filament v3, v4, and v5. The component API is stable. The only thing that changes between versions is import paths and some method names, which are easy to update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;After applying all four levels to that 1,247-line resource, the resource file itself dropped to 187 lines. The form is a list of section references. The table is a list of column and action references. Each section and action lives in its own file, testable and reusable.&lt;/p&gt;

&lt;p&gt;The total line count across all files is actually higher than the original single file. That's the right trade-off. Code organization isn't about fewer lines. It's about each file doing one thing, being easy to find, and being safe to change without side effects.&lt;/p&gt;

&lt;p&gt;Working with Filament on a project that's outgrowing its current structure? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>architecture</category>
      <category>php</category>
    </item>
    <item>
      <title>I Stopped Putting Everything in Service Classes. Here's the Decision Tree I Use Now</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 03 Jun 2026 14:39:11 +0000</pubDate>
      <link>https://dev.to/hafiz619/i-stopped-putting-everything-in-service-classes-heres-the-decision-tree-i-use-now-40dl</link>
      <guid>https://dev.to/hafiz619/i-stopped-putting-everything-in-service-classes-heres-the-decision-tree-i-use-now-40dl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;A few years ago I had a &lt;code&gt;UserService&lt;/code&gt; that had grown to 28 methods. It handled registration, email verification, password resets, subscription upgrades, profile updates, and account deletion. It was the most-imported class in the codebase and the most dangerous to touch. One change to the registration flow meant scrolling past 400 lines of code that had nothing to do with it.&lt;/p&gt;

&lt;p&gt;At some point I stopped and asked myself what a Service class actually is in Laravel. There's no &lt;code&gt;php artisan make:service&lt;/code&gt; command. No interface requirement. No convention in the framework documentation telling you what belongs there. We just started putting things in Services because someone told us to move logic out of controllers, and Services were the first pattern we learned.&lt;/p&gt;

&lt;p&gt;The result is predictable. The controller gets thin. The Service gets fat. The problem moved, it didn't get solved.&lt;/p&gt;

&lt;p&gt;This post is the decision tree I wish I'd had at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three patterns, briefly
&lt;/h2&gt;

&lt;p&gt;Before the tree, a quick grounding on what each pattern actually does, because the language is inconsistent in the community.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Service class&lt;/strong&gt; groups related operations on the same domain object. A &lt;code&gt;SubscriptionService&lt;/code&gt; knows how to subscribe a user, upgrade them, cancel them, and check their current plan. It holds methods, not just one. Think of it as the "everything about subscriptions" class. It can be stateful or stateless.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;Action class&lt;/strong&gt; handles a single, atomic operation. &lt;code&gt;CreateUser&lt;/code&gt;, &lt;code&gt;SendPasswordResetEmail&lt;/code&gt;, &lt;code&gt;ChargePaymentMethod&lt;/code&gt;. One class, one job, one public method. It can't be a Job because you need the result immediately. It can be reused from a controller, an Artisan command, another class, and a test. The key word is reuse across contexts.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Job&lt;/strong&gt; runs asynchronously on the queue. Full stop. That's the definition. If your code doesn't need to run in the background, it's not a Job, no matter how tempting it is to make it one. Jobs are for fire-and-forget work where you don't need the result before the HTTP response returns.&lt;/p&gt;

&lt;p&gt;There's some overlap. But the overlap is a signal that you need to choose, not that both options are equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision tree
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" 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;Walk through it with any piece of logic and you'll get an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong when you ignore the tree
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bloated Service.&lt;/strong&gt; You create a &lt;code&gt;UserService&lt;/code&gt; for user registration. Six months later someone adds password reset to it because, well, it's user-related. Then profile updates. Then account deletion. Then a method that checks if the user is eligible for a discount. Your Service is now a 600-line class with no coherent identity. Every test for registration pulls in the entire class including all the subscription logic you never wanted to touch.&lt;/p&gt;

&lt;p&gt;The fix: split by responsibility, not by model. &lt;code&gt;RegistrationService&lt;/code&gt; handles registration. &lt;code&gt;PasswordResetService&lt;/code&gt; handles password resets. Or, if those operations are simple and atomic, make them Actions instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Action folder explosion.&lt;/strong&gt; The opposite problem. You adopt the Action pattern and start creating an Action for every single thing. &lt;code&gt;GetUserByEmailAction&lt;/code&gt;. &lt;code&gt;FormatDateAction&lt;/code&gt;. &lt;code&gt;ValidatePostcodeAction&lt;/code&gt;. After 200 files, navigating &lt;code&gt;app/Actions&lt;/code&gt; is slower than just looking in the controller.&lt;/p&gt;

&lt;p&gt;Actions should answer a clear question: "Would I want to call this from a controller &lt;em&gt;and&lt;/em&gt; from an Artisan command &lt;em&gt;and&lt;/em&gt; from a queue job?" If the answer is no, keep it inline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jobs used as glorified Actions.&lt;/strong&gt; The most common mistake. You've got some logic that feels heavy, so you make it a Job. But you dispatch it synchronously with &lt;code&gt;dispatch()-&amp;gt;now()&lt;/code&gt;, or you realise you need the return value, so you jump through hoops. A Job that you always dispatch synchronously and need a result from is just an Action wearing the wrong costume. Make it an Action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real examples from production
&lt;/h2&gt;

&lt;p&gt;A few scenarios I've hit that show how the tree plays out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Charging a subscription.&lt;/strong&gt; Is it async? No. You need the result to know whether to give the user access. Single operation? Yes. Used in multiple places (web checkout, API checkout, CLI seeder)? Yes. This is an Action: &lt;code&gt;ChargeSubscription::handle($user, $plan)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sending a weekly digest email to 50,000 users.&lt;/strong&gt; Async? Yes. Job. You chunk the users, dispatch one Job per batch, move on. The Job calls a Mailable. You don't need the result in the HTTP response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything related to how a subscription works.&lt;/strong&gt; Subscribe, cancel, check status, apply promo code, handle webhook from Stripe. These are multiple operations on the same domain concept, with shared logic (checking existing state before changing it). This is a Service: &lt;code&gt;SubscriptionService&lt;/code&gt;. Each method handles one transition, but they all need to know about each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sending a single transactional email.&lt;/strong&gt; Async? Yes, probably. But &lt;code&gt;Mail::queue()&lt;/code&gt; handles that. You don't need a Job class wrapping a Mailable. The Mailable itself handles queueing when sent via &lt;code&gt;Mail::queue()&lt;/code&gt;. This is one of those cases where the extra class adds ceremony without adding value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people argue about
&lt;/h2&gt;

&lt;p&gt;The honest version of this debate is that Service vs Action is often a team preference more than a technical requirement. Both work. The real risk isn't choosing the wrong one. It's applying one pattern to everything regardless of fit.&lt;/p&gt;

&lt;p&gt;I use Services when I'm working on a domain area with multiple operations that share state or context. I use Actions when I have a single operation I know I'll call from multiple places. I use neither when the logic is simple enough to live in the controller and I don't expect to reuse it.&lt;/p&gt;

&lt;p&gt;The rule I enforce on my own projects: if a class has more than five methods or more than 150 lines, it needs to be split or reconsidered. That ceiling forces the question "what does this class actually do?" before it becomes a dumping ground.&lt;/p&gt;

&lt;p&gt;For a deeper look at async patterns and how Jobs fit into a larger queue architecture, the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue jobs guide&lt;/a&gt; covers sizing workers, retries, and the production setup worth knowing before you start dispatching at scale.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Should I always use an Action over a Service for new code?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not always. Actions work best for atomic, stateless operations with a single entry point. If you're building a domain area (subscriptions, invoicing, notifications) where multiple operations share context, a Service is cleaner. The question to ask is: does this class do one thing, or does it know everything about a concept?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should Actions live in the directory structure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/Actions/&lt;/code&gt;. For larger projects, namespace further by domain: &lt;code&gt;app/Actions/Billing/ChargeSubscription.php&lt;/code&gt;. Keep the name as a verb-noun pair: &lt;code&gt;CreateUser&lt;/code&gt;, &lt;code&gt;SendPasswordReset&lt;/code&gt;. Avoid names like &lt;code&gt;UserAction&lt;/code&gt;. That's just a Service with a different suffix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the &lt;code&gt;lorisleiva/laravel-actions&lt;/code&gt; package?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's a solid package. It lets a single Action class run as a controller, a Job, a listener, and a command depending on context. Worth considering if your team commits to the pattern across the codebase. For testing Actions and Services, the &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest 4 testing guide&lt;/a&gt; covers the isolation patterns. It adds real value when you have Actions that need to run in multiple contexts. For simpler projects it's additional overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a Job call an Action?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, and this is often the right pattern. The Job handles the queue mechanics (retries, delays, backoff). The Action handles the actual logic. &lt;code&gt;ProcessSubscriptionRenewal::handle()&lt;/code&gt; dispatches work, calls &lt;code&gt;ChargeSubscription::handle()&lt;/code&gt;, and handles the queue-specific failure cases. Clean separation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Events and Listeners?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Events and Listeners are for decoupled side effects after something happens. The &lt;a href="https://hafiz.dev/blog/how-laravel-events-listeners-observers-actually-work" rel="noopener noreferrer"&gt;Events, Listeners, and Observers guide&lt;/a&gt; covers the patterns in depth. A user registered, fire &lt;code&gt;UserRegistered&lt;/code&gt;, let the listeners handle the welcome email, the onboarding sequence, the analytics event. Don't use Actions or Services for this. That's the Events system doing its job. The relationship is: Actions and Services cause things to happen, Events communicate that they happened.&lt;/p&gt;

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

&lt;p&gt;The pattern you choose matters less than applying it consistently and knowing when to break from it. Service classes aren't wrong. Actions aren't always better. Jobs aren't for synchronous logic dressed up with a queue.&lt;/p&gt;

&lt;p&gt;The moment a class starts doing too many things, it's a signal to reach for the tree.&lt;/p&gt;

&lt;p&gt;Got a codebase you're trying to untangle? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

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




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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Side projects, early MVPs, apps you're not sure will stick.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly base&lt;/th&gt;
&lt;th&gt;Typical all-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Starter&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$4-8/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Hobby + Hetzner CX22&lt;/td&gt;
&lt;td&gt;$12 + €4.49&lt;/td&gt;
&lt;td&gt;~$17/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare Hetzner CX22&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;€4.49 (~$5/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

&lt;p&gt;You've found traction. Deployment and uptime start mattering.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly base&lt;/th&gt;
&lt;th&gt;Typical all-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Growth&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$25-45/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Growth + Hetzner CX32&lt;/td&gt;
&lt;td&gt;$19 + ~€9&lt;/td&gt;
&lt;td&gt;~$30/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare Hetzner CX32&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;~€9 (~$10/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

&lt;p&gt;Infrastructure decisions affect your margins and your incident response.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Typical monthly cost&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Growth&lt;/td&gt;
&lt;td&gt;$60-200+&lt;/td&gt;
&lt;td&gt;Usage scales with traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Business + multiple Hetzner&lt;/td&gt;
&lt;td&gt;$39 + $40-80&lt;/td&gt;
&lt;td&gt;More DevOps work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare VPS (load balanced)&lt;/td&gt;
&lt;td&gt;$25-60&lt;/td&gt;
&lt;td&gt;Cheapest, most complex&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>laravel</category>
      <category>deployment</category>
      <category>devops</category>
      <category>laravelcloud</category>
    </item>
  </channel>
</rss>
