{"id":262,"date":"2026-05-21T13:01:35","date_gmt":"2026-05-21T13:01:35","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/laravel-queues-2026-defaults-i-stopped-trusting-in-production\/"},"modified":"2026-05-21T13:01:35","modified_gmt":"2026-05-21T13:01:35","slug":"laravel-queues-2026-defaults-i-stopped-trusting-in-production","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/laravel-queues-2026-defaults-i-stopped-trusting-in-production\/","title":{"rendered":"Laravel Queues in 2026: Defaults I Stopped Trusting in Production"},"content":{"rendered":"<p>The first Laravel queue job I ever shipped to production ran 47 times. It was a &ldquo;send welcome email&rdquo; job. The user got 47 welcome emails. I learned about queue retries that day, in the loudest possible way.<\/p>\n<p>That was 2019. I&rsquo;ve gotten better. The queue I run today isn&rsquo;t fancier, just less wrong. Most of the improvements were unlearning defaults I&rsquo;d never actually thought about. Laravel&rsquo;s queue API is good. The defaults are calibrated for &ldquo;you&rsquo;re playing around&rdquo;, not for &ldquo;you&rsquo;re paying AWS for SQS by the message&rdquo;.<\/p>\n<p>This is the short list of habits I&rsquo;ve changed, with the actual config I run now. If you&rsquo;ve ever had a job run more times than it should have, this one&rsquo;s for you.<\/p>\n<h2 id=\"tries-vs-maxexceptions-and-why-i-always-set-both\">tries vs maxExceptions, and why I always set both<\/h2>\n<p>For years I set <code>--tries=3<\/code> on <code>php artisan queue:work<\/code> and called it a day. I assumed it meant &ldquo;retry up to 3 times, then stop&rdquo;. It does. Sort of.<\/p>\n<p>Here&rsquo;s the catch. <code>tries<\/code> counts every attempt, whether the job failed because of an exception or because the worker crashed mid-run. So a job that hits an OOM kill three times in a row gets marked permanently failed without ever actually throwing. That&rsquo;s fine. But a job that throws a recoverable exception (network blip, transient DB error) also burns a try. Three transient errors in an hour and you&rsquo;re in the failed_jobs table forever.<\/p>\n<p>What I do now: I set <code>tries<\/code> high (10) and <code>maxExceptions<\/code> low (3). <code>tries<\/code> is the hard cap on attempts including crashes. <code>maxExceptions<\/code> is the cap on raised exceptions. The pair gives me &ldquo;give up after 3 real errors, but let me reboot the worker without losing the job&rdquo;.<\/p>\n<pre><code class=\"language-php\">class ProcessPayment implements ShouldQueue\n{\n    public int $tries = 10;\n    public int $maxExceptions = 3;\n    public int $timeout = 60;\n    public int $backoff = 30; \/\/ seconds between retries\n}\n<\/code><\/pre>\n<p>I learned about <code>backoff<\/code> separately. The default is zero. Zero means &ldquo;retry immediately, possibly hammering the upstream that just rejected you&rdquo;. 30s with jitter is fine for most cases. For external APIs, I use the array form: <code>public array $backoff = [10, 30, 60, 300];<\/code> so I back off harder if it keeps failing.<\/p>\n<p>One more piece I always set: <code>timeout<\/code>. The queue worker default is 60 seconds, but if you have a job that legitimately takes 4 minutes, it gets killed mid-run and the failure looks identical to a crash. Worse, depending on driver, the job can be picked up again before the timeout has actually elapsed, so you get two workers running the same job at the same time. I set <code>timeout<\/code> per job to whatever the realistic upper bound is plus 30%, and I set the worker&rsquo;s <code>--timeout<\/code> to one second higher than the highest job timeout.<\/p>\n<h2 id=\"buschain-vs-busbatch-when-each-one-earns-its-keep\">Bus::chain vs Bus::batch, when each one earns its keep<\/h2>\n<p>This is where I see the most confusion in code review. Chains and batches are both ways to coordinate jobs, but they have different semantics, and people pick whichever feels familiar.<\/p>\n<p>Chains are for sequential, dependent work. Each job runs after the previous one succeeds. If any job in the chain fails, the rest don&rsquo;t run.<\/p>\n<pre><code class=\"language-php\">Bus::chain([\n    new ProvisionAccount($user),\n    new SeedDefaultProjects($user),\n    new SendWelcomeEmail($user),\n])-&gt;dispatch();\n<\/code><\/pre>\n<p>If <code>ProvisionAccount<\/code> fails, the welcome email doesn&rsquo;t go out. Good. That user doesn&rsquo;t have an account yet.<\/p>\n<p>Batches are for parallel work with a shared completion signal. Jobs in a batch can run concurrently. You can hook <code>then()<\/code>, <code>catch()<\/code>, and <code>finally()<\/code> callbacks.<\/p>\n<pre><code class=\"language-php\">Bus::batch([\n    new SyncStripeCustomer($user),\n    new SyncMailchimpContact($user),\n    new IndexInAlgolia($user),\n])-&gt;then(function ($batch) {\n    Log::info('Synced user '.$batch-&gt;id);\n})-&gt;dispatch();\n<\/code><\/pre>\n<p>The trap I see in code review: people use a chain because the jobs &ldquo;should run in order&rdquo;, when the order doesn&rsquo;t actually matter and a batch would be twice as fast. Or people use a batch and then are confused that one job depends on another&rsquo;s output. Pick by data dependency, not by what feels organized.<\/p>\n<h2 id=\"idempotency-is-the-part-laravel-wont-do-for-you\">Idempotency is the part Laravel won&rsquo;t do for you<\/h2>\n<p>This one took me longest to internalize. The <a href=\"https:\/\/laravel.com\/docs\/12.x\/queues\" rel=\"nofollow noopener\" target=\"_blank\">Laravel queue docs<\/a> mention it briefly and move on. Every retry should be safe to run.<\/p>\n<p>That means: a job that charges a credit card needs an idempotency key. A job that sends an email needs a &ldquo;have I sent this already?&rdquo; check. A job that updates a database row needs to be idempotent or use <code>firstOrCreate<\/code>\/<code>updateOrCreate<\/code>. The framework cannot do this for you. The framework will happily run your job twice and let your business logic decide what that means.<\/p>\n<p>The pattern I default to now, especially around payments:<\/p>\n<pre><code class=\"language-php\">public function handle(Stripe $stripe): void\n{\n    $key = &quot;charge:{$this-&gt;order-&gt;id}&quot;;\n    if (Cache::has($key)) {\n        return; \/\/ already charged this order\n    }\n    $stripe-&gt;charge($this-&gt;order, idempotency_key: $key);\n    Cache::put($key, true, now()-&gt;addDays(7));\n}\n<\/code><\/pre>\n<p>That&rsquo;s the minimum. For more serious systems, the idempotency check has to live in the same transaction as the side effect, so a worker crash between &ldquo;I just charged&rdquo; and &ldquo;let me record that I charged&rdquo; doesn&rsquo;t leave you double-charging. Laravel hints at this in the events section of the docs, but the responsibility is yours.<\/p>\n<h2 id=\"horizon-defaults-i-always-change\">Horizon defaults I always change<\/h2>\n<p>I love <a href=\"https:\/\/laravel.com\/docs\/12.x\/horizon\" rel=\"nofollow noopener\" target=\"_blank\">Laravel Horizon<\/a>. I do not love its defaults.<\/p>\n<p>Things I change on every new project:<\/p>\n<p><code>balanceMaxShift<\/code> and <code>balanceCooldown<\/code>. The default auto-balance is aggressive. On low-traffic queues it churns workers up and down constantly. I set <code>balance =&gt; 'auto'<\/code>, <code>maxShift =&gt; 1<\/code>, <code>cooldown =&gt; 10<\/code> so it adjusts slowly and predictably.<\/p>\n<p>Per-queue worker counts. Horizon will happily put your <code>high<\/code> priority queue on the same supervisor as <code>low<\/code>. I split them into separate supervisors with explicit min\/max process counts. Email and webhook jobs go on <code>medium<\/code>. Anything user-facing goes on <code>high<\/code> with a smaller max. Reporting goes on <code>low<\/code>.<\/p>\n<p><code>tries<\/code> at the supervisor level. Horizon respects the per-job <code>tries<\/code>, but the supervisor default is 1, which silently overrides if you forgot to set it on the job. I set the supervisor <code>tries<\/code> to 5 as a floor.<\/p>\n<pre><code class=\"language-php\">'environments' =&gt; [\n    'production' =&gt; [\n        'supervisor-high' =&gt; [\n            'connection' =&gt; 'redis',\n            'queue' =&gt; ['high'],\n            'balance' =&gt; 'auto',\n            'maxProcesses' =&gt; 5,\n            'tries' =&gt; 5,\n            'timeout' =&gt; 60,\n        ],\n    ],\n],\n<\/code><\/pre>\n<p>I covered this kind of config debt in more detail in my <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-12-in-production-the-features-i-actually-kept\/\" rel=\"noopener\">Laravel 12 in production post<\/a>, but the queue config is the bit I tweak most often.<\/p>\n<h2 id=\"failed-jobs-are-a-queue-treat-them-like-one\">Failed jobs are a queue. Treat them like one<\/h2>\n<p>The <code>failed_jobs<\/code> table is not an archive. It is an inbox. I monitor its size and alert when it grows faster than I can investigate. If I ignore it, it grows until either I miss a real outage hiding inside what I assumed was noise, or I hit some other operational issue I should have caught earlier.<\/p>\n<p>What I run now:<\/p>\n<ul>\n<li>A daily report of distinct exception messages from <code>failed_jobs<\/code> with counts. Most of them tell me about a buggy job or a flaky dependency I&rsquo;d forgotten about.<\/li>\n<li>A retry policy by exception class, not by blanket retry. Network errors get retried automatically with a script. Validation errors do not, because retrying a validation error just produces the same failure.<\/li>\n<li>A 14-day TTL. After two weeks, failed jobs are either fixed or genuinely lost. Either way they shouldn&rsquo;t sit in the table forever.<\/li>\n<\/ul>\n<p>You can read more about how I think about production reliability across stacks on <a href=\"https:\/\/abrarqasim.com\/about\/\" rel=\"noopener\">my about page<\/a>.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>If you only do one thing after reading this: open your Laravel app&rsquo;s main job class, find your most-dispatched job, and ask one question. What happens if this job runs twice? If you have a clear answer (it&rsquo;s idempotent, or you have an idempotency key, or it&rsquo;s a no-op on second run), you&rsquo;re fine. If you don&rsquo;t, you have one bug. Fix that one before you do anything else.<\/p>\n<p>Then go set <code>maxExceptions<\/code> on every job that talks to an external API. Then go to bed.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Five Laravel queue habits I changed after years of failed jobs, with the maxExceptions, Horizon, and idempotency configs I now reach for on every new project.<\/p>\n","protected":false},"author":2,"featured_media":261,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Five Laravel queue habits I changed after years of failed jobs, with the maxExceptions, Horizon, and idempotency configs I now reach for on every new project.","rank_math_focus_keyword":"laravel queue","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,52],"tags":[49,306,56,53,308,175,307],"class_list":["post-262","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-php","tag-backend","tag-horizon","tag-laravel","tag-php","tag-production","tag-queues","tag-redis"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/262","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=262"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/262\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/261"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=262"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=262"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=262"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}