<?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 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>
    </item>
  </channel>
</rss>
