{"slug": "a-series-of-unfortunate-jobs", "title": "A Series of Unfortunate Jobs", "summary": "A developer documented two common Laravel queue pitfalls that can silently break production jobs. The first involves retryUntil(), whose expiration timestamp is evaluated when a job is dispatched rather than when it begins processing, causing delayed jobs to fail immediately with MaxAttemptsExceededException. The second is that Laravel Horizon's actual default for tries is zero, meaning unlimited retries, contrary to the documented single-attempt behavior unless tries is explicitly set to 1.", "body_md": "You can't tell me Laravel queues haven't bitten you at least once.\n\nIt's great that we get them out of the box, but man, they can be confusing as hell sometimes. Over the years, I've screwed up, more than once, and collected quite a few lessons along the way.\n\nClaude is currently **Pondering..**, I'm waiting, and bored, so shall we?\n\nI want you to look at the code below and tell me if you can spot anything wrong:\n\n``` php\n<?php\n\nnamespace App\\Jobs;\n\nuse DateTime;\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\n\nclass SendAbandonedCartReminder implements ShouldQueue\n{\n    use Queueable;\n\n    public function retryUntil(): DateTime\n    {\n        return now()->addMinutes(10);\n    }\n\n    public function handle(): void\n    {\n        // business logic\n    }\n}\n\nSendAbandonedCartReminder::dispatch($cart)->delay(now()->addDay());\n```\n\nNothing sus, right? The customer left their cart, so we nudge them a day later. And if the mail provider is having a bad day, we keep retrying for 10 minutes, then give up.\n\nExcept that reminder will never be sent. Not once.\n\nIf you spotted it, you probably learned this one during a fun debugging session 😅. If not, the misconception here is assuming that `retryUntil()` starts counting from the moment the job starts processing.\n\nWell, kind sir, that's where you're wrong.\n\n`retryUntil()` is evaluated when the job is pushed onto the queue, and the expiration timestamp is baked into the job payload. By the time the job becomes available to the workers, a day later, that timestamp is long gone. The worker fails it with the one and only `MaxAttemptsExceededException`, and `handle()` never runs.\n\nSame drill, take a look at the code below:\n\n``` php\n<?php\n\nnamespace App\\Jobs;\n\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\n\nclass SyncOrderToCrm implements ShouldQueue\n{\n    use Queueable;\n\n    public function handle(): void\n    {\n        // business logic\n    }\n}\n```\n\nThere is no `$tries` in sight, sooo, what happens if the job fails?\n\nGood question, that is one good question.\n\nFor as long as I can remember, I've used the database driver locally. It's just convenient right? Production, however, is a different beast. That's Horizon territory, and it only makes sense.\n\nNow, Horizon ships with a supervisor called `supervisor-1`, and for the love of God, it's ugly. I like giving supervisors proper names, like what do you mean `supervisor-1`?\n\nSo, naturally, I defined a `data-import-supervisor`, and the config looked like this:\n\n``` js\n'defaults' => [], // the ugly supervisor-1 gone\n\n'environments' => [\n    'production' => [\n        'data-import-supervisor' => [ // no 'tries' => 1\n            'connection' => 'redis',\n            'queue' => ['data-import'],\n            'balance' => 'auto',\n            'maxProcesses' => 10,\n            'timeout' => 1800,\n        ],\n    ],\n],\n```\n\nNow, I knew for a fact that when `$tries` isn't specified, the job is attempted once. \n\nI'd lived by that for years, never passing a single argument to `queue:work` locally with the DB driver, and every failed job being attempted exactly once.\n\nSo, in my head, Horizon would behave the same. I mean, it wraps `queue:work`, so it only made sense to omit what was obvious. I hate bloat. And to be fair, it wasn't just me. The docs had my back on this one:\n\nIf you don't set the `tries` option, Horizon *defaults to a single attempt*, unless the job class defines `$tries`, which takes precedence over the Horizon configuration.\n\nWhat I didn't freaking know is that this only holds if `tries` is explicitly set to 1 somewhere. Horizon's actual default is [zero](https://github.com/laravel/horizon/blob/5.x/src/Console/SupervisorCommand.php#L36), and zero means unlimited.\n\nSo, back to the question. What happens when the job fails?\n\n`queue:work`, the job is attempted once and marked as failed. Pretty much what you'd expect.`tries` in it, the job is retried indefinitely. Becuase why would you?\nI know, uncalled for.\n\nBut hey, now you know: **always pin your `$tries`**.\n\nUnique jobs. We all have at least one in our app, right?\n\n``` php\n<?php\n\nnamespace App\\Jobs;\n\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Contracts\\Queue\\ShouldBeUnique;\n\nclass SyncOrderToCrm implements ShouldQueue, ShouldBeUnique\n{\n    use Queueable;\n\n    public function handle(): void\n    {\n        // business logic\n    }\n}\n```\n\nThe way it works is simple: the job gets pushed, it acquires a lock, a worker processes it, the lock gets released, and la vie en rose.\n\nMy question is: what happens to the lock if the job never gets to release it?\n\nWell, first, how do we even end up there? There are multiple ways, one of which you've already met.\n\nRemember `retryUntil()` from Gotcha #1? It's back. Take the same job, make it `ShouldBeUniqueUntilProcessing`, and give it an hour to live:\n\n``` php\n<?php\n\nnamespace App\\Jobs;\n\nuse DateTime;\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Contracts\\Queue\\ShouldBeUniqueUntilProcessing;\n\nclass SyncOrderToCrm implements ShouldQueue, ShouldBeUniqueUntilProcessing\n{\n    use Queueable;\n\n    public function retryUntil(): DateTime\n    {\n        return now()->addHour();\n    }\n\n    public function handle(): void\n    {\n        // business logic\n    }\n}\n```\n\nNow dispatch it on a bad day. The queue is drowning, and the job sits there for two hours. A worker finally picks it up, sees that `retryUntil()` is in the past, and fails it without ever running it. Fair enough, that's what we asked for.\n\nExcept, with `ShouldBeUniqueUntilProcessing`, the lock is released the moment processing starts, so the failure path assumes it's long gone and doesn't touch it. But processing never started. The lock is still there, and from now on, every dispatch of that job is silently skipped.\n\nSo, how long till it expires?\n\nWell yeah, technically we'll all be dead by 2286, so call it never. Still, 3 drivers, 3 different answers. [Database](https://github.com/laravel/framework/blob/v13.30.1/src/Illuminate/Cache/DatabaseLock.php#L122-L129), [Redis](https://github.com/laravel/framework/blob/v13.30.1/src/Illuminate/Cache/RedisLock.php#L34-L41), [File](https://github.com/laravel/framework/blob/v13.30.1/src/Illuminate/Cache/FileStore.php#L480-L485).\n\nBut hey, now you know: **always set an explicit `uniqueFor`**.\n\nNo code this time, more of a situation. Paraphrased from a [Laracasts thread](https://laracasts.com/discuss/channels/laravel/appjobstestjob-has-been-attempted-too-many-times):\n\nI'm setting up a job on Laravel 11 with Horizon, default configuration, and `$timeout = 900` on the job. I keep getting `App\\Jobs\\TestJob has been attempted too many times`. The thing is, the job doesn't fail and it isn't retried. I added some logging, and after the error is raised I still see `TestJob completed successfully`. The job takes 2 or 3 minutes at most. I've run out of ideas.\n\nAny idea what might be causing this?\n\nYes, it's `retry_after`.\n\nIt's the number of seconds a job can stay reserved before the queue assumes it died. Once that time passes, the job goes back onto the queue for another worker to pick up. The default is 90 seconds.\n\nSo while the first worker was still busy processing the job, a second worker picked it up, saw that it had already used its single attempt, and threw the exception without ever running it. Meanwhile, the first worker finished and logged a success. Pretty fun to debug right?\n\nWhat's worse is that, had `$tries` been higher, the second worker would have actually run it. You'd then have your job processed twice, so if your job isn't idempotent, you're in for one hell of a debugging session.\n\nSo, kids, what did we learn today? That's right, your `retry_after` should always be higher than your job's `timeout`.\n\nTechnically, this one isn't a queue issue, but it does bite you when working with queues. So yea might as well include it.\n\nImagine you're building an API integration where you're allowed to make 10 requests per minute.\n\nThe API requests are queued, obviously, and the first thought is to use Laravel's `RateLimited` job middleware to enforce that rule. So you do exactly that.\n\n``` js\nRateLimiter::for('crm', fn () => Limit::perMinute(10));\nphp\n<?php\n\nnamespace App\\Jobs;\n\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Queue\\Middleware\\RateLimited;\n\nclass SyncOrderToCrm implements ShouldQueue\n{\n    use Queueable;\n\n    public function middleware(): array\n    {\n        return [new RateLimited('crm')];\n    }\n\n    public function handle(): void\n    {\n        // business logic\n    }\n}\n```\n\nSomehow, God knows why, you still get spammed with `429` errors, what??\n\nAfter a while of caveman debugging, you realize that Laravel's rate limiter is a fixed-window counter: it counts hits for 60 seconds, then resets to zero and forgets the previous minute ever happened.\n\nNow say one request lands at 12:00:00 and starts the window. Nothing else happens until 12:00:59, when 9 requests land at once. All allowed, the counter is at 10. At 12:01:00 the counter resets, and the next 10 sail through.\n\nThe provider, however, doesn't care about your windows. It sees 19 requests in a minute and hands you a sweet `429`.\n\nBut hey, next time you're building an API integration, you'll know. Hopefully not the hard way.\n\nThis one is less painful than the previous ones, but depending on your codebase, it could be something you have right now and have never noticed.\n\nEvery job in this article used one trait, `Queueable`. Inspect it, and that one trait is really four:\n\n```\ntrait Queueable\n{\n    use Dispatchable, InteractsWithQueue, QueueableByBus, SerializesModels;\n}\n```\n\nOf these, `SerializesModels` is probably the coolest. Its sole job is to \"represent\" models in a way that's efficient and can be reconstructed later. Otherwise, we could end up with a huge payload that some drivers, like SQS, would flat out reject.\n\nAnyway, somewhere in your code, you have a model with a couple of relationships loaded:\n\n``` php\n$order = Order::query()\n    ->with([\n        'items' => fn (Builder $query) => $query->latest()->limit(3),\n        'payments' => fn (Builder $query) => $query->where('status', 'captured'),\n    ])\n    ->find($id);\n```\n\nThen another piece of code dispatches a job with that model:\n\n```\nSyncOrderToCrm::dispatch($order);\n```\n\nIf you inspect the payload sitting in your queue, you'll find something like:\n\n```\nO:23:\"App\\Jobs\\SyncOrderToCrm\":1:{s:5:\"order\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":5:{s:5:\"class\";s:16:\"App\\Models\\Order\";s:2:\"id\";i:5;s:9:\"relations\";a:2:{i:0;s:5:\"items\";i:1;s:8:\"payments\";}s:10:\"connection\";s:5:\"mysql\";s:15:\"collectionClass\";N;}}\n```\n\nNow, here's the thing. Do you see any information beyond the class, the ID, and the names of the loaded relationships? No. Not the `limit(3)`, not the `where('status', 'captured')`, nothing.\n\nSee where I'm going with this?\n\nSo when the worker unserializes the payload to reconstruct the model, all it can do is fetch the order by ID and load every relationship on that list. These are the exact queries it runs:\n\n```\nselect * from `orders` where `orders`.` id` = ? limit 1\nselect * from `items` where `items`.` order_id` in (5)\nselect * from `payments` where `payments`.` order_id` in (5)\n```\n\nNow, if your job depends on those loaded relationships (please don't), you could end up with some terrible edge cases. Take this `handle()`:\n\n``` php\npublic function handle(Crm $crm): void\n{\n    $crm->updateOrder($this->order->id, [\n        'paid_total' => $this->order->payments->sum('amount'),\n        'recent_items' => $this->order->items->pluck('sku'),\n    ]);\n}\n```\n\nYou filtered `payments` down to captured ones before dispatching, so `paid_total` looks right locally. On the worker, `payments` is every payment on that order, including failed attempts and refunds, and the CRM now says the customer paid more than they actually did.\n\nSame story for `items`. You loaded the latest three, but the CRM gets all of them.\n\nAnd even if you don't depend on them, if one of those relationships happens to have eighty thousand rows, the worker will hydrate eighty thousand models, hit its memory limit, and die.\n\nSo yes, keep in mind how `SerializesModels` works. In most cases, if not all, if for some reason you have to pass the model to a job instead of its identifier, strip its relationships with the `#[WithoutRelations]` attribute and load exactly what the job needs inside `handle()`.\n\nBut hey, now you know.\n\nLooks like my Claude instance finished cogitating, so that'll do.\n\nIf all of this taught me one thing, it's to be explicit with my jobs. The defaults are well thought out, sure, but I'd rather read a `$timeout` than assume one. Explicit is predictable, and predictable means less guesswork about what's going on when things break.", "url": "https://wpnews.pro/news/a-series-of-unfortunate-jobs", "canonical_source": "https://dev.to/oussamamater/a-series-of-unfortunate-jobs-33bk", "published_at": "2026-09-10 15:47:32+00:00", "updated_at": "2026-09-10 16:15:12.495870+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Laravel", "Horizon", "Claude"], "alternates": {"html": "https://wpnews.pro/news/a-series-of-unfortunate-jobs", "markdown": "https://wpnews.pro/news/a-series-of-unfortunate-jobs.md", "text": "https://wpnews.pro/news/a-series-of-unfortunate-jobs.txt", "jsonld": "https://wpnews.pro/news/a-series-of-unfortunate-jobs.jsonld"}}