<?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: laravel-o11y</title>
    <description>The latest articles on DEV Community by laravel-o11y (@laravelo11y).</description>
    <link>https://dev.to/laravelo11y</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%2F4096042%2F0dfa2533-f30d-4a22-9374-68f1797878dd.png</url>
      <title>DEV Community: laravel-o11y</title>
      <link>https://dev.to/laravelo11y</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/laravelo11y"/>
    <language>en</language>
    <item>
      <title>Laravel Middleware: Requests, Queued Jobs and the HTTP Client</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Mon, 12 Oct 2026 09:55:50 +0000</pubDate>
      <link>https://dev.to/laravelo11y/laravel-middleware-requests-queued-jobs-and-the-http-client-1me1</link>
      <guid>https://dev.to/laravelo11y/laravel-middleware-requests-queued-jobs-and-the-http-client-1me1</guid>
      <description>&lt;p&gt;Laravel has middleware in three places that most apps use every day. HTTP middleware wraps each incoming request, job middleware wraps each queued job, and Guzzle middleware wraps each outgoing call made through the HTTP client. The three signatures differ, but each one is a layer that receives the input and a callable for everything below it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All three kinds are a function wrapped around "the rest".&lt;/strong&gt; Each layer gets the thing being processed and a &lt;code&gt;$next&lt;/code&gt;. It can run code around the call to &lt;code&gt;$next&lt;/code&gt;, or return without making it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request and job middleware run on the same class.&lt;/strong&gt; Both go through &lt;code&gt;Illuminate\Pipeline\Pipeline&lt;/code&gt;. HTTP client middleware is Guzzle's own handler stack, which is why its signature looks nothing like the other two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job middleware also covers queued listeners, mailables, notifications and broadcast events.&lt;/strong&gt; They all run through the same pipeline, so &lt;code&gt;RateLimited&lt;/code&gt; works on a queued notification exactly as it does on a job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A job middleware that doesn't call &lt;code&gt;$next&lt;/code&gt; counts as a success.&lt;/strong&gt; &lt;code&gt;Skip&lt;/code&gt;, a &lt;code&gt;dontRelease()&lt;/code&gt; limiter or your own early return deletes the job, and if it was part of a chain the next link still runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Http::globalMiddleware()&lt;/code&gt; only sees Laravel's client.&lt;/strong&gt; SDKs that build their own Guzzle client need the middleware pushed onto their stack too. Faked requests do pass through it, so it can be tested with &lt;code&gt;Http::fake()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a Livewire page, route middleware mostly runs once.&lt;/strong&gt; Component actions only re-run middleware on Livewire's persistent list, which includes &lt;code&gt;auth&lt;/code&gt; and &lt;code&gt;can&lt;/code&gt; but not &lt;code&gt;verified&lt;/code&gt;, &lt;code&gt;throttle&lt;/code&gt; or your own classes. Filament's &lt;code&gt;isPersistent&lt;/code&gt; flag adds to the same list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What middleware is
&lt;/h2&gt;

&lt;p&gt;Middleware is code that runs between a caller and a handler, and can change the input or stop it before the handler sees it. The handler might be a controller, a job's &lt;code&gt;handle()&lt;/code&gt; method or the cURL call at the bottom of Guzzle. The middleware is given only the input and a callable that runs everything below it, so the same pattern works for all of them.&lt;/p&gt;

&lt;p&gt;Each middleware is a layer. The input passes inwards through every layer to the handler, and the result passes back out through the same layers in reverse, so a layer can run code before the handler and after 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="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="nv"&gt;$input&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// on the way in: inspect or change $input, or return early&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;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// on the way out: inspect or change $result&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$result&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 a layer returns without calling &lt;code&gt;$next&lt;/code&gt;, nothing inside it runs. An authentication middleware uses this to turn away a guest before the controller is constructed, and a rate limiter uses it to put a job back on the queue before the job calls the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why use it
&lt;/h2&gt;

&lt;p&gt;Some code has to run for many handlers but isn't part of what any of them does. Every admin route needs an authenticated admin, and every job that calls a vendor API has to stay under the vendor's rate limit. Written inline, that check is copied into dozens of controllers and jobs, and sooner or later one of them is missed.&lt;/p&gt;

&lt;p&gt;As middleware, the check is written once and attached by name. The controller keeps what the endpoint does, with nothing about who may call it, and the job keeps its work, with nothing about pacing. The order is written down too: the session starts before authentication, and authentication runs before authorisation, in a list you can read in one place.&lt;/p&gt;

&lt;p&gt;The cost is that you can't see middleware from the handler. If a middleware rewrites input or drops a job, the cause is in a file the reader of the controller never opens. Most of the gotchas later in this post are of that kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Laravel runs it
&lt;/h2&gt;

&lt;p&gt;Request and job middleware both run on &lt;code&gt;Illuminate\Pipeline\Pipeline&lt;/code&gt;, which you can also use directly. It takes an object, a list of stages, and a final callback, and folds the stages into nested closures so the first stage in the list is the outermost 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Pipeline&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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;through&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nc"&gt;ApplyCoupon&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;CalculateTax&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;ReserveStock&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;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;$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="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage is a class with a &lt;code&gt;handle($passable, Closure $next)&lt;/code&gt; method, resolved from the container, or a closure with the same signature. The HTTP kernel builds exactly this with the request as the passable and the router as the final callback. The queue worker builds it with the job as the passable and the job's &lt;code&gt;handle()&lt;/code&gt; as the final callback.&lt;/p&gt;

&lt;p&gt;Guzzle middleware is built differently. It is a function that takes the next handler and returns a new handler, and a handler takes a PSR-7 request and options and returns a promise. The layering is the same, but code that runs after the handler goes in a &lt;code&gt;then()&lt;/code&gt; on the promise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Signature&lt;/th&gt;
&lt;th&gt;Attached with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;handle(Request $request, Closure $next)&lt;/code&gt;, on &lt;code&gt;Pipeline&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bootstrap/app.php&lt;/code&gt;, &lt;code&gt;-&amp;gt;middleware()&lt;/code&gt; on routes, controllers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;handle(object $job, Closure $next)&lt;/code&gt;, on &lt;code&gt;Pipeline&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The job's &lt;code&gt;middleware()&lt;/code&gt; method, or &lt;code&gt;-&amp;gt;through()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP client&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fn (callable $handler): callable&lt;/code&gt;, on Guzzle's &lt;code&gt;HandlerStack&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Http::withMiddleware()&lt;/code&gt;, &lt;code&gt;Http::globalMiddleware()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Request middleware
&lt;/h2&gt;

&lt;p&gt;A request middleware receives the &lt;code&gt;Illuminate\Http\Request&lt;/code&gt; and a &lt;code&gt;$next&lt;/code&gt;, and must return a response, either its own or the one &lt;code&gt;$next&lt;/code&gt; produced. Generate one with &lt;code&gt;php artisan make:middleware&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\Http\Middleware&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;Closure&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\Http\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Context&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&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;AssignRequestId&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;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Request-Id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$id&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="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;'X-Request-Id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Context::add()&lt;/code&gt; puts the id on every log line written during the request, and on every job dispatched from it, since Laravel carries context into the job payload. The response header lets a client quote the id back to you when something goes wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it gets attached
&lt;/h3&gt;

&lt;p&gt;Request middleware can be declared in four places: on every request, in a group, on a route, or on a controller. The place decides which requests the middleware sees, and together they decide the order it runs in. Since Laravel 11 the first two are configured in &lt;code&gt;bootstrap/app.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Global middleware runs before the router has matched a route, so it also sees 404s and requests with no matching route. That also means it runs too early to read the session, the logged-in user or route parameters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withMiddleware&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;Middleware&lt;/span&gt; &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AssignRequestId&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;// after Laravel's defaults&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BlockBadBots&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;// before them&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConvertEmptyStringsToNull&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="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TrustProxies&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;CloudflareTrustProxies&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$middleware-&amp;gt;use([...])&lt;/code&gt; replaces Laravel's global list with your own.&lt;/p&gt;

&lt;p&gt;A group is a named list of middleware. Laravel defines two: &lt;code&gt;web&lt;/code&gt; (cookies, session, CSRF and route model binding) and &lt;code&gt;api&lt;/code&gt; (binding, plus throttling if you turn it on). &lt;code&gt;withRouting()&lt;/code&gt; wraps &lt;code&gt;routes/web.php&lt;/code&gt; in the first and &lt;code&gt;routes/api.php&lt;/code&gt; in the second, which is why you never see them in your route files. You can change either group or define your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withMiddleware&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;Middleware&lt;/span&gt; &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;SetLocale&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="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prepend&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;EnsureApiVersion&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="nv"&gt;$middleware&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="s1"&gt;'partner'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;AuthenticatePartner&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;'throttle:partner'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;appendToGroup()&lt;/code&gt;, &lt;code&gt;prependToGroup()&lt;/code&gt;, &lt;code&gt;removeFromGroup()&lt;/code&gt; and &lt;code&gt;replaceInGroup()&lt;/code&gt; do the same for any group by name.&lt;/p&gt;

&lt;p&gt;Routes take middleware by class name or by an alias registered in &lt;code&gt;bootstrap/app.php&lt;/code&gt;. Groups of routes pass theirs down to every route inside, and a route adds its own on top:&lt;br&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;// bootstrap/app.php&lt;/span&gt;
&lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;IdentifyTenant&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;// 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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;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;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;'/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;InvoiceController&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/invoices/{invoice}/send'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SendInvoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role: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;withoutMiddleware&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parameters go after a colon, so &lt;code&gt;role:editor,admin&lt;/code&gt; calls &lt;code&gt;handle($request, $next, 'editor', 'admin')&lt;/code&gt;. &lt;code&gt;withoutMiddleware()&lt;/code&gt; takes a middleware back off a route or a group of routes. It works for route and group middleware, including subclasses of the class you name, but not for the global stack.&lt;/p&gt;

&lt;p&gt;Laravel 13 added the &lt;code&gt;#[Middleware]&lt;/code&gt; attribute, which goes on a controller class or on one of its methods. &lt;code&gt;only&lt;/code&gt; and &lt;code&gt;except&lt;/code&gt; limit a class-level attribute to some methods, and &lt;code&gt;#[WithoutMiddleware]&lt;/code&gt; removes one:&lt;br&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\Routing\Attributes\Controllers\Middleware&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\Routing\Attributes\Controllers\WithoutMiddleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Middleware('auth')]&lt;/span&gt;
&lt;span class="na"&gt;#[Middleware('verified', except: ['show'])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoiceController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Middleware('throttle:exports')]&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;export&lt;/span&gt;&lt;span class="p"&gt;(&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="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[WithoutMiddleware('auth')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;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="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;The older way, which still works, is to implement &lt;code&gt;HasMiddleware&lt;/code&gt; and return the list from a static 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;Illuminate\Routing\Controllers\HasMiddleware&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\Routing\Controllers\Middleware&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;InvoiceController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;HasMiddleware&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;middleware&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'auth'&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;Middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;except&lt;/span&gt;&lt;span class="o"&gt;:&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="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The order it runs in
&lt;/h3&gt;

&lt;p&gt;A request goes through two pipelines. The kernel runs the global middleware, then hands the request to the router, which matches a route and runs that route's middleware around the controller. The route's list is put together in four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Collect.&lt;/strong&gt; Group middleware comes first, from the outermost group inward, so &lt;code&gt;web&lt;/code&gt; leads for anything in &lt;code&gt;routes/web.php&lt;/code&gt;. Then the route's own &lt;code&gt;-&amp;gt;middleware()&lt;/code&gt;, then the controller's: &lt;code&gt;HasMiddleware&lt;/code&gt; entries, then attributes, with a parent class's before a child's and the class's before the method's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand.&lt;/strong&gt; Aliases become class names and group names become their contents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove.&lt;/strong&gt; Anything named in &lt;code&gt;withoutMiddleware()&lt;/code&gt; or &lt;code&gt;#[WithoutMiddleware]&lt;/code&gt; is dropped, and so is any repeat of a middleware already in the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort.&lt;/strong&gt; The list is reordered against the priority list, described below.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The global list is never sorted. It runs exactly as written: prepends, then Laravel's defaults, then appends. On the way back the response passes through every layer in reverse, so the last middleware to run before the controller is the first to see the response.&lt;/p&gt;

&lt;p&gt;The priority list holds the middleware that depend on each other, in the order they have to run: &lt;code&gt;HandlePrecognitiveRequests&lt;/code&gt;, &lt;code&gt;EncryptCookies&lt;/code&gt;, &lt;code&gt;AddQueuedCookiesToResponse&lt;/code&gt;, &lt;code&gt;StartSession&lt;/code&gt;, &lt;code&gt;ShareErrorsFromSession&lt;/code&gt;, anything implementing &lt;code&gt;AuthenticatesRequests&lt;/code&gt;, &lt;code&gt;ThrottleRequests&lt;/code&gt;, &lt;code&gt;ThrottleRequestsWithRedis&lt;/code&gt;, &lt;code&gt;AuthenticatesSessions&lt;/code&gt;, &lt;code&gt;SubstituteBindings&lt;/code&gt; and &lt;code&gt;Authorize&lt;/code&gt;. When two of these appear out of order, the later one is moved up in front of the earlier one. Middleware that isn't in the list keeps the position you wrote it in.&lt;/p&gt;

&lt;p&gt;For the &lt;code&gt;POST /invoices/{invoice}/send&lt;/code&gt; route above, after the global stack, that gives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EncryptCookies               web group
AddQueuedCookiesToResponse   web group
StartSession                 web group
ShareErrorsFromSession       web group
PreventRequestForgery        web group
Authenticate                 'auth', moved up by the priority list
SubstituteBindings           web group
role:billing                 the route's own
                             ('tenant' removed by withoutMiddleware)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth&lt;/code&gt; was written after the whole &lt;code&gt;web&lt;/code&gt; group, but it runs before &lt;code&gt;SubstituteBindings&lt;/code&gt;. A guest gets the login redirect and doesn't learn whether invoice 42 exists.&lt;/p&gt;

&lt;p&gt;Your own middleware goes into the list when it has to run before or after one of these. A tenant middleware usually has to run before route model binding, so that &lt;code&gt;{invoice}&lt;/code&gt; is looked up in the tenant's data:&lt;br&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;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prependToPriorityList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubstituteBindings&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prepend&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IdentifyTenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;appendToPriorityList(after: ..., append: ...)&lt;/code&gt; does the opposite, and &lt;code&gt;priority([...])&lt;/code&gt; replaces the whole list.&lt;/p&gt;

&lt;p&gt;A middleware with a &lt;code&gt;terminate($request, $response)&lt;/code&gt; method gets called again after the response has been sent to the browser. Route middleware is terminated before global middleware, each in the order it ran rather than in reverse. Each call gets a fresh instance unless you bind the class as a singleton, so state set in &lt;code&gt;handle()&lt;/code&gt; is gone by then.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it is used for
&lt;/h3&gt;

&lt;p&gt;Most request middleware decides who may make the request: authentication, roles and permissions, signed URLs, verified email, API token abilities and IP allow-lists. The next biggest group sets something up for the rest of the request, such as the current tenant, the locale or a request id. The rest either cleans up the request (trusting proxy headers, trimming strings, rejecting oversized bodies) or changes the response, by adding security headers, cache headers and ETags, or by serving a cached copy of the whole page. Throttling, honeypots and idempotency keys for payment endpoints are middleware too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Laravel's built-in request middleware
&lt;/h3&gt;

&lt;p&gt;A new Laravel 13 app runs these on every request, in this order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Global middleware&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ValidatePathEncoding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rejects a path that isn't valid UTF-8 with a &lt;code&gt;MalformedUrlException&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InvokeDeferredCallbacks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs the callbacks registered with &lt;code&gt;defer()&lt;/code&gt; after the response is sent.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TrustHosts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only when enabled with &lt;code&gt;$middleware-&amp;gt;trustHosts()&lt;/code&gt;. Rejects requests for a &lt;code&gt;Host&lt;/code&gt; you don't serve.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TrustProxies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads the client IP, scheme and host from &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers set by proxies you trust.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HandleCors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Answers preflight requests and adds CORS headers from &lt;code&gt;config/cors.php&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreventRequestsDuringMaintenance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Serves the 503 page while &lt;code&gt;php artisan down&lt;/code&gt; is in effect.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ValidatePostSize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rejects a body larger than PHP's &lt;code&gt;post_max_size&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TrimStrings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Trims whitespace from input, except password fields.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConvertEmptyStringsToNull&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Turns empty input strings into &lt;code&gt;null&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;web&lt;/code&gt; group adds &lt;code&gt;EncryptCookies&lt;/code&gt;, &lt;code&gt;AddQueuedCookiesToResponse&lt;/code&gt;, &lt;code&gt;StartSession&lt;/code&gt;, &lt;code&gt;ShareErrorsFromSession&lt;/code&gt;, &lt;code&gt;PreventRequestForgery&lt;/code&gt; and &lt;code&gt;SubstituteBindings&lt;/code&gt;. The &lt;code&gt;api&lt;/code&gt; group has only &lt;code&gt;SubstituteBindings&lt;/code&gt;, plus Sanctum's &lt;code&gt;EnsureFrontendRequestsAreStateful&lt;/code&gt; after &lt;code&gt;$middleware-&amp;gt;statefulApi()&lt;/code&gt; and &lt;code&gt;throttle:api&lt;/code&gt; after &lt;code&gt;$middleware-&amp;gt;throttleApi()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PreventRequestForgery&lt;/code&gt; is new in Laravel 13 and replaces &lt;code&gt;ValidateCsrfToken&lt;/code&gt;, which remains as a deprecated subclass. It lets a state-changing request through when the browser's &lt;code&gt;Sec-Fetch-Site&lt;/code&gt; header says &lt;code&gt;same-origin&lt;/code&gt;, and falls back to the CSRF token otherwise. &lt;code&gt;allowSameSite&lt;/code&gt; extends that to sibling subdomains, and &lt;code&gt;originOnly&lt;/code&gt; drops the token fallback altogether.&lt;/p&gt;

&lt;p&gt;These are registered as aliases for use on routes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alias&lt;/th&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Authenticate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Requires a logged-in user, optionally for a given guard: &lt;code&gt;auth:sanctum&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth.basic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AuthenticateWithBasicAuth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP Basic authentication against your user provider.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth.session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AuthenticateSession&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Logs out other sessions when the password changes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cache.headers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SetCacheHeaders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sets &lt;code&gt;Cache-Control&lt;/code&gt; and optionally an ETag: &lt;code&gt;cache.headers:public;max_age=3600;etag&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;can&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Authorize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs a gate or policy ability: &lt;code&gt;can:update,post&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;guest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RedirectIfAuthenticated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sends logged-in users away from login and register pages.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;password.confirm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RequirePassword&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asks for the password again before a sensitive action.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;precognitive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HandlePrecognitiveRequests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs validation only, for live form validation with Precognition.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;signed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidateSignature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rejects a URL whose signature is missing, wrong or expired.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;throttle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ThrottleRequests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rate limits by a named limiter or inline numbers: &lt;code&gt;throttle:uploads&lt;/code&gt;, &lt;code&gt;throttle:60,1&lt;/code&gt;. Becomes &lt;code&gt;ThrottleRequestsWithRedis&lt;/code&gt; after &lt;code&gt;throttleWithRedis()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;verified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EnsureEmailIsVerified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Requires a verified email address.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few more ship in the framework but aren't registered anywhere, so you attach them by class name. &lt;code&gt;FrameGuard&lt;/code&gt; sets &lt;code&gt;X-Frame-Options: SAMEORIGIN&lt;/code&gt;. &lt;code&gt;CheckResponseForModifications&lt;/code&gt; answers a matching &lt;code&gt;If-None-Match&lt;/code&gt; with a 304. &lt;code&gt;AddLinkHeadersForPreloadedAssets&lt;/code&gt; turns the assets Vite preloads into &lt;code&gt;Link&lt;/code&gt; headers, so the browser can start fetching them before it has parsed the HTML. Laravel 13 also has &lt;code&gt;PrefersJsonResponses&lt;/code&gt;, enabled with &lt;code&gt;-&amp;gt;prefersJsonResponses()&lt;/code&gt; on the application builder, which treats a request with no &lt;code&gt;Accept&lt;/code&gt; header, or only wildcards, as asking for JSON. That suits an API whose clients forget the header and then get an HTML error page back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Popular request middleware packages
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Middleware it adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/spatie/laravel-csp" rel="noopener noreferrer"&gt;spatie/laravel-csp&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AddCspHeaders&lt;/code&gt;, a Content Security Policy built from preset classes, with nonces.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/spatie/laravel-responsecache" rel="noopener noreferrer"&gt;spatie/laravel-responsecache&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CacheResponse&lt;/code&gt;, &lt;code&gt;DoNotCacheResponse&lt;/code&gt; and the newer &lt;code&gt;FlexibleCacheResponse&lt;/code&gt;, which store whole responses.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/spatie/laravel-honeypot" rel="noopener noreferrer"&gt;spatie/laravel-honeypot&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ProtectAgainstSpam&lt;/code&gt;, which rejects forms filled in by bots.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/bepsvpt/secure-headers" rel="noopener noreferrer"&gt;bepsvpt/secure-headers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SecureHeadersMiddleware&lt;/code&gt;, for HSTS, CSP, Permissions-Policy and the rest from one config file.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/spatie/laravel-http-logger" rel="noopener noreferrer"&gt;spatie/laravel-http-logger&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HttpLogger&lt;/code&gt;, which logs incoming requests, by default only the ones that change data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/monicahq/laravel-cloudflare" rel="noopener noreferrer"&gt;monicahq/laravel-cloudflare&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;TrustProxies&lt;/code&gt; that keeps Cloudflare's IP ranges up to date, so &lt;code&gt;$request-&amp;gt;ip()&lt;/code&gt; is the visitor's.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/akaunting/laravel-firewall" rel="noopener noreferrer"&gt;akaunting/laravel-firewall&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;firewall.ip&lt;/code&gt;, &lt;code&gt;firewall.sqli&lt;/code&gt;, &lt;code&gt;firewall.xss&lt;/code&gt;, &lt;code&gt;firewall.all&lt;/code&gt; and a dozen more, as a basic application firewall.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/square1-io/laravel-idempotency" rel="noopener noreferrer"&gt;square1/laravel-idempotency&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;IdempotencyMiddleware&lt;/code&gt;, which replays the stored response for a repeated &lt;code&gt;Idempotency-Key&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of the most installed middleware packages are now in core and shouldn't be added to a new app. &lt;code&gt;fruitcake/laravel-cors&lt;/code&gt; is marked abandoned and was replaced by &lt;code&gt;HandleCors&lt;/code&gt; in Laravel 9.2, and &lt;code&gt;fideloper/proxy&lt;/code&gt; by the framework's &lt;code&gt;TrustProxies&lt;/code&gt; in Laravel 9. If an upgrade still has either in &lt;code&gt;composer.json&lt;/code&gt;, remove it. We wrote up what responsecache's cache key does with tracking parameters in &lt;a href="https://boring-observability.dev/blog/laravel-responsecache-hit-rate" rel="noopener noreferrer"&gt;why our response cache kept missing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job middleware
&lt;/h2&gt;

&lt;p&gt;Job middleware wraps one attempt at a queued job. The worker builds a pipeline from the job's middleware with the job's &lt;code&gt;handle()&lt;/code&gt; at the centre, so the middleware runs on the worker, every time the job is picked up, retries included. Return the list from a &lt;code&gt;middleware()&lt;/code&gt; method, or attach it at dispatch time with &lt;code&gt;through()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Middleware\RateLimited&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Middleware\WithoutOverlapping&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;SyncInvoiceToXero&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;middleware&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RateLimited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xero'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WithoutOverlapping&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;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;account_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;expireAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// or for one dispatch only&lt;/span&gt;
&lt;span class="nc"&gt;SyncInvoiceToXero&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;through&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;RateLimited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xero'&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of calling &lt;code&gt;$next($job)&lt;/code&gt;, a job middleware can call &lt;code&gt;$job-&amp;gt;release($seconds)&lt;/code&gt; to put the job back on the queue for later, &lt;code&gt;$job-&amp;gt;fail($e)&lt;/code&gt; to send it to the failed jobs table, or &lt;code&gt;$job-&amp;gt;delete()&lt;/code&gt; to drop it. Writing one takes a class with a &lt;code&gt;handle()&lt;/code&gt; method. This one stops calling a vendor for a minute after a call fails:&lt;br&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\Jobs\Middleware&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;Closure&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\Cache&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;Throwable&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;PauseWhenVendorIsDown&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;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$vendor&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;object&lt;/span&gt; &lt;span class="nv"&gt;$job&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;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="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"vendor-down:&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;vendor&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&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;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&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;$job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;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="s2"&gt;"vendor-down:&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;vendor&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="kc"&gt;true&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;throw&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  It also runs on listeners, mail and notifications
&lt;/h3&gt;

&lt;p&gt;Anything Laravel queues goes through the same pipeline. A queued listener can define &lt;code&gt;middleware(OrderShipped $event)&lt;/code&gt;, a queued mailable &lt;code&gt;middleware()&lt;/code&gt;, and a queued notification &lt;code&gt;middleware(object $notifiable, string $channel)&lt;/code&gt;, which is called once per channel. That last one is useful for limiting only the channel that has a limit:&lt;br&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="nv"&gt;$notifiable&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;$channel&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="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s1"&gt;'vonage'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;RateLimited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sms'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it is used for
&lt;/h3&gt;

&lt;p&gt;The common case is someone else's limit. A vendor allows 60 calls a minute, so every job that calls it shares a limiter. Close behind is mutual exclusion: two jobs updating the same account must not interleave, so they take a lock keyed by the account. Job middleware also backs off from a failing dependency, for example pausing for ten minutes after ten exceptions in a row rather than spending every retry. It drops work that no longer matters, such as a job whose order or batch was cancelled. And it sets up context before the job runs, such as the tenant database or the locale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Laravel's built-in job middleware
&lt;/h3&gt;

&lt;p&gt;All of these live in &lt;code&gt;Illuminate\Queue\Middleware&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Middleware&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Options&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RateLimited&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Applies a limiter defined with &lt;code&gt;RateLimiter::for()&lt;/code&gt; and releases the job when the limit is reached.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;releaseAfter()&lt;/code&gt;, &lt;code&gt;dontRelease()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RateLimitedWithRedis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same, using Redis directly rather than the cache store.&lt;/td&gt;
&lt;td&gt;As above, plus &lt;code&gt;connection()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WithoutOverlapping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Takes a cache lock on a key so only one job with that key runs at a time.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;releaseAfter()&lt;/code&gt;, &lt;code&gt;dontRelease()&lt;/code&gt;, &lt;code&gt;expireAfter()&lt;/code&gt;, &lt;code&gt;shared()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ThrottlesExceptions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After a number of exceptions, releases the job for a cool-down instead of running it.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;backoff()&lt;/code&gt;, &lt;code&gt;by()&lt;/code&gt;, &lt;code&gt;byJob()&lt;/code&gt;, &lt;code&gt;when()&lt;/code&gt;, &lt;code&gt;deleteWhen()&lt;/code&gt;, &lt;code&gt;failWhen()&lt;/code&gt;, &lt;code&gt;report()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ThrottlesExceptionsWithRedis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same, counting in Redis.&lt;/td&gt;
&lt;td&gt;As above, plus &lt;code&gt;connection()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Skip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deletes the job without running it when a condition holds.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Skip::when()&lt;/code&gt;, &lt;code&gt;Skip::unless()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Release&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New in Laravel 13. Puts the job back on the queue without running it when a condition holds.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Release::when($condition, $seconds)&lt;/code&gt;, &lt;code&gt;Release::unless()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FailOnException&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails the job straight away, without retries, on the exceptions you list.&lt;/td&gt;
&lt;td&gt;An array of classes, or a closure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SkipIfBatchCancelled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns without running the job when its batch has been cancelled.&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Skipped jobs count as done, and releases use up tries
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Not calling &lt;code&gt;$next&lt;/code&gt; is a success.&lt;/strong&gt; When the pipeline returns without the job being released or failed, the worker deletes it as completed. That is what &lt;code&gt;Skip&lt;/code&gt; and &lt;code&gt;SkipIfBatchCancelled&lt;/code&gt; do, and also what &lt;code&gt;RateLimited&lt;/code&gt; and &lt;code&gt;WithoutOverlapping&lt;/code&gt; do after &lt;code&gt;dontRelease()&lt;/code&gt;. In a chain, a skipped link, or one whose middleware simply returns, is treated as done and the next link is dispatched. If the rest of the chain depends on the skipped work, fail the job instead. A &lt;code&gt;ShouldBeUnique&lt;/code&gt; link works the other way round, since a held lock &lt;a href="https://boring-observability.dev/blog/laravel-job-chains-at-scale" rel="noopener noreferrer"&gt;skips it and silently ends the rest of the chain&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release spends an attempt.&lt;/strong&gt; The worker counts the attempt when it picks the job up, before any middleware runs. A job that keeps meeting a held &lt;code&gt;WithoutOverlapping&lt;/code&gt; lock or an exhausted limiter is released again and again, and can reach its &lt;code&gt;$tries&lt;/code&gt; and fail without &lt;code&gt;handle()&lt;/code&gt; having run once. Give limited jobs a &lt;code&gt;retryUntil()&lt;/code&gt; deadline rather than a small number of tries. We cover the limiter options in depth in &lt;a href="https://boring-observability.dev/blog/laravel-queue-rate-limiting-concurrency" rel="noopener noreferrer"&gt;rate-limited APIs and Laravel queues&lt;/a&gt;, the locking ones in &lt;a href="https://boring-observability.dev/blog/laravel-job-uniqueness-controls" rel="noopener noreferrer"&gt;ShouldBeUnique, WithoutOverlapping and their gotchas&lt;/a&gt;, and how releases and &lt;code&gt;retryUntil()&lt;/code&gt; bend a job's backoff in &lt;a href="https://boring-observability.dev/blog/laravel-job-retries-backoff-unique-locks" rel="noopener noreferrer"&gt;Laravel job retries and backoff with unique locks&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Popular job middleware packages
&lt;/h3&gt;

&lt;p&gt;The core classes cover most needs, so there are few job middleware packages with real usage.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/spatie/laravel-rate-limited-job-middleware" rel="noopener noreferrer"&gt;spatie/laravel-rate-limited-job-middleware&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A Redis-backed &lt;code&gt;RateLimited&lt;/code&gt; with a fluent &lt;code&gt;allow(30)-&amp;gt;everySeconds(60)&lt;/code&gt; API. It predates core's &lt;code&gt;RateLimited&lt;/code&gt;, which now overlaps with it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/harris21/laravel-fuse" rel="noopener noreferrer"&gt;harris21/laravel-fuse&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CircuitBreakerMiddleware&lt;/code&gt;, a circuit breaker with closed, open and half-open states. While open it releases jobs rather than failing them, and its failure classifier keeps 429s and auth errors from tripping it. Still pre-1.0.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Multi-tenancy packages make jobs tenant-aware too, but not through job middleware. spatie/laravel-multitenancy uses a &lt;code&gt;TenantAware&lt;/code&gt; interface and stancl/tenancy a queue bootstrapper, so the tenant is restored before your middleware runs.&lt;/p&gt;

&lt;p&gt;We maintain a small one ourselves. &lt;a href="https://boring-observability.dev/open-source/unique-job-middleware" rel="noopener noreferrer"&gt;Unique Job Middleware&lt;/a&gt; checks a &lt;code&gt;ShouldBeUnique&lt;/code&gt; job's lock again when a worker picks it up, because Laravel only checks it at dispatch and chains, batches and retries go around that check.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTP client middleware
&lt;/h2&gt;

&lt;p&gt;Laravel's &lt;code&gt;Http&lt;/code&gt; facade builds a Guzzle client for each request, and lets you add Guzzle middleware to it. There are three ways in, each available per request or for the whole application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Context&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\Http&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\Log&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;Psr\Http\Message\RequestInterface&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;Psr\Http\Message\ResponseInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// change the outgoing request&lt;/span&gt;
&lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withRequestMiddleware&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;RequestInterface&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Trace-Id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Context&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;'request_id'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.example.com/orders'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// look at or change the response&lt;/span&gt;
&lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;globalResponseMiddleware&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;ResponseInterface&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;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'vendor responded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStatusCode&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;span class="c1"&gt;// a full Guzzle middleware, which sees the request, the response and the failure&lt;/span&gt;
&lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;globalMiddleware&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;LogOutgoingRequests&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two are shortcuts for Guzzle's &lt;code&gt;Middleware::mapRequest()&lt;/code&gt; and &lt;code&gt;mapResponse()&lt;/code&gt;. The third takes any Guzzle middleware, which is what you need for anything that has to see a request that never got a response, such as a timeout. A full one 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SignWithHmac&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;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$secret&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;callable&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;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RequestInterface&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;array&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBody&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;secret&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;$handler&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;withHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Signature'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ResponseInterface&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;span class="c1"&gt;// runs once the response has arrived&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;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it is used for
&lt;/h3&gt;

&lt;p&gt;Authentication is the most common job: adding a bearer token and refreshing it when it expires, or signing the request with OAuth 1, HMAC or AWS SigV4. Logging and timing each call is next, often with a trace id passed on to the vendor. Then there is retrying on a 503 while honouring &lt;code&gt;Retry-After&lt;/code&gt;, limiting how fast you call an API, and stopping calls to a vendor that keeps failing. Caching middleware follows the vendor's &lt;code&gt;Cache-Control&lt;/code&gt; so an unchanged resource isn't fetched twice, and in tests a history middleware records each request to assert on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built-in HTTP client middleware
&lt;/h3&gt;

&lt;p&gt;Laravel doesn't ship Guzzle middleware classes of its own. What it ships is the registration API above, plus a &lt;code&gt;retry()&lt;/code&gt; method that is implemented outside the stack: &lt;code&gt;Http::retry(3, 100)&lt;/code&gt; sends the request through the whole stack again for each attempt, so your middleware sees every one. Guzzle itself has these, on &lt;code&gt;GuzzleHttp\Middleware&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Middleware&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;httpErrors()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Throws on 4xx and 5xx responses. In the default stack, but Laravel's client turns it off with &lt;code&gt;http_errors =&amp;gt; false&lt;/code&gt; and leaves throwing to &lt;code&gt;-&amp;gt;throw()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redirect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Follows redirects according to &lt;code&gt;allow_redirects&lt;/code&gt;. In the default stack.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cookies()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sends and stores cookies through a cookie jar. In the default stack.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prepareBody()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sets &lt;code&gt;Content-Length&lt;/code&gt; and &lt;code&gt;Content-Type&lt;/code&gt; for the body. In the default stack.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Guzzle 8 only. Handles authentication challenges, and sits in the default stack after &lt;code&gt;redirect()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;retry($decider, $delay)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Retries when your decider callback says so, with your delay.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;log($logger, $formatter)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Logs requests and responses through a PSR-3 logger and a &lt;code&gt;MessageFormatter&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;history($container)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Appends each request, response and error to an array.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tap($before, $after)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Calls a function before and after each request without changing it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mapRequest()&lt;/code&gt;, &lt;code&gt;mapResponse()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Transform the request or the response with a function.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Laravel pushes its own handlers after yours, and the one that serves &lt;code&gt;Http::fake()&lt;/code&gt; responses is the innermost. So your middleware runs for faked requests too, and a test can assert that the signature header was added without a real server.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Http::globalMiddleware()&lt;/code&gt; only reaches requests made through Laravel's client. SDKs that create their own &lt;code&gt;GuzzleHttp\Client&lt;/code&gt;, the AWS SDK among them and with it every S3 call, never see it. For those, push the same middleware onto a &lt;code&gt;HandlerStack&lt;/code&gt; and give the SDK a client built on it. &lt;a href="https://boring-observability.dev/blog/instrumenting-guzzle-laravel" rel="noopener noreferrer"&gt;Instrumenting Guzzle in Laravel&lt;/a&gt; walks through that, along with why a logging middleware should record from &lt;code&gt;on_stats&lt;/code&gt; rather than from the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Popular HTTP client middleware packages
&lt;/h3&gt;

&lt;p&gt;Most of these are plain Guzzle middleware, written for any PHP project, and plug into Laravel through &lt;code&gt;Http::withMiddleware()&lt;/code&gt; or &lt;code&gt;globalMiddleware()&lt;/code&gt;. Laravel 13 accepts Guzzle 7 or 8 and Laravel 12 only Guzzle 7. Some packages' latest major needs Guzzle 8, so on Laravel 12 you stay on their previous one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/Kevinrob/guzzle-cache-middleware" rel="noopener noreferrer"&gt;kevinrob/guzzle-cache-middleware&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CacheMiddleware&lt;/code&gt;, HTTP caching that follows &lt;code&gt;Cache-Control&lt;/code&gt;, with a &lt;code&gt;LaravelCacheStorage&lt;/code&gt; adapter for your cache store.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/guzzle/oauth-subscriber" rel="noopener noreferrer"&gt;guzzlehttp/oauth-subscriber&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Oauth1&lt;/code&gt;, OAuth 1.0 request signing. The current release needs Guzzle 8.1.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/caseyamcl/guzzle_retry_middleware" rel="noopener noreferrer"&gt;caseyamcl/guzzle_retry_middleware&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GuzzleRetryMiddleware&lt;/code&gt;, retries on 429 and 503 that honour &lt;code&gt;Retry-After&lt;/code&gt;. Version 3 needs Guzzle 8, so use 2.x on Laravel 12.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/kamermans/guzzle-oauth2-subscriber" rel="noopener noreferrer"&gt;kamermans/guzzle-oauth2-subscriber&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OAuth2Middleware&lt;/code&gt;, fetching and refreshing OAuth 2 tokens for client-credentials and other grants.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/spatie/guzzle-rate-limiter-middleware" rel="noopener noreferrer"&gt;spatie/guzzle-rate-limiter-middleware&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RateLimiterMiddleware::perSecond(3)&lt;/code&gt; or &lt;code&gt;perMinute()&lt;/code&gt;, which delays calls to stay under a limit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/ackintosh/ganesha" rel="noopener noreferrer"&gt;ackintosh/ganesha&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GuzzleMiddleware&lt;/code&gt;, a circuit breaker with Redis, Memcached and APCu storage.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/bilfeldt/laravel-http-client-logger" rel="noopener noreferrer"&gt;bilfeldt/laravel-http-client-logger&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;An &lt;code&gt;Http::log()&lt;/code&gt; macro and a &lt;code&gt;LoggingMiddleware&lt;/code&gt; for Laravel's client.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two logging middlewares that still appear in older answers, &lt;code&gt;gmponos/guzzle_logger&lt;/code&gt; and &lt;code&gt;rtheunissen/guzzle-log-middleware&lt;/code&gt;, haven't had a release since 2022 and 2023. For metrics rather than log lines, our free &lt;a href="https://boring-observability.dev/open-source/httptheus" rel="noopener noreferrer"&gt;httptheus&lt;/a&gt; is a Guzzle middleware that exports request durations and failures by host to Prometheus, and &lt;a href="https://boring-observability.dev/requizon" rel="noopener noreferrer"&gt;Requizon&lt;/a&gt; uses the same hook to keep a browsable record of every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Livewire and Filament middleware
&lt;/h2&gt;

&lt;p&gt;Livewire doesn't add a new kind of middleware. It uses ordinary request middleware, but changes where it runs. A Livewire page is rendered by a normal route, so that route's middleware runs on the first load as usual. After that, every click, form submit and &lt;code&gt;wire:model&lt;/code&gt; update is a POST to one shared endpoint, &lt;code&gt;/livewire-{hash}/update&lt;/code&gt; in Livewire 4, which has only the &lt;code&gt;web&lt;/code&gt; group on it. The page's own route middleware doesn't run on those requests unless it is persistent.&lt;/p&gt;

&lt;h3&gt;
  
  
  What persistent middleware is
&lt;/h3&gt;

&lt;p&gt;Persistent middleware is route middleware that Livewire runs again on each component update, as if the update were a request to the page's original route. Livewire saves the page's path and method in every component's snapshot. On an update it builds a copy of the request with that path, looks up the middleware on the matching route, and runs the ones that are also on its persistent list.&lt;/p&gt;

&lt;p&gt;The default list holds &lt;code&gt;Authenticate&lt;/code&gt; (the &lt;code&gt;auth&lt;/code&gt; alias), &lt;code&gt;Authorize&lt;/code&gt; (&lt;code&gt;can&lt;/code&gt;), &lt;code&gt;SubstituteBindings&lt;/code&gt;, &lt;code&gt;AuthenticateWithBasicAuth&lt;/code&gt;, Sanctum's &lt;code&gt;EnsureFrontendRequestsAreStateful&lt;/code&gt;, Jetstream's &lt;code&gt;AuthenticateSession&lt;/code&gt;, and the &lt;code&gt;App\Http\Middleware&lt;/code&gt; versions of &lt;code&gt;Authenticate&lt;/code&gt; and &lt;code&gt;RedirectIfAuthenticated&lt;/code&gt;. So a user who is logged out, or whose &lt;code&gt;can:&lt;/code&gt; ability is revoked, while a page is open is stopped at their next click. Anything else on the route, such as &lt;code&gt;verified&lt;/code&gt;, &lt;code&gt;password.confirm&lt;/code&gt;, &lt;code&gt;throttle&lt;/code&gt; or your own middleware, protects the page load and none of the actions taken on the page. Add a class to the list in a service provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Http\Middleware\EnsureTeamIsActive&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\Auth\Middleware\EnsureEmailIsVerified&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\Livewire&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addPersistentMiddleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nc"&gt;EnsureEmailIsVerified&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;EnsureTeamIsActive&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For middleware that should run on every update whatever page it came from, Livewire lets you replace the update route with &lt;code&gt;Livewire::setUpdateRoute()&lt;/code&gt; and put middleware on it. That middleware runs before any component is restored, which is why tenancy packages use it: the tenant database has to be connected before Livewire loads the component's models.&lt;br&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;Livewire&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setUpdateRoute&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;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InitializeTenancyByDomain&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;h3&gt;
  
  
  Where persistent middleware behaves differently
&lt;/h3&gt;

&lt;p&gt;None of these is a bug, but each one can leave a check out without any error.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It has to be on the route and on the list.&lt;/strong&gt; Adding a class to the list does nothing for pages whose route doesn't have it, and a class on the route does nothing on updates until it is on the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The list matches class names.&lt;/strong&gt; Livewire compares it with the route's middleware after aliases and groups are expanded, so a closure or a group name never matches. Parameters come from the route, so &lt;code&gt;can:update,post&lt;/code&gt; works once &lt;code&gt;Authorize&lt;/code&gt; is listed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the "before" half counts.&lt;/strong&gt; The middleware runs on a copy of the request, after the real &lt;code&gt;web&lt;/code&gt; group, and the pipeline ends in an empty response that is thrown away. Headers or cookies set after &lt;code&gt;$next&lt;/code&gt; never reach the browser. A redirect it returns is turned into an &lt;code&gt;abort()&lt;/code&gt; with that response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Livewire::test()&lt;/code&gt; skips it.&lt;/strong&gt; Persistent middleware only runs on requests to the real update endpoint, so a component test passes whether or not a class is on the list. A browser test or a feature test that posts to the endpoint will catch a missing entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint moved in Livewire 4.&lt;/strong&gt; It changed from &lt;code&gt;/livewire/update&lt;/code&gt; to &lt;code&gt;/livewire-{hash}/update&lt;/code&gt;, with the hash taken from your &lt;code&gt;APP_KEY&lt;/code&gt;. CSRF exceptions, firewall rules and maintenance-mode exceptions written for &lt;code&gt;livewire/*&lt;/code&gt; stop matching after the upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locale middleware doesn't need to be listed.&lt;/strong&gt; Livewire saves the app locale in the snapshot and restores it on each update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Filament panels
&lt;/h3&gt;

&lt;p&gt;Filament panels are Livewire pages, and the panel provider sets their middleware in three lists. Each takes an &lt;code&gt;isPersistent&lt;/code&gt; argument, which passes the classes on to Livewire's list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Panel&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Panel&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$panel&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="cm"&gt;/* the panel's own stack, in place of the web group */&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;Authenticate&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;tenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;ApplyTenantScopes&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;isPersistent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filament's docs say it directly: by default, panel middleware runs when the page is first loaded and not on later Livewire requests. Their tenancy guide uses a middleware that adds global scopes for the current tenant, and that one has to be persistent. Without the flag the scopes are missing on every action taken after the page loads, and a table filter or a bulk action can query other tenants' rows.&lt;/p&gt;

&lt;p&gt;Two other details catch people. A panel builds its own stack rather than using your app's &lt;code&gt;web&lt;/code&gt; group, so middleware you append to &lt;code&gt;web&lt;/code&gt; skips panel page loads, but it does run on the panel's Livewire updates, which go through &lt;code&gt;web&lt;/code&gt;. And Filament doesn't rely on middleware to keep users out of pages and resources. It runs &lt;code&gt;canAccess()&lt;/code&gt; again from a Livewire hook on every update, so removing a user's access to a page takes effect on their next click.&lt;/p&gt;

&lt;h3&gt;
  
  
  Livewire and Filament's built-in middleware
&lt;/h3&gt;

&lt;p&gt;Livewire's own middleware is small. &lt;code&gt;RequireLivewireHeaders&lt;/code&gt; sits on the update route and returns a 404 to any request without the &lt;code&gt;X-Livewire&lt;/code&gt; header. &lt;code&gt;DisableBackButtonCacheMiddleware&lt;/code&gt; is pushed onto the global stack and adds no-store headers when Livewire's back-button cache feature is active for the page, so pressing Back reloads it instead of showing a stale copy. Livewire also turns off &lt;code&gt;TrimStrings&lt;/code&gt; and &lt;code&gt;ConvertEmptyStringsToNull&lt;/code&gt; for its own requests, and its file upload route is throttled with &lt;code&gt;throttle:60,1&lt;/code&gt; by default.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Filament middleware&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Persistent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Authenticate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Requires a logged-in user who passes &lt;code&gt;canAccessPanel()&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AuthenticateSession&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filament's version of Laravel's &lt;code&gt;AuthenticateSession&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SetUpPanel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Makes the panel the current one. Always first in the panel stack.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IdentifyTenant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resolves the tenant from the URL and returns a 404 unless the user passes &lt;code&gt;canAccessTenant()&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DispatchServingFilamentEvent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fires the &lt;code&gt;ServingFilament&lt;/code&gt; event, which plugins listen to.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DisableBladeIconComponents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Turns off Blade Icons' components inside the panel.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email verification&lt;/td&gt;
&lt;td&gt;Added per page when the panel has &lt;code&gt;-&amp;gt;emailVerification()&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EnsureMultiFactorAuthenticationIsEnabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sends users without MFA to set it up, when the panel requires it.&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Popular Livewire and Filament packages with middleware
&lt;/h3&gt;

&lt;p&gt;There are no widely used Livewire middleware packages. The most installed rate-limiting package, &lt;a href="https://github.com/danharrin/livewire-rate-limiting" rel="noopener noreferrer"&gt;danharrin/livewire-rate-limiting&lt;/a&gt;, is a trait you call from a component action, because a &lt;code&gt;throttle&lt;/code&gt; route middleware can't tell one Livewire action from another. Filament's own login and password-reset pages use it. Filament plugins do ship middleware, and whether they register it as persistent varies:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Middleware it adds&lt;/th&gt;
&lt;th&gt;Persistent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/bezhansalleh/filament-shield" rel="noopener noreferrer"&gt;bezhansalleh/filament-shield&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SyncShieldTenant&lt;/code&gt;, which sets spatie/laravel-permission's team id to the current tenant.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeffgreco13/filament-breezy" rel="noopener noreferrer"&gt;jeffgreco13/filament-breezy&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MustTwoFactor&lt;/code&gt;, which sends users to the 2FA challenge, added through &lt;code&gt;authMiddleware()&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/bezhansalleh/filament-language-switch" rel="noopener noreferrer"&gt;bezhansalleh/filament-language-switch&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SwitchLanguageLocale&lt;/code&gt;, which applies the language the user picked.&lt;/td&gt;
&lt;td&gt;No, which is fine because Livewire keeps the locale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/stephenjude/filament-two-factor-authentication" rel="noopener noreferrer"&gt;stephenjude/filament-two-factor-authentication&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TwoFactorChallenge&lt;/code&gt; and &lt;code&gt;ForceTwoFactorSetup&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two 2FA plugins check for a completed challenge on page loads only. From reading their source, a page that is already open keeps accepting actions after the 2FA session expires, until the user navigates. If 2FA guards something sensitive, add the plugin's middleware with &lt;code&gt;Livewire::addPersistentMiddleware()&lt;/code&gt;. Inertia has none of this to think about: every Inertia visit is a normal request to its route, so the route's middleware always runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel AI SDK agent middleware
&lt;/h2&gt;

&lt;p&gt;An agent in Laravel's AI SDK (&lt;code&gt;laravel/ai&lt;/code&gt;) runs in steps. Each step sends the conversation to the model, and if the model asks for tools, the SDK runs them and starts another step with the results. Agent middleware wraps each step. It receives a &lt;code&gt;PendingStep&lt;/code&gt;, which holds the model, instructions, messages and tools about to be sent, and a &lt;code&gt;$next&lt;/code&gt; that calls the model.&lt;/p&gt;

&lt;p&gt;This changed in v1.0.0, released on 23 September 2026. Before that, agent middleware wrapped the whole prompt once and received an &lt;code&gt;AgentPrompt&lt;/code&gt;, so tutorials and packages written earlier in 2026 use a signature that no longer works. An agent declares its middleware by implementing &lt;code&gt;HasMiddleware&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Middleware\SendOnlyAfterDraft&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasMiddleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;middleware&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SendOnlyAfterDraft&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;Generate the middleware with &lt;code&gt;php artisan make:agent-middleware&lt;/code&gt;. This one keeps the agent from emailing a customer until it has drafted the email in an earlier step:&lt;br&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\Ai\Middleware&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;Closure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\PendingStep&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;SendOnlyAfterDraft&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;PendingStep&lt;/span&gt; &lt;span class="nv"&gt;$step&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$drafted&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="nv"&gt;$step&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;flatMap&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;$done&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;$done&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;contains&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;$call&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;$call&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="s1"&gt;'DraftEmail'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$drafted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$step&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withoutTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SendEmail'&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;$step&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PendingStep&lt;/code&gt; is immutable. &lt;code&gt;withModel()&lt;/code&gt;, &lt;code&gt;withInstructions()&lt;/code&gt;, &lt;code&gt;withMessages()&lt;/code&gt;, &lt;code&gt;withTools()&lt;/code&gt;, &lt;code&gt;onlyTools()&lt;/code&gt;, &lt;code&gt;withoutTools()&lt;/code&gt;, &lt;code&gt;withToolChoice()&lt;/code&gt;, &lt;code&gt;withMaxTokens()&lt;/code&gt; and &lt;code&gt;withProviderOptions()&lt;/code&gt; each return a changed copy, and the change applies to this step only. The step also carries the steps already completed and their combined token usage. Code that should run after the model answers goes in &lt;code&gt;$next($step)-&amp;gt;then()&lt;/code&gt;, which receives the &lt;code&gt;StepResponse&lt;/code&gt; before any tools run, for streamed responses too. A middleware can also return a &lt;code&gt;StepResponse&lt;/code&gt; of its own instead of calling &lt;code&gt;$next&lt;/code&gt;, and the model isn't called for that step.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it is used for
&lt;/h3&gt;

&lt;p&gt;Logging is the usual first one: the model, the prompt and the tokens each step used. The Laravel docs show two more. One summarises the middle of a conversation once it passes 40 messages, so a long tool-calling run stays inside the context window; the shortened history is sent for that step only and the stored conversation keeps everything. The other drops an expensive search tool after the first step. Beyond the docs, middleware can send later steps to a cheaper model with &lt;code&gt;withModel()&lt;/code&gt;, answer repeated prompts from a cache, and act as a guardrail that rejects prompt injection or removes personal data before the messages leave your server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built-in agent middleware
&lt;/h3&gt;

&lt;p&gt;The SDK doesn't ship agent middleware for you to attach. The one class it has, &lt;code&gt;RememberConversation&lt;/code&gt;, stores the conversation for agents that use &lt;code&gt;RemembersConversations&lt;/code&gt;, and the SDK adds it itself in a separate pipeline around the whole prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agent middleware packages
&lt;/h3&gt;

&lt;p&gt;The SDK is new and so is its package ecosystem. The one package written for the per-step API is &lt;a href="https://github.com/promptphp/intercept" rel="noopener noreferrer"&gt;promptphp/intercept&lt;/a&gt; (v1.0.0 from 27 September 2026). It ships &lt;code&gt;PromptInjectionGuard&lt;/code&gt;, &lt;code&gt;PIIRedactor&lt;/code&gt; and &lt;code&gt;ToolApprovalGuard&lt;/code&gt;. &lt;code&gt;padosoft/laravel-ai-guardrails&lt;/code&gt; has input and output guardrail middleware but only installs with the SDK's 0.x releases. Outside the SDK, Prism has no middleware, and NeuronAI has its own agent and workflow middleware built from &lt;code&gt;before()&lt;/code&gt; and &lt;code&gt;after()&lt;/code&gt; hooks rather than &lt;code&gt;$next&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other places Laravel uses middleware
&lt;/h2&gt;

&lt;p&gt;Outside the kinds above, middleware turns up in a handful of smaller places:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The command bus&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Bus::pipeThrough([...])&lt;/code&gt; registers pipes that wrap every command dispatched with &lt;code&gt;dispatchSync()&lt;/code&gt; and every queued job when it runs, inside the job's own middleware. It isn't in the docs, but it's the one place to add something to every job at once. Each call replaces the list, so two packages that both use it overwrite each other.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Pipeline&lt;/code&gt; itself&lt;/td&gt;
&lt;td&gt;The facade is available for your own multi-step processes, such as checkout or import steps, with &lt;code&gt;send()&lt;/code&gt;, &lt;code&gt;through()&lt;/code&gt;, &lt;code&gt;then()&lt;/code&gt; and &lt;code&gt;withinTransaction()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Folio&lt;/td&gt;
&lt;td&gt;Page middleware, declared in the page with &lt;code&gt;middleware(['auth'])&lt;/code&gt; or for a path with &lt;code&gt;Folio::path()-&amp;gt;middleware()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel MCP&lt;/td&gt;
&lt;td&gt;Ordinary route middleware on the server route, for example &lt;code&gt;Mcp::web('/mcp', WeatherServer::class)-&amp;gt;middleware('throttle:mcp')&lt;/code&gt;. There is no per-tool middleware; a tool's &lt;code&gt;shouldRegister()&lt;/code&gt; method decides whether a request can see it at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka consumers&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/mateusjunges/laravel-kafka" rel="noopener noreferrer"&gt;mateusjunges/laravel-kafka&lt;/a&gt; takes &lt;code&gt;-&amp;gt;withMiddleware(fn ($message, $next) =&amp;gt; $next($message))&lt;/code&gt; on a consumer, wrapping each message before your handler.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broadcasting&lt;/td&gt;
&lt;td&gt;Route middleware on the channel authorisation endpoint, set in &lt;code&gt;withBroadcasting()&lt;/code&gt;, for example &lt;code&gt;auth:sanctum&lt;/code&gt; for an API.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some places have hooks that look like middleware but can't wrap or stop anything. &lt;code&gt;Queue::before()&lt;/code&gt; and &lt;code&gt;Queue::after()&lt;/code&gt; are event listeners, scheduled tasks have &lt;code&gt;before()&lt;/code&gt;, &lt;code&gt;after()&lt;/code&gt; and &lt;code&gt;when()&lt;/code&gt;, and Eloquent has observers and scopes. If you need to stop something from running, use one of the pipelines above.&lt;/p&gt;

&lt;p&gt;A job dropped by middleware leaves no failed job and no exception, so it is hard to notice. In &lt;a href="https://boring-observability.dev/skyline" rel="noopener noreferrer"&gt;Skyline&lt;/a&gt;, the &lt;a href="https://boring-observability.dev/skyline/docs/locks-and-limits" rel="noopener noreferrer"&gt;Locks &amp;amp; Limits&lt;/a&gt; screen counts how often each limiter released or dropped a job. Its log also records jobs dropped by &lt;code&gt;dontRelease()&lt;/code&gt; and chain links skipped by a unique lock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What kinds of middleware does Laravel have?
&lt;/h3&gt;

&lt;p&gt;Three main kinds. HTTP middleware wraps incoming requests and is configured in bootstrap/app.php, on routes or on controllers. Job middleware wraps each attempt at a queued job, and also runs for queued listeners, mailables, notifications and broadcast events. HTTP client middleware is Guzzle middleware added with Http::withMiddleware() or Http::globalMiddleware(). There are smaller ones too: Bus::pipeThrough() pipes for the command bus, page middleware in Folio, persistent middleware in Livewire and Filament, and agent middleware in the Laravel AI SDK, which wraps each step an agent sends to the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between global, group and route middleware in Laravel?
&lt;/h3&gt;

&lt;p&gt;Global middleware runs on every request, including ones that match no route, and is added with $middleware-&amp;gt;append() or prepend(). Group middleware runs on every route in a group such as web or api, and is added with $middleware-&amp;gt;web() or api(). Route middleware runs only where you attach it with -&amp;gt;middleware(), usually by an alias. withoutMiddleware() can remove route and group middleware but not global middleware.&lt;/p&gt;

&lt;h3&gt;
  
  
  What job middleware does Laravel include?
&lt;/h3&gt;

&lt;p&gt;Nine classes in Illuminate\Queue\Middleware: RateLimited and RateLimitedWithRedis, WithoutOverlapping, ThrottlesExceptions and ThrottlesExceptionsWithRedis, Skip, Release (new in Laravel 13), FailOnException and SkipIfBatchCancelled. Return them from a job's middleware() method or attach them at dispatch time with -&amp;gt;through().&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a Laravel job middleware does not call $next?
&lt;/h3&gt;

&lt;p&gt;If the middleware does not release or fail the job, the worker deletes it as completed. Skip, SkipIfBatchCancelled and RateLimited or WithoutOverlapping with dontRelease() all work this way. In a chain, the next link is still dispatched, so a skipped job does not stop the rest of the chain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Http::globalMiddleware() apply to every outgoing request?
&lt;/h3&gt;

&lt;p&gt;Only to requests made through Laravel's Http facade, including Http::pool() and faked requests under Http::fake(). SDKs that build their own GuzzleHttp\Client, such as the AWS SDK behind the S3 filesystem driver, never pass through it. Push the same middleware onto a Guzzle HandlerStack and give the SDK a client built on that stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is persistent middleware in Livewire?
&lt;/h3&gt;

&lt;p&gt;Route middleware that Livewire runs again on component update requests. After a Livewire page loads, every action posts to one shared update endpoint, so the page route's middleware does not run on it. Livewire re-runs the middleware that is both on the original route and on its persistent list, which by default includes auth, can and SubstituteBindings but not verified, password.confirm, throttle or custom middleware. Add classes with Livewire::addPersistentMiddleware(), or in Filament pass isPersistent: true to middleware(), authMiddleware() or tenantMiddleware().&lt;/p&gt;

&lt;h3&gt;
  
  
  How does middleware work in the Laravel AI SDK?
&lt;/h3&gt;

&lt;p&gt;An agent that implements HasMiddleware returns middleware classes with a handle(PendingStep $step, Closure $next) method. Since laravel/ai 1.0 the middleware runs once for each generation step, not once per prompt, and can change the model, messages or tools for that step, run code after the model answers with -&amp;gt;then(), or return its own StepResponse to skip the model call. Generate one with php artisan make:agent-middleware.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>laravel</category>
      <category>php</category>
      <category>programming</category>
    </item>
    <item>
      <title>Laravel Horizon Alerting: What Horizon Sends on Its Own and What Prometheus Adds</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Wed, 30 Sep 2026 20:06:32 +0000</pubDate>
      <link>https://dev.to/laravelo11y/laravel-horizon-alerting-what-horizon-sends-on-its-own-and-what-prometheus-adds-5g7n</link>
      <guid>https://dev.to/laravelo11y/laravel-horizon-alerting-what-horizon-sends-on-its-own-and-what-prometheus-adds-5g7n</guid>
      <description>&lt;p&gt;Horizon will tell you about one thing: a queue whose estimated wait has crossed a threshold. It will not tell you that the wait ended, that jobs are failing, that a supervisor was killed for memory, or that Horizon itself stopped an hour ago. Most teams find this out the usual way, from a customer asking where their invoice is.&lt;/p&gt;

&lt;p&gt;This post walks up the layers you can add. It starts with what ships in Horizon, moves to the gauges &lt;code&gt;spatie/laravel-prometheus&lt;/code&gt; exports and the alert rules those support, then to a counter-based exporter and the rules that only counters make possible. The last part covers the conditions no scrape carries, which is where Skyline's alerts come in. The rules are the ones we run in production, and for each we say which exporter can and cannot evaluate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Horizon has one notification, &lt;code&gt;LongWaitDetected&lt;/code&gt;.&lt;/strong&gt; It is built on a forecast of time to clear, repeats every five minutes while the condition lasts, never reports recovery, and is evaluated by a supervisor, so it is silent when Horizon is down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spatie's Horizon collectors are gauges read at scrape time.&lt;/strong&gt; They are enough for "Horizon is not running", "Horizon is paused" and "this backlog has not moved". They cannot express a failure rate, and they have nothing per job class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counters unlock the rules worth paging on.&lt;/strong&gt; With processed, failed and retried counters plus the age of the oldest pending job, you can alert on failure ratio, on a backlog nobody is draining, and on latency instead of length.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In Grafana, keep the comparison out of the PromQL.&lt;/strong&gt; A query like &lt;code&gt;x &amp;gt; 900&lt;/code&gt; returns no series when it is false, and Grafana reads that as No Data. Put the threshold in a threshold expression instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some failures never become a time series.&lt;/strong&gt; A stranded unique lock, a job dropped by a rate limiter, a reservation that expired mid-run and a &lt;code&gt;timeout&lt;/code&gt; above &lt;code&gt;retry_after&lt;/code&gt; produce no failures and no backlog. Those need checks that read queue state directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Horizon sends on its own
&lt;/h2&gt;

&lt;p&gt;Horizon's notification is configured in two places. The thresholds live in &lt;code&gt;config/horizon.php&lt;/code&gt;, keyed by connection and queue, and the destinations are set in your &lt;code&gt;HorizonServiceProvider&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="c1"&gt;// config/horizon.php&lt;/span&gt;
&lt;span class="s1"&gt;'waits'&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;'redis:default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'redis:reports'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'redis:housekeeping'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 0 turns the check off for that queue&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;

&lt;span class="c1"&gt;// app/Providers/HorizonServiceProvider.php&lt;/span&gt;
&lt;span class="nc"&gt;Horizon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;routeSlackNotificationsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://hooks.slack.com/services/...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'#ops'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Horizon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;routeMailNotificationsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ops@example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Horizon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;routeSmsNotificationsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'+15555550100'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A queue you do not list gets 60 seconds. Once a minute, one supervisor in the fleet takes a lock, calculates the wait for every queue it knows about, and raises &lt;code&gt;LongWaitDetected&lt;/code&gt; for each one over its threshold. That event sends the notification. It is a reasonable first alert, and it is worth knowing exactly how it behaves before relying on it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The wait is a forecast.&lt;/strong&gt; It comes from &lt;code&gt;WaitTimeCalculator&lt;/code&gt;: ready jobs multiplied by the queue's average runtime, divided by the processes serving it. Ten thousand fast jobs produce a large number and a single job stuck at the head of a queue produces a small one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It repeats every five minutes.&lt;/strong&gt; The listener takes a 300-second lock per connection and queue before sending, so a backlog that lasts two hours sends twenty-four messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It never says the wait ended.&lt;/strong&gt; There is no recovery message, so a channel that went quiet might mean the queue drained or might mean someone muted it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queues balanced together are one entry.&lt;/strong&gt; A supervisor working &lt;code&gt;high,default&lt;/code&gt; is keyed &lt;code&gt;redis:high,default&lt;/code&gt;, and the threshold applies to the pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It needs a running supervisor.&lt;/strong&gt; The check hangs off the supervisor's loop. When Horizon is down there is no loop, and the one alert you have cannot fire during the outage it was meant for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Events Horizon fires and tells nobody about
&lt;/h3&gt;

&lt;p&gt;Horizon raises several other events at the moments you would want to hear about, with no notification attached. &lt;code&gt;SupervisorOutOfMemory&lt;/code&gt; fires when a supervisor exceeds its memory limit and is about to be terminated, &lt;code&gt;MasterSupervisorOutOfMemory&lt;/code&gt; does the same for the master, and &lt;code&gt;UnableToLaunchProcess&lt;/code&gt; fires when a worker process will not start. You can listen to them yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Log&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\Horizon\Events\SupervisorOutOfMemory&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\Horizon\Events\UnableToLaunchProcess&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;SupervisorOutOfMemory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SupervisorOutOfMemory&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="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slack'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Horizon supervisor out of memory'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;supervisor&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;'memory_mb'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMemoryUsage&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="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;UnableToLaunchProcess&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UnableToLaunchProcess&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="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slack'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Horizon could not launch a worker'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'command'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCommandLine&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 same pattern with Laravel's &lt;code&gt;JobFailed&lt;/code&gt; event is the alert most teams write first, and the one that gets muted first. One malformed record on a job dispatched four hundred times an hour fills the channel before anyone is awake. A failure alert has to be about a rate, and a rate needs something that counts.&lt;/p&gt;

&lt;p&gt;For Horizon being down, the built-in tool is &lt;code&gt;php artisan horizon:status&lt;/code&gt;. It exits 0 when Horizon is running, 1 when a master is paused and 2 when no master is reporting, so a cron job or an uptime monitor that runs commands can watch it from outside the fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gauges from spatie/laravel-prometheus
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/spatie/laravel-prometheus" rel="noopener noreferrer"&gt;spatie/laravel-prometheus&lt;/a&gt; is a general Prometheus library for Laravel with a set of Horizon collectors. Uncomment &lt;code&gt;$this-&amp;gt;registerHorizonCollectors()&lt;/code&gt; in the published &lt;code&gt;PrometheusServiceProvider&lt;/code&gt; and the &lt;code&gt;/prometheus&lt;/code&gt; route serves seven gauges. The default namespace is &lt;code&gt;app&lt;/code&gt;, so the series arrive with that prefix.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;th&gt;What it reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_horizon_status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 running, 0 if any master is paused, -1 if no master is reporting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_horizon_master_supervisors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many master supervisors are reporting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_horizon_current_workload{queue}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ready jobs per supervisor pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_horizon_current_processes{queue}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Worker processes per supervisor pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_horizon_failed_recent_jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Failures inside the &lt;code&gt;trim.recent_failed&lt;/code&gt; window, a week by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_horizon_jobs_per_minute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Horizon's own jobs-per-minute figure for the current snapshot window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_horizon_recent_jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Jobs inside the &lt;code&gt;trim.recent&lt;/code&gt; window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our production rule file has five Horizon alerts. Four of them only ask a current-value question, which is what a gauge is for, and they port to these series directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;horizon-spatie&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizonNotRunning&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_horizon_status == -1 or absent(app_horizon_status)&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizonPaused&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_horizon_status == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;

      &lt;span class="c1"&gt;# The backlog is the same size it was 15 minutes ago, and not empty.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueProcessingStalled&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;(app_horizon_current_workload offset 15m == app_horizon_current_workload)&lt;/span&gt;
          &lt;span class="s"&gt;and (app_horizon_current_workload &amp;gt; 0)&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;

      &lt;span class="c1"&gt;# The queue has not reached zero once in 24 hours. The inner max_over_time&lt;/span&gt;
      &lt;span class="c1"&gt;# bridges short gaps in the series left by restarts and deploys.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueBacklogNotZeroFor24Hours&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;min_over_time(&lt;/span&gt;
            &lt;span class="s"&gt;max_over_time(app_horizon_current_workload[10m])[24h:1m]&lt;/span&gt;
          &lt;span class="s"&gt;) &amp;gt; 0&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;absent()&lt;/code&gt; matters in the first rule. If the application is unreachable or the scrape target was removed, the series is gone, and a comparison against a series that does not exist matches nothing.&lt;/p&gt;

&lt;p&gt;Two details change compared with the originals. &lt;code&gt;app_horizon_status&lt;/code&gt; folds paused and running into one series with no label, so &lt;code&gt;HorizonPaused&lt;/code&gt; cannot name which master is paused. And the &lt;code&gt;queue&lt;/code&gt; label is the supervisor's queue string: a pool working &lt;code&gt;high,default&lt;/code&gt; arrives as one series with &lt;code&gt;queue="high,default"&lt;/code&gt;, and the workload is the pool's total.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app_horizon_master_supervisors&lt;/code&gt; gives you one more rule for free. Set it against the number of servers that should be running Horizon, and it catches both a server that dropped out and the duplicate master tree from our &lt;a href="https://boring-observability.dev/blog/postmortem-horizon-orphan-workers-supervisor-upgrade" rel="noopener noreferrer"&gt;supervisor upgrade postmortem&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizonMasterCountWrong&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_horizon_master_supervisors != &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;   &lt;span class="c1"&gt;# your server count&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The rule that does not port
&lt;/h3&gt;

&lt;p&gt;Our fifth rule is "more than 20 failed jobs in an hour, sustained for two hours". It is written with &lt;code&gt;increase()&lt;/code&gt; over a failure counter, and the Spatie collectors have no counter. &lt;code&gt;app_horizon_failed_recent_jobs&lt;/code&gt; is a gauge over a sliding window, so it goes down as old failures leave the window. &lt;code&gt;increase()&lt;/code&gt; treats every drop as a counter reset and the result means nothing.&lt;/p&gt;

&lt;p&gt;There is a workaround if you can live with its side effect. The gauge counts failures newer than &lt;code&gt;trim.recent_failed&lt;/code&gt; minutes, so setting that to 60 turns it into "failures in the past hour":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/horizon.php&lt;/span&gt;
&lt;span class="s1"&gt;'trim'&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;'recent_failed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// was 10080&lt;/span&gt;
    &lt;span class="s1"&gt;'failed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// the failed jobs list keeps its week&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 yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FailedJobsIncreasing&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_horizon_failed_recent_jobs &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The side effect is that the dashboard's failed-jobs figure becomes the past hour as well, since it reads the same setting. The figure is also fleet-wide. There is no queue or job class on it, so the alert tells you that something is failing and you go to the dashboard to find out what.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is missing from the Horizon collectors&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no age of the oldest pending job, no wait time, no retries and nothing per job class. Spatie's separate queue collectors do export &lt;code&gt;queue_oldest_pending_job_age&lt;/code&gt;, but their documentation says not to run them alongside the Horizon collectors. The jobs-per-minute gauge has its own problems as an alert input, covered in the &lt;a href="https://boring-observability.dev/blog/laravel-horizon-prometheus-grafana" rel="noopener noreferrer"&gt;Horizon in Prometheus&lt;/a&gt; post. Also set &lt;code&gt;allowed_ips&lt;/code&gt; before deploying the route, because an empty list allows everyone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Counters change which rules you can write
&lt;/h2&gt;

&lt;p&gt;Our &lt;a href="https://boring-observability.dev/open-source/horizon-prometheus-exporter" rel="noopener noreferrer"&gt;Horizon Prometheus Exporter&lt;/a&gt; is open source and runs on stock Horizon. Workers count each outcome into Redis, the counts only go up, and &lt;code&gt;/horizon/prometheus&lt;/code&gt; serves them. It exports processed, failed and retried counters per queue and per job class, a wait-time summary, and gauges for queue length, oldest pending age, time to clear, processes and paused state. Every &lt;code&gt;queue&lt;/code&gt; label names one queue, with the pool in a &lt;code&gt;group&lt;/code&gt; label.&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 boring-o11y/horizon-prometheus-exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this exporter our five production rules run as they are written. We scrape Skyline's endpoint rather than this package, and the two use the same metric names, so the file is the same either way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizonAlerts&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizonNotRunning&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;horizon_up != 1 or absent(horizon_up)&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizonPaused&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;horizon_master_supervisor_paused == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueProcessingStalled&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;(horizon_queue_length{queue!=""} offset 15m == horizon_queue_length{queue!=""})&lt;/span&gt;
          &lt;span class="s"&gt;and (horizon_queue_length{queue!=""} &amp;gt; 0)&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueBacklogNotZeroFor24Hours&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;min_over_time(&lt;/span&gt;
            &lt;span class="s"&gt;max_over_time(horizon_queue_length{queue!=""}[10m])[24h:1m]&lt;/span&gt;
          &lt;span class="s"&gt;) &amp;gt; 0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FailedJobsIncreasing&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum(increase(horizon_queue_failed_total[1h])) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HorizonPaused&lt;/code&gt; now carries a &lt;code&gt;master&lt;/code&gt; label, so the alert names the machine. &lt;code&gt;FailedJobsIncreasing&lt;/code&gt; is a real &lt;code&gt;increase()&lt;/code&gt; over a counter and needs no change to Horizon's trimming.&lt;/p&gt;

&lt;p&gt;Those five were written when gauges were all we had. With counters and the oldest-pending gauge there are better ones, and these are the rules we would add.&lt;/p&gt;

&lt;h3&gt;
  
  
  Latency instead of length
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueBacklogAgeing&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;max by (queue) (horizon_queue_oldest_pending_seconds) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;900&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueWaitHigh&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;sum by (queue) (rate(horizon_queue_wait_seconds_sum[10m]))&lt;/span&gt;
      &lt;span class="s"&gt;/ sum by (queue) (rate(horizon_queue_wait_seconds_count[10m])) &amp;gt; 120&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is how long the job at the head of the queue has been ready to run. It is 0 on an empty queue, small on a fast one however deep it is, and climbs without limit when nothing is consuming. The second is the average wait of the jobs that were picked up, which catches a queue that is moving but slower than it promised. Set both per queue from what the queue is for. A password reset is late at a minute and a nightly export is fine at half an hour.&lt;/p&gt;

&lt;h3&gt;
  
  
  A backlog nobody is draining
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueNotMoving&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;max by (queue) (horizon_queue_length) &amp;gt; 0&lt;/span&gt;
    &lt;span class="s"&gt;unless sum by (queue) (rate(horizon_queue_processed_total[10m])) &amp;gt; 0&lt;/span&gt;
    &lt;span class="s"&gt;unless max by (queue) (horizon_queue_paused) == 1&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This replaces &lt;code&gt;QueueProcessingStalled&lt;/code&gt;. Comparing a length with itself fifteen minutes ago has two failure cases: a busy queue that happens to land on the same number, and a stalled queue that keeps receiving dispatches, whose length changes every minute. Asking whether anything finished has neither. The last clause leaves out queues somebody paused on purpose, which get their own low-severity rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueLeftPaused&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;max by (queue) (horizon_queue_paused) == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ticket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Failure ratio, per queue and per job class
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueFailureRatio&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;sum by (queue) (rate(horizon_queue_failed_total[5m]))&lt;/span&gt;
      &lt;span class="s"&gt;/ (&lt;/span&gt;
        &lt;span class="s"&gt;sum by (queue) (rate(horizon_queue_processed_total[5m]))&lt;/span&gt;
        &lt;span class="s"&gt;+ sum by (queue) (rate(horizon_queue_failed_total[5m]))&lt;/span&gt;
      &lt;span class="s"&gt;) &amp;gt; 0.05&lt;/span&gt;
    &lt;span class="s"&gt;and sum by (queue) (rate(horizon_queue_processed_total[5m])) &amp;gt; 0.1&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JobClassFailing&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum by (job_class) (increase(horizon_job_failed_total[15m])) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;and&lt;/code&gt; clause is a throughput floor of about six jobs a minute. Without it a queue that ran one job in five minutes and failed it reads as 100 per cent. The per-class rule is there because a ratio per queue dilutes: one class failing every run on a queue shared with twenty others can stay under five per cent indefinitely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Falling behind, and retry churn
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;QueueFallingBehind&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;max by (queue) (deriv(horizon_queue_length[10m])) &amp;gt; 0&lt;/span&gt;
    &lt;span class="s"&gt;and max by (queue) (horizon_queue_time_to_clear_seconds) &amp;gt; 900&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ticket&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JobRetryChurn&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;sum by (job_class) (rate(horizon_job_retried_total[15m]))&lt;/span&gt;
      &lt;span class="s"&gt;/ sum by (job_class) (rate(horizon_job_processed_total[15m])) &amp;gt; 1&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ticket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time to clear is a forecast, and a forecast over a line is a bad page on its own. Paired with a backlog that is still growing it says arrivals are outpacing workers while the fix is still cheap. The retry rule counts jobs released after an exception, so a class retrying more often than it completes is leaning on its &lt;code&gt;tries&lt;/code&gt; to get through.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same rules as Grafana alerts
&lt;/h3&gt;

&lt;p&gt;Everything above is a Prometheus rule file, evaluated by Prometheus and routed by Alertmanager. Grafana lists those rules read-only under its alerting pages. If you would rather have Grafana evaluate them, each one becomes a Grafana-managed rule with the PromQL as the query and &lt;code&gt;for&lt;/code&gt; as the pending period.&lt;/p&gt;

&lt;p&gt;One thing needs to change on the way over. A PromQL comparison filters: &lt;code&gt;horizon_queue_oldest_pending_seconds &amp;gt; 900&lt;/code&gt; returns no series at all when every queue is healthy, and Grafana reports that as No Data. Query the bare metric and put the 900 in a threshold expression, so the rule always has a value to evaluate. Then set "Alert state if no data" to Alerting on the &lt;code&gt;horizon_up&lt;/code&gt; rule, which does the job &lt;code&gt;absent()&lt;/code&gt; does in the rule file.&lt;/p&gt;

&lt;p&gt;Whichever evaluates them, scrape one web server. Every instance reads the same Redis, so three targets return the same series three times and every &lt;code&gt;sum()&lt;/code&gt; triples.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a scrape runs out
&lt;/h2&gt;

&lt;p&gt;A scrape carries numbers that exist for every queue or job class at every moment. That shape fits throughput, backlog and latency well. It fits badly when the fault is a fact about one lock, one job or one line of config.&lt;/p&gt;

&lt;p&gt;Take a job class that has silently stopped running because a unique lock outlived the worker that held it. Every dispatch is skipped. Nothing fails, so the failure ratio is flat. Nothing is queued, so length and age are zero. Throughput for that class drops to nothing, which looks the same as a quiet afternoon. You could write an "expected throughput" rule per class, and you would be maintaining a list of what each job ought to be doing.&lt;/p&gt;

&lt;p&gt;The other gap is detail. A rule can say the failure ratio on &lt;code&gt;default&lt;/code&gt; is eight per cent. It cannot say which lock, which limiter or which supervisor setting is responsible, because those were never labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alerts Skyline adds
&lt;/h2&gt;

&lt;p&gt;Skyline is our fork of Horizon, and its &lt;a href="https://boring-observability.dev/skyline/docs/alerts" rel="noopener noreferrer"&gt;alert pipeline&lt;/a&gt; runs inside the application against the same Redis. It covers the ground above without a metrics stack: Horizon down, oldest job age per queue, a queue with workers and no completions, a backlog that keeps growing, a pause nobody resumed and a failure rate per queue or per job class. If you have built the Prometheus rules you already have those, and Skyline's endpoint exports the same metric names, so the rule file keeps working.&lt;/p&gt;

&lt;p&gt;The checks below are the ones the rule file has no equivalent for. They read lock owners, limiter buckets, worker exit reasons and supervisor config, none of which either exporter publishes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Fires when&lt;/th&gt;
&lt;th&gt;Why the metrics miss it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stranded_lock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A unique or overlap lock is still held and the job that took it is gone&lt;/td&gt;
&lt;td&gt;No failures and no backlog; the dispatches are skipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;limiter_drop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rate-limit, overlap or exception-throttle middleware dropped or deleted jobs&lt;/td&gt;
&lt;td&gt;A dropped job is recorded as completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reservation_expired&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A job was still reserved when &lt;code&gt;retry_after&lt;/code&gt; ran out, so it will run twice&lt;/td&gt;
&lt;td&gt;Both runs succeed and count as processed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;misconfiguration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A supervisor's &lt;code&gt;timeout&lt;/code&gt; is not below its connection's &lt;code&gt;retry_after&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;It is config, and nothing has gone wrong yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;job_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One job class keeps timing out&lt;/td&gt;
&lt;td&gt;A timed-out attempt is retried, and only counts as failed when attempts run out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;worker_crash_loop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Workers die without reporting a reason, faster than a threshold&lt;/td&gt;
&lt;td&gt;The supervisor replaces them, so the process count never drops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;memory_restarts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Workers keep being recycled for their memory limit&lt;/td&gt;
&lt;td&gt;Nothing fails; throughput sags while workers boot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;process_launch_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A worker process would not start&lt;/td&gt;
&lt;td&gt;A single event between two scrapes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;supervisor_out_of_memory&lt;/code&gt;, &lt;code&gt;master_out_of_memory&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A supervisor or the master exceeded its memory limit and was restarted&lt;/td&gt;
&lt;td&gt;A single event between two scrapes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Work that vanishes without failing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;stranded_lock&lt;/code&gt; is the case from the previous section. Skyline records which job holds each &lt;code&gt;ShouldBeUnique&lt;/code&gt; and &lt;code&gt;WithoutOverlapping&lt;/code&gt; lock for its &lt;a href="https://boring-observability.dev/skyline/docs/locks-and-limits" rel="noopener noreferrer"&gt;Locks &amp;amp; Limits&lt;/a&gt; screen, so the check can tell a lock whose job is still queued or running from one whose job no longer exists. After five minutes in that state it alerts with the job class, the lock key, how long it has been held, when it expires and how many dispatches it has skipped. How locks get stranded in the first place is in the &lt;a href="https://boring-observability.dev/blog/laravel-job-uniqueness-controls" rel="noopener noreferrer"&gt;ShouldBeUnique post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;limiter_drop&lt;/code&gt; watches the middleware that discards jobs by design: &lt;code&gt;RateLimited&lt;/code&gt; with &lt;code&gt;dontRelease()&lt;/code&gt;, &lt;code&gt;WithoutOverlapping&lt;/code&gt; without &lt;code&gt;releaseAfter()&lt;/code&gt;, and &lt;code&gt;ThrottlesExceptions&lt;/code&gt; with &lt;code&gt;deleteWhen()&lt;/code&gt;. Each leaves the job marked completed, so the processed counter goes up for work that did not happen. The check fires at ten drops in an hour for one limiter or job class. Some applications drop on purpose, a "latest wins" sync for example, so each limiter takes its own threshold or a zero to leave it out:&lt;br&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;'limiter_drop'&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;'threshold'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'circuit_open'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// also alert when an exception throttle holds back 50 jobs an hour&lt;/span&gt;
    &lt;span class="s1"&gt;'groups'&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;'App\\Jobs\\SyncLatestPrices'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'rate_limiter:uploads'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Jobs that run twice
&lt;/h3&gt;

&lt;p&gt;When a job is still running at the moment its reservation expires, the queue hands it to a second worker. Both runs complete, both count as processed, and the customer is charged twice. &lt;code&gt;reservation_expired&lt;/code&gt; reports it once per queue with a count and the job classes involved, at critical severity, because it is a correctness problem and not a slow one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;misconfiguration&lt;/code&gt; is the same fault before it happens. A supervisor whose &lt;code&gt;timeout&lt;/code&gt; is at or above its connection's &lt;code&gt;retry_after&lt;/code&gt; will produce a double run as soon as one job runs long enough. Stock Horizon accepts that config without comment. Skyline prints a warning when it starts, where nobody is looking after a deploy that went fine, so the check also alerts with the supervisor, the connection and both values, repeats once a day, and resolves when a deploy picks up the corrected config. The &lt;a href="https://boring-observability.dev/blog/laravel-queue-timeout-retry-after" rel="noopener noreferrer"&gt;timeout vs &lt;code&gt;retry_after&lt;/code&gt; post&lt;/a&gt; explains why the two have to be ordered that way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workers dying, by cause
&lt;/h3&gt;

&lt;p&gt;Stock Horizon shows the configured number of processes whether workers are healthy or being replaced every thirty seconds. Skyline records why each worker stopped, and three checks read that record. &lt;code&gt;worker_crash_loop&lt;/code&gt; counts workers that died without giving a reason, which means a PHP fatal or a kill from outside, and fires at five in five minutes per supervisor. &lt;code&gt;memory_restarts&lt;/code&gt; counts workers recycled for reaching their memory limit and fires at ten in fifteen minutes. One memory restart is the supervisor doing its job, so the crash check leaves those out and this one looks at the rate.&lt;/p&gt;

&lt;p&gt;The memory alert also names the job classes that retained the most heap over the window, since Skyline measures heap growth per class. That turns "workers on &lt;code&gt;default&lt;/code&gt; keep running out of memory" into a class name to open. Both checks need &lt;a href="https://boring-observability.dev/skyline/docs/insights" rel="noopener noreferrer"&gt;Insights&lt;/a&gt; enabled, which is what records the restarts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;job_timeout&lt;/code&gt; fires when one class times out five times in ten minutes. A timed-out job is killed, retried, and often times out again, so the failure rate only sees it once the attempts are spent. By then a slow dependency has been holding workers for an hour. Timeouts are counted per class and per queue, and Skyline exports them as &lt;code&gt;horizon_job_timed_out_total&lt;/code&gt; and &lt;code&gt;horizon_queue_timed_out_total&lt;/code&gt; if you would rather write that rule in Prometheus.&lt;/p&gt;

&lt;p&gt;The last three are the events from the top of this post: &lt;code&gt;process_launch_failed&lt;/code&gt;, &lt;code&gt;supervisor_out_of_memory&lt;/code&gt; and &lt;code&gt;master_out_of_memory&lt;/code&gt;. They report once when the event happens, through the same channels as everything else, in place of the listeners you would otherwise write.&lt;/p&gt;

&lt;h3&gt;
  
  
  How they are delivered
&lt;/h3&gt;

&lt;p&gt;Every check holds for a &lt;code&gt;for&lt;/code&gt; window before notifying, repeats at most every fifteen minutes while it lasts, and sends a recovery message saying how long the condition held. Notifications are withheld for five minutes after a deploy while the timers keep counting. Each alert has a severity, and &lt;code&gt;routes&lt;/code&gt; decides which channels a severity goes to, across mail, Slack, SMS and a JSON webhook for PagerDuty or Opsgenie.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;HORIZON_ALERTS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;HORIZON_ALERT_SLACK&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://hooks.slack.com/services/..."&lt;/span&gt;
&lt;span class="py"&gt;HORIZON_ALERT_WEBHOOK&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://events.example.com/queue-alerts"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything specific to your application goes in a class that extends &lt;code&gt;Check&lt;/code&gt; and is registered with &lt;code&gt;Horizon::alertCheck()&lt;/code&gt;. "No invoices processed since midnight on a weekday" is a rule like that, and it gets the same hold window, cooldown, recovery message and routing as the built-in ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which layer to stop at
&lt;/h2&gt;

&lt;p&gt;If you run no metrics stack, configure &lt;code&gt;LongWaitDetected&lt;/code&gt; with a threshold per queue and put &lt;code&gt;horizon:status&lt;/code&gt; behind a cron or an uptime check. That covers a growing backlog and Horizon being down, which are the two outages that cost the most.&lt;/p&gt;

&lt;p&gt;If you already run Prometheus and Spatie's package, the four gauge rules are worth the ten minutes they take. Once you want a failure rate, or an alert that separates a busy queue from a stuck one, you need counters and the oldest-pending age, and that is the point to add the exporter.&lt;/p&gt;

&lt;p&gt;The Skyline checks are for the failures that leave the graphs looking normal. A rule file and those checks do not compete: the rules stay in Prometheus next to your database and host alerts, and the checks cover the conditions that never reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Laravel Horizon have built-in alerts?
&lt;/h3&gt;

&lt;p&gt;One. Horizon sends a LongWaitDetected notification by mail, Slack or SMS when a queue's estimated wait passes its threshold in horizon.waits, 60 seconds by default. It repeats every five minutes while the wait lasts and sends nothing when it clears. There is no built-in notification for failed jobs, for a supervisor running out of memory, or for Horizon not running.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I get alerted when Horizon stops running?
&lt;/h3&gt;

&lt;p&gt;From outside Horizon, because its own notification is evaluated by a running supervisor. Run php artisan horizon:status from cron or an uptime monitor: it exits 0 when running, 1 when paused and 2 when inactive. With Prometheus, alert on app_horizon_status == -1 from spatie/laravel-prometheus or horizon_up == 0 from a counter exporter, and add absent() so a missing series also fires.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I alert on failed jobs with spatie/laravel-prometheus?
&lt;/h3&gt;

&lt;p&gt;Only roughly. Its horizon_failed_recent_jobs gauge counts failures inside Horizon's trim.recent_failed window, a week by default, and has no queue or job class label. It is a sliding window and not a counter, so increase() and rate() do not work on it. Setting trim.recent_failed to 60 makes it failures in the past hour, which also changes the figure on Horizon's dashboard. A failure ratio per queue needs an exporter that keeps counters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Grafana alert on a Horizon metric show No Data?
&lt;/h3&gt;

&lt;p&gt;Usually because the comparison is inside the PromQL. A query such as horizon_queue_oldest_pending_seconds &amp;gt; 900 returns no series when every queue is under the threshold, and Grafana treats an empty result as No Data. Query the metric without the comparison and add a threshold expression for the 900, so the rule always has a value to evaluate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Horizon problems can Prometheus not alert on?
&lt;/h3&gt;

&lt;p&gt;The ones that produce no failures and no backlog. A unique lock left behind by a dead worker makes every dispatch of that job get skipped. A job dropped by RateLimited with dontRelease() is recorded as completed. A job that outlives retry_after runs twice and both runs count as processed. These need a check that reads lock owners, limiter counts or supervisor config directly, which is what Skyline's alert checks do.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>laravel</category>
      <category>monitoring</category>
      <category>php</category>
    </item>
    <item>
      <title>Rate-Limited APIs and Laravel Queues: One Request at a Time, Without Starving the Rest</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Tue, 29 Sep 2026 14:42:29 +0000</pubDate>
      <link>https://dev.to/laravelo11y/rate-limited-apis-and-laravel-queues-one-request-at-a-time-without-starving-the-rest-k0c</link>
      <guid>https://dev.to/laravelo11y/rate-limited-apis-and-laravel-queues-one-request-at-a-time-without-starving-the-rest-k0c</guid>
      <description>&lt;p&gt;You are integrating with an API that allows sixty calls a minute and no parallel requests at all. Send two at once and it returns a 429, or worse, corrupts the record you were updating. Your queue workers, meanwhile, are built to run as many jobs at once as they can.&lt;/p&gt;

&lt;p&gt;Each of Laravel's answers to this problem addresses one of two different limits, and most teams reach for the wrong one. This article goes through the options the framework and Horizon give you (the middleware, the limiters, the process counts) and what each one costs. It ends with a single serial slot, shared by a fast lane and a slow lane, where urgent work can still go first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Sixty a minute" and "one at a time" are different limits.&lt;/strong&gt; A rate limit bounds how often a job may start; a concurrency limit bounds how many may be in flight. &lt;code&gt;RateLimited&lt;/code&gt; handles the first and does nothing about the second: sixty workers can fire simultaneously and stay within sixty a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel has no "don't pull" primitive.&lt;/strong&gt; Every built-in throttle pops the job, finds it can't run it, and pushes it back. BullMQ leaves rate-limited jobs waiting in the queue; Laravel turns them into churn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That churn has three costs:&lt;/strong&gt; a released job goes to the back of the queue, it consumes an attempt, and it lands in the delayed set, where Horizon mostly stops showing it to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cheapest answer is not in the docs:&lt;/strong&gt; a dedicated supervisor with &lt;code&gt;maxProcesses =&amp;gt; 1&lt;/code&gt;. Concurrency of one comes from the process count, with no locks, no releases and no burned attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority on a single worker needs weights.&lt;/strong&gt; Put &lt;code&gt;api-high&lt;/code&gt; and &lt;code&gt;api-default&lt;/code&gt; on that one worker and they share the limiter with no extra code. With strict priority order, though, a flood on &lt;code&gt;api-high&lt;/code&gt; starves &lt;code&gt;api-default&lt;/code&gt; to zero. Weighting the queues gives &lt;code&gt;api-default&lt;/code&gt; a fixed share instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two limits that look like one
&lt;/h2&gt;

&lt;p&gt;The vendor's docs state two limits. "60 requests per minute" and "no concurrent requests" are different constraints, and no single setting satisfies both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;rate limit&lt;/strong&gt; bounds how often a call may start: 60 per minute, 5 per second. It says nothing about how many are in flight at once.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;concurrency limit&lt;/strong&gt; bounds how many calls may run simultaneously, in our case exactly one. It says nothing about how often they may start.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are independent. Sixty calls per minute, each taking ten seconds, is ten requests in flight at any moment: within the rate limit and well over the concurrency limit. One call at a time, each taking ten milliseconds, is a hundred requests a second: serial, and far over the rate limit. You need both limits, and Laravel provides them in different places.&lt;/p&gt;

&lt;p&gt;The distinction is visible in Laravel's API. &lt;code&gt;Redis::throttle()&lt;/code&gt; is a rate limiter and &lt;code&gt;Redis::funnel()&lt;/code&gt; is a concurrency limiter; the &lt;code&gt;RateLimited&lt;/code&gt; middleware is the former and &lt;code&gt;WithoutOverlapping&lt;/code&gt; is the latter. Most "how do I rate-limit a Laravel job" answers online cover the rate half and leave the concurrency half unsolved. The options below cover both, with what each one costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: the &lt;code&gt;RateLimited&lt;/code&gt; middleware
&lt;/h2&gt;

&lt;p&gt;The documented answer, and the right tool for the "60 per minute" half. Define a named limiter, then attach the middleware to the job:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;// In AppServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'vendor-api'&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;object&lt;/span&gt; &lt;span class="nv"&gt;$job&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;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Middleware\RateLimitedWithRedis&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;middleware&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RateLimitedWithRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'vendor-api'&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the limit is hit, the middleware calls &lt;code&gt;$job-&amp;gt;release()&lt;/code&gt; with a delay of "time until the window resets, plus three seconds." You can override that with &lt;code&gt;releaseAfter()&lt;/code&gt;, or call &lt;code&gt;dontRelease()&lt;/code&gt;, which is easy to misread. &lt;code&gt;dontRelease()&lt;/code&gt; does not fail the job and does not defer it. It deletes it: no exception, no &lt;code&gt;failed_jobs&lt;/code&gt; row, no &lt;code&gt;failed()&lt;/code&gt; hook.&lt;/p&gt;

&lt;p&gt;Prefer &lt;code&gt;RateLimitedWithRedis&lt;/code&gt; over plain &lt;code&gt;RateLimited&lt;/code&gt; whenever the limit is an external contract. The generic version checks the counter and increments it as two separate cache operations. That is a time-of-check-to-time-of-use race: several workers can pass the check before any of them increments, and together they exceed your vendor's limit. The Redis variant does both in a single atomic Lua script. The docs describe it only as "more efficient."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; documented, declarative, per-job, and the limiter key is shared across every job that names it, so two different job classes can draw on one budget. &lt;strong&gt;Cons:&lt;/strong&gt; it limits rate and nothing else. Sixty workers can still hit the API in the same instant and remain within sixty per minute. And every throttled job pays the release costs described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: &lt;code&gt;WithoutOverlapping&lt;/code&gt;, a mutex
&lt;/h2&gt;

&lt;p&gt;This covers the concurrency half, and it gives you the number you want: one.&lt;br&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\Queue\Middleware\WithoutOverlapping&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;middleware&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WithoutOverlapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'vendor-api'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expireAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three settings in that snippet matter, and all three defaults are wrong for this use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shared()&lt;/code&gt; is required here.&lt;/strong&gt; By default the lock key includes a hash of the job's class name, so &lt;code&gt;SyncContact&lt;/code&gt; and &lt;code&gt;PushInvoice&lt;/code&gt; get separate mutexes and call your no-parallel-requests API in parallel. &lt;code&gt;shared()&lt;/code&gt; drops the class from the key, which is what makes both jobs contend for the same lock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;releaseAfter()&lt;/code&gt; defaults to &lt;code&gt;0&lt;/code&gt;.&lt;/strong&gt; A blocked job is re-queued with no delay: pop, fail the lock, re-enqueue, pop, fail the lock. That loop keeps a worker core busy and burns an attempt on every pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expireAfter()&lt;/code&gt; defaults to never.&lt;/strong&gt; The lock is released in a &lt;code&gt;finally&lt;/code&gt; block, so an exception is fine, but a worker killed by &lt;code&gt;--timeout&lt;/code&gt; or SIGKILL never runs it. The lock is then orphaned permanently, and that key stays locked until someone force-releases it by hand. Always set it above your job timeout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; mutual exclusion, works on any lock-capable cache driver, and it composes with &lt;code&gt;RateLimited&lt;/code&gt;: stack both and you have covered both limits. &lt;strong&gt;Cons:&lt;/strong&gt; the concurrency is fixed at one; there is no &lt;code&gt;-&amp;gt;limit(n)&lt;/code&gt;. It is release-based, so it pays the full churn cost. And a lock is a coordination mechanism you have to operate, with expiries to tune and orphans to clean up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: funnels and throttles inside the job
&lt;/h2&gt;

&lt;p&gt;Below the middleware sit the primitives they are built on. &lt;code&gt;Redis::funnel()&lt;/code&gt; is the one that matters here, because it is the only thing in Laravel that expresses "at most N of these at once" for N greater than one:&lt;br&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\Redis&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="nc"&gt;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'vendor-api'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;then&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;callTheVendor&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&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="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;funnel&lt;/code&gt; caps how many run at once; its sibling &lt;code&gt;throttle()-&amp;gt;allow(60)-&amp;gt;every(60)&lt;/code&gt; caps how often they start. Both are Lua-backed and atomic, and both are undocumented. They were in the queue docs up to Laravel 8 and were dropped when &lt;code&gt;RateLimited&lt;/code&gt; landed. The classes still ship and still work, but tutorials that teach them cite documentation that no longer exists.&lt;/p&gt;

&lt;p&gt;There are two traps. First, &lt;code&gt;block()&lt;/code&gt; defaults to three seconds, and that is three seconds of the worker sleeping inside your job's runtime. It pushes the job toward &lt;code&gt;retry_after&lt;/code&gt;, at which point a second worker picks the job up while the first is still holding it, and you make the duplicate API call you were trying to prevent. Pass &lt;code&gt;block(0)&lt;/code&gt; to fail fast. Second, omitting the failure closure throws a &lt;code&gt;LimiterTimeoutException&lt;/code&gt; instead of releasing, which counts as a job failure.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;releaseAfter(120)&lt;/code&gt; above is the slot's safety TTL. If a worker is hard-killed mid-job, the slot is not returned until it expires, so it must exceed your job timeout. Otherwise a second worker is handed the slot while the first is still running, and the &lt;code&gt;limit(1)&lt;/code&gt; guarantee no longer holds. Set it too long, though, and one stuck job stalls your serial pipeline until the TTL runs out.&lt;/p&gt;

&lt;p&gt;Laravel 12 added the same concept to the cache layer as &lt;code&gt;Cache::funnel()&lt;/code&gt;, which works on any lock-capable driver and is documented. The semantics are the same, without the Redis dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; atomic, and the only way to express a concurrency limit above one. &lt;strong&gt;Cons:&lt;/strong&gt; the Redis variants are undocumented, they block by default, there is no middleware wrapper, and the code lives inside &lt;code&gt;handle()&lt;/code&gt;, which mixes coordination logic into your business logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4: one queue, one worker
&lt;/h2&gt;

&lt;p&gt;Every option so far gets concurrency of one by having many workers compete for a lock, with the releasing, re-queueing and attempt-burning that implies. The queue already has a concurrency control, and it is the most reliable one available: the number of worker processes.&lt;/p&gt;

&lt;p&gt;Give the API its own queue and give that queue exactly one worker, and concurrency of one follows from the setup instead of being enforced. There is nothing to contend for. No lock, no release, no attempt burned, and FIFO order is preserved because no job ever loses its place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'supervisor-vendor-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;'connection'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api-high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api-default'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'minProcesses'&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="s1"&gt;'maxProcesses'&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="s1"&gt;'rest'&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="s1"&gt;'timeout'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;maxProcesses =&amp;gt; 1&lt;/code&gt; is the setting that matters, and it is easy to get wrong. It is tempting to assume &lt;code&gt;balance =&amp;gt; false&lt;/code&gt; already pins the process count, but it does not: even unbalanced, Horizon scales the pool toward the number of ready jobs, clamped between &lt;code&gt;minProcesses&lt;/code&gt; and &lt;code&gt;maxProcesses&lt;/code&gt;. Pinning both ends to &lt;code&gt;1&lt;/code&gt; is what guarantees a single serial slot.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rest&lt;/code&gt;, one of Horizon's more obscure options, sleeps the worker for N seconds after each job. On a single-process supervisor that works as a rate limit: one job per (duration + 1s) stays comfortably under sixty a minute. There is no middleware, no limiter key, no released job, no consumed attempt, and nothing is pushed to the back of the queue. The option appears in no version of the Laravel documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; the cheapest correct answer. No release churn, no lock administration, FIFO preserved, and it covers both limits at once. &lt;strong&gt;Cons:&lt;/strong&gt; two. It is coarse: &lt;code&gt;rest&lt;/code&gt; paces every job on that worker identically and cannot burst up to a limit the way a token bucket can. And &lt;code&gt;maxProcesses&lt;/code&gt; is a per-host cap, not a cluster-wide one.&lt;/p&gt;

&lt;p&gt;The per-host cap breaks the guarantee without any error. Every machine running &lt;code&gt;php artisan horizon&lt;/code&gt; builds its own provisioning plan and deploys every supervisor matching its environment. Nothing in Horizon coordinates process counts across hosts: there is no cluster-wide cap, no lock and no leader election. Three app servers running Horizon against the same Redis give you three of these "single" workers, and three parallel calls to an API that permits none.&lt;/p&gt;

&lt;p&gt;The fix is to make exactly one host provision this supervisor by giving it an environment of its own. Horizon resolves its environment from &lt;code&gt;--environment&lt;/code&gt;, then &lt;code&gt;config('horizon.env')&lt;/code&gt;, then &lt;code&gt;APP_ENV&lt;/code&gt;, so the simplest lever is the flag, set in that host's Supervisor or systemd unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the one host that owns the vendor API. Everywhere else: php artisan horizon&lt;/span&gt;
php artisan horizon &lt;span class="nt"&gt;--environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;serial
&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="s1"&gt;'environments'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'production'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// ...your normal supervisors, on every app server&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="s1"&gt;'serial'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-vendor-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;'connection'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api-high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api-default'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'minProcesses'&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="s1"&gt;'maxProcesses'&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="s1"&gt;'rest'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;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;Environment matching is a first-match wildcard lookup, and a host whose environment matches nothing starts zero supervisors, silently. If you cannot guarantee single-host ownership, or would rather not stake your vendor contract on a deployment detail, keep a &lt;code&gt;funnel(1)&lt;/code&gt; or a &lt;code&gt;WithoutOverlapping&lt;/code&gt; as a backstop. It should almost never fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 5: chains and drip-feeds
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Job chains&lt;/strong&gt; (&lt;code&gt;Bus::chain()&lt;/code&gt;) serialize by construction: job N+1 is not enqueued until job N succeeds. For a known, finite, ordered batch, such as "sync these 200 records, in order", this works well and needs no locks. But a chain is a closed list. Producers elsewhere in your app cannot append to it, two chains run in parallel with no mutual exclusion between them, and one failure abandons the rest of the chain. It serializes one batch, not access to the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drip-feeding&lt;/strong&gt; means a scheduled command that dispatches N jobs a minute, or a self-redispatching "pacemaker" job that pulls work off a list on an interval. Teams build it when the built-ins fall short. It works, and it has the same advantage as the single worker: nothing is popped that cannot be run. But you are now hand-rolling a scheduler on top of a queue, the scheduler's one-minute floor sets your resolution, and you own every edge case yourself. Use it only if the single-worker supervisor cannot express your pacing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 6: the packages
&lt;/h2&gt;

&lt;p&gt;The ecosystem has filled part of the gap, and the two mature packages solve different problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/spatie/laravel-rate-limited-job-middleware" rel="noopener noreferrer"&gt;spatie/laravel-rate-limited-job-middleware&lt;/a&gt;&lt;/strong&gt; wraps &lt;code&gt;Redis::throttle&lt;/code&gt; in a fluent middleware and adds what core lacks: exponential backoff on release, time-based attempts, and events when the limit is hit. It is maintained and widely used. It is still a rate limiter, and still release-based.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/mxl/laravel-queue-rate-limit" rel="noopener noreferrer"&gt;mxl/laravel-queue-rate-limit&lt;/a&gt;&lt;/strong&gt; throttles at the worker level, which core cannot do. Configure &lt;code&gt;'allows' =&amp;gt; 1, 'every' =&amp;gt; 5&lt;/code&gt; for a queue and it makes the worker sleep instead of releasing the job. No attempts consumed, no re-queueing, no reordering. The cost is coarseness, since it throttles the whole queue regardless of job class. On a dedicated API queue that is what you want anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concurrency limiting above one is still missing. Core gives you exactly one (&lt;code&gt;WithoutOverlapping&lt;/code&gt;) or an undocumented funnel, and no maintained package covers the range in between well.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options, side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Limits rate?&lt;/th&gt;
&lt;th&gt;Limits concurrency?&lt;/th&gt;
&lt;th&gt;Release churn?&lt;/th&gt;
&lt;th&gt;Main cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RateLimited&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Non-atomic; can overshoot a hard limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RateLimitedWithRedis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Solves only half the problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WithoutOverlapping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, exactly 1&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Lock orphans; three wrong defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Redis::funnel()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, N&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Undocumented; blocks the worker by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Redis::throttle()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Undocumented; blocks the worker by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chains&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Within a chain&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Closed set; can't serialize the API globally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One worker + &lt;code&gt;rest&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, exactly 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Coarse pacing; per-host, not cluster-wide&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The cost of nearly every option: Laravel pops before it checks
&lt;/h2&gt;

&lt;p&gt;Every lock- and middleware-based option in the table has release churn. It is why teams who add the &lt;code&gt;RateLimited&lt;/code&gt; middleware often end up with a queue that is slower, drops more jobs and is harder to see than before.&lt;/p&gt;

&lt;p&gt;When a job is rate-limited, Laravel has already popped it: reserved it, unserialized it and re-fetched its Eloquent models from the database. Only then does the middleware run, find the budget spent, and push the job back. In BullMQ, rate-limited jobs stay in the waiting state: the worker declines to pull, and nothing else happens. Laravel has no "don't pull" primitive, so it pulls the job and puts it back. Rate limiting in Laravel therefore does not reduce work. It converts work into churn, and the churn grows with how far over the limit you are.&lt;/p&gt;

&lt;p&gt;That design has three consequences, and the documentation mentions only one of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Released jobs go to the back of the queue.&lt;/strong&gt; On Redis, &lt;code&gt;release()&lt;/code&gt; puts the job in the delayed set; when it matures it is &lt;code&gt;RPUSH&lt;/code&gt;ed onto the tail. On the database driver it is a new row with a new auto-increment id, sorted last. The job that was next in line becomes last in line every time it is throttled. Under sustained load, a job can be starved indefinitely while newer work overtakes it. For the jobs you are throttling, FIFO order is inverted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Released jobs consume attempts.&lt;/strong&gt; This one is documented, and it is still the most common way this setup fails: your retry budget, which exists to protect you from failures, is spent on successful backpressure. With the default &lt;code&gt;tries&lt;/code&gt; of 1, a job fails permanently the first time it is throttled. The fix is to count time instead of attempts, with &lt;code&gt;retryUntil()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Released jobs collide again as a group.&lt;/strong&gt; Every job blocked on the same limiter gets the same release delay; middleware releases have no jitter and no exponential backoff. They all mature at the same instant, are migrated back in one batch, and are popped and throttled again together on the next tick. The group stays synchronized indefinitely.
&lt;/li&gt;
&lt;/ul&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;DateTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Retry until a deadline instead of for a fixed number of attempts.&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;retryUntil&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DateTime&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;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;plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&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;Teams have followed this path to its end (custom throttling middleware, then dynamic per-user queues, then a forked Horizon autoscaler) and written post-mortems about it. That is a lot of engineering for calling an API slowly, and it is the strongest argument for an approach that never releases a job in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that works: one slot, one limiter
&lt;/h2&gt;

&lt;p&gt;The API needs serial access and a paced rate, you want urgent work to overtake routine work, and you want as little churn as possible. That gives you one supervisor, two queues and one worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'supervisor-vendor-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;'connection'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api-high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api-default'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'minProcesses'&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="s1"&gt;'maxProcesses'&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="c1"&gt;// the serial slot&lt;/span&gt;
    &lt;span class="s1"&gt;'rest'&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="c1"&gt;// the rate limit&lt;/span&gt;
    &lt;span class="s1"&gt;'timeout'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&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;Because both queues are drained by the same single process, they cannot produce a parallel request. No lock forbids it; there is no second worker to make one. The two queues share the concurrency budget by sharing the worker. And if you add a &lt;code&gt;RateLimited('vendor-api')&lt;/code&gt; middleware for a limit &lt;code&gt;rest&lt;/code&gt; can't express, both queues share that budget too, because the limiter is keyed by its name, not by the queue.&lt;/p&gt;

&lt;p&gt;Keep the rest of your queues, and the other twenty supervisors doing your main work, out of this. This supervisor is a deliberate bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting urgent jobs jump the line
&lt;/h2&gt;

&lt;p&gt;A customer clicks "sync now" and expects their record pushed within seconds, but there are 4,000 nightly-reconciliation jobs on the same queue and the vendor allows one call a second. That is an hour of waiting behind work nobody is watching.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;balance =&amp;gt; false&lt;/code&gt;, the queue list is a strict left-to-right priority order: a worker only looks at &lt;code&gt;api-default&lt;/code&gt; when &lt;code&gt;api-high&lt;/code&gt; is empty. Dispatching to &lt;code&gt;api-high&lt;/code&gt; therefore does what you want. The urgent job is picked up next, and it uses the same single slot and the same limiter, so jumping the line cannot breach the API contract.&lt;br&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;SyncContact&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api-high'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strict priority is absolute. Push a few thousand jobs onto &lt;code&gt;api-high&lt;/code&gt; and &lt;code&gt;api-default&lt;/code&gt; gets nothing for as long as &lt;code&gt;api-high&lt;/code&gt; stays non-empty. With one worker and a rate limit throttling you on purpose, that can be hours. This is the same problem covered in &lt;a href="https://boring-observability.dev/blog/laravel-horizon-queue-balancing-tradeoffs" rel="noopener noreferrer"&gt;Horizon's balancing trade-offs&lt;/a&gt;, and a rate-limited single worker is its worst case, because your throughput is capped by contract.&lt;/p&gt;

&lt;p&gt;Turning balancing on does not help. With &lt;code&gt;balance =&amp;gt; 'auto'&lt;/code&gt;, Horizon ignores queue order and gives each queue its own process pool, which means two workers and two parallel API calls, the thing the vendor forbids. Auto-balancing and serial access are mutually exclusive.&lt;/p&gt;

&lt;p&gt;Skyline's &lt;a href="https://boring-observability.dev/skyline/docs/weighted-queues" rel="noopener noreferrer"&gt;weighted queues&lt;/a&gt; keep the single shared worker of &lt;code&gt;balance =&amp;gt; false&lt;/code&gt;, so the serial slot survives, and have it check the queues proportionally instead of in strict order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'supervisor-vendor-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;'connection'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api-high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api-default'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'minProcesses'&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="s1"&gt;'maxProcesses'&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="s1"&gt;'rest'&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="s1"&gt;'queueWeights'&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;'api-high'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// 'api-default' omitted =&amp;gt; default weight of 1&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At five to one, roughly five in every six pickups favour &lt;code&gt;api-high&lt;/code&gt;, and the sixth still reaches &lt;code&gt;api-default&lt;/code&gt;. Urgent work gets most of a scarce, contractually capped resource, and the nightly reconciliation keeps draining instead of stalling until morning. There is still one worker and one API call at a time.&lt;/p&gt;

&lt;p&gt;For one-off urgency (a single job, now, without a second queue), &lt;a href="https://boring-observability.dev/skyline/docs/front-of-queue-dispatching" rel="noopener noreferrer"&gt;front-of-queue dispatching&lt;/a&gt; pushes a single job onto the head of the Redis list rather than the tail:&lt;br&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\Horizon\InteractsWithFrontOfQueue&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;SyncContact&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dispatchable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithFrontOfQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;SyncContact&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onFront&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;onFront()&lt;/code&gt; acts on the ready list. A job sitting in the delayed set because a rate limiter released it is not on that list yet, so it cannot be moved to the front until it matures. This is the ordering problem from the release section again: once a job has been released, its position is decided by when it matures, not by you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can no longer see
&lt;/h2&gt;

&lt;p&gt;This architecture has one more cost: it makes your queue harder to see.&lt;/p&gt;

&lt;p&gt;Every job that a rate limiter releases lands in the delayed set. It is not running, not failed and not in the ready list; it is in a fourth state that Horizon barely shows. A backlog of throttled work can be almost absent from the dashboard you are checking to find out why nothing is happening. Jobs delayed beyond your retention window can drop off the dashboard altogether while still pending, a &lt;a href="https://github.com/laravel/horizon/issues/723" rel="noopener noreferrer"&gt;long-standing Horizon issue&lt;/a&gt; that was closed without a fix.&lt;/p&gt;

&lt;p&gt;The single-slot pattern adds a second visibility cost. With &lt;code&gt;balance =&amp;gt; false&lt;/code&gt;, Horizon registers the pool under the comma-joined name of its queues, so your two lanes stop being two rows and become one pseudo-queue called &lt;code&gt;redis:api-high,api-default&lt;/code&gt;. You cannot set a per-queue wait threshold for them, and the metrics dashboard shows them as a single entry. The pattern that gave you a serial slot costs you per-queue visibility, on the queue you most need to watch because you throttle it on purpose.&lt;/p&gt;

&lt;p&gt;In Skyline, &lt;a href="https://boring-observability.dev/skyline/docs/dashboard-operations" rel="noopener noreferrer"&gt;delayed and retrying jobs are first-class&lt;/a&gt;: you can see what is waiting and when each job is due, and run any of them immediately instead of waiting out a backoff window. You can open &lt;code&gt;api-default&lt;/code&gt; and read the jobs inside it, in order. And when the vendor goes down, you can &lt;a href="https://boring-observability.dev/skyline/docs/pausing-queues" rel="noopener noreferrer"&gt;pause that one queue&lt;/a&gt; and let the work accumulate, instead of a rate limiter running 4,000 jobs through their retry budgets against an API that is returning 503s. None of that needs a migration or a code change: see &lt;a href="https://boring-observability.dev/skyline/vs/horizon" rel="noopener noreferrer"&gt;Skyline vs Horizon&lt;/a&gt; for what swapping the package does and does not touch.&lt;/p&gt;

&lt;p&gt;One change is worth making by hand. Import &lt;code&gt;RateLimitedWithRedis&lt;/code&gt; from &lt;code&gt;Laravel\Horizon\Middleware&lt;/code&gt; instead of &lt;code&gt;Illuminate\Queue\Middleware&lt;/code&gt;, and Skyline's &lt;a href="https://boring-observability.dev/skyline/docs/locks-and-limits" rel="noopener noreferrer"&gt;Locks &amp;amp; Limits&lt;/a&gt; screen counts how often each limiter released or dropped a job, while a job dropped by &lt;code&gt;dontRelease()&lt;/code&gt; is logged instead of vanishing.&lt;/p&gt;

&lt;p&gt;If you are deciding which queue a job belongs on in the first place, that is practice #2 in &lt;a href="https://boring-observability.dev/blog/laravel-background-jobs-best-practices" rel="noopener noreferrer"&gt;12 best practices for Laravel background jobs&lt;/a&gt;. And if the jobs piling up behind your rate limiter are duplicates of each other rather than distinct work, read &lt;a href="https://boring-observability.dev/blog/laravel-job-uniqueness-controls" rel="noopener noreferrer"&gt;the &lt;code&gt;ShouldBeUnique&lt;/code&gt; gotchas&lt;/a&gt; next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I limit a Laravel job to one at a time?
&lt;/h3&gt;

&lt;p&gt;Two ways. The lock-based way is the WithoutOverlapping middleware with -&amp;gt;shared(). Without shared(), the lock key includes the job class, so two different job classes still run in parallel. The cheaper way is structural: give the job its own queue and a Horizon supervisor with maxProcesses =&amp;gt; 1, so a single worker process drains it serially. That needs no locks, releases no jobs and consumes no attempts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the RateLimited middleware prevent parallel requests?
&lt;/h3&gt;

&lt;p&gt;No. A rate limit bounds how often a job may start; it says nothing about how many run at once. Sixty workers can call an API simultaneously and still be within a limit of sixty per minute. To bound concurrency you need WithoutOverlapping, Redis::funnel(), or a supervisor pinned to one worker process.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between Redis::throttle() and Redis::funnel()?
&lt;/h3&gt;

&lt;p&gt;throttle() is a rate limiter: at most N starts per time window. funnel() is a concurrency limiter: at most N running at once, with each slot held for as long as the job runs and returned when it finishes. Both are atomic Lua scripts. Both were dropped from the Laravel documentation after version 8, and both still ship and work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do my rate-limited jobs fail after a few minutes?
&lt;/h3&gt;

&lt;p&gt;Because releasing a rate-limited job back onto the queue still increments its attempt count. With the default tries of 1, a job fails permanently the first time it is throttled, having used its only attempt on waiting for the limiter. Define retryUntil() on the job so its retry budget is a deadline instead of an attempt count.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I let an urgent job jump ahead of a rate-limited queue?
&lt;/h3&gt;

&lt;p&gt;Run an api-high and an api-default queue on the same single-worker supervisor: they share the worker, so they share the concurrency budget, and they share the rate limiter because it is keyed by name rather than by queue. With balance =&amp;gt; false the queue list is a strict priority order, so a flood on api-high starves api-default entirely. Skyline's queueWeights check the two in proportion instead (5:1 here), so urgent jobs mostly go first and api-default is still checked on every poll.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>laravel</category>
      <category>php</category>
    </item>
    <item>
      <title>Full-Page Caching in Laravel: Large Redis Entries, predis vs phpredis, and Compression</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Tue, 29 Sep 2026 11:22:31 +0000</pubDate>
      <link>https://dev.to/laravelo11y/full-page-caching-in-laravel-large-redis-entries-predis-vs-phpredis-and-compression-3lic</link>
      <guid>https://dev.to/laravelo11y/full-page-caching-in-laravel-large-redis-entries-predis-vs-phpredis-and-compression-3lic</guid>
      <description>&lt;p&gt;A full-page cache stores the whole rendered response, so an entry is as large as the page it holds. Our Redis monitor showed response-cache entries of about 25MB, and ordinary listing pages were taking 1.6 to 2.8MB each. The hit rate looked fine. Redis memory and the size of every read did not.&lt;/p&gt;

&lt;p&gt;Most of that size goes away with compression, and where it happens depends on the Redis client. phpredis can compress every value on a connection with one config option. predis can't, so on predis the compression has to happen in the cache serializer. The examples use &lt;a href="https://github.com/spatie/laravel-responsecache" rel="noopener noreferrer"&gt;spatie/laravel-responsecache&lt;/a&gt;, the open-source full-page cache for Laravel that &lt;a href="https://spatie.be" rel="noopener noreferrer"&gt;Spatie&lt;/a&gt; maintains, but the Redis side applies to any cache that stores rendered HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rendered HTML compresses well.&lt;/strong&gt; Our listing pages went from 2.71MB to 0.08MB with zlib, and a 1.47MB article page went to 169KB with zstd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;predis has no compression option.&lt;/strong&gt; phpredis compresses values with lzf, lz4 or zstd when you set &lt;code&gt;compression&lt;/code&gt; on the connection, provided the extension was built with that algorithm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the response cache its own connection.&lt;/strong&gt; On a compressed connection, &lt;code&gt;Cache::increment()&lt;/code&gt; silently returns &lt;code&gt;false&lt;/code&gt; unless &lt;code&gt;pack_ignore_numbers&lt;/code&gt; is set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enabling compression needs no flush, but disabling it does.&lt;/strong&gt; A compressed connection reads old plain entries, and an uncompressed client can't read compressed ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On predis, compress in the serializer.&lt;/strong&gt; &lt;code&gt;gzcompress()&lt;/code&gt; reaches the same size as zstd but costs about seven times as much per write.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a full-page cache entry gets big
&lt;/h2&gt;

&lt;p&gt;responsecache serializes the response to JSON with its status, headers and body, and stores that string. The body is everything the browser receives, including inline SVG icons, JSON-LD blocks, inline scripts and a Livewire snapshot for every component on the page. A listing page with a card per result, each carrying its images, prices and links, reaches megabytes quickly. JSON then escapes every quote and slash, so the stored string is larger than the HTML.&lt;/p&gt;

&lt;p&gt;The number of entries multiplies that. Our hasher keys each page by locale, currency and user, so one URL can hold dozens of guest entries, and every query-string variant gets its own set. The 25MB entries came from a URL that no page links to, which was still rendered and cached for every visitor who reached it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redis-cli --memkeys&lt;/code&gt; samples the keyspace and reports the largest key of each type by memory, and &lt;code&gt;MEMORY USAGE&lt;/code&gt; gives the size of a single key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redis-cli &lt;span class="nt"&gt;-n&lt;/span&gt; 1 &lt;span class="nt"&gt;--memkeys&lt;/span&gt;
redis-cli &lt;span class="nt"&gt;-n&lt;/span&gt; 1 MEMORY USAGE &lt;span class="s2"&gt;"laravel_cache:9f2c...-eur-en-guest"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis keeps every entry in memory, so a few hundred page variants can push the instance into &lt;code&gt;maxmemory&lt;/code&gt; eviction. If the cache store is shared with the rest of the app, the keys it evicts can be sessions, locks or anything else. Each cache hit also moves the full value from Redis to PHP and decodes it there, which on a busy page is most of the traffic between the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  predis and phpredis
&lt;/h2&gt;

&lt;p&gt;Laravel talks to Redis through one of two clients, chosen by &lt;code&gt;database.redis.client&lt;/code&gt; (the &lt;code&gt;REDIS_CLIENT&lt;/code&gt; variable). The framework defaults to &lt;code&gt;phpredis&lt;/code&gt;, but many apps run predis because it installs with Composer and needs no extension, which is the only option on hosts that don't allow extensions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;predis&lt;/th&gt;
&lt;th&gt;phpredis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Pure PHP library, &lt;code&gt;composer require predis/predis&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;C extension, &lt;code&gt;pecl install redis&lt;/code&gt; or a distro package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol parsing&lt;/td&gt;
&lt;td&gt;In PHP&lt;/td&gt;
&lt;td&gt;In C&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value compression&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;lzf, lz4, zstd, set per connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value serializers&lt;/td&gt;
&lt;td&gt;None, Laravel's cache calls &lt;code&gt;serialize()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;php, igbinary, msgpack, JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy cost&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;The extension in every image, CI runner and dev machine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For small values the two perform close enough that the choice rarely matters. For values the size of a page, phpredis's built-in compression is the main reason to prefer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What compression costs
&lt;/h2&gt;

&lt;p&gt;These are the stored size and the time per put and get for a 1.47MB response-cache payload written through Laravel's &lt;code&gt;RedisStore&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client and compression&lt;/th&gt;
&lt;th&gt;Stored&lt;/th&gt;
&lt;th&gt;Put&lt;/th&gt;
&lt;th&gt;Get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;predis&lt;/td&gt;
&lt;td&gt;1,500KB&lt;/td&gt;
&lt;td&gt;1.03ms&lt;/td&gt;
&lt;td&gt;0.55ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;predis + &lt;code&gt;gzcompress()&lt;/code&gt; level 6&lt;/td&gt;
&lt;td&gt;172KB&lt;/td&gt;
&lt;td&gt;17.1ms&lt;/td&gt;
&lt;td&gt;2.15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phpredis&lt;/td&gt;
&lt;td&gt;1,500KB&lt;/td&gt;
&lt;td&gt;0.40ms&lt;/td&gt;
&lt;td&gt;0.51ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phpredis lzf&lt;/td&gt;
&lt;td&gt;334KB&lt;/td&gt;
&lt;td&gt;2.29ms&lt;/td&gt;
&lt;td&gt;2.46ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phpredis lz4&lt;/td&gt;
&lt;td&gt;277KB&lt;/td&gt;
&lt;td&gt;1.52ms&lt;/td&gt;
&lt;td&gt;0.52ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phpredis zstd level 3&lt;/td&gt;
&lt;td&gt;169KB&lt;/td&gt;
&lt;td&gt;2.42ms&lt;/td&gt;
&lt;td&gt;0.81ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phpredis zstd level 9&lt;/td&gt;
&lt;td&gt;143KB&lt;/td&gt;
&lt;td&gt;11.6ms&lt;/td&gt;
&lt;td&gt;0.70ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;zstd at its default level stores the page in about a ninth of the space. It adds 2ms to a write and about 0.3ms to a read. Writes happen only on a cache miss, which already pays for a full render, so the write cost barely registers. Reads happen on every hit, and zstd and lz4 both decompress quickly enough that a hit stays under a millisecond. lz4 suits you better if write time matters more than memory. Level 9 saves another 15% for five times the write cost, which we didn't think was worth it.&lt;/p&gt;

&lt;p&gt;The timings assume Redis close to PHP. With a managed Redis in another zone, or any link where bandwidth is the limit, the uncompressed reads slow down in proportion to their size, and the compressed ones barely change. The ratio depends on the markup too. Our own listing pages repeat the same card markup many times, and they compressed about 33 times with zlib, where the article page above compressed about 9 times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enabling phpredis with compression
&lt;/h2&gt;

&lt;p&gt;The extension compresses only with algorithms it was compiled with. The PECL build asks about lzf, zstd and lz4 at configure time and skips any whose library headers are missing. A Dockerfile that gets all three:&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;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; liblz4-dev libzstd-dev &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pecl &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--configureoptions&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        &lt;span class="s1"&gt;'enable-redis-igbinary="no" enable-redis-msgpack="no" enable-redis-lzf="yes" enable-redis-zstd="yes" enable-redis-lz4="yes"'&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        redis &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker-php-ext-enable redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A distro package or a hosting panel's build may leave some of them out, so check before relying on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;php &lt;span class="nt"&gt;--ri&lt;/span&gt; redis | &lt;span class="nb"&gt;grep &lt;/span&gt;compression
Available compression &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; lzf, zstd, lz4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the line is missing or doesn't list the algorithm you configure, rebuild the extension first. Then switch the client. &lt;code&gt;database.redis.client&lt;/code&gt; applies to every Redis connection in the app, so this moves the default connection, the cache, the queue and Horizon to phpredis together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;REDIS_CLIENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;phpredis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step needs no flush. With no serializer or compression options set, both clients store and read the same bytes.&lt;/p&gt;

&lt;p&gt;Compression is set per connection, and we keep it on the response cache only. Add a connection and a cache store for 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="c1"&gt;// config/database.php, under 'redis'&lt;/span&gt;
&lt;span class="s1"&gt;'responses'&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;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_URL'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'6379'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_RESPONSE_CACHE_DB'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'compression'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;COMPRESSION_ZSTD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'compression_level'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'pack_ignore_numbers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;

&lt;span class="c1"&gt;// config/cache.php, under 'stores'&lt;/span&gt;
&lt;span class="s1"&gt;'responses'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'responses'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'lock_connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'responses'&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 shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RESPONSE_CACHE_DRIVER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;responses
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Redis::COMPRESSION_ZSTD&lt;/code&gt; is a constant of the extension's class, so every environment that loads the config needs phpredis, including CI and &lt;code&gt;config:cache&lt;/code&gt; in a build step.&lt;/p&gt;

&lt;p&gt;A separate store also limits what a clear removes. With no &lt;code&gt;responsecache.cache.tag&lt;/code&gt; set, &lt;code&gt;ResponseCache::clear()&lt;/code&gt; flushes the whole store it's pointed at, and on a store of its own that means only responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counters on a compressed connection
&lt;/h2&gt;

&lt;p&gt;phpredis compresses every value it writes through that connection, integers included. Redis's &lt;code&gt;INCRBY&lt;/code&gt; then finds a compressed blob where it expects a number and refuses it, and phpredis reports that as &lt;code&gt;false&lt;/code&gt; with no 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="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'responses'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'n'&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;60&lt;/span&gt;&lt;span class="p"&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;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'responses'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'n'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// false, and 'n' is still 5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel's &lt;code&gt;RateLimiter&lt;/code&gt; works around this by turning compression off around its own writes, so &lt;code&gt;throttle&lt;/code&gt; keeps counting. Your own &lt;code&gt;Cache::add()&lt;/code&gt; and &lt;code&gt;Cache::increment()&lt;/code&gt; pairs don't get that treatment. The &lt;code&gt;pack_ignore_numbers&lt;/code&gt; option in the config above tells phpredis to store numbers as plain values, and with it the same two lines return &lt;code&gt;6&lt;/code&gt;. A connection used only by the response cache has no counters on it in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying and rolling back
&lt;/h2&gt;

&lt;p&gt;phpredis with compression on still reads values written without it, with lzf, lz4 and zstd alike. The deploy that turns compression on therefore needs no flush. Old entries are served until they expire, and new ones are written compressed.&lt;/p&gt;

&lt;p&gt;Going back is different. predis, or phpredis without the &lt;code&gt;compression&lt;/code&gt; option, gets the compressed bytes back as they are, and &lt;code&gt;RedisStore&lt;/code&gt;'s &lt;code&gt;unserialize()&lt;/code&gt; fails on them. If you roll back the config, clear the response cache store as part of the rollback:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The same applies to anything else reading that Redis database directly, such as a script or a second app on predis.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you have to stay on predis
&lt;/h2&gt;

&lt;p&gt;Without the extension, compress before the value reaches the client. responsecache lets you replace its serializer, and a subclass of its &lt;code&gt;JsonSerializer&lt;/code&gt; can zlib the JSON using only &lt;code&gt;ext-zlib&lt;/code&gt;, which nearly every PHP build has:&lt;br&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\ResponseCache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\ResponseCache\Exceptions\CouldNotUnserialize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\ResponseCache\Serializers\JsonSerializer&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;Symfony\Component\HttpFoundation\Response&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;CompressedJsonSerializer&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JsonSerializer&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;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Response&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;gzcompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;serialize&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="mi"&gt;6&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;unserialize&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;$serializedResponse&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="c1"&gt;// Entries cached before compression are plain JSON, and a zlib stream never starts with '{'&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$serializedResponse&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="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="nb"&gt;unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$serializedResponse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;gzuncompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$serializedResponse&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;$json&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CouldNotUnserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Could not gzuncompress cached response'&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="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/responsecache.php&lt;/span&gt;
&lt;span class="s1"&gt;'serializer'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;App\ResponseCache\CompressedJsonSerializer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This took our listing pages from 2.71MB to 0.08MB. The value is already a binary string when Laravel receives it, so it works with predis and phpredis alike, and the fallback for JSON entries means the deploy needs no flush. A corrupt entry throws &lt;code&gt;CouldNotUnserialize&lt;/code&gt;, which the middleware treats as a miss, and the next render overwrites it.&lt;/p&gt;

&lt;p&gt;zlib is slower than phpredis's zstd. In the table above, &lt;code&gt;gzcompress()&lt;/code&gt; at level 6 stores the page at about the same size as zstd but takes 17ms per write against 2.4ms, and 2.2ms per read against 0.8ms. The write cost falls only on misses, which makes it acceptable when you can't install an extension. If you can install &lt;code&gt;ext-zstd&lt;/code&gt; to use in the serializer, installing phpredis takes the same effort and needs no custom class.&lt;/p&gt;

&lt;p&gt;Compression makes each entry smaller but doesn't change how many of them get rendered. If the cache is missing more than it should, &lt;a href="https://boring-observability.dev/blog/laravel-responsecache-hit-rate" rel="noopener noreferrer"&gt;the responsecache hit-rate post&lt;/a&gt; goes through the click ids and key mismatches that caused most of our misses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does predis support compression?
&lt;/h3&gt;

&lt;p&gt;No. predis sends values to Redis as it gets them. To compress on predis, compress before the value reaches the client, for example in a custom responsecache serializer that calls gzcompress(). phpredis has compression built in: set compression to Redis::COMPRESSION_ZSTD, COMPRESSION_LZ4 or COMPRESSION_LZF on the connection in config/database.php.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check which compression algorithms phpredis supports?
&lt;/h3&gt;

&lt;p&gt;Run php --ri redis and look for the "Available compression" line. The extension only compresses with algorithms it was compiled with, so a PECL build needs the liblz4 and libzstd headers installed and the enable-redis-lz4 and enable-redis-zstd configure options set.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Cache::increment() return false with phpredis compression?
&lt;/h3&gt;

&lt;p&gt;phpredis compresses every value it writes through the connection, integers included, so INCRBY finds a compressed blob instead of a number and fails, which phpredis returns as false. Set pack_ignore_numbers to true on the connection so numbers are stored plain, or keep counters off the compressed connection. Laravel's RateLimiter turns compression off around its own writes and is not affected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to flush Redis when enabling phpredis compression?
&lt;/h3&gt;

&lt;p&gt;No. A compressed phpredis connection still reads values written without compression, so old entries are served until they expire. Disabling compression is different: predis or an uncompressed phpredis connection cannot read compressed values, so clear the cache store when rolling back.&lt;/p&gt;

</description>
      <category>caching</category>
      <category>laravel</category>
      <category>performance</category>
      <category>redis</category>
    </item>
    <item>
      <title>Instrumenting Guzzle in Laravel: Logging and Metrics for Every Outgoing Request</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Tue, 29 Sep 2026 11:21:48 +0000</pubDate>
      <link>https://dev.to/laravelo11y/instrumenting-guzzle-in-laravel-logging-and-metrics-for-every-outgoing-request-2jh7</link>
      <guid>https://dev.to/laravelo11y/instrumenting-guzzle-in-laravel-logging-and-metrics-for-every-outgoing-request-2jh7</guid>
      <description>&lt;p&gt;Most Laravel apps spend a good share of every request waiting on someone else's server: the payment provider, the shipping API, the CRM, S3. When one of them gets slow, you see your own response times rise and have to work out whose fault it was. Instrumenting the outgoing side answers that directly, and in Laravel nearly all of that traffic goes through Guzzle, which gives you a very good place to hook in.&lt;/p&gt;

&lt;p&gt;This post builds that hook. We start with a Guzzle middleware registered once on Laravel's HTTP client, look at what the client's own events can and can't tell you, cover the SDKs that build their own Guzzle client, and then deal with the case that most logging setups miss: the request that never got a response at all. At the end, we compare logs with metrics and point at the two packages we maintain for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Register a Guzzle middleware with &lt;code&gt;Http::globalMiddleware()&lt;/code&gt;.&lt;/strong&gt; It runs for every &lt;code&gt;Http::&lt;/code&gt; call, including each request in &lt;code&gt;Http::pool()&lt;/code&gt;, and it is the one hook that sees the request, the response and the failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record from Guzzle's &lt;code&gt;on_stats&lt;/code&gt; option, not from the response.&lt;/strong&gt; &lt;code&gt;on_stats&lt;/code&gt; fires for every transfer, including a timeout or a refused connection, with the transfer time and cURL's error number. A handler that only looks at responses never sees those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel's &lt;code&gt;ConnectionFailed&lt;/code&gt; event carries no timing.&lt;/strong&gt; It holds the request and the exception, and the only way to tell a timeout from a DNS failure is to read the exception's message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDKs with their own Guzzle client are invisible to &lt;code&gt;Http::globalMiddleware()&lt;/code&gt;.&lt;/strong&gt; The AWS SDK (and with it the S3 filesystem driver) is one. Push the same middleware onto their handler stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're counting transfers, not calls.&lt;/strong&gt; A redirect produces one transfer per hop and &lt;code&gt;Http::retry(3)&lt;/code&gt; up to three, and each one passes through the middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where your outgoing requests go
&lt;/h2&gt;

&lt;p&gt;Laravel's &lt;code&gt;Http&lt;/code&gt; facade is a wrapper around Guzzle. Every call through it builds a Guzzle handler stack, and Laravel lets you add to that stack for every request the application makes. That covers your own integration code, &lt;code&gt;Http::pool()&lt;/code&gt;, and any package that uses Laravel's client internally.&lt;/p&gt;

&lt;p&gt;It doesn't cover code that creates a &lt;code&gt;GuzzleHttp\Client&lt;/code&gt; of its own. Many SDKs do, because they are written to work outside Laravel too. Those requests go through a separate stack that Laravel never sees, so they need instrumenting separately. The mechanism is the same in both cases, which is why it's worth writing the middleware as a plain Guzzle middleware rather than something Laravel-specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Guzzle middleware for every &lt;code&gt;Http::&lt;/code&gt; call
&lt;/h2&gt;

&lt;p&gt;A Guzzle middleware is a function that takes the next handler and returns a new one. The handler receives the request and the request options and returns a promise. Here is one that logs every transfer:&lt;br&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\Http&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;GuzzleHttp\TransferStats&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\Log&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;Psr\Http\Message\RequestInterface&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;Throwable&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;LogOutgoingRequests&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;callable&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;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RequestInterface&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;array&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'on_stats'&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="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'on_stats'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;TransferStats&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;try&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;$stats&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getResponse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="nv"&gt;$errno&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHandlerErrorData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

                    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'outbound'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'http.out'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                        &lt;span class="s1"&gt;'method'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMethod&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                        &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEffectiveUri&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHost&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                        &lt;span class="s1"&gt;'path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEffectiveUri&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPath&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                        &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStatusCode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                        &lt;span class="s1"&gt;'ms'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTransferTime&lt;/span&gt;&lt;span class="p"&gt;()&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;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="s1"&gt;'curl_error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;is_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$errno&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$errno&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;]);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$previous&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="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;$handler&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="nv"&gt;$options&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;Register it once, in a service provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Http\LogOutgoingRequests&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\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;globalMiddleware&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;LogOutgoingRequests&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 middleware doesn't touch the promise. All the recording happens in &lt;code&gt;on_stats&lt;/code&gt;, an option that Guzzle's cURL handler calls once per transfer, after the transfer has finished, whatever the outcome. We keep any &lt;code&gt;on_stats&lt;/code&gt; callback the caller already set and call it afterwards, so a request that passes its own &lt;code&gt;on_stats&lt;/code&gt; still gets it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;try&lt;/code&gt; block matters more than it looks. Guzzle treats an exception thrown from the stats callback as a failure of the request itself, so a full disk or a misconfigured log channel would turn every outgoing call in the app into an exception. Instrumentation should never be able to break the thing it observes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;At this layer a 500 is still a response&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Laravel's client doesn't throw on a 4xx or 5xx unless you call &lt;code&gt;throw()&lt;/code&gt; or exhaust a &lt;code&gt;retry()&lt;/code&gt;. A plain Guzzle client does, through its &lt;code&gt;http_errors&lt;/code&gt; middleware, but that sits outside the middleware you pushed. Your middleware sees the 500 as an ordinary response before it becomes a &lt;code&gt;ServerException&lt;/code&gt;, so recording status codes there works the same whichever client made the call.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Laravel's HTTP client events give you
&lt;/h2&gt;

&lt;p&gt;Laravel also dispatches three events from its client, and a listener is less code than a middleware. They are worth knowing, because they are where most "log every outgoing request" answers start:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Fires when&lt;/th&gt;
&lt;th&gt;Carries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RequestSending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before every attempt&lt;/td&gt;
&lt;td&gt;The request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ResponseReceived&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A response arrived, of any status&lt;/td&gt;
&lt;td&gt;The request and the response. &lt;code&gt;$response-&amp;gt;transferStats&lt;/code&gt; holds Guzzle's &lt;code&gt;TransferStats&lt;/code&gt;, so the timing is there.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConnectionFailed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No response arrived&lt;/td&gt;
&lt;td&gt;The request and a &lt;code&gt;ConnectionException&lt;/code&gt;. Nothing else.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The successful path is well covered. The failed path isn't. &lt;code&gt;ConnectionFailed&lt;/code&gt; has no transfer time, so you can't tell a request that failed instantly from one that held a worker for 30 seconds. And the cause is only in the exception message, as a string like &lt;code&gt;cURL error 28: Connection timed out after 1001 milliseconds&lt;/code&gt;, which you would have to parse to count timeouts separately from DNS failures.&lt;/p&gt;

&lt;p&gt;The events also exist only on Laravel's client. The SDK traffic in the next section never dispatches them, so an events-based setup can't be extended to cover it. The middleware can.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDKs that bring their own Guzzle client
&lt;/h2&gt;

&lt;p&gt;We checked which common PHP SDKs depend on Guzzle directly. The AWS SDK, Google's API client and Resend's SDK all do, and each builds its own &lt;code&gt;GuzzleHttp\Client&lt;/code&gt;. That includes every S3 call made through Laravel's filesystem, because the S3 driver uses the AWS SDK. A test with a plain &lt;code&gt;new Client()&lt;/code&gt; confirms that &lt;code&gt;Http::globalMiddleware()&lt;/code&gt; never sees these requests.&lt;/p&gt;

&lt;p&gt;The fix is to push the same middleware onto a handler stack and give the SDK a client built on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Http\LogOutgoingRequests&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;GuzzleHttp\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;GuzzleHttp\HandlerStack&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$stack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HandlerStack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$stack&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;push&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;LogOutgoingRequests&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'handler'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$stack&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How you hand that client over depends on the SDK. Most accept one through a constructor option or a factory method. &lt;code&gt;openai-php/client&lt;/code&gt;, for example, takes it through &lt;code&gt;OpenAI::factory()-&amp;gt;withHttpClient($client)&lt;/code&gt;. SDKs built on PSR-18 discovery, like &lt;code&gt;openai-php/client&lt;/code&gt; and Mailgun's, will pick Guzzle up if it's installed but still construct their own instance, so the global middleware doesn't reach them either.&lt;/p&gt;

&lt;p&gt;Not every SDK uses Guzzle. Stripe's and Twilio's PHP SDKs ship their own cURL client and don't require Guzzle at all, so a Guzzle middleware won't see their traffic. Check the SDK's &lt;code&gt;composer.json&lt;/code&gt; before assuming your middleware covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The requests that never got a response
&lt;/h2&gt;

&lt;p&gt;The outages that hurt most often don't produce a 500 at all. The vendor's load balancer stops answering and your requests hang until the timeout, or a DNS change goes wrong and the hostname stops resolving. There is no response to log in either case, and an instrumentation layer that hangs off the response, such as a &lt;code&gt;then()&lt;/code&gt; on the promise or a response middleware, has nothing to record.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;on_stats&lt;/code&gt; still fires. We tested it against Laravel 13.24 and Guzzle 7.15 with a closed port, a hostname under &lt;code&gt;.invalid&lt;/code&gt;, an unroutable address and a server that answered too slowly. In each case the callback ran once, with &lt;code&gt;hasResponse()&lt;/code&gt; returning false and cURL's error number available from &lt;code&gt;getHandlerErrorData()&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;cURL error&lt;/th&gt;
&lt;th&gt;Transfer time&lt;/th&gt;
&lt;th&gt;Laravel throws&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connection refused&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;~0 s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ConnectionException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS lookup failed&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;The lookup time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ConnectionException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect timeout (&lt;code&gt;connectTimeout(1)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;1.00 s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ConnectionException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response timeout (&lt;code&gt;timeout(1)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;1.00 s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ConnectionException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The transfer time on a timeout is how long your code waited, roughly the timeout you configured. Keep those rows in your latency data rather than filtering them out. A p95 that ignores timed-out calls will look healthy during exactly the incident you wanted it for. TLS failures arrive the same way under their own error numbers: 35 for a failed handshake, 60 for a certificate that won't verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirects and retries each count as a transfer
&lt;/h2&gt;

&lt;p&gt;The middleware sits inside Guzzle's redirect handling, so a request that is redirected once produces two transfers, and &lt;code&gt;on_stats&lt;/code&gt; fires for both. Laravel's &lt;code&gt;retry()&lt;/code&gt; goes further and sends the request through the whole stack again for each attempt. &lt;code&gt;Http::retry(3)&lt;/code&gt; against a host refusing connections logged three transfers and dispatched three &lt;code&gt;RequestSending&lt;/code&gt; and &lt;code&gt;ConnectionFailed&lt;/code&gt; pairs. Against an endpoint returning 500 it logged three transfers too.&lt;/p&gt;

&lt;p&gt;That is the right thing to record, since each attempt had its own latency and its own way of failing. It does mean your transfer count will be higher than the number of calls your code made. When a vendor bills per request, or a rate limit counts attempts, the transfer count is the number they see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs or metrics?
&lt;/h2&gt;

&lt;p&gt;Everything so far writes one log line per transfer, which answers questions about a particular call: what happened to the refund request for order 8134, and what did the vendor say? It's poor at trends. Working out the p95 latency to one host over the last week from log lines means a log pipeline that can aggregate, and most can't do it cheaply.&lt;/p&gt;

&lt;p&gt;Metrics are the other way round. A duration histogram labelled by host and outcome gives you latency percentiles and error rates per vendor at almost no storage cost, and it can drive alerts. It can't tell you about any single call. Most teams end up wanting both, because you use them at different moments: the chart shows you something is wrong, and the individual records show you what.&lt;/p&gt;

&lt;p&gt;If you build the metrics yourself from the middleware above, the label to watch is the path. Every distinct value becomes its own series, and &lt;code&gt;/v1/orders/8134&lt;/code&gt; and &lt;code&gt;/v1/orders/8135&lt;/code&gt; are different values. Replace numeric and UUID segments with a placeholder before using the path as a label, or leave the path out and label by host only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prometheus metrics with httptheus
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/boring-o11y/httptheus" rel="noopener noreferrer"&gt;httptheus&lt;/a&gt; is the metrics half of this post, packaged. It's free and MIT-licensed, and it's built the way this post describes: a Guzzle middleware registered with &lt;code&gt;Http::globalMiddleware()&lt;/code&gt;, recording from &lt;code&gt;on_stats&lt;/code&gt;. Install it and there is nothing else to wire up:&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 boring-o11y/httptheus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exports two metrics. &lt;code&gt;httptheus_client_request_duration_seconds&lt;/code&gt; is a histogram labelled by host, method, endpoint and status class, where a transfer with no response gets &lt;code&gt;status_class="error"&lt;/code&gt;, so timeouts stay in your latency percentiles. &lt;code&gt;httptheus_client_request_errors_total&lt;/code&gt; counts only transfers that produced no response, with a &lt;code&gt;reason&lt;/code&gt; label (&lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;dns&lt;/code&gt;, &lt;code&gt;connection_refused&lt;/code&gt;, &lt;code&gt;tls&lt;/code&gt;, &lt;code&gt;network&lt;/code&gt; or &lt;code&gt;other&lt;/code&gt;) read from the cURL error number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# p95 latency to one vendor
histogram_quantile(0.95, sum by (le) (
  rate(httptheus_client_request_duration_seconds_bucket{host="api.stripe.com"}[5m])
))

# timeouts per second, by host
sum by (host) (rate(httptheus_client_request_errors_total{reason="timeout"}[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paths are normalised into the &lt;code&gt;endpoint&lt;/code&gt; label before they are recorded, with an allow-list if you want a hard cap on cardinality. If you already run spatie/laravel-prometheus, the metrics appear on its &lt;code&gt;/prometheus&lt;/code&gt; route with no configuration. Otherwise they're served at &lt;code&gt;/httptheus/metrics&lt;/code&gt;. It also ships a Grafana dashboard, and SDK clients are covered by pushing its &lt;code&gt;RecordHttpMetrics&lt;/code&gt; middleware onto their stack, as above.&lt;/p&gt;

&lt;h2&gt;
  
  
  A record of every call, with Requizon
&lt;/h2&gt;

&lt;p&gt;The log-line half is where we think a dashboard does better than a log channel. When a vendor integration starts failing, the questions are which calls failed, how, since when, and what the vendor sent back. A &lt;code&gt;grep&lt;/code&gt; through JSON logs answers them slowly, and only if you logged the response body, which you probably shouldn't do for every call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://boring-observability.dev/requizon" rel="noopener noreferrer"&gt;Requizon&lt;/a&gt; is the dashboard we built for that. It uses the same &lt;code&gt;on_stats&lt;/code&gt; hook, stores each transfer in your own MySQL database and serves a Horizon-style UI at &lt;code&gt;/requizon&lt;/code&gt;. Each call is classified as a connection error, an HTTP error or an application error, and a failed call keeps its response body so you can read what the vendor actually said.&lt;/p&gt;

&lt;p&gt;Application errors are the case metrics can't catch: an API that answers &lt;code&gt;200 OK&lt;/code&gt; and puts the failure in the body. You register one detector that knows what failure looks like for each API, and those calls stop counting as successes. Request parameters are stored with anything that looks like a credential redacted, and headers are never stored. &lt;a href="https://boring-observability.dev/requizon/docs/what-gets-recorded" rel="noopener noreferrer"&gt;What Requizon records&lt;/a&gt; lists exactly what goes into a row, and &lt;a href="https://boring-observability.dev/requizon/vs" rel="noopener noreferrer"&gt;how it compares with Nightwatch and Telescope&lt;/a&gt; covers where it overlaps with the tools you may already run.&lt;/p&gt;

&lt;p&gt;SDK traffic is handled the way it is everywhere else in this post:&lt;br&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;BoringO11y\Requizon\Guzzle\RecordHttpRequests&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$stack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HandlerStack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$stack&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;push&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="nc"&gt;RecordHttpRequests&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="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'handler'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$stack&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;httptheus and Requizon read the same hook and can run side by side. If your integrations are the kind that go wrong in ways a status code doesn't show, the rate limits and backoff in &lt;a href="https://boring-observability.dev/blog/laravel-queue-rate-limiting-concurrency" rel="noopener noreferrer"&gt;rate-limited APIs and Laravel queues&lt;/a&gt; are the natural next read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I log every outgoing HTTP request in Laravel?
&lt;/h3&gt;

&lt;p&gt;Register a Guzzle middleware with Http::globalMiddleware() in a service provider. It runs for every call made through Laravel's HTTP client, including Http::pool(). Record from Guzzle's on_stats request option rather than from the response, because on_stats also fires for timeouts, DNS failures and refused connections, where there is no response to log.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't Http::globalMiddleware() see my SDK's requests?
&lt;/h3&gt;

&lt;p&gt;Because the SDK builds its own GuzzleHttp\Client, with its own handler stack, and never goes through Laravel's HTTP client. The AWS SDK, and with it the S3 filesystem driver, Google's API client and Resend's SDK all do this. Push the same middleware onto a HandlerStack and give the SDK a client built on it. Stripe's and Twilio's SDKs use their own cURL client, so a Guzzle middleware can't see them at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I tell a timeout from a DNS failure in Laravel's HTTP client?
&lt;/h3&gt;

&lt;p&gt;Both throw a ConnectionException, and the ConnectionFailed event carries only the request and that exception. The cause is cURL's error number: 28 for a timeout, 6 for a DNS failure, 7 for a refused connection. Guzzle's TransferStats exposes it through getHandlerErrorData() in an on_stats callback, along with the transfer time. Otherwise it only appears inside the exception message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Http::retry() count as one request or several?
&lt;/h3&gt;

&lt;p&gt;Several. Each attempt goes through the whole Guzzle handler stack again, so Http::retry(3) against a failing host produces three transfers, three on_stats callbacks and three RequestSending events. Redirects are counted the same way, one transfer per hop, because middleware you add sits inside Guzzle's redirect handling.&lt;/p&gt;

</description>
      <category>api</category>
      <category>laravel</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Laravel Job timeout vs retry_after: The Ordering Rule Nothing Enforces</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Fri, 18 Sep 2026 07:20:24 +0000</pubDate>
      <link>https://dev.to/laravelo11y/laravel-job-timeout-vs-retryafter-the-ordering-rule-nothing-enforces-5ch5</link>
      <guid>https://dev.to/laravelo11y/laravel-job-timeout-vs-retryafter-the-ordering-rule-nothing-enforces-5ch5</guid>
      <description>&lt;p&gt;Two numbers, in two different configuration files, decide whether a queued job runs once or twice. One is &lt;code&gt;retry_after&lt;/code&gt;, in &lt;code&gt;config/queue.php&lt;/code&gt;. The other is your worker's &lt;code&gt;timeout&lt;/code&gt;, in &lt;code&gt;config/horizon.php&lt;/code&gt; or on the job class itself. Laravel ships them 30 seconds apart and mentions the relationship twice in prose. No code checks it at boot, at dispatch, or when a supervisor starts a worker.&lt;/p&gt;

&lt;p&gt;Get the order wrong and a job that is still running is handed to a second worker. What you see next depends on a third setting, &lt;code&gt;tries&lt;/code&gt;. Either two workers execute the same job concurrently and both report success, or a job you never saw run twice lands in your failed jobs list with &lt;code&gt;MaxAttemptsExceededException&lt;/code&gt;, for work that completed. The rest of this article covers the mechanism behind both symptoms, the exact defaults, and the ordering rule you have to enforce yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;retry_after&lt;/code&gt; is a lease, not a delay.&lt;/strong&gt; Popping a job reserves it for &lt;code&gt;retry_after&lt;/code&gt; seconds. When the lease expires the job goes back on the ready queue whether or not it is still running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The defaults are 60 and 90, if you kept the shipped config.&lt;/strong&gt; Worker &lt;code&gt;timeout&lt;/code&gt; defaults to 60s, the shipped &lt;code&gt;retry_after&lt;/code&gt; to 90s. Omit &lt;code&gt;retry_after&lt;/code&gt; from a connection and the framework falls back to 60, making the two equal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equal values are not safe.&lt;/strong&gt; The lease starts at &lt;code&gt;pop()&lt;/code&gt; and the timeout alarm is armed a few steps later, so with matching values the reservation always expires first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The symptom depends on &lt;code&gt;tries&lt;/code&gt;.&lt;/strong&gt; With &lt;code&gt;tries =&amp;gt; 1&lt;/code&gt; (the value in Horizon's published config) you get a failure recorded against a job that succeeded. With &lt;code&gt;tries =&amp;gt; 2&lt;/code&gt; or more you get concurrent double execution, and nothing fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule:&lt;/strong&gt; job &lt;code&gt;timeout&lt;/code&gt; &amp;lt; supervisor &lt;code&gt;timeout&lt;/code&gt; &amp;lt; &lt;code&gt;retry_after&lt;/code&gt;, with margin at each step. Nothing in Laravel tells you when it stops holding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two numbers and their defaults
&lt;/h2&gt;

&lt;p&gt;Both settings are measured in seconds and are easy to confuse. &lt;code&gt;retry_after&lt;/code&gt; belongs to the queue connection and sets how long a reservation is honoured. &lt;code&gt;timeout&lt;/code&gt; belongs to the worker and sets how long a single job may occupy a process. They have different owners, run on different clocks, and nothing enforces a relationship between them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;retry_after&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Owned by&lt;/td&gt;
&lt;td&gt;The worker process (or the job class)&lt;/td&gt;
&lt;td&gt;The queue connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configured in&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;config/horizon.php&lt;/code&gt;, &lt;code&gt;--timeout&lt;/code&gt;, &lt;code&gt;$timeout&lt;/code&gt;, &lt;code&gt;#[Timeout]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;config/queue.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;td&gt;60 (&lt;code&gt;queue:work --timeout=60&lt;/code&gt;, and the Horizon supervisor default)&lt;/td&gt;
&lt;td&gt;90 as shipped in &lt;code&gt;config/queue.php&lt;/code&gt;; 60 if the key is absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enforced by&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pcntl_alarm()&lt;/code&gt; in the worker&lt;/td&gt;
&lt;td&gt;A score on the reserved set, checked by whichever worker pops next&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On expiry&lt;/td&gt;
&lt;td&gt;The worker kills itself&lt;/td&gt;
&lt;td&gt;The job becomes available to everyone again&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A stock Laravel skeleton pairs a 60-second timeout with a 90-second &lt;code&gt;retry_after&lt;/code&gt;, which is a correct configuration with 30 seconds of slack. But the 90 is a value in your configuration file, not a framework default. The framework's own fallback, used whenever a connection array has no &lt;code&gt;retry_after&lt;/code&gt; key, is 60:&lt;br&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;// Illuminate\Queue\Connectors\RedisConnector&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedisQueue&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;redis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'queue'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nc"&gt;Arr&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'connection'&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;connection&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Arr&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'retry_after'&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="c1"&gt;// not 90&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;A connection written by hand (a second Redis connection for a dedicated queue, a connection built in a test, anything copied from a blog post rather than from &lt;code&gt;config/queue.php&lt;/code&gt;) gets 60 with no warning, equal to the default worker timeout. That configuration is broken from the first job that runs long, and it looks reasonable in a pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;retry_after&lt;/code&gt; does
&lt;/h2&gt;

&lt;p&gt;The name suggests a delay before a retry. It is a lease: popping the job sets a deadline on its reservation, and once that deadline passes any worker can take the job, including while the first worker is still running it.&lt;/p&gt;

&lt;p&gt;On Redis, popping a job is a single Lua script. It &lt;code&gt;lpop&lt;/code&gt;s the payload off the ready list, increments the payload's &lt;code&gt;attempts&lt;/code&gt; counter, and adds the result to a sorted set, scored with the moment the reservation expires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Illuminate\Queue\LuaScripts::pop()&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lpop'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&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="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;~=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="c1"&gt;-- Increment the attempt count and place job on the reserved queue...&lt;/span&gt;
    &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cjson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attempts'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attempts'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cjson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'zadd'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&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="n"&gt;ARGV&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="n"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lpop'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&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="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ARGV[1]&lt;/code&gt; is &lt;code&gt;now + retry_after&lt;/code&gt;. No timer watches that score. Instead, every &lt;code&gt;pop()&lt;/code&gt; on that queue begins by sweeping 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="c1"&gt;// Illuminate\Queue\RedisQueue&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;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$queue&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="nv"&gt;$index&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="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;migrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prefixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQueueRedisKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$queue&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;&lt;code&gt;migrate()&lt;/code&gt; moves two things back onto the ready list: delayed jobs whose time has come, and reserved jobs whose lease has expired. It cannot tell the difference between a job whose worker was killed by a deploy and a job whose worker is still running it. Both look like an expired score.&lt;/p&gt;

&lt;p&gt;The database driver reaches the same outcome through SQL rather than a sorted set. &lt;code&gt;isReservedButExpired()&lt;/code&gt; widens the "next available job" query to include any row whose &lt;code&gt;reserved_at&lt;/code&gt; is older than &lt;code&gt;now - retry_after&lt;/code&gt;, so it also has no way to know whether the original worker is alive.&lt;/p&gt;

&lt;p&gt;Two drivers behave differently. Beanstalkd has no &lt;code&gt;retry_after&lt;/code&gt; default of its own and falls back to Pheanstalk's TTR. SQS has no &lt;code&gt;retry_after&lt;/code&gt; at all: the connector never reads the key, because the lease lives on Amazon's side as the queue's Default Visibility Timeout. Its default is 30 seconds, half of Laravel's default worker timeout. An SQS queue created with the console defaults and consumed by a stock worker is misconfigured from the start, and no edit to &lt;code&gt;config/queue.php&lt;/code&gt; will fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;timeout&lt;/code&gt; does
&lt;/h2&gt;

&lt;p&gt;The worker's timeout is independent of the queue. It is a POSIX alarm, set per job, inside the worker process:&lt;br&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;// Illuminate\Queue\Worker::registerTimeoutHandler()&lt;/span&gt;
&lt;span class="nb"&gt;pcntl_signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SIGALRM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...record the failure, dispatch JobTimedOut...&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;kill&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="nv"&gt;$timedOutExitCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;EXIT_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;WorkerStopReason&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;TimedOut&lt;/span&gt;&lt;span class="p"&gt;);&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="nb"&gt;pcntl_alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;max&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;timeoutForJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three consequences follow from this code, and each can break the ordering rule without anyone touching &lt;code&gt;config/queue.php&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The job's own timeout takes precedence.&lt;/strong&gt; &lt;code&gt;timeoutForJob()&lt;/code&gt; returns the job's &lt;code&gt;$timeout&lt;/code&gt; property or &lt;code&gt;#[Timeout]&lt;/code&gt; attribute if it has one, and only falls back to the worker's option. A single &lt;code&gt;#[Timeout(300)]&lt;/code&gt; on a slow report job overrides a correctly configured 60-second supervisor and outlasts a 90-second lease.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--timeout=0&lt;/code&gt; disables it.&lt;/strong&gt; &lt;code&gt;pcntl_alarm(0)&lt;/code&gt; cancels the alarm rather than firing immediately, so a zero timeout means no timeout. Nothing bounds the job's runtime, while the lease keeps expiring on schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without pcntl there is no timeout.&lt;/strong&gt; The handler sits behind a &lt;code&gt;supportsAsyncSignals()&lt;/code&gt; check. On a build without the extension no alarm is set and nothing reports that, so long jobs are duplicated with no local symptom to reproduce.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the two collide, and why equal values fail
&lt;/h2&gt;

&lt;p&gt;Take a 90-second job with &lt;code&gt;retry_after&lt;/code&gt; and &lt;code&gt;timeout&lt;/code&gt; both set to 90, a configuration that looks tidy in a config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=0.00   worker A pops the job; reservation scored to expire at t=90.00
t=0.02   JobReserved fires, the worker enters process()
t=0.03   pcntl_alarm(90) armed, will fire at t=90.03
t=90.00  worker B pops the same queue; migrate() sees an expired score
         and moves the still-running job back onto the ready list
t=90.00  worker B pops it, attempts becomes 2
t=90.03  worker A's alarm fires, 30ms after worker B took the job
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lease starts at the pop. The alarm is armed several steps later: after the reservation is written, after the &lt;code&gt;JobReserved&lt;/code&gt; event and its listeners run, and after the worker enters &lt;code&gt;process()&lt;/code&gt;. With identical values the reservation expires first every time, by the length of that setup. Even when the alarm does fire first, the handler still has to record the failure and exit before the process lets go of anything.&lt;/p&gt;

&lt;p&gt;This is also why the bug is hard to reproduce. Migration is lazy: it only happens when somebody pops that queue. A supervisor with a single worker, occupied by the long job, never sweeps its own expired reservation, so the misconfiguration stays invisible until a second worker exists. Autoscale from one process to two, or move from a laptop to production, and the same code starts running jobs twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two symptoms, and which one you get
&lt;/h2&gt;

&lt;p&gt;Once the job has been migrated back and popped by a second worker, &lt;code&gt;tries&lt;/code&gt; decides what happens next. The Lua script incremented &lt;code&gt;attempts&lt;/code&gt; on that second pop, and &lt;code&gt;Worker::process()&lt;/code&gt; checks the attempt count before it fires the job:&lt;br&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;// Illuminate\Queue\Worker::process()&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;raiseBeforeJobEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connectionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$job&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;markJobAsFailedIfAlreadyExceedsMaxAttempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$connectionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;maxTries&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  With &lt;code&gt;tries =&amp;gt; 1&lt;/code&gt;: a failure on work that succeeded
&lt;/h3&gt;

&lt;p&gt;One attempt is what most Horizon installs run with: the &lt;code&gt;config/horizon.php&lt;/code&gt; that Horizon publishes sets &lt;code&gt;'tries' =&amp;gt; 1&lt;/code&gt; on its supervisor, and a job class with no &lt;code&gt;$tries&lt;/code&gt; of its own inherits it. (Drop the key entirely and &lt;code&gt;horizon:supervisor&lt;/code&gt; falls back to &lt;code&gt;--tries=0&lt;/code&gt;, which means unlimited.) The migrated copy arrives at worker B carrying &lt;code&gt;attempts = 2&lt;/code&gt;, exceeds the limit before &lt;code&gt;fire()&lt;/code&gt; is reached, and is failed immediately with &lt;code&gt;MaxAttemptsExceededException&lt;/code&gt;. Its body never runs.&lt;/p&gt;

&lt;p&gt;There is no duplicate execution, but you now have a failed job in the dashboard for work that is still running and completes successfully a few seconds later. Worker A finishes, tries to delete its reservation, and removes nothing, because the entry was migrated away while it worked. The same job id ends up with one failure and one success, and the stack trace points at a timeout that never happened.&lt;/p&gt;

&lt;p&gt;If you have chased "&lt;code&gt;MaxAttemptsExceededException&lt;/code&gt; on a job that clearly worked", this is one of two ways to get there. The other is lock-based middleware spending attempts on releases, which has &lt;a href="https://boring-observability.dev/blog/laravel-job-uniqueness-controls" rel="noopener noreferrer"&gt;its own set of failure modes&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  With &lt;code&gt;tries =&amp;gt; 2&lt;/code&gt; or more: concurrent execution with no error
&lt;/h3&gt;

&lt;p&gt;Most applications raise &lt;code&gt;tries&lt;/code&gt; eventually, because retries are the point of a queue and because middleware like &lt;code&gt;RateLimited&lt;/code&gt; and &lt;code&gt;WithoutOverlapping&lt;/code&gt; consume attempts. With two or more attempts allowed, the check passes, and worker B calls &lt;code&gt;fire()&lt;/code&gt; while worker A is inside the same job's &lt;code&gt;handle()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both copies run to completion, so the card is charged twice or the email goes out twice. Neither copy throws or is marked failed, and the dashboard shows one completed job. Laravel, Horizon and your error tracker record nothing unusual. The evidence is in your data, as duplicate rows, doubled counters or a customer with two receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways a correct config becomes an incorrect one
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;timeout&lt;/code&gt; usually ends up above &lt;code&gt;retry_after&lt;/code&gt; as a side effect of a change that looked local:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A job gained a timeout of its own.&lt;/strong&gt; A nightly export starts taking four minutes, someone adds &lt;code&gt;#[Timeout(300)]&lt;/code&gt; to the job class, and the supervisor's 60 no longer applies to that job while the connection's 90-second lease is unchanged. The job class is the highest-precedence setting and the one furthest from the config file where the constraint lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A supervisor timeout was raised to fix timeouts.&lt;/strong&gt; Jobs are being killed at 60 seconds, so the supervisor &lt;code&gt;timeout&lt;/code&gt; goes to 120. The kills stop and the lease problem above starts, because &lt;code&gt;retry_after&lt;/code&gt; is in a different file and was not part of the change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new connection was added without the key.&lt;/strong&gt; It gets the 60-second framework fallback described above. The values are equal from the first deploy, and the diff contains no line that would catch it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The queue moved to SQS.&lt;/strong&gt; The lease is now a Default Visibility Timeout of 30 seconds set on the AWS side, and &lt;code&gt;config/queue.php&lt;/code&gt; has no say in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The opposite mistake is setting &lt;code&gt;'retry_after' =&amp;gt; null&lt;/code&gt; on a Redis connection. That skips reserved-job migration altogether, so a job whose worker dies is never recovered. You avoid duplicates and lose jobs instead, with no error to show for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ordering rule, with numbers
&lt;/h2&gt;

&lt;p&gt;The full chain has three links, because Horizon's supervisor timeout is a separate value from the job's, and with &lt;code&gt;balance =&amp;gt; 'auto'&lt;/code&gt; Horizon force-kills workers it considers hung during scale-down:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;job &lt;code&gt;timeout&lt;/code&gt; &amp;lt; supervisor &lt;code&gt;timeout&lt;/code&gt; &amp;lt; &lt;code&gt;retry_after&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each step needs more than a second of headroom. A worked example for a job whose realistic worst case is three minutes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Realistic worst-case runtime&lt;/td&gt;
&lt;td&gt;180s&lt;/td&gt;
&lt;td&gt;Measured: the p99 from your own metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job &lt;code&gt;#[Timeout]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;240s&lt;/td&gt;
&lt;td&gt;Above the worst case, so normal runs are never killed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supervisor &lt;code&gt;timeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;td&gt;Above every job timeout in the supervisor, so scale-down does not kill live work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection &lt;code&gt;retry_after&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;390s&lt;/td&gt;
&lt;td&gt;Above the supervisor timeout, plus room for the alarm handler to finish and exit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Over-shooting &lt;code&gt;retry_after&lt;/code&gt; has a small, bounded cost: a job whose worker did die waits longer before it is recovered. Under-shooting it causes duplicate execution, so when in doubt, make the lease longer.&lt;/p&gt;

&lt;p&gt;A supervisor serves one connection, so you can check the constraint in a test:&lt;br&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;// tests/Feature/QueueTimeoutOrderingTest.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;test_supervisor_timeouts_stay_below_their_connection_lease&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'horizon.defaults'&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;$name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$supervisor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$supervisor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'connection'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="c1"&gt;// The 60 mirrors the framework's own fallback when the key is absent.&lt;/span&gt;
        &lt;span class="nv"&gt;$retryAfter&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="s2"&gt;"queue.connections.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.retry_after"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$retryAfter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$supervisor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'timeout'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"Supervisor [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] can hand a running job to a second worker."&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 test covers supervisors. It cannot see a &lt;code&gt;$timeout&lt;/code&gt; property or a &lt;code&gt;#[Timeout]&lt;/code&gt; attribute on an individual job class, which is the most common way the chain breaks. Those you can only catch at runtime, when the worker resolves the effective timeout for the job in front of it.&lt;/p&gt;

&lt;p&gt;Skyline runs the supervisor half of that check for you: &lt;code&gt;php artisan horizon&lt;/code&gt; warns at startup about any supervisor whose &lt;code&gt;timeout&lt;/code&gt; is not below its connection's &lt;code&gt;retry_after&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching it in the logs
&lt;/h2&gt;

&lt;p&gt;This misconfiguration can survive in production for months because the queue's own instrumentation reports it as ordinary activity. An expired reservation looks like a recovered job, and a second worker picking the job up looks like any other pickup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://boring-observability.dev/skyline/docs/job-lifecycle-logging" rel="noopener noreferrer"&gt;Skyline's job lifecycle logging&lt;/a&gt; writes one line per transition, tagged with the job id, including the transitions Laravel does not log: reserved, migrated, released and timed out. When a job leaves the reserved set because its lease ran out, the line is a warning that names the cause, and the pattern to look for is a single job id reserved twice with that warning between the two reservations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[11:04:12] queue.DEBUG: [job:91827364] reserved from [exports] and started processing.
&lt;/span&gt;&lt;span class="gp"&gt;[11:05:42] queue.WARNING: [job:91827364] was still reserved on [exports] when its reservation expired (retry_after=90s) and has been put back on the queue. Either the worker running attempt 1 died, or the job runs longer than retry_after and this copy will run while the first is still going;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;keep every job&lt;span class="s1"&gt;'s timeout below retry_after (reason=reservation_expired).
&lt;/span&gt;&lt;span class="go"&gt;[11:05:45] queue.DEBUG: [job:91827364] reserved from [exports] and started processing.
[11:06:20] queue.DEBUG: [job:91827364] completed.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job was picked up at 11:04:12. Ninety seconds later, still running because no completion line had appeared, it was put back on the ready queue and picked up again three seconds after that. A &lt;code&gt;grep&lt;/code&gt; for the job id is enough to tell a retry from a double reservation, which the dashboard alone cannot do. The reserved lines are &lt;code&gt;debug&lt;/code&gt;, so enable that level on the channel for the investigation and turn it off afterwards; the expired-reservation line is a &lt;code&gt;warning&lt;/code&gt; and can stay on permanently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;If you are working through queue reliability more broadly, &lt;a href="https://boring-observability.dev/blog/laravel-background-jobs-best-practices" rel="noopener noreferrer"&gt;12 best practices for Laravel background jobs&lt;/a&gt; covers the idempotency habits that make a double execution survivable rather than expensive, and &lt;a href="https://boring-observability.dev/blog/laravel-queue-rate-limiting-concurrency" rel="noopener noreferrer"&gt;rate-limited APIs and Laravel queues&lt;/a&gt; covers the middleware that pushes jobs toward their timeout in the first place. For the logging shown above, see &lt;a href="https://boring-observability.dev/skyline/docs/job-lifecycle-logging" rel="noopener noreferrer"&gt;job lifecycle logging&lt;/a&gt;. &lt;a href="https://boring-observability.dev/skyline/vs/horizon" rel="noopener noreferrer"&gt;Skyline vs Horizon&lt;/a&gt; covers what else changes when you swap the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is retry_after in Laravel queues?
&lt;/h3&gt;

&lt;p&gt;It is a lease on a reserved job, not a delay before a retry. Popping a job writes it to the connection's reserved set with an expiry of now plus retry_after seconds. Every later pop on that queue first sweeps that set and moves anything expired back onto the ready queue, whether or not the worker that reserved it is still running it. Nothing checks the original worker is alive, so an expired lease on a live job looks the same as one on a worker killed by a deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the default values of timeout and retry_after?
&lt;/h3&gt;

&lt;p&gt;The worker timeout defaults to 60 seconds. That is the default of queue:work --timeout, queue:listen --timeout and the Horizon supervisor timeout option. retry_after is 90 seconds in the config/queue.php that ships with the Laravel skeleton, but the framework's own fallback, used whenever a connection array omits the key, is 60. SQS has no retry_after at all; its lease is the queue's Default Visibility Timeout on the AWS side, which defaults to 30 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my Laravel job running twice?
&lt;/h3&gt;

&lt;p&gt;Almost always because its effective timeout is not smaller than the connection's retry_after, so the reservation expires while the job is still running and a second worker picks the same payload up. The effective timeout may not be the one in your supervisor config: a $timeout property or #[Timeout] attribute on the job class takes precedence, and --timeout=0 disables the alarm entirely rather than firing it immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to set timeout equal to retry_after?
&lt;/h3&gt;

&lt;p&gt;No. The reservation clock starts at pop(), while the timeout alarm is armed several steps later: after the reservation is written, after JobReserved and its listeners run, and after the worker enters process(). With identical values the reservation expires first every time, and the alarm handler still has to record the failure and exit after that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did my job fail with MaxAttemptsExceededException when it actually succeeded?
&lt;/h3&gt;

&lt;p&gt;Because the reserved copy was migrated back and popped by a second worker, and the pop incremented the attempt count. With tries set to 1, as in the config/horizon.php that Horizon publishes, the second copy exceeds its limit before fire() is reached and is failed immediately, while the first copy is still running and goes on to complete. With tries above 1 the attempt check passes and both copies execute, with no failure recorded to tell you.&lt;/p&gt;

</description>
      <category>laravel</category>
    </item>
    <item>
      <title>Laravel Horizon Queue Balancing: Idle Workers vs. Starved Queues</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Sun, 13 Sep 2026 16:51:50 +0000</pubDate>
      <link>https://dev.to/laravelo11y/laravel-horizon-queue-balancing-idle-workers-vs-starved-queues-42mi</link>
      <guid>https://dev.to/laravelo11y/laravel-horizon-queue-balancing-idle-workers-vs-starved-queues-42mi</guid>
      <description>&lt;p&gt;Every Horizon supervisor makes you choose. Turn balancing on and you pay for a floor of idle workers. Turn it off and a busy queue starves the queues listed after it. The more queues you run, the more each option costs, and many teams find this out only when a flood on one queue stalls all the others.&lt;/p&gt;

&lt;p&gt;This article explains where the trade-off comes from, why running many small queues makes it worse, and how Skyline's &lt;a href="https://boring-observability.dev/skyline/docs/weighted-queues" rel="noopener noreferrer"&gt;weighted queues&lt;/a&gt; give you a shared pool of workers without starvation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Balancing on&lt;/strong&gt; (&lt;code&gt;'auto'&lt;/code&gt; or &lt;code&gt;'simple'&lt;/code&gt;) gives every queue its own worker pool. Nothing starves, but you pay a permanent floor of resident workers that scales with your queue count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balancing off&lt;/strong&gt; (&lt;code&gt;false&lt;/code&gt;) runs one shared pool with no idle floor, but the queue list becomes a strict priority order, so a flood on one queue starves every queue below it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both costs scale with the number of queues&lt;/strong&gt;, which penalises the teams who split work into many queues to get per-queue pausing, draining and visibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skyline's &lt;code&gt;queueWeights&lt;/code&gt;&lt;/strong&gt; keeps the shared pool of unbalanced mode but checks queues in proportion to their weights (3 : 2 : 1) rather than in strict order. Important work gets most of the pickups and no queue drops to zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you end up with thirty queues in the first place
&lt;/h2&gt;

&lt;p&gt;A queue is the smallest unit Horizon lets you observe and operate on, and Laravel's &lt;code&gt;queue:pause&lt;/code&gt; and &lt;code&gt;queue:clear&lt;/code&gt; commands both act on one queue at a time. So when you want operational control ("show me how &lt;code&gt;exports&lt;/code&gt; is doing", "pause &lt;code&gt;webhooks&lt;/code&gt; while the downstream API is down", "empty the &lt;code&gt;reindex&lt;/code&gt; backlog without touching payments"), you split work into more, narrower queues. (Skyline adds &lt;a href="https://boring-observability.dev/skyline/docs/pausing-queues" rel="noopener noreferrer"&gt;pausing and resuming a single queue&lt;/a&gt; from the dashboard rather than the whole supervisor.)&lt;/p&gt;

&lt;p&gt;That is how a healthy app ends up with thirty queues: &lt;code&gt;payments&lt;/code&gt;, &lt;code&gt;emails&lt;/code&gt;, &lt;code&gt;webhooks-stripe&lt;/code&gt;, &lt;code&gt;webhooks-github&lt;/code&gt;, &lt;code&gt;exports&lt;/code&gt;, &lt;code&gt;imports&lt;/code&gt;, &lt;code&gt;reindex&lt;/code&gt;, &lt;code&gt;thumbnails&lt;/code&gt;, and on down the list. Each one gets its own dashboard row and its own pause and drain switch.&lt;/p&gt;

&lt;p&gt;Both balancing modes have a cost that grows with the number of queues, so the teams that split work most finely pay the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option A: &lt;code&gt;balance =&amp;gt; 'auto'&lt;/code&gt; and the idle-worker floor
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;balance =&amp;gt; 'auto'&lt;/code&gt; (or &lt;code&gt;'simple'&lt;/code&gt;), Horizon allocates worker processes per queue. The auto balancer scales each queue's worker count up and down with its workload, but never below &lt;code&gt;minProcesses&lt;/code&gt;, which defaults to &lt;code&gt;1&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;'supervisor-1'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'payments'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'emails'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'exports'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ...27 more */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'minProcesses'&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="s1"&gt;'maxProcesses'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;minProcesses&lt;/code&gt; guarantees that every queue always has at least one worker watching it, so a flood on &lt;code&gt;exports&lt;/code&gt; can't prevent &lt;code&gt;payments&lt;/code&gt; from being serviced. Each queue is isolated, and for many setups that makes this the right default.&lt;/p&gt;

&lt;p&gt;The cost is that one minimum worker per queue across thirty queues is a permanent floor of thirty worker processes. They are booted, resident, and idle most of the time, because most queues are empty most of the time. Each PHP worker is a full framework boot holding tens of megabytes of RAM whether or not it ever picks up a job.&lt;/p&gt;

&lt;p&gt;The floor also counts against your ceiling. If &lt;code&gt;minProcesses&lt;/code&gt; across thirty queues already pins thirty workers and &lt;code&gt;maxProcesses&lt;/code&gt; is 40, the auto balancer has only ten processes left for the queue that is busy. Raising &lt;code&gt;maxProcesses&lt;/code&gt; gives that queue more room, but the thirty idle workers are still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option B: &lt;code&gt;balance =&amp;gt; false&lt;/code&gt; and starvation by list order
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;balance =&amp;gt; false&lt;/code&gt;, Horizon runs a single shared pool of workers. Every worker listens to every queue and checks them in the order you listed them. There is no per-queue floor and no idle minimum, and workers go wherever the work is. On paper this is the efficient option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'supervisor-1'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'low'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'maxProcesses'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is the list order. Without balancing, the queue list is a strict left-to-right priority. A worker only looks at &lt;code&gt;default&lt;/code&gt; when &lt;code&gt;high&lt;/code&gt; is empty, and only looks at &lt;code&gt;low&lt;/code&gt; when both are empty. Under light load you won't notice. Under sustained load, the order decides who gets served at all:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A flood on a higher-priority queue starves every queue below it until the flood drains.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Push fifty thousand jobs onto &lt;code&gt;high&lt;/code&gt; and every worker in the pool works on it. &lt;code&gt;default&lt;/code&gt; and &lt;code&gt;low&lt;/code&gt; get no workers for as long as &lt;code&gt;high&lt;/code&gt; stays non-empty. Dropping the idle-worker floor cost you the isolation that balancing guaranteed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between &lt;code&gt;balance&lt;/code&gt; auto, simple and false?
&lt;/h2&gt;

&lt;p&gt;Horizon ships two balancing strategies and an off switch. The two strategies are closer than most people assume: both &lt;code&gt;'auto'&lt;/code&gt; and &lt;code&gt;'simple'&lt;/code&gt; give every queue its own pool of worker processes, and they differ only in whether that allocation can move.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;'simple'&lt;/code&gt; splits the supervisor's processes evenly across the queues in the list and leaves them there. Ten processes across two queues means five each, even if one of them has been empty all day.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'auto'&lt;/code&gt; starts from the same per-queue pools but continuously reallocates them to match each queue's workload, scaling a busy queue up toward &lt;code&gt;maxProcesses&lt;/code&gt; and a quiet one down to &lt;code&gt;minProcesses&lt;/code&gt;. (Whether "workload" means queue size or estimated time-to-clear is configurable via &lt;code&gt;autoScalingStrategy&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;false&lt;/code&gt; is not a balancing strategy. There are no per-queue pools: one shared pool of workers listens to every queue and checks them in the order you listed them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Side by side, with Skyline's weighted mode in the last column:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;'simple'&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;'auto'&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;false&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;false&lt;/code&gt; + weights&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Worker pools&lt;/td&gt;
&lt;td&gt;One per queue, fixed even split&lt;/td&gt;
&lt;td&gt;One per queue, resized to load&lt;/td&gt;
&lt;td&gt;One shared pool&lt;/td&gt;
&lt;td&gt;One shared pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle-worker floor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;maxProcesses&lt;/code&gt; ÷ queues, always resident&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;minProcesses&lt;/code&gt; per queue (defaults to 1)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can a queue starve?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt;, strict list order&lt;/td&gt;
&lt;td&gt;No, every queue keeps a share&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priority model&lt;/td&gt;
&lt;td&gt;None (even split)&lt;/td&gt;
&lt;td&gt;None (load-driven)&lt;/td&gt;
&lt;td&gt;Strict left-to-right&lt;/td&gt;
&lt;td&gt;Proportional (3 : 2 : 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adds capacity to a spike&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt;, scales processes up&lt;/td&gt;
&lt;td&gt;Shared pool absorbs it&lt;/td&gt;
&lt;td&gt;Shared pool absorbs it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The upstream reference for the two strategies is the &lt;a href="https://laravel.com/docs/horizon#balancing-strategies" rel="noopener noreferrer"&gt;Horizon balancing documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the trade-off gets worse with every queue you add
&lt;/h2&gt;

&lt;p&gt;The two options are mirror images:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Balancing on&lt;/strong&gt; gives you isolation (no queue starves) and charges you a per-queue process floor (idle workers, and less of the ceiling left for busy queues).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balancing off&lt;/strong&gt; gives you a shared pool (no idle floor) and charges you starvation (strict priority order means a flood on one queue stops every queue below it).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both costs scale with queue count, which is why teams with many queues feel them most. More queues means a taller idle floor under Option A, and under Option B a longer priority list with more queues below any flood. The granularity that made thirty queues attractive makes both options expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skyline's third option: weight the queues
&lt;/h2&gt;

&lt;p&gt;The dilemma assumes that "balancing off" must mean strict priority: all of &lt;code&gt;high&lt;/code&gt; before any of &lt;code&gt;default&lt;/code&gt;. Skyline keeps the single shared pool of &lt;code&gt;balance =&amp;gt; false&lt;/code&gt;, with no per-queue minimum and no idle floor, and replaces strict list order with a proportional one via a &lt;code&gt;queueWeights&lt;/code&gt; map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'supervisor-1'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'low'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'maxProcesses'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queueWeights'&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;'high'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// 'low' is omitted, so it gets the default weight of 1&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of always checking &lt;code&gt;high&lt;/code&gt; first, workers check the queues in proportion to their weights, roughly 3 : 2 : 1. About 50% of pickups favour &lt;code&gt;high&lt;/code&gt;, 33% &lt;code&gt;default&lt;/code&gt; and 17% &lt;code&gt;low&lt;/code&gt;. Queues you leave out of the map get a weight of &lt;code&gt;1&lt;/code&gt;, so a queue you forget to list still gets picked up.&lt;/p&gt;

&lt;p&gt;Take the same flood of fifty thousand jobs on &lt;code&gt;high&lt;/code&gt;. Under strict priority, &lt;code&gt;low&lt;/code&gt; would get no workers until &lt;code&gt;high&lt;/code&gt; drained. Under weights, most pickups still go to &lt;code&gt;high&lt;/code&gt;, which is weighted highest, but one in six reaches &lt;code&gt;low&lt;/code&gt;, so &lt;code&gt;low&lt;/code&gt; keeps draining instead of stalling.&lt;/p&gt;

&lt;h2&gt;
  
  
  How weighted queues compare with both balancing modes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No idle-worker floor.&lt;/strong&gt; Weights live under &lt;code&gt;balance =&amp;gt; false&lt;/code&gt;, so there's no per-queue &lt;code&gt;minProcesses&lt;/code&gt;. Thirty queues do not mean thirty resident workers. They share one pool, the same as plain unbalanced mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No starvation.&lt;/strong&gt; Every queue has a weight of at least 1, so every queue is checked on a regular cadence. A flood can reduce a sibling queue's share, but not its throughput to zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit priority.&lt;/strong&gt; A 3 : 2 : 1 map says how much each queue matters, instead of encoding priority in list position or paying for it with dedicated worker pools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-queue operations stay.&lt;/strong&gt; You keep per-queue stats, &lt;a href="https://boring-observability.dev/skyline/docs/pausing-queues" rel="noopener noreferrer"&gt;pause&lt;/a&gt;, drain and &lt;a href="https://boring-observability.dev/skyline/docs/dashboard-operations" rel="noopener noreferrer"&gt;inspect&lt;/a&gt; for all thirty queues without paying the cost of balancing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When should you still use &lt;code&gt;balance =&amp;gt; 'auto'&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Weighted queues don't replace &lt;code&gt;balance =&amp;gt; 'auto'&lt;/code&gt; everywhere. Auto-balancing's strength is elastic capacity: it starts more workers for a queue that is spiking, stops them afterwards, and scales the total process count to demand. Weights don't add processes. They redistribute a fixed pool across queues. If your bottleneck is throughput on one queue and you have CPU headroom, auto-scaling is still the better fit.&lt;/p&gt;

&lt;p&gt;Weighting fits a specific, common setup best: many queues kept for operational granularity, most of them idle most of the time, with occasional floods on one queue that should not hold up the others. That is also where the balance on/off choice costs the most.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>horizon</category>
    </item>
    <item>
      <title>Extending the Horizon Dashboard: Adding a UI to a Compiled Vue Bundle</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Sun, 13 Sep 2026 16:39:28 +0000</pubDate>
      <link>https://dev.to/laravelo11y/extending-the-horizon-dashboard-adding-a-ui-to-a-compiled-vue-bundle-598m</link>
      <guid>https://dev.to/laravelo11y/extending-the-horizon-dashboard-adding-a-ui-to-a-compiled-vue-bundle-598m</guid>
      <description>&lt;p&gt;Horizon's dashboard is a compiled Vue application with no plugin API. If you want to add a panel to the job details page, the usual options are to fork the package or to publish its assets and patch the bundle, and both mean redoing the work on every release. &lt;a href="https://github.com/knobik/laravel-horizon-job-output" rel="noopener noreferrer"&gt;knobik/laravel-horizon-job-output&lt;/a&gt; takes a third approach that needs neither.&lt;/p&gt;

&lt;p&gt;The package gives a queued job the same output API an Artisan command has (&lt;code&gt;$this-&amp;gt;info()&lt;/code&gt;, &lt;code&gt;table()&lt;/code&gt;, &lt;code&gt;withProgressBar()&lt;/code&gt;) and streams that output onto Horizon's job details page while the job runs. It also adds a Reserved Jobs page to the sidebar and a Cancel Batch card to the batch screen. None of this forks Horizon, publishes its assets or modifies Horizon's code.&lt;/p&gt;

&lt;p&gt;This article walks through how it does that. The same techniques apply to any package that extends a Laravel package whose frontend ships pre-compiled.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Credit where it is due&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every technique below comes from &lt;a href="https://github.com/knobik/laravel-horizon-job-output" rel="noopener noreferrer"&gt;knobik/laravel-horizon-job-output&lt;/a&gt;, by &lt;a href="https://github.com/knobik" rel="noopener noreferrer"&gt;knobik&lt;/a&gt;, MIT licensed. The code quoted in this article is theirs, abridged for length. We are writing it up because it solves cleanly a problem most people solve by forking. If any of it is useful to you, star the repo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Get your data in through the repository, not a new endpoint.&lt;/strong&gt; &lt;code&gt;RedisJobRepository::$keys&lt;/code&gt; is a public whitelist read with &lt;code&gt;HMGET&lt;/code&gt;. Append a field to it and that field flows through Horizon's existing &lt;code&gt;/api/jobs/{id}&lt;/code&gt; response with no route or controller override.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Override the layout view, then render the original from a second namespace.&lt;/strong&gt; &lt;code&gt;addNamespace('horizon-original', …)&lt;/code&gt; plus &lt;code&gt;prependNamespace('horizon', …)&lt;/code&gt; lets you patch Horizon's real rendered HTML instead of shipping a copy of it that drifts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mount inside Vue's in-DOM template, not into Vue-owned DOM.&lt;/strong&gt; A &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; spliced in after &lt;code&gt;&amp;lt;router-view&amp;gt;&amp;lt;/router-view&amp;gt;&lt;/code&gt; compiles to a static node: Vue renders it once and never patches it again, so your plain JavaScript owns it safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Horizon's catch-all route gives you SPA routes for free.&lt;/strong&gt; It serves the layout for any path under the dashboard prefix, so a URL the compiled router has no route for renders an empty router view, which leaves your mount as the only thing on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register routes in &lt;code&gt;register()&lt;/code&gt;, not &lt;code&gt;boot()&lt;/code&gt;.&lt;/strong&gt; Every provider's &lt;code&gt;register()&lt;/code&gt; runs before any provider's &lt;code&gt;boot()&lt;/code&gt;, which is the only placement that beats Horizon's catch-all regardless of package discovery order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume every anchor will move.&lt;/strong&gt; Each patch is independently optional, logs what the dashboard will be missing, and a scheduled CI job runs the suite against &lt;code&gt;laravel/horizon:dev-master&lt;/code&gt; so drift arrives as a warning rather than a bug report.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a dashboard with no seams
&lt;/h2&gt;

&lt;p&gt;Horizon ships its frontend as a compiled bundle in &lt;code&gt;public/vendor/horizon&lt;/code&gt;, mounted by a Blade layout that is little more than one &lt;code&gt;&amp;lt;div id="horizon"&amp;gt;&lt;/code&gt; containing a &lt;code&gt;&amp;lt;router-view&amp;gt;&lt;/code&gt;, a sidebar and a script tag. There is no &lt;code&gt;Horizon::registerPanel()&lt;/code&gt;, no view slot and no JavaScript event bus.&lt;/p&gt;

&lt;p&gt;That leaves three usual options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fork Horizon&lt;/td&gt;
&lt;td&gt;You now maintain a queue dashboard. Every upstream release is a merge.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish and patch the assets&lt;/td&gt;
&lt;td&gt;Your patch is a build artifact in &lt;code&gt;public/&lt;/code&gt;. &lt;code&gt;horizon:publish&lt;/code&gt; overwrites it on the next deploy without warning.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ship a copy of the layout view&lt;/td&gt;
&lt;td&gt;Works until Horizon changes its layout, at which point your users get the old dashboard with none of the new features and no error to explain it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The package does none of these. It uses Horizon's rendered HTML as the extension point and treats every assumption about that HTML as provisional: it patches the render output, not the source. The rest of the design follows from that decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting your data into Horizon's API
&lt;/h2&gt;

&lt;p&gt;The output has to reach the browser first. The obvious way is a new endpoint, &lt;code&gt;GET /horizon/api/job-output/{id}&lt;/code&gt;, with your own controller and your own Redis read. The package avoids adding one.&lt;/p&gt;

&lt;p&gt;Horizon reads each job out of Redis in &lt;code&gt;RedisJobRepository&lt;/code&gt;, and it does not use &lt;code&gt;HGETALL&lt;/code&gt;. It reads a fixed whitelist of hash fields with &lt;code&gt;HMGET&lt;/code&gt;, and that whitelist is a public property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Knobik\HorizonJobOutput\HorizonJobOutputServiceProvider&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;exposeOutputOnJobRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="nv"&gt;$repository&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="nb"&gt;property_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'keys'&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="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="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JobOutputStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FIELD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;JobOutputStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FIELD&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 repository is a singleton, so appending &lt;code&gt;output&lt;/code&gt; to it once at boot applies process-wide. From then on, Horizon's own &lt;code&gt;/api/jobs/{id}&lt;/code&gt; endpoint returns the field alongside &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;payload&lt;/code&gt; and the rest. The dashboard makes no second request, and there is no new route or controller whose authorization could be wrong: the data comes back from an endpoint Horizon already gates.&lt;/p&gt;

&lt;p&gt;Storage follows the same approach. The output is written as a field on Horizon's own job hash rather than under a key of its own:&lt;br&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;// Knobik\HorizonJobOutput\RedisJobOutputStore&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;put&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;$jobId&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;$output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jobId&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FIELD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sharing the key means sharing its TTL. Horizon already trims completed, failed and recent jobs on the schedule set by &lt;code&gt;horizon.trim.*&lt;/code&gt;, and because the output is a field on that same hash, the same policy trims it. There is no cleanup command to run and no way for the two lifetimes to drift apart; a separate key would have needed a retention policy of its own. The &lt;code&gt;exists()&lt;/code&gt; guard is what keeps this safe: &lt;code&gt;HSET&lt;/code&gt; on a missing key would create a new hash with no expiry, a leak that grows by one key per job and never shrinks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Owning the layout without forking it
&lt;/h2&gt;

&lt;p&gt;Laravel's view finder resolves &lt;code&gt;horizon::layout&lt;/code&gt; through a namespace hint. A package that registers its own view directory under the &lt;code&gt;horizon&lt;/code&gt; namespace with &lt;code&gt;prependNamespace()&lt;/code&gt; wins that lookup. That part is well known. The problem is that you then have to supply a layout, which usually means copying Horizon's and keeping the copy in sync with every release.&lt;/p&gt;

&lt;p&gt;The package avoids that by keeping Horizon's own view path reachable under a second name before taking over the first:&lt;br&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;// Knobik\HorizonJobOutput\HorizonJobOutputServiceProvider&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;registerViewOverride&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$finder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFinder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$hints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$finder&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHints&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hints&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'horizon'&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$finder&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'horizon-original'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hints&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'horizon'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$finder&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prependNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'horizon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/../resources/views'&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 override view is then three lines, none of them Horizon's markup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{!! app(\Knobik\HorizonJobOutput\LayoutDecorator::class)-&amp;gt;decorate(
    view('horizon-original::layout', ['isDownForMaintenance' =&amp;gt; $isDownForMaintenance])-&amp;gt;render()
) !!}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Horizon renders its real layout, and the decorator receives the resulting HTML string and splices things into it. A Horizon release that changes the sidebar, the theme switcher or the asset URLs changes them here too, because this is Horizon's layout with a few extra nodes added.&lt;/p&gt;

&lt;p&gt;Two more details are worth copying. The registration runs from &lt;code&gt;$this-&amp;gt;app-&amp;gt;booted()&lt;/code&gt; and through &lt;code&gt;callAfterResolving('view', …)&lt;/code&gt;, so a request that never renders a view never pays to construct Blade's finder. And every splice goes through one method, which logs a missing anchor and returns the HTML unchanged:&lt;br&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;// Knobik\HorizonJobOutput\LayoutDecorator&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;patch&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;$html&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;$anchor&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;$insert&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;$missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$from&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="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$anchor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$from&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;$position&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$anchor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$missing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// logs what the dashboard will be missing&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$html&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="nb"&gt;substr_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$before&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$position&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$anchor&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each caller passes its own &lt;code&gt;$missing&lt;/code&gt; string, such as "the output panel will not be shown", "the Reserved Jobs link will be missing" or "nothing this package adds will load". If a Horizon release moves an anchor, the result is a log line naming the feature that disappeared, rather than a 500 on the dashboard or a blank panel with nothing in the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to put a mount point in a Vue app you don't control
&lt;/h2&gt;

&lt;p&gt;The usual approach is to wait for the SPA to render and then &lt;code&gt;appendChild&lt;/code&gt; into it. That puts your node inside DOM that Vue's virtual DOM believes it owns, and the next patch (a poll updating the job status, or a route change) either removes it or leaves Vue diffing against a tree that no longer matches.&lt;/p&gt;

&lt;p&gt;The package inserts its mounts server-side, into the HTML string, immediately after Horizon's router 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="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ROUTER_VIEW_ANCHOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;router-view&amp;gt;&amp;lt;/router-view&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ROUTER_VIEW_ANCHOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mounts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'the output panel will not be shown'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That position puts the &lt;code&gt;&amp;lt;div id="hjo-root"&amp;gt;&lt;/code&gt; inside &lt;code&gt;#horizon&lt;/code&gt;, which Vue uses as its in-DOM template. Vue compiles the element's existing markup into its render function, and a node with no directives, bindings or interpolation compiles to a static node: rendered once, then skipped by every later patch. So the mount sits inside the app, at the right place in the layout, and the diff never touches it. Plain JavaScript can own it.&lt;/p&gt;

&lt;p&gt;The scripts and styles go just before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;, outside &lt;code&gt;#horizon&lt;/code&gt;, so Vue never tries to compile them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The general rule&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you need to add DOM to a compiled SPA you don't control, add it to the template the app compiles from, before the app boots, and not to the DOM the app has already rendered. Markup in the template becomes a static node the framework leaves alone. A node appended to rendered DOM can be removed by the next re-render.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Adding a whole page the compiled router has never heard of
&lt;/h2&gt;

&lt;p&gt;The package also adds a new page, Reserved Jobs at &lt;code&gt;/horizon/reserved&lt;/code&gt;, to a compiled router that has no route for it.&lt;/p&gt;

&lt;p&gt;This works because Horizon's dashboard routes end in a catch-all GET route that matches every path under the prefix and returns the same layout. So &lt;code&gt;/horizon/reserved&lt;/code&gt; already serves the app. The compiled Vue router finds no route matching that path and renders an empty &lt;code&gt;&amp;lt;router-view&amp;gt;&lt;/code&gt;, which leaves the package's own mount, placed immediately after it, as the only content in the column.&lt;/p&gt;

&lt;p&gt;The sidebar link differs from Horizon's own nav markup in one way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"nav-item"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&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;"{$href}"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"nav-link d-flex align-items-center"&lt;/span&gt; &lt;span class="na"&gt;data-hjo-nav&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;Reserved Jobs&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a plain &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;, not a &lt;code&gt;&amp;lt;router-link&amp;gt;&lt;/code&gt;. The nav is inside &lt;code&gt;#horizon&lt;/code&gt;, so Vue compiles whatever is put there, and a &lt;code&gt;router-link&lt;/code&gt; pointing at a route the bundle does not know about resolves to nothing. A plain href performs a full navigation, and Vue leaves it alone. The href is built from &lt;code&gt;horizon.proxy_path&lt;/code&gt; and &lt;code&gt;horizon.path&lt;/code&gt;, mirroring how Horizon's own bundle computes its base path, so the link still works with a custom dashboard path or behind a reverse proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering routes Horizon's catch-all won't swallow
&lt;/h2&gt;

&lt;p&gt;The Reserved Jobs page needs an API endpoint, and that endpoint lives under the same prefix as the catch-all. When two routes match, the one registered first wins.&lt;/p&gt;

&lt;p&gt;Horizon adds its catch-all from &lt;code&gt;boot()&lt;/code&gt;, so the package registers its routes from &lt;code&gt;register()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mergeConfigFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/../config/horizon-job-output.php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'horizon-job-output'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ... bindings ...&lt;/span&gt;

    &lt;span class="c1"&gt;// Registered here rather than in boot(). Horizon's dashboard ends in a&lt;/span&gt;
    &lt;span class="c1"&gt;// catch-all route matching everything under its prefix, added from its&lt;/span&gt;
    &lt;span class="c1"&gt;// own boot(), and whichever route is registered first wins. Every&lt;/span&gt;
    &lt;span class="c1"&gt;// provider's register() runs before any provider's boot(), so this is&lt;/span&gt;
    &lt;span class="c1"&gt;// the only placement that beats the catch-all no matter what order the&lt;/span&gt;
    &lt;span class="c1"&gt;// packages were discovered in.&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;registerRoutes&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 don't control package discovery order, but the container guarantees that every &lt;code&gt;register()&lt;/code&gt; runs before any &lt;code&gt;boot()&lt;/code&gt;, so a route registered in &lt;code&gt;register()&lt;/code&gt; always comes first.&lt;/p&gt;

&lt;p&gt;The cost is that Horizon has not booted yet, so its route group cannot be reused and has to be rebuilt, middleware stack included:&lt;br&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;function&lt;/span&gt; &lt;span class="n"&gt;middleware&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="nv"&gt;$middleware&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'horizon.middleware'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;class_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SentinelMiddleware&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;array_unshift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SentinelMiddleware&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="s1"&gt;':horizon'&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;$middleware&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;Newer Horizon versions collect this into a named &lt;code&gt;horizon&lt;/code&gt; middleware group, but that group does not exist across the whole &lt;code&gt;^5.0&lt;/code&gt; range the package supports, and naming a group that was never registered makes the router try to resolve a class by that name. Rebuilding the list works on every version in the range.&lt;/p&gt;

&lt;p&gt;Authorization needs more care. Horizon attaches its &lt;code&gt;Authenticate&lt;/code&gt; middleware in its base controller, not on the route group, so a controller that does not extend that base class gets no authorization check. The package applies the middleware explicitly, around the whole route file rather than per route:&lt;br&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/reserved-jobs.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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Authenticate&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;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;'/api/reserved-jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReservedJobsController&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/reserved-jobs/release'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReservedJobsController&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;'release'&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 you are writing your own Horizon extension, check where the package you are extending applies its gate. A route group under &lt;code&gt;horizon.middleware&lt;/code&gt; alone gets you the &lt;code&gt;web&lt;/code&gt; stack and nothing else, so your endpoint would be open to anyone who can reach the dashboard's URL, whether or not they pass the &lt;code&gt;viewHorizon&lt;/code&gt; gate.&lt;/p&gt;

&lt;p&gt;The feature toggles are enforced in the controllers, not around route registration:&lt;br&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;index&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="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'horizon-job-output.reserved_page'&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="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'jobs'&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;reserved&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gating the registration would bake the setting into a cached route table, so changing the config would have no effect until someone ran &lt;code&gt;route:clear&lt;/code&gt;. Checking in the controller avoids that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reacting to navigation without access to the router
&lt;/h2&gt;

&lt;p&gt;The panel has to know when the user navigates to a job details page. It cannot ask the router, because the router is inside a bundle the package has no reference to. Vue Router in history mode pushes state rather than reloading, so the package wraps the two history methods it calls and re-announces them as an ordinary DOM event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pushState&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;replaceState&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&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;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hjo:navigated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onNavigation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hjo:navigated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;popstate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOMContentLoaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;sync&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 browser fires &lt;code&gt;popstate&lt;/code&gt; for the back and forward buttons; the wrapper covers navigation the app triggers itself, which does not fire it. This code lives in a shared support module concatenated ahead of the feature scripts, so the history methods are wrapped once however many features are enabled, instead of by whichever feature script happened to load first.&lt;/p&gt;

&lt;p&gt;The current screen is then read from the URL, since every Horizon screen has its own path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;currentJobId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;support&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dashboardPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;jobs&lt;/span&gt;&lt;span class="se"&gt;\/[^/]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/([^/]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\/?&lt;/span&gt;&lt;span class="sr"&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="nx"&gt;preview&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="nx"&gt;preview&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;failed&lt;/span&gt;&lt;span class="se"&gt;\/([^/]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\/?&lt;/span&gt;&lt;span class="sr"&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="nx"&gt;failed&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="nx"&gt;failed&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Vue creates the mount point when it compiles the layout template, on a cold load this code can run before the element exists. &lt;code&gt;whenElementExists&lt;/code&gt; retries up to 50 times at 100ms intervals instead of assuming a boot order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping frontend assets with no build step
&lt;/h2&gt;

&lt;p&gt;A package extending a compiled dashboard cannot add itself to that dashboard's build. Publishing files into &lt;code&gt;public/&lt;/code&gt; works, but it needs a publish step in every deploy, and Horizon's own &lt;code&gt;horizon:publish&lt;/code&gt; writes to the same directory.&lt;/p&gt;

&lt;p&gt;So everything is inlined into the layout at render time: one &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;, one &lt;code&gt;&amp;lt;script type="module"&amp;gt;&lt;/code&gt;, and a settings object serialised with &lt;code&gt;Js::from()&lt;/code&gt;. That is the same helper Horizon uses for its own settings, and it applies the escaping needed to embed data inside a script tag.&lt;/p&gt;

&lt;p&gt;The terminal renderer takes more work. The package vendors an &lt;a href="https://xtermjs.org" rel="noopener noreferrer"&gt;xterm.js&lt;/a&gt; build so a progress bar redraws in place on the dashboard as it would in a shell. An ESM build ends in an export statement, and nothing can import from an inline module, so the export does nothing there. The package rewrites it into a global assignment as it inlines the file:&lt;br&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;const&lt;/span&gt; &lt;span class="no"&gt;EXPORT_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/export\s*\{\s*(\w+)\s+as\s+Terminal\s*\}\s*;?/'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The export is the last statement in the bundle, so only the tail is&lt;/span&gt;
&lt;span class="c1"&gt;// searched. Running the pattern over the whole 345KB build would repeat&lt;/span&gt;
&lt;span class="c1"&gt;// that scan on every dashboard request for no added certainty.&lt;/span&gt;
&lt;span class="nv"&gt;$tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$js&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TAIL_BYTES&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="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;EXPORT_PATTERN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PREG_OFFSET_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[horizon-job-output] Could not rewrite the xterm export, so the terminal renderer was skipped. …'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'css'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'js'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$js&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$js&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'globalThis.HorizonJobOutputTerminal = '&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$matches&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;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;';'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rewriting a vendored bundle with a regular expression is brittle, and the package handles it accordingly. The pattern runs over the last 512 bytes only, a miss is logged, and the panel falls back to an HTML renderer that collapses the control sequences and needs no extra payload. A failed rewrite costs you the xterm renderer, not the output panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing output from a job that is already running
&lt;/h2&gt;

&lt;p&gt;The dashboard side needs output to show, and getting an output object into a running job has problems of its own.&lt;/p&gt;

&lt;p&gt;It cannot happen at dispatch. A queued job is serialised, and an unserialised object never runs its constructor, so anything the constructor attached is gone by the time the worker has it. The attachment has to happen at execution time, inside the worker, which is what a global bus pipe allows:&lt;br&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;function&lt;/span&gt; &lt;span class="n"&gt;registerBusPipe&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$dispatcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;BusDispatcherContract&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$dispatcher&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;BusDispatcher&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$property&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReflectionProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dispatcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pipes'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$pipes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dispatcher&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$pipes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CaptureJobOutput&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="nv"&gt;$pipes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="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="nv"&gt;$pipes&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CaptureJobOutput&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="nv"&gt;$dispatcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pipeThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pipes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pipeThrough()&lt;/code&gt; replaces the pipe list outright and there is no getter, so the package reads the existing pipes by reflection and appends to them. Replacing the list instead would drop the pipes other packages have registered, with no error.&lt;/p&gt;

&lt;p&gt;Two cases need their own handling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Artisan commands run inside a job.&lt;/strong&gt; The console kernel writes a command's output to whatever buffer it is handed and discards it when handed nothing. So the kernel is decorated for the length of the job (its &lt;code&gt;call()&lt;/code&gt; supplies the job's output as the default buffer) and restored in a &lt;code&gt;finally&lt;/code&gt;, because a worker handles one job after another in the same process and a stale decorator would feed a finished job's output. The facade's resolved instance is cleared alongside the binding, since &lt;code&gt;Artisan::call()&lt;/code&gt; is how a job runs a command in practice and a facade holds on to whatever it resolved first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queued Artisan commands.&lt;/strong&gt; &lt;code&gt;Artisan::queue()&lt;/code&gt; dispatches a &lt;code&gt;QueuedCommand&lt;/code&gt;, which does not use &lt;code&gt;InteractsWithQueue&lt;/code&gt;, so nothing ever sets a job on it and the bus pipe has no way to reach the Horizon id its output belongs on. The package listens to &lt;code&gt;Queue::before()&lt;/code&gt; / &lt;code&gt;after()&lt;/code&gt;, keeps the job the worker has in hand in a small singleton, and only hands out its id when the payload's &lt;code&gt;commandName&lt;/code&gt; matches the command being piped. That check stops a command dispatched inside another job from writing over the outer job's output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The write path is buffered with a flush interval, capped at &lt;code&gt;max_bytes&lt;/code&gt;, and flushed with &lt;code&gt;force: true&lt;/code&gt; from a &lt;code&gt;finally&lt;/code&gt;. A job that throws keeps whatever it wrote before the exception, which is usually the output you most want to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing for the day Horizon changes
&lt;/h2&gt;

&lt;p&gt;Everything above depends on internals that carry no compatibility guarantee: a public property on a repository, a private property read by reflection, two string anchors in rendered markup, and the shape of a trailing export in a vendored bundle. Any Horizon release could break one of them, so the package is built to make that breakage cheap and visible.&lt;/p&gt;

&lt;p&gt;Every patch is independently optional: a missing anchor costs you that one feature and logs which one. Every failure is a &lt;code&gt;Log::warning&lt;/code&gt; naming the anchor and the consequence, so the first person to hit it can diagnose it without reading the package source. And a scheduled CI job tests against Horizon's development branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/horizon-canary.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;41&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;

&lt;span class="c1"&gt;# ...&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Horizon from its development branch&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;composer require --no-update "laravel/horizon:dev-master"&lt;/span&gt;
          &lt;span class="s"&gt;composer update --no-interaction --prefer-dist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs the full suite against &lt;code&gt;laravel/horizon:dev-master&lt;/code&gt; once a week and, on failure, opens a labelled issue listing which four internals might have moved. Upstream drift shows up as an issue on a Monday morning, not as a user's bug report after a release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, generalised
&lt;/h2&gt;

&lt;p&gt;The same mechanisms work for extending any Laravel package that ships a compiled frontend:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to…&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;What it depends on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add a field to an existing API response&lt;/td&gt;
&lt;td&gt;Mutate the repository's field whitelist at boot&lt;/td&gt;
&lt;td&gt;The property staying public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add markup to a view you don't own&lt;/td&gt;
&lt;td&gt;Re-register the original under a second namespace, prepend your own, render and patch&lt;/td&gt;
&lt;td&gt;String anchors in the rendered HTML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own DOM inside a compiled SPA&lt;/td&gt;
&lt;td&gt;Splice a bare element into the in-DOM template server-side; it compiles to a static node&lt;/td&gt;
&lt;td&gt;The framework not patching static nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a page to a compiled router&lt;/td&gt;
&lt;td&gt;Use the host's catch-all route; render into your own mount when the router matches nothing&lt;/td&gt;
&lt;td&gt;A catch-all existing at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beat a catch-all route&lt;/td&gt;
&lt;td&gt;Register from &lt;code&gt;register()&lt;/code&gt;, not &lt;code&gt;boot()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing; the container guarantees the order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observe SPA navigation&lt;/td&gt;
&lt;td&gt;Wrap &lt;code&gt;history.pushState&lt;/code&gt;/&lt;code&gt;replaceState&lt;/code&gt;, re-dispatch as an event, plus &lt;code&gt;popstate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;History-mode routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attach state to a running job&lt;/td&gt;
&lt;td&gt;A global bus pipe, appended to the existing pipes reflectively&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Dispatcher::$pipes&lt;/code&gt; staying where it is&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Apart from the route ordering, none of these dependencies is guaranteed. The approach holds up because each assumption is isolated to one feature, degrades to that feature missing plus a log line saying so, and is checked by CI against the upstream development branch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/knobik/laravel-horizon-job-output" rel="noopener noreferrer"&gt;The source&lt;/a&gt; is around 1,500 lines of PHP and JavaScript, and close to half of it is comments explaining why the code is written the way it is.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>horizon</category>
      <category>vue</category>
    </item>
    <item>
      <title>Laravel Background Jobs: 12 Best Practices for Production Queues</title>
      <dc:creator>laravel-o11y</dc:creator>
      <pubDate>Wed, 26 Aug 2026 16:18:00 +0000</pubDate>
      <link>https://dev.to/laravelo11y/laravel-background-jobs-12-best-practices-for-production-queues-5gcm</link>
      <guid>https://dev.to/laravelo11y/laravel-background-jobs-12-best-practices-for-production-queues-5gcm</guid>
      <description>&lt;p&gt;Queued jobs are the part of a Laravel app that runs when you're not looking — after the request is gone, during a deploy, against data that may have changed or vanished, on code one version newer than the code that dispatched them. Most job bugs aren't logic errors; they're assumptions about &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;how many times&lt;/em&gt; a job runs that quietly hold in development and fall apart under production load.&lt;/p&gt;

&lt;p&gt;Here are twelve practices that keep a queue healthy under real traffic. They build on each other: small arguments make idempotency easier, idempotency makes retries safe, safe retries make backoff useful, and so on. The last one — backwards compatibility across deploys — is the one that bites hardest and no linter catches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Assume every job runs at least twice.&lt;/strong&gt; Laravel's queues are at-least-once, and a worker killed mid-job re-runs it from scratch — with the side effects of the first attempt still committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep jobs small, atomic and explicitly queued.&lt;/strong&gt; One job per record beats one job that loops over 10,000: a failure then retries one unit of work instead of everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the framework handle failure.&lt;/strong&gt; Declarative &lt;code&gt;$tries&lt;/code&gt; and exponential &lt;code&gt;backoff()&lt;/code&gt; rather than manual re-runs, and &lt;code&gt;afterCommit()&lt;/code&gt; so a worker can't outrun your transaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the whole contract before using &lt;code&gt;ShouldBeUnique&lt;/code&gt; or batches.&lt;/strong&gt; Both fail silently — a missing &lt;code&gt;uniqueId()&lt;/code&gt; drops jobs with no error anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The queue outlives your deploy.&lt;/strong&gt; Jobs serialized against old code are deserialized by new workers, so renaming a job class or adding a constructor argument without a class-level default destroys work that is already in flight.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Keep arguments small
&lt;/h2&gt;

&lt;p&gt;Every byte of constructor arguments is a byte in Redis, multiplied by every queued instance. Don't pass big arrays, blobs, or rich DTOs "to save a query." Pass the minimal identifiers and re-read what you need inside &lt;code&gt;handle()&lt;/code&gt;. A queue backlog of fat jobs is how you OOM Redis.&lt;/p&gt;

&lt;p&gt;If you pass Eloquent models, Laravel can serialize just the class and the ID and automatically re-reload the record on execution — as long as you include the &lt;a href="https://laravel.com/docs/queues#class-structure" rel="noopener noreferrer"&gt;&lt;code&gt;SerializesModels&lt;/code&gt;&lt;/a&gt; trait.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Every job should declare its queue
&lt;/h2&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;GenerateImageThumbnails&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="nv"&gt;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'media'&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;Horizon routes work by queue name, and each supervisor is tuned for a workload — concurrency, timeout, balance strategy. A job with no &lt;code&gt;$queue&lt;/code&gt; lands on &lt;code&gt;default&lt;/code&gt;, where a slow image resize sits behind — or ahead of — a latency-sensitive job.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Mixing fast and slow, or urgent and bulk, on one queue defeats the entire point of having supervisors.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Pick the queue that matches the work's shape and SLA, and make sure a matching Horizon supervisor processes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make jobs idempotent
&lt;/h2&gt;

&lt;p&gt;Horizon delivers &lt;strong&gt;at-least-once&lt;/strong&gt;. A worker that's killed mid-job — deploy, OOM, timeout — leaves the job to be retried, so the same job body can run twice. Design every job so that running it twice produces the same end state as running it once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check before you create (&lt;code&gt;firstOrCreate&lt;/code&gt;, &lt;code&gt;updateOrCreate&lt;/code&gt;, "already sent?" guards).&lt;/li&gt;
&lt;li&gt;Use unique constraints as a backstop, not wishful thinking.&lt;/li&gt;
&lt;li&gt;Never assume "this ran, therefore it ran exactly once."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The realistic failure mode isn't a clean re-run from the top — it's a job that did &lt;strong&gt;partial work and then got killed before completing&lt;/strong&gt;. Redis only removes a job from its &lt;code&gt;reserved&lt;/code&gt; set on success, so a hard kill (SIGKILL from a per-job &lt;code&gt;timeout&lt;/code&gt;, or the supervisor force-killing a worker that outlived the shutdown grace window during a deploy) leaves the job to be migrated back and re-run from scratch — with whatever side effects the first attempt already committed still in place.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;attempts&lt;/code&gt; is incremented when the job is &lt;em&gt;reserved&lt;/em&gt;, not when it fails, each kill-and-recover cycle burns one try; after &lt;code&gt;maxTries&lt;/code&gt; such cycles the job is marked failed having run its side effects several times with no clean completion. So idempotency has to cover &lt;em&gt;resuming after a partial run&lt;/em&gt;, not just "don't duplicate a fully-successful run."&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep jobs atomic and short
&lt;/h2&gt;

&lt;p&gt;A deploy restarts workers; a job that exceeds its &lt;code&gt;$timeout&lt;/code&gt; is killed mid-flight. If your job does five writes and dies after three, the retry redoes all five — and the first three had better be idempotent (see above).&lt;/p&gt;

&lt;p&gt;Prefer &lt;strong&gt;one job per record&lt;/strong&gt; over one job that loops 10,000 records. A per-record job that dies retries &lt;em&gt;one&lt;/em&gt; record; a mega-job retries everything and may never finish inside the timeout window. Fan out:&lt;br&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;Item&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="nf"&gt;eachById&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;Item&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="nc"&gt;SyncItemJob&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Retries and backoff — let the framework do it
&lt;/h2&gt;

&lt;p&gt;Don't manually re-run failed jobs and don't catch-and-swallow. Configure retries declaratively:&lt;br&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="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// exponential backoff — required for anything hitting an external API&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;backoff&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&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;Hammering an API with immediate retries turns a transient blip into an outage. Back off. Let jobs that genuinely can't succeed land in &lt;a href="https://laravel.com/docs/queues#dealing-with-failed-jobs" rel="noopener noreferrer"&gt;&lt;code&gt;failed_jobs&lt;/code&gt;&lt;/a&gt; — that's a signal to fix the root cause, not a thing to mass-retry. Use &lt;code&gt;$this-&amp;gt;fail($e)&lt;/code&gt; to bail early when you &lt;em&gt;know&lt;/em&gt; a retry won't help.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Dispatch after the transaction commits
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;$booking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nc"&gt;SendBookingConfirmation&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$booking&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;afterCommit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;a href="https://laravel.com/docs/queues#jobs-and-database-transactions" rel="noopener noreferrer"&gt;&lt;code&gt;afterCommit()&lt;/code&gt;&lt;/a&gt; (or the connection-level &lt;code&gt;'after_commit' =&amp;gt; true&lt;/code&gt;), a fast worker can pick up the job and &lt;code&gt;find()&lt;/code&gt; a booking that &lt;strong&gt;hasn't been committed yet&lt;/strong&gt; — &lt;code&gt;ModelNotFoundException&lt;/code&gt;, a spurious failure that only reproduces under load. Dispatch the side effect only once the data it depends on is durable.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The &lt;code&gt;ShouldBeUnique&lt;/code&gt; contract — three ways to get it wrong
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/queues#unique-jobs" rel="noopener noreferrer"&gt;Unique jobs&lt;/a&gt; are where the most expensive, silent bugs live. This section is the short version; the long one — including how uniqueness interacts with &lt;code&gt;WithoutOverlapping&lt;/code&gt;, retries and orphaned locks — is in &lt;a href="https://boring-observability.dev/blog/laravel-job-uniqueness-controls" rel="noopener noreferrer"&gt;Laravel job uniqueness controls&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;uniqueFor&lt;/code&gt;.&lt;/strong&gt; A plain &lt;code&gt;ShouldBeUnique&lt;/code&gt; lock is acquired at dispatch and released only when processing reaches a terminal state (success, or final failure after &lt;code&gt;maxTries&lt;/code&gt;) — it is held across the whole processing window &lt;em&gt;and&lt;/em&gt; across backoff retries. Without &lt;code&gt;uniqueFor&lt;/code&gt; the lock is created with no TTL (&lt;code&gt;forever()&lt;/code&gt;), so it is reclaimed &lt;em&gt;only&lt;/em&gt; by that eventual terminal run. Normally that's fine: a worker killed mid-job leaves the job to be retried, and the retry's terminal state clears the lock. The exposure is twofold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Until that retry completes, every new dispatch of the same unique job is &lt;strong&gt;silently dropped&lt;/strong&gt; — not errored, just gone. &lt;code&gt;uniqueFor&lt;/code&gt; bounds this window to a known TTL instead of "however long the kill→retry→finish cycle takes."&lt;/li&gt;
&lt;li&gt;If the job &lt;em&gt;never&lt;/em&gt; reaches a terminal run — a &lt;code&gt;uniqueId()&lt;/code&gt; that reads external mutable state (so the release computes a different key than acquire), a lost reserved entry, or &lt;code&gt;maxTries: 0&lt;/code&gt; with a job that's killed every attempt — the &lt;code&gt;forever()&lt;/code&gt; lock is never reclaimed and the job is silently undispatchable until the key is cleared by hand. &lt;code&gt;uniqueFor&lt;/code&gt; is the only thing that auto-recovers it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Caveat: &lt;code&gt;uniqueFor&lt;/code&gt; must exceed your worst-case total processing-plus-retry time, or the lock expires mid-legitimate-run and a duplicate &lt;em&gt;can&lt;/em&gt; be dispatched. It's a ceiling on the deadlock risk, not a free safety net — keep &lt;code&gt;uniqueId()&lt;/code&gt; a pure function of the job's own data.&lt;br&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="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$uniqueFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// lock self-heals after an hour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;uniqueId&lt;/code&gt; is mandatory for parameterized jobs.&lt;/strong&gt; The lock key is &lt;code&gt;laravel_unique_job:&amp;lt;class&amp;gt;:&amp;lt;uniqueId&amp;gt;&lt;/code&gt;. Omit &lt;code&gt;uniqueId&lt;/code&gt; on a job that takes arguments and the key collapses to the class name alone — so &lt;code&gt;SyncCompany(1)&lt;/code&gt; and &lt;code&gt;SyncCompany(2)&lt;/code&gt; share one lock and &lt;strong&gt;one of them is silently dropped at dispatch&lt;/strong&gt;. Lost work, no error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;uniqueId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;companyId&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 genuinely class-wide singleton is fine — make the intent explicit by returning a constant from &lt;code&gt;uniqueId()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Never bulk or batch a unique job
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Queue::bulk()&lt;/code&gt; / &lt;code&gt;Bus::bulk()&lt;/code&gt; push raw payloads straight to Redis, skipping the dispatcher that acquires the lock — uniqueness silently does nothing. And &lt;a href="https://laravel.com/docs/queues#job-batching" rel="noopener noreferrer"&gt;batching&lt;/a&gt; a unique job means a dropped duplicate desyncs the batch's up-front job count, so its &lt;code&gt;then&lt;/code&gt;/&lt;code&gt;finally&lt;/code&gt; callbacks never fire and the batch hangs as "pending" forever. Dispatch unique jobs individually.&lt;/p&gt;

&lt;p&gt;Periodic / scheduled jobs should implement &lt;code&gt;ShouldBeUnique&lt;/code&gt; so a slow run doesn't overlap the next tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. A batchable job must honour cancellation
&lt;/h2&gt;

&lt;p&gt;Cancelling a batch only stops &lt;em&gt;future&lt;/em&gt; dispatches — jobs already on the queue still wake up and run their full body unless they check. For anything that mutates state (writes files, calls APIs, charges cards), that's wasted work against a batch the caller abandoned. Guard 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="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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cancelled&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... heavy work&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…or centralise it with the &lt;code&gt;Illuminate\Queue\Middleware\SkipIfBatchCancelled&lt;/code&gt; &lt;a href="https://laravel.com/docs/queues#job-middleware" rel="noopener noreferrer"&gt;middleware&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Don't &lt;code&gt;sleep()&lt;/code&gt; in a job
&lt;/h2&gt;

&lt;p&gt;A sleeping job pins a worker doing nothing, starving every other job behind it. If you need to wait — rate limits, a not-yet-ready upstream — &lt;strong&gt;release the job back with a delay&lt;/strong&gt; 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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&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="c1"&gt;// re-queue, free the worker, try again in a minute&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  11. The queue outlives your deploy — backwards compatibility
&lt;/h2&gt;

&lt;p&gt;This is the one that bites hardest and isn't covered by any linter, so read it twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you deploy, there are already jobs sitting in the queue, serialized against the &lt;em&gt;old&lt;/em&gt; code.&lt;/strong&gt; A worker on the new code has to deserialize and run them. Two ways this goes wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Renaming or moving a job class
&lt;/h3&gt;

&lt;p&gt;The serialized payload stores the &lt;strong&gt;fully-qualified class name&lt;/strong&gt; — &lt;code&gt;App\Products\Jobs\SyncStockJob&lt;/code&gt;. Rename the class, move it to another namespace, or delete it, and every already-queued instance becomes unresolvable: deserialization throws, the job lands in &lt;code&gt;failed_jobs&lt;/code&gt;, the work is lost.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two-phase it.&lt;/strong&gt; Keep the old class (even as a thin subclass of the new one) for one deploy cycle, let the queue drain, then remove it in a follow-up deploy.&lt;/li&gt;
&lt;li&gt;Or &lt;strong&gt;drain the queue&lt;/strong&gt; of that job type before shipping the rename.&lt;/li&gt;
&lt;li&gt;Don't rename a hot job class in the same PR that renames its behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Changing constructor arguments
&lt;/h3&gt;

&lt;p&gt;The subtle one. &lt;strong&gt;PHP's &lt;code&gt;unserialize()&lt;/code&gt; does not call the constructor&lt;/strong&gt; — it restores the saved properties directly. So your constructor's default values do &lt;em&gt;nothing&lt;/em&gt; for jobs already in the queue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SyncStockJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ⚠️ promoted, no class-level default.&lt;/span&gt;
    &lt;span class="c1"&gt;// An old payload serialized before $force existed restores WITHOUT it →&lt;/span&gt;
    &lt;span class="c1"&gt;// accessing $this-&amp;gt;force throws "must not be accessed before initialization".&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;$productId&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;bool&lt;/span&gt; &lt;span class="nv"&gt;$force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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 constructor default &lt;code&gt;= false&lt;/code&gt; only helps &lt;em&gt;new&lt;/em&gt; dispatches. An old queued job never goes through the constructor, so its &lt;code&gt;$force&lt;/code&gt; stays uninitialized, and the new &lt;code&gt;handle()&lt;/code&gt; that reads it crashes. Fix: give the property a &lt;strong&gt;class-level default&lt;/strong&gt; so deserialized old jobs fall back gracefully:&lt;br&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;SyncStockJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← real default, survives unserialize&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;$productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$force&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Which job changes are safe to deploy?
&lt;/h3&gt;

&lt;p&gt;The question to ask of any change is: &lt;em&gt;what happens to a payload that was serialized an hour ago, by the code you are about to replace?&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Safe in one deploy?&lt;/th&gt;
&lt;th&gt;What happens / what to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Edit &lt;code&gt;handle()&lt;/code&gt; body&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Behaviour isn't serialized — only properties are. Just make sure it tolerates the old payload shape.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a property &lt;em&gt;with&lt;/em&gt; a class-level default&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Old payloads restore without the key and fall back to the default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a promoted constructor argument&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;unserialize()&lt;/code&gt; never calls the constructor, so the property stays uninitialized and reading it throws. Give it a class-level default instead.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove or rename a property&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;In-flight payloads still carry the old key. Deprecate over one deploy cycle, then remove.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename / move / delete the job class&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The FQCN is stored in the payload; queued jobs become unresolvable and land in &lt;code&gt;failed_jobs&lt;/code&gt;. Two-phase it, or drain first.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change the queue name&lt;/td&gt;
&lt;td&gt;Yes, with care&lt;/td&gt;
&lt;td&gt;Jobs already queued stay on the old queue — keep a supervisor processing it until it drains.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The general rules that fall out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New properties get a class-level default value&lt;/strong&gt; (or are nullable), never a bare typed property.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't remove or rename a property&lt;/strong&gt; a queued payload still carries without a deprecation window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;handle()&lt;/code&gt; must tolerate both the old and the new payload shape&lt;/strong&gt; for the length of one deploy cycle.&lt;/li&gt;
&lt;li&gt;When in doubt, &lt;strong&gt;two-phase&lt;/strong&gt;: ship the backwards-compatible change, let the old jobs drain, clean up in a later deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  12. Watch it run
&lt;/h2&gt;

&lt;p&gt;Use &lt;a href="https://laravel.com/docs/horizon" rel="noopener noreferrer"&gt;Horizon&lt;/a&gt;. Watch queue wait times, failure rates, and throughput per queue — a backlog on one supervisor is invisible from the others. A job that's "working" in the sense of not erroring can still be silently 20 minutes behind. Instrumentation is how you find out before a customer does.&lt;/p&gt;

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

&lt;p&gt;Write every job assuming it will run twice, run late, run against a deleted record, and run on code that's one version newer than the code that dispatched it. Idempotency, atomicity, backoff, &lt;code&gt;afterCommit&lt;/code&gt;, an explicit queue, the full &lt;code&gt;ShouldBeUnique&lt;/code&gt; / batch contracts, and — above all — backwards compatibility across deploys.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>horizon</category>
    </item>
  </channel>
</rss>
