<?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 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>
