Skip to content

Laravel

Laravel Queue Workers in Production: Supervisor, Timeouts, Horizon and Failed Jobs

Run Laravel 13 queue workers reliably: Supervisor config, --timeout vs retry_after vs stopwaitsecs, deploy restarts, Horizon, failed jobs and queue monitoring.

11 min read By the Hanoo team

Queues are where a Laravel app that works on your laptop starts behaving strangely in production. Jobs run twice. Workers keep running last week's code. Emails stop sending and nobody notices for a day. A worker grows to 900 MB and the server starts swapping.

Almost all of these come from a few settings that don't agree with each other: --timeout, retry_after and Supervisor's stopwaitsecs. Add workers that never restart, and failed jobs that nobody looks at. This guide covers how to run Laravel 13 queue workers reliably on your own Ubuntu server: the driver, the Supervisor setup, the timeouts, deploys, Horizon, failed jobs and monitoring. It picks up where our guide to deploying Laravel 13 to production leaves off.

How a Laravel queue worker actually works

php artisan queue:work starts a long-running PHP process. It boots your application once, then loops: take a job from the queue, run it, take the next one. This has three consequences:

  • The worker does not see new code until it restarts. Deploying without restarting workers means jobs run old code against a new database schema.
  • Memory builds up. Anything a job leaves behind, such as a large collection or an image held by GD, stays in that process until it exits.
  • It will exit sooner or later: on a timeout, a fatal error, a memory limit or a restart signal. Something has to start it again, and that is Supervisor's job.

Pick a queue driver

DriverGood forNotes
databaseMost apps; the default since Laravel 11No extra service. Uses a jobs table in your existing MariaDB, MySQL or PostgreSQL database
redis (Redis or Valkey)High job volume, many workers, HorizonFaster and lighter on the database. Horizon requires it
sqsApps already on AWSRetries are controlled by SQS's visibility timeout, not retry_after
syncTests and local development onlyRuns the job immediately in the web request. No queue at all

If you dispatch a few thousand jobs an hour, the database driver is fine, and it means one less service to run and back up. Move to Redis or Valkey when the jobs table becomes a hot spot or when you want Horizon. Valkey is the open-source, BSD-licensed fork of Redis, and Laravel's redis driver works with it unchanged. See Valkey vs Redis for how to choose and set it up.

.env
QUEUE_CONNECTION=database
# or, with Redis or Valkey on the same server:
# QUEUE_CONNECTION=redis
# REDIS_CLIENT=phpredis
# REDIS_HOST=127.0.0.1

Run workers under Supervisor

Supervisor starts your workers, restarts them when they exit and runs as many copies as you ask for. Install it:

Terminal
sudo apt install supervisor

Create /etc/supervisor/conf.d/app-worker.conf:

INI
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php8.4 /srv/app/current/artisan queue:work --sleep=3 --tries=3 --backoff=10 --max-time=3600 --timeout=90
directory=/srv/app/current
user=app
numprocs=2
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopwaitsecs=120
redirect_stderr=true
stdout_logfile=/srv/app/shared/storage/logs/worker.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=5

Load it and start the workers:

Terminal
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status

What each important line does:

  • user=app runs the worker as the application's own Linux user, never root. Files the job writes, such as cache and uploads, get the right owner, and a bug in a job cannot touch the rest of the system.
  • --max-time=3600 makes each worker exit after an hour, so Supervisor starts a fresh process. This is the simplest fix for memory build-up. --max-jobs=1000 works too.
  • numprocs=2 starts two workers. More processes means more jobs run at the same time, but each one is a full PHP process using RAM and a database connection.
  • stopasgroup and killasgroup make sure child processes started by a job are stopped along with the worker.
  • stdout_logfile_maxbytes rotates the log. Without it the log file grows forever.

Get the three timeouts right

This is the most common cause of jobs running twice or being killed halfway through. There are three separate timers:

SettingWhereWhat it means
--timeout (or $timeout on the job)WorkerHow long one job may run before the worker process kills it. Default is 60 seconds. Needs the pcntl extension
retry_afterconfig/queue.php, per connectionHow long the queue waits before it assumes a reserved job was lost and hands it to another worker. Default is 90 seconds
stopwaitsecsSupervisorHow long Supervisor waits for a worker to finish its current job after asking it to stop, before it kills it

The rule, with a few seconds' margin at each step:

  • If --timeout is longer than retry_after, a slow job is handed to a second worker while the first is still running it. That is how jobs run twice: two emails, two charges.
  • If stopwaitsecs is shorter than your longest job, every deploy or supervisorctl restart kills jobs halfway through.

If one type of job really needs ten minutes, such as a big import or a video encode, don't raise every limit for it. Put it on its own queue, with its own connection and its own worker:

PHP
// config/queue.php — a second connection with a longer retry_after
'redis-long' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => 'long',
    'retry_after' => 960,
    'block_for' => null,
],
INI
[program:app-worker-long]
command=php8.4 /srv/app/current/artisan queue:work redis-long --queue=long --timeout=900 --tries=1 --max-time=3600
user=app
numprocs=1
stopwaitsecs=930
autostart=true
autorestart=true
PHP
ImportCatalogue::dispatch($file)->onConnection('redis-long')->onQueue('long');

Queue priorities

A single worker can serve several queues in a strict order:

Terminal
php artisan queue:work --queue=high,default,low

The worker always empties high before taking a job from default. That keeps password-reset emails from waiting behind ten thousand newsletter jobs. For a strict guarantee, give high its own worker as well, so a slow low job can't hold up the process.

Restart workers on every deploy

Workers keep the code they started with. After each deploy, after the new release is live:

Terminal
php artisan queue:restart

This asks every worker to finish its current job and then exit, and Supervisor starts fresh ones on the new code. The restart signal is stored in your cache, so the cache store must be shared by the web app and the workers. array won't work; file, database or redis will.

If you use zero-downtime releases with a current symlink, point Supervisor at the symlink (/srv/app/current/artisan) and set directory=/srv/app/current, so new workers start in the new release. If your schedule includes sub-minute tasks, also run php artisan schedule:interrupt after deploying, so a running schedule:run stops using the old code.

Write jobs that survive retries

Any job can run more than once: after a timeout, a crash or a restart. Plan for it.

PHP
<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Throwable;

class SendInvoice implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public int $timeout = 60;
    public array $backoff = [10, 60, 300];   // wait 10 s, then 1 min, then 5 min

    public function __construct(public Order $order) {}

    public function middleware(): array
    {
        return [(new WithoutOverlapping($this->order->id))->expireAfter(120)];
    }

    public function handle(): void
    {
        if ($this->order->invoice_sent_at) {
            return; // already done on an earlier attempt
        }

        // ... send the invoice ...

        $this->order->update(['invoice_sent_at' => now()]);
    }

    public function failed(?Throwable $exception): void
    {
        // notify the team, mark the order, etc.
    }
}
  • Check before you act. The invoice_sent_at check makes the job safe to run twice. This is called making the job idempotent.
  • Back off between retries. Retrying a failing API every second just gets you rate-limited. An array of $backoff values spaces the attempts out.
  • Middleware uses up attempts. WithoutOverlapping and RateLimited release the job back to the queue, and each release counts as an attempt. Raise $tries when you use them.
  • Free heavy resources. Workers don't reboot the framework between jobs, so drop big objects and close what you opened.

Failed jobs

After its last attempt, a job is written to the failed_jobs table. Learn these commands:

Terminal
php artisan queue:failed                    # list failed jobs
php artisan queue:retry <uuid>              # retry one
php artisan queue:retry --queue=emails      # retry all from one queue
php artisan queue:retry all                 # retry everything
php artisan queue:forget <uuid>             # delete one
php artisan queue:prune-failed --hours=168  # delete records older than a week

Schedule the cleanup so the table doesn't grow forever, in routes/console.php:

PHP
use Illuminate\Support\Facades\Schedule;

Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();

Monitor the queue

A queue that is slowly falling behind looks fine until customers complain. Laravel can warn you when a queue gets too long:

PHP
// routes/console.php
Schedule::command('queue:monitor database:default,database:high --max=100')->everyMinute();
PHP
// AppServiceProvider::boot()
use Illuminate\Queue\Events\QueueBusy;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;

Event::listen(function (QueueBusy $event) {
    Notification::route('mail', 'ops@example.com')
        ->notify(new \App\Notifications\QueueIsBacklogged($event->connectionName, $event->queue, $event->size));
});

Also listen for Queue::failing(...) to report each failure to your error tracker, and check supervisorctl status after deploys. A worker stuck in FATAL or BACKOFF usually means the command itself fails, for example because of a wrong PHP binary or a missing .env.

Laravel Horizon

Horizon replaces your hand-written worker commands with a configuration file in your repository, adds a dashboard at /horizon, and can scale workers per queue automatically. It needs the redis queue connection. Valkey works because it speaks the same protocol. Horizon does not support Redis Cluster.

Terminal
composer require laravel/horizon
php artisan horizon:install

Supervisor then runs a single horizon process instead of many queue:work processes:

INI
[program:app-horizon]
command=php8.4 /srv/app/current/artisan horizon
directory=/srv/app/current
user=app
autostart=true
autorestart=true
stopwaitsecs=3600
redirect_stderr=true
stdout_logfile=/srv/app/shared/storage/logs/horizon.log

Things to get right with Horizon:

  • Deploys run php artisan horizon:terminate instead of queue:restart.
  • Protect the dashboard. In production it is only reachable by users allowed by the viewHorizon gate in HorizonServiceProvider.
  • Schedule horizon:snapshot every five minutes, or the metrics graphs stay empty.
  • Horizon's timeout must stay a few seconds below retry_after, as with queue:work.
  • With auto balancing, queue order does not set priority. Use separate supervisors in config/horizon.php when one queue must always win.

How many workers do you need?

Start small and measure. Each worker is a PHP process that typically uses 50–150 MB depending on your app, and holds a database connection.

  • Mostly waiting jobs (HTTP APIs, email, webhooks) spend their time on the network. You can run several workers per CPU core.
  • CPU-heavy jobs (images, PDFs, exports) keep a core busy. Use about one worker per core, minus what PHP-FPM needs for web traffic.
  • Watch memory, not just CPU. numprocs × worker memory + PHP-FPM + the database must fit in RAM with room to spare.

If jobs wait longer than you'd like and the server has headroom, add workers. If the server is busy, add a server, or move the slow jobs to their own queue with a limited number of workers.

Run queue workers with Hanoo

Hanoo sets up the server side of all this on your own Ubuntu server, from the dashboard:

  • Supervisor programs per application. Turn on Supervisor for an application and add a program with your queue:work or horizon command. Hanoo always runs it as the application's own Linux user, refuses user=root, and lets you start, stop and restart it, see its status and read the end of its log from the dashboard.
  • Restarts as part of every deploy. The deploy pipeline has an after activation phase. Put php artisan queue:restart (or horizon:terminate) there and it runs once the new release is live. Each step's output and timing is shown on the Deployments tab.
  • The scheduler without crontab. Add php8.4 artisan schedule:run every minute under the application's Cron jobs tab. Hanoo runs it as the application user, shows the next run times before you save, and lets you run it on demand and read the output.
  • A database queue out of the box. Create the application with a MariaDB database and the database queue driver works immediately.

The workers, the code and the server stay yours. Supervisor configs and systemd timers are standard Ubuntu.

Frequently asked questions

Why are my Laravel jobs running twice?

Usually because the job runs longer than the connection's retry_after, so the queue hands it to a second worker. Make --timeout shorter than retry_after, make sure your longest job finishes within --timeout, and make jobs safe to repeat.

Why don't my queue workers pick up new code after a deploy?

Workers load your application once and keep it in memory. Run php artisan queue:restart after every deploy (or horizon:terminate with Horizon), and make sure Supervisor starts workers from the current release directory.

What should stopwaitsecs be for Laravel workers?

Longer than your longest-running job, plus a margin. Otherwise Supervisor kills jobs mid-run whenever workers are stopped or restarted. For workers capped at --timeout=90, a value of 120 is safe.

Should I use the database or Redis queue driver?

Start with database if your volume is modest. It needs no extra service. Switch to Redis or Valkey when job volume puts real load on the database, or when you want Horizon's dashboard and auto-scaling.

Does Laravel Horizon work with Valkey?

Yes. Horizon needs the redis queue connection, and Valkey speaks the Redis protocol, so you point REDIS_HOST at Valkey and nothing else changes. Horizon does not support Redis Cluster mode.

How do I stop a queue worker from using too much memory?

Recycle it. --max-time=3600 or --max-jobs=1000 makes each worker exit on schedule, and Supervisor starts a fresh one. --memory=256 makes a worker exit once it crosses that many megabytes. Inside jobs, release large objects when you're done with them.