How to Run Laravel Queue Workers in Production with Supervisor (2026)
Last verified: 8 October 2026 against the official Laravel 12.x queue documentation.
On your laptop, running Laravel queue workers is easy: you open a terminal tab, run php artisan queue:work, and forget about it. On a production server there is no terminal tab. A deploy, a server reboot, or one crashed process silently stops your background jobs — emails never send, webhooks never fire, imports stall halfway — and nothing tells you until a customer complains.
This guide walks you through the full production setup: choosing a queue driver, writing jobs that survive retries, keeping workers alive with Supervisor, restarting them on every deploy, and handling failed jobs like an operator. Every command and config here is checked against the official Laravel docs.
Step 1: Pick a queue driver
Laravel ships with several queue backends, and the choice matters less than you think at the start — but pick deliberately.
Database driver: the zero-infrastructure start
Set QUEUE_CONNECTION=database in your .env. You need a jobs table to hold the queued payloads; new Laravel apps already ship the 0001_01_01_000002_create_jobs_table.php migration. If yours is missing, generate it:
php artisan make:queue-table
php artisan migrate
The database driver polls your database for new jobs, so it adds query load on every poll. For a side project or a low-volume app this is completely fine and it means one less service to run.
Redis driver: faster, and required for Horizon
Set QUEUE_CONNECTION=redis and make sure you have a Redis client: predis/predis via Composer, or the phpredis PHP extension. Redis lets workers block on the queue instead of polling, which is more efficient, and it unlocks Laravel Horizon for monitoring (more on that in Step 8).
One Redis setting deserves your attention now: retry_after (90 seconds by default). It is how long Laravel waits before assuming a reserved job was abandoned and making it available again. We will come back to it in Step 2, because getting it wrong causes jobs to run twice.
Step 2: Write jobs that survive production
Generate a job class with Artisan and keep the handle method focused on one task:
php artisan make:job SendWelcomeEmail
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
// Retry at most 3 times before the job is marked failed
public $tries = 3;
// Wait 30s, then 2 minutes, then 10 minutes between retries
public function backoff(): array
{
return [30, 120, 600];
}
// Kill the job if it runs longer than 60 seconds
public $timeout = 60;
public function __construct(public User $user) {}
public function handle(): void
{
// Send the email...
}
// Runs when the job finally fails - alert someone here
public function failed(?Throwable $exception): void
{
// Notify your team, e.g. via Slack or email
}
}
Three rules that will save you at 2am:
- Keep
$timeoutwell belowretry_after. If a job’s timeout equals or exceeds the connection’sretry_after, another worker can pick the still-running job up and run it twice. Give yourself margin: a 60-second timeout against the 90-second Redis default is a sane starting point. - Use backoff with intent. The default behaviour releases a failed job back onto the queue immediately, which hammers a flaky API. An exponential
backoff()array like the one above is kinder to third-party services. - Make jobs idempotent. Retries mean
handle()can run more than once. Sending the same welcome email twice is embarrassing; charging a card twice is a disaster. Design accordingly.
Step 3: Use queue:work, not queue:listen, in production
Laravel ships two ways to process jobs. queue:listen boots the entire framework fresh for every job — convenient in development because code changes take effect immediately, but slow and memory-hungry. queue:work boots once and processes jobs in a long-running daemon process: far less overhead per job, which is exactly what you want on a server.
The trade-off is that a queue:work daemon holds your code in memory, so it will not see new code after a deploy. Step 6 fixes that. In development, keep using whichever you like; in production, always queue:work.
Step 4: Keep Laravel queue workers alive with Supervisor
A queue:work process can stop for many reasons: a deploy signal, a worker timeout, an out-of-memory kill, or a plain crash. The Laravel docs are explicit here: in production you need a process monitor that detects when your queue:work processes exit and restarts them automatically. On Linux, that tool is Supervisor.
Install it on Ubuntu:
sudo apt-get install supervisor
Supervisor configs live in /etc/supervisor/conf.d/. Create laravel-worker.conf (adapted from the official Laravel documentation example):
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/my-app/artisan queue:work redis --sleep=3 --tries=3 --timeout=90 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/my-app/storage/logs/worker.log
stopwaitsecs=3600
What each directive does:
| Directive | Why it matters |
|---|---|
process_name |
Names each worker laravel-worker_00, _01, etc. so you can tell them apart in logs. |
command |
The exact queue:work invocation. Use the full path to artisan and set your connection, sleep, tries, and timeout flags here. |
autostart=true |
Starts the workers when Supervisor itself starts — for example after a server reboot. |
autorestart=true |
Restarts a worker whenever it exits, whether it crashed, hit --max-time, or was gracefully stopped by queue:restart. |
stopasgroup / killasgroup |
Sends stop/kill signals to the whole process group so no orphaned child processes survive a restart. |
user |
The system user running the worker. Set it to whoever owns your app files (www-data on a typical Ubuntu LEMP stack, forge on Forge). |
numprocs |
How many worker processes to run. Each is a separate PHP process pulling jobs independently. |
stopwaitsecs |
How long Supervisor waits for a worker to finish its current job before killing it. The docs warn: set this higher than your longest-running job, or Supervisor may kill a job mid-flight. |
Now load the config and start the workers:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"
Check on them any time with sudo supervisorctl status. If a worker is in a FATAL state, read the log file from stdout_logfile — nine times out of ten it is a wrong path, a permissions problem, or a bad .env value.
Step 5: Prioritise and scale with queue names
One worker pool treating every job equally is fine until your password-reset emails queue behind a 10,000-row CSV import. Give jobs different lanes. Dispatch onto named queues:
SendWelcomeEmail::dispatch($user)->onQueue('high');
GenerateReport::dispatch($report)->onQueue('low');
Then point workers at queues in priority order — a worker only moves to the next queue when the earlier ones are empty:
php artisan queue:work redis --queue=high,default,low
For real isolation, run separate Supervisor programs per lane so a flood of low-priority jobs can never starve the high-priority ones: one [program:laravel-worker-high] with --queue=high and a couple of processes, another for default,low. Scale by watching the backlog: if jobs consistently wait, raise numprocs; if workers sit idle, lower it.
Two worker flags deserve a place in every production command:
--max-jobs=1000— recycle the worker after 1,000 jobs. Long-running PHP processes slowly leak memory; periodic restarts keep them healthy, and Supervisor starts a fresh one instantly.--max-time=3600— recycle the worker after an hour for the same reason.
Step 6: Restart workers on every deploy
This is the step most tutorials skip and the one that causes the strangest production bugs: your queue:work processes booted your application once and hold that code in memory. Deploy new code and the web requests get it — but the workers keep running the old version, sometimes for weeks, until someone restarts them.
Laravel gives you a purpose-built command for this: php artisan queue:restart. It signals every worker to gracefully finish its current job and then exit instead of pulling another one. Because Supervisor has autorestart=true, fresh workers boot immediately with the new code.
Add it to your deploy script, right after migrations:
php artisan migrate --force
php artisan queue:restart
If you use Laravel Horizon instead of raw workers (Step 8), run php artisan horizon:terminate on deploy instead — Horizon will gracefully wind down its workers and Supervisor brings them back with the fresh code.
Step 7: Handle failed jobs like an operator
Jobs fail. APIs go down, payloads are malformed, edge cases bite. After a job exhausts its attempts, Laravel stores it in a failed_jobs table instead of silently dropping it. Create that table if your app does not have it yet:
php artisan make:queue-failed-table
php artisan migrate
The failed-job workflow you will actually use:
# List failed jobs (id, connection, queue, failure time)
php artisan queue:failed
# Retry one failed job by its id
php artisan queue:retry 91401d2c-0784-4f43-824c-34f94a33c24d
# Retry every failed job
php artisan queue:retry all
# Delete a single failed job you do not care about
php artisan queue:forget 91401d2c-0784-4f43-824c-34f94a33c24d
# Wipe the whole failed_jobs table
php artisan queue:flush
Two habits make this painless. First, put a failed() method on important jobs (see Step 2) that alerts your team — a failed payment webhook should page someone, not sit in a table. Second, prune the table so it does not grow forever. Laravel ships php artisan queue:prune-failed, which deletes records older than 24 hours by default; run it from the scheduler with a longer window:
// routes/console.php
Schedule::command('queue:prune-failed --hours=168')->weekly();
Step 8: Graduate to Horizon when Redis is your driver
Plain Supervisor workers are enough for a long time. Reach for Laravel Horizon when you want visibility: a dashboard showing queue throughput, runtimes, and failed jobs, plus auto-balancing that shifts worker processes toward whichever queue is backing up.
Installation is two commands:
composer require laravel/horizon
php artisan horizon:install
You then define worker pools in config/horizon.php and run php artisan horizon under Supervisor instead of queue:work — Horizon manages the worker processes itself. The dashboard lives at /horizon; gate it to admins in your HorizonServiceProvider so the public cannot see your queue internals. Horizon only supports Redis, so this step is off the table if you are on the database driver.
Troubleshooting: when Laravel queue workers stop processing
Run through this list before you start changing config at random:
- Jobs sit in the queue and nothing processes them. Check that the connection in the worker command matches
QUEUE_CONNECTIONin your.env— a worker started withqueue:work rediswill never see jobs dispatched to the database driver. If you recently changed.env, runphp artisan config:clearand restart the workers; they boot with whatever config was cached. - Workers are processing, but with old code. You deployed without
php artisan queue:restart. Run it now. - The same job runs twice. Your job
$timeoutis at or above the connection’sretry_after, so a second worker reclaims the reservation while the first is still working. Lower the timeout. - Supervisor kills jobs mid-run on deploys.
stopwaitsecsis shorter than your longest job. Raise it above that job’s runtime. - Workers die immediately with FATAL in
supervisorctl status. Read the log file fromstdout_logfile. It is almost always a wrong artisan path, a file-permission issue, or a PHP extension missing on the server.
Production checklist
QUEUE_CONNECTIONset to a real driver (notsync) in production.env- Jobs table migrated (
make:queue-table) or Redis reachable failed_jobstable migrated (make:queue-failed-table)- Supervisor installed, config in
/etc/supervisor/conf.d/, workersRUNNING stopwaitsecshigher than your longest job; job$timeoutbelowretry_after--max-jobs/--max-timeset so workers recyclephp artisan queue:restartin the deploy script (orhorizon:terminate)failed()hooks alerting your team on critical jobsqueue:prune-failedscheduled so the table stays small
Get these nine right and your background jobs become boring infrastructure — which is exactly what you want. Jobs get picked up in seconds, deploys do not leave stale workers behind, and failures land somewhere you will actually see them.
Further Reading & References
- Laravel 12.x Queues documentation — the official reference for every option, driver, and Artisan command in this post.
- Understanding Laravel Queues: queue:work vs queue:listen and Why queue:restart Matters (dev.to) — clear walkthrough of the worker lifecycle and why restarts matter on deploy.
- Your Queue Worker Gets a SIGTERM on Every Deploy (dev.to) — deep dive into what actually happens between SIGTERM and SIGKILL during deployments.
- Laravel Job timeout vs retry_after: The Ordering Rule Nothing Enforces (dev.to) — why timeout must stay below retry_after, with a timeline of the double-processing bug.
- Scaling Laravel Queues on Database Driver with Supervisor (Medium) — per-queue Supervisor programs and scaling numbers for the database driver.
- Laravel Horizon in Production: Configuring AI Queue Workloads That Actually Hold (dev.to) — production Horizon supervisor pools and what the defaults get wrong.
- Queue Workers (ahmadmayahi/clean-code-in-laravel, GitHub) — annotated Supervisor config with a directive-by-directive breakdown.



