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
| Driver | Good for | Notes |
|---|---|---|
database | Most apps; the default since Laravel 11 | No extra service. Uses a jobs table in your existing MariaDB, MySQL or PostgreSQL database |
redis (Redis or Valkey) | High job volume, many workers, Horizon | Faster and lighter on the database. Horizon requires it |
sqs | Apps already on AWS | Retries are controlled by SQS's visibility timeout, not retry_after |
sync | Tests and local development only | Runs 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.
QUEUE_CONNECTION=database
# or, with Redis or Valkey on the same server:
# QUEUE_CONNECTION=redis
# REDIS_CLIENT=phpredis
# REDIS_HOST=127.0.0.1Run workers under Supervisor
Supervisor starts your workers, restarts them when they exit and runs as many copies as you ask for. Install it:
sudo apt install supervisorCreate /etc/supervisor/conf.d/app-worker.conf:
[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=5Load it and start the workers:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl statusWhat each important line does:
user=appruns the worker as the application's own Linux user, neverroot. 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=3600makes each worker exit after an hour, so Supervisor starts a fresh process. This is the simplest fix for memory build-up.--max-jobs=1000works too.numprocs=2starts 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.stopasgroupandkillasgroupmake sure child processes started by a job are stopped along with the worker.stdout_logfile_maxbytesrotates 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:
| Setting | Where | What it means |
|---|---|---|
--timeout (or $timeout on the job) | Worker | How long one job may run before the worker process kills it. Default is 60 seconds. Needs the pcntl extension |
retry_after | config/queue.php, per connection | How long the queue waits before it assumes a reserved job was lost and hands it to another worker. Default is 90 seconds |
stopwaitsecs | Supervisor | How 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
--timeoutis longer thanretry_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
stopwaitsecsis shorter than your longest job, every deploy orsupervisorctl restartkills 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:
// 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,
],[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=trueImportCatalogue::dispatch($file)->onConnection('redis-long')->onQueue('long');Queue priorities
A single worker can serve several queues in a strict order:
php artisan queue:work --queue=high,default,lowThe 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:
php artisan queue:restartThis 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
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_atcheck 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
$backoffvalues spaces the attempts out. - Middleware uses up attempts.
WithoutOverlappingandRateLimitedrelease the job back to the queue, and each release counts as an attempt. Raise$trieswhen 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:
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 weekSchedule the cleanup so the table doesn't grow forever, in routes/console.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:
// routes/console.php
Schedule::command('queue:monitor database:default,database:high --max=100')->everyMinute();// 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.
composer require laravel/horizon
php artisan horizon:installSupervisor then runs a single horizon process instead of many queue:work processes:
[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.logThings to get right with Horizon:
- Deploys run
php artisan horizon:terminateinstead ofqueue:restart. - Protect the dashboard. In production it is only reachable by users allowed by the
viewHorizongate inHorizonServiceProvider. - Schedule
horizon:snapshotevery five minutes, or the metrics graphs stay empty. - Horizon's
timeoutmust stay a few seconds belowretry_after, as withqueue:work. - With
autobalancing, queue order does not set priority. Use separate supervisors inconfig/horizon.phpwhen 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:workorhorizoncommand. Hanoo always runs it as the application's own Linux user, refusesuser=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(orhorizon: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:runevery 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
databasequeue 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.