# 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.

Source: https://hanoo.io/blog/laravel-queue-workers-production · Published 2026-11-09 · Updated 2026-11-09 · 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](/blog/deploy-laravel-13-production-ubuntu) leaves off.

> **In short:** run `php artisan queue:work` under Supervisor as the application's user, with `--max-time` so workers recycle. Keep `--timeout` several seconds shorter than `retry_after`, and keep Supervisor's `stopwaitsecs` longer than your longest job. Run `queue:restart` after every deploy. Prune failed jobs, and watch queue length with `queue:monitor`.

## 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](/blog/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:

```bash
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:

```bash
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:

| 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:

> **longest job < `--timeout` < `retry_after` < `stopwaitsecs`**

- 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:

```bash
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:

```bash
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:

```bash
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](https://laravel.com/docs/13.x/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.

```bash
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.
