Development
Laravel queues in production: supervision, retries and the failures you never see
Queues are the part of a Laravel app most likely to be silently broken in production, because when they fail, nothing visibly breaks.
By Irfan Bhat · · 6 min read
Queues are where Laravel applications quietly break. Not loudly. That would be easier. A worker dies, jobs stop processing, and the site keeps serving pages perfectly. Emails are not sent, exports are not generated, webhooks are not delivered, and the first anyone knows is a customer asking where their receipt is, three days later.
Everything below is about making that failure mode impossible, or at least loud.
A worker must be supervised
The most common mistake I find is php artisan queue:work started in an SSH session, or in a screen, or from a cron job with no locking. All three will stop, and nothing will notice.
Supervisor is the standard answer, and the configuration is short:
; /etc/supervisor/conf.d/app-worker.conf
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=high,default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/app-worker.log
stopwaitsecs=3600
Three lines in there do the real work, and they are the ones most often wrong:
stopwaitsecs=3600, how long Supervisor waits for a worker to finish its current job before killing it. If this is lower than your longest job, deploys will kill jobs mid-execution.--max-time=3600. the worker exits cleanly after an hour and Supervisor restarts it. This contains memory growth, which is real: a long-lived PHP process accumulates.numprocs, how many workers. This is a memory calculation, exactly like PHP-FPM pool sizing. Each worker is a full PHP process.
On a server already using systemd for everything else, a systemd unit with Restart=always does the same job and gives you one supervision mechanism instead of two. Either is fine. Neither is not.
queue:work, not queue:listen
queue:work boots the framework once and processes jobs in a long-lived process. queue:listen boots the framework for every job, far slower, and only useful in local development where you want code changes picked up without restarting.
The consequence of the long-lived process is the thing people forget: your code is loaded into memory at boot. Deploying new code does not change what a running worker executes. Which brings us to deployments.
Restart workers on every deploy
Without this step, workers keep running the previous release indefinitely, executing old job classes against a new database schema, which produces some genuinely confusing bugs.
php artisan queue:restart
This does not kill anything. It signals workers to exit gracefully once they finish the current job, and the supervisor starts fresh ones. Put it in the deploy script, after the new code is live and migrations have run.
Retries and backoff
Default retry behaviour is rarely what you want. Configure it per job class, based on why that job might actually fail.
class SyncCustomerToCrm implements ShouldQueue
{
public int $tries = 5;
public int $timeout = 120;
// Exponential-ish backoff in seconds, per attempt
public array $backoff = [10, 60, 300, 900];
// Stop retrying after this point regardless of attempts left
public function retryUntil(): \DateTime
{
return now()->addHours(6);
}
public function handle(CrmClient $crm): void { /* ... */ }
public function failed(\Throwable $e): void
{
// Runs after the final attempt, notify, mark state, clean up
}
}
The reasoning behind each:
- Backoff, not immediate retry. If a third-party API is rate limiting you, retrying instantly makes it worse. Escalating delays give it room to recover.
timeoutbelowretry_after. The queue config'sretry_aftermust exceed the job'stimeout, or the queue will release the job for a second worker while the first is still running it. This is the classic cause of duplicate processing.retryUntil. Some failures are permanent. Retrying a job for a deleted record forever is just noise.failed(). The hook that runs after the last attempt. Without it, failures land silently in a table nobody reads.
Jobs must be safe to run twice
Assume every job will run more than once. A worker killed mid-execution, a timeout that fires while the work is actually completing, a retry after a network error where the request did in fact succeed, all produce duplicate execution.
If the job charges a card, sends an email or creates a record, running it twice is a real incident.
public function handle(): void
{
$order = Order::find($this->orderId);
// Guard on state, not on the assumption of single execution
if (! $order || $order->invoice_sent_at !== null) {
return;
}
$this->mailer->sendInvoice($order);
$order->update(['invoice_sent_at' => now()]);
}
For external calls, pass an idempotency key where the provider supports one. Every serious payment API does. That way the retry is safe at their end regardless of what happens at yours.
Also useful: ShouldBeUnique to prevent the same job being queued twice concurrently, and WithoutOverlapping as a middleware for jobs that must not run in parallel with themselves.
The failed_jobs table nobody reads
Laravel writes permanently failed jobs to failed_jobs. In most applications I audit, that table has entries going back months and nobody has looked at it.
Wire it into whatever you already watch. The JobFailed event is the hook:
Queue::failing(function (JobFailed $event) {
report($event->exception); // Sentry, Bugsnag, wherever errors go
// Optionally alert loudly for specific critical job classes
});
Add a scheduled check that alerts when the failed count crosses a threshold, and prune old records so the table stays useful. A failed job that nobody is told about is identical, from the business's point of view, to a job that never ran.
Monitor depth, not just liveness
Knowing a worker process is alive is not enough. A worker can be running while the queue grows faster than it drains, which looks healthy and is not.
Watch three things:
- Queue depth, pending jobs per queue. A rising trend means throughput is insufficient.
- Oldest pending job age. the most honest signal. If something queued forty minutes ago has not been picked up, something is wrong now.
- Failure rate, as a rate, not a count. A sudden change usually means a dependency broke.
Horizon gives you all of this for Redis queues, with a dashboard and configurable wait-time alerts. On any application where queues carry real work, it is worth the setup.
Separate queues by urgency
One queue for everything means a 20,000-row export blocks the password reset email behind it.
# Workers drain "high" completely before touching "default"
php artisan queue:work redis --queue=high,default,low
Better still, run separate worker pools per queue so a saturated low-priority pool cannot starve the high-priority one at all. Dispatch with ->onQueue('high') for anything a user is waiting on.
Production checklist
- Workers run under Supervisor or systemd with automatic restart.
stopwaitsecsexceeds the longest job duration.queue:restartruns on every deploy, after migrations.- Per-job
tries,backoffandtimeout, withretry_aftergreater thantimeout. - Every job is idempotent, guarded on state rather than on assumed single execution.
- Failed jobs report into error tracking and alert on threshold.
- Queue depth and oldest-job age monitored, not just process liveness.
- Urgent work on a separate queue with its own workers.
--max-timeset so workers recycle and memory growth is contained.
None of it is complicated. It is just the part that gets skipped because the application works fine in development, where the queue driver is sync and none of this exists. More on how I set these up under Laravel development.
Written by Irfan Bhat, web developer & server engineer, currently technical support & development at ElySpace. More about me, or get in touch if this raised a question.