Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

How to Run Laravel Queue Workers in Production with Supervisor (2026)

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 $timeout well below retry_after. If a job’s timeout equals or exceeds the connection’s retry_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:

  1. Jobs sit in the queue and nothing processes them. Check that the connection in the worker command matches QUEUE_CONNECTION in your .env — a worker started with queue:work redis will never see jobs dispatched to the database driver. If you recently changed .env, run php artisan config:clear and restart the workers; they boot with whatever config was cached.
  2. Workers are processing, but with old code. You deployed without php artisan queue:restart. Run it now.
  3. The same job runs twice. Your job $timeout is at or above the connection’s retry_after, so a second worker reclaims the reservation while the first is still working. Lower the timeout.
  4. Supervisor kills jobs mid-run on deploys. stopwaitsecs is shorter than your longest job. Raise it above that job’s runtime.
  5. Workers die immediately with FATAL in supervisorctl status. Read the log file from stdout_logfile. It is almost always a wrong artisan path, a file-permission issue, or a PHP extension missing on the server.

Production checklist

  • QUEUE_CONNECTION set to a real driver (not sync) in production .env
  • Jobs table migrated (make:queue-table) or Redis reachable
  • failed_jobs table migrated (make:queue-failed-table)
  • Supervisor installed, config in /etc/supervisor/conf.d/, workers RUNNING
  • stopwaitsecs higher than your longest job; job $timeout below retry_after
  • --max-jobs / --max-time set so workers recycle
  • php artisan queue:restart in the deploy script (or horizon:terminate)
  • failed() hooks alerting your team on critical jobs
  • queue:prune-failed scheduled 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

Leave a Comment