Skip to content

Laravel Horizon in Production: The Queue Setup I Stopped Babysitting

Laravel Horizon in Production: The Queue Setup I Stopped Babysitting

Confession: for about two years I ran Laravel queues the boring way. php artisan queue:work behind supervisord, one config per environment, and a Slack alert when jobs went sideways. It mostly worked. Until it didn’t.

Last winter I got a 3am ping from a client’s app. Their nightly export job had been silently retrying for six hours because a Redis eviction had killed the reserved key mid-run. I logged in, tailed a log, guessed at the state, restarted workers, and went back to bed feeling stupid. That was the week I finally moved every Laravel project I own onto Horizon.

This is what actually changed. Not the marketing tour. The stuff that mattered on a Tuesday when a queue was misbehaving.

What queue:work actually leaves you doing yourself

I don’t want to trash queue:work. It’s fine. But every operational thing I used to bolt on the side, Horizon just does.

A dashboard that shows me current throughput, wait time, and per-queue backlog without SSH. A first-class concept of supervisor processes that scale worker count based on load. Job payload inspection for failed jobs, including tags I set on the job itself. Metrics I can graph without wiring up Prometheus by hand.

The old me had a Grafana panel reading LLEN queues:default from Redis, plus a scrappy Python script that parsed Laravel’s failed_jobs table into Slack notifications. All of that is either gone or three lines of config now.

Here’s the shape of my old worker unit for context:

[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=high,default --sleep=1 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=8

Eight workers, hardcoded. During a big export I was over-provisioned. At 3am I was under-provisioned. Nothing shifted based on the queue.

The Horizon config that actually matters

Install is the usual composer require laravel/horizon then php artisan horizon:install. The interesting part is config/horizon.php. This is the shape I run in production:

'environments' => [
    'production' => [
        'supervisor-fast' => [
            'connection' => 'redis',
            'queue' => ['emails', 'webhooks'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 2,
            'maxProcesses' => 12,
            'memory' => 192,
            'tries' => 3,
            'timeout' => 60,
        ],
        'supervisor-exports' => [
            'connection' => 'redis',
            'queue' => ['exports'],
            'balance' => 'simple',
            'minProcesses' => 1,
            'maxProcesses' => 3,
            'memory' => 512,
            'tries' => 1,
            'timeout' => 1800,
        ],
    ],
],

Two things I got wrong the first time.

balance matters more than the docs make it sound. auto with autoScalingStrategy: time is what I actually want in most apps: workers migrate from a quiet queue to a busy one, and the strategy picks based on estimated time to clear the backlog rather than raw job count. simple splits workers evenly, which I want only for slow queues where I don’t want small jobs starving a big one.

One supervisor per timeout class, not one per queue. I used to define a supervisor per queue. Bad. If your emails need to finish in 60 seconds and your exports need 30 minutes, you want two supervisors. Timeouts are set per-supervisor, not per-job.

Tag your jobs (this is the sleeper feature)

The dashboard’s Failed Jobs tab is fine, but the tags are what changed my life. Every job I ship now gets tagged with the entity it touches:

class SendInvoiceEmail implements ShouldQueue
{
    public function __construct(public Invoice $invoice) {}

    public function tags(): array
    {
        return ['invoice:'.$this->invoice->id, 'user:'.$this->invoice->user_id];
    }

    public function handle(): void
    {
        // send it
    }
}

Now when a customer emails me at 11pm asking why they didn’t get their receipt, I paste their user ID into the Horizon search and see every job ever queued for them, passed or failed. I used to grep production logs for this. I lost hours doing that.

For genuine failure alerts, I wire the JobFailed event into a small notification bus. Horizon’s own notifications config handles wait-time and long-wait alerts. Anything else routes through my normal error stack. If you want the wider picture of how I structure that side of a Laravel app, my Laravel Octane and FrankenPHP notes cover the request side.

Redis is doing the actual work

Worth remembering: Horizon isn’t magic. Everything you see in the dashboard is Redis lists, Redis sorted sets, and a small metrics snapshot table. The queue itself is a Redis list that workers pop off using blocking list operations, moving jobs onto a reserved list until they finish. If a worker dies mid-job, Redis doesn’t recover it for you. The reserved job sits there until a supervisor process reclaims it after its visibility timeout.

Which is why the winter incident I mentioned happened. My Redis eviction policy was allkeys-lru, my instance was memory-pressured, and Redis quietly dropped keys that Horizon expected to still exist. I switched to noeviction on the queue Redis and moved cache to its own instance. That’s not a Horizon lesson, it’s the lesson Horizon forced me to learn about my stack.

Observability I actually use

The built-in metrics view is good, but I still export three things to Grafana via a tiny scheduled command.

Oldest waiting job age per queue. This is my alert signal, not queue depth. Failed job rate over the last hour. Average job runtime per class name, so I can spot regressions when a payload changes and jobs quietly get slower.

You can pull these from Horizon’s Redis metrics directly, or use one of the community Prometheus exporters. If you’re already on Sentry, their queue monitoring product handles the alerting end without the Grafana glue, which is what I set up for my consulting work these days. There’s a running list of that work over on my portfolio if you want to see the shape of it.

When I still reach for raw workers

Two cases.

Very small apps where I don’t want the Horizon dashboard as an operational surface at all. If the whole app is one queued email an hour, queue:work behind a systemd unit is fewer moving parts and one less package to keep patched.

Long-running data pipelines that I’d rather run as a real batch job. Horizon can technically handle 30-minute jobs, but the observability lies to you: a job running for 25 minutes looks the same as a stuck one until it isn’t. For anything I’d call a pipeline I use a separate runner and log to my normal ingestion path instead of the queue.

For the everyday Laravel 12 app with mixed workloads, though, I don’t run raw workers anymore. Horizon paid for itself the day I stopped writing my own dashboards. If you’re catching up on other things that shipped in that release, my Laravel 12 features roundup has the rest.

What to do this week

If you’re already on Laravel 11 or 12, spend a couple of hours doing this.

  1. composer require laravel/horizon, publish the config, set up two supervisors like the shape above. One for short jobs, one for long ones.
  2. Add a tags() method to your five loudest jobs. Search for a real customer ID in the dashboard and see what falls out.
  3. Turn on Horizon’s built-in wait-time notification for at least the queue you care about most.
  4. Change your alert from queue depth > N to oldest waiting job age > N seconds. Depth lies. Age doesn’t.

That’s it. You’ll have caught something in the first week you would’ve missed. And you’ll finally know what your workers are actually doing at 3am, which is the whole point.