Laravel Horizon is a free, first-party dashboard and process manager for Redis queues. It replaces hand written Supervisor configs with one PHP file, shows live throughput, wait times, and failures in a browser, and scales worker processes up and down while your app runs. If your Laravel app pushes work onto Redis, Horizon is how you find out what that work is actually doing.

Here is what install guides skip. A queue that behaves in staging has one property production does not. Nobody is watching it. Jobs stack up quietly, a worker dies at 3am, and you learn about it when a customer asks where their invoice went.

You already know queues move slow work out of the request cycle. The harder half is operating them. This Laravel Horizon tutorial covers the install, the config keys that actually change behaviour, how to read every number on the dashboard, how the balancing strategies decide worker counts, and the two misconfigurations that quietly cause duplicate jobs in production. Every example runs on a standard Laravel app with a Redis connection, nothing exotic.

Key Takeaways

What Laravel Horizon actually does

Horizon does three things. It starts and supervises queue worker processes from a single config file, it records what those workers did so a dashboard can show it, and it changes the number of processes per queue while the application is running.

Without it, a production Laravel queue usually means a Supervisor .conf file with a hard coded numprocs, a long queue:work command, and no visibility past the failed_jobs table. That works. It just cannot tell you that your emails queue has been backed up for 40 minutes.

Horizon compared to a plain queue worker

Concern

queue:work with Supervisor

Laravel Horizon

Worker configuration

Shell flags plus a .conf file per queue

One PHP array in config/horizon.php, versioned with the app

Scaling

Fixed process count you edit and reload by hand

Autoscales per queue between minProcesses and maxProcesses

Visibility

The failed_jobs table and log files

Live dashboard with throughput, runtime, wait time, and payloads

Failure handling

Retry by job id from the CLI

Search by tag, read the stack trace, retry from the browser

Queue drivers

Redis, database, SQS, Beanstalkd

Redis only

Redis is not optional

Horizon stores its metrics in Redis and supervises Redis queue connections only. If your QUEUE_CONNECTION is database or sqs, Horizon has nothing to manage. Switch the connection to redis first, or stay on queue:work with Supervisor. The official Laravel Horizon documentation is explicit about this, and it is the most common reason a first install shows an empty dashboard.

You also need a real Redis server that survives restarts, not an instance somebody started by hand. Redis persistence settings decide whether queued jobs survive a reboot, so check them before you trust the queue with anything a customer paid for.

Queued work is the part of a Laravel app that is easiest to get subtly wrong, and jobs, batches, retries, and their tests are mostly convention. You can generate a production-ready Laravel app from a prompt and still own every line it writes.

Laravel Horizon setup in under 10 minutes

Laravel Horizon setup is short. Install the package, publish its assets, and run the process.

composer require laravel/horizon
php artisan horizon:install
php artisan horizon

The install command publishes config/horizon.php and app/Providers/HorizonServiceProvider.php. The php artisan horizon command replaces queue:work completely. Do not run both, or two supervisors will fight over the same queues. For the generator commands you will reach for alongside Horizon, the php artisan make:model cheatsheet covers the flags worth memorising.

Next, define what each environment runs. This is the file you will edit most.

'environments' => [
    'production' => [
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['high', 'default', 'emails'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 1,
            'maxProcesses' => 12,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
            'tries' => 3,
            'timeout' => 60,
            'memory' => 128,
        ],
    ],
],

One supervisor per workload type is the pattern that ages well. Fast transactional jobs and slow exports have different timeouts and a different appetite for processes, so give them separate supervisors rather than one long queue list.

Locking down the dashboard

The Laravel Horizon dashboard lives at /horizon and is open only in the local environment by default. Production access runs through the viewHorizon gate.

// app/Providers/HorizonServiceProvider.php
protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        return $user->hasRole('ops');
    });
}

A team check, an admin flag, or an IP allow list all work here. What does not work is leaving the gate as published and assuming nobody will guess the URL. The dashboard exposes raw job payloads, which routinely contain customer data.

How to read the Horizon dashboard

Installing Horizon takes minutes. Reading it well is the skill that pays. The horizon dashboard has five screens, and four numbers on them explain almost every incident.

The four numbers that matter

  • Jobs per minute is throughput. Compare it to how fast jobs arrive. If the arrival rate stays higher for more than a few minutes, the backlog only grows.

  • Max wait time is how long the oldest pending job has sat in the queue. This is the number to alert on. A wait time climbing steadily means you are under provisioned or something is blocking.

  • Runtime per job class tells you which job is expensive. A job whose average runtime doubles after a release is usually an N+1 query that slipped through review.

  • Processes shows how many workers Horizon has allocated per queue right now. If it sits pinned at your maximum while wait time climbs, the ceiling is the bottleneck.

Metrics need horizon:snapshot on a schedule. Without it the Metrics tab stays empty and people conclude Horizon is broken.

// routes/console.php on Laravel 11 and 12
Schedule::command('horizon:snapshot')->everyFiveMinutes();

Tags make failures searchable

Add a tags() method to a job and Horizon indexes it. That turns 40,000 completed jobs into something you can query when one customer reports a problem.

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

Failed jobs keep their payload, exception, and stack trace, and they retry from the browser. Retrying blind is a trap though. Read the exception first, because a job that failed on a validation rule will just fail again three times and burn Redis memory doing it.

Balancing strategies and worker autoscaling

The balance key decides how Horizon distributes processes across the queues in a supervisor. There are three values and they behave very differently.

Strategy

Behaviour

Use it when

false

Works queues strictly in the order listed, with one fixed pool

You want simple priority and predictable capacity

simple

Splits processes evenly across every queue in the supervisor

Queues carry similar, steady volume

auto

Adds and removes processes per queue based on current load

Volume is spiky or uneven, which describes most real apps

The auto strategy is the default and the right answer most of the time. It watches each queue and shifts processes toward whichever one needs them, inside the bounds you set.

  • minProcesses is the floor per queue, so a queue never drops to zero workers and stalls.

  • maxProcesses is the ceiling for the entire supervisor across every queue it owns.

  • balanceMaxShift is how many processes Horizon may add or remove on each balancing pass.

  • balanceCooldown is how many seconds it waits between passes.

  • autoScalingStrategy chooses whether scaling reacts to estimated time to clear a queue or to raw job count.

Leave balanceMaxShift at 1 and balanceCooldown at 3 unless you have a specific reason. Aggressive shifts make worker counts oscillate, and process startup costs more than the throughput you gain.

Picking maxProcesses without breaking your database

An agency developer once inherited a config with maxProcesses set to 60, chosen because the queue felt slow. The backlog drained fine. Then the app started throwing connection limit errors during business hours, because 60 workers each held a database connection on top of normal web traffic. They dropped the ceiling to 12, moved slow report exports to their own supervisor, and the queue cleared faster than it had at 60.

Every worker is a PHP process with its own database connection, its own memory, and its own share of CPU. Size maxProcesses against your connection limit and your server memory, not against how impatient you feel about the backlog.

Scaling Redis queues without creating new bugs

The retry_after trap

A team running a subscription billing app set every job timeout to 120 seconds and left retry_after at the default 90. Nothing broke under normal load, because most jobs finished in under a second. Then a payment provider slowed down, one invoice job ran for 100 seconds, and Redis handed the same payload to a second worker at the 90 second mark. Customers received two receipts for one charge. The dashboard showed it plainly once someone looked, the same tag appearing twice within seconds.

The rule is one line. retry_after must be larger than the longest timeout any job on that connection can reach. The Laravel queue documentation spells this out, and it is still the most common source of duplicate side effects in production.

// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 300, // must exceed the longest job timeout
    'block_for' => 5,
],

Memory limits and worker recycling

Long lived PHP processes accumulate memory. Horizon gives you three controls in the supervisor config. memory sets the megabyte limit at which a worker restarts itself, maxJobs restarts it after a set number of jobs, and maxTime restarts it after a set number of seconds. Setting maxTime to 3600 is cheap insurance against a slow leak in a package you do not control.

Give critical work its own lane

A password reset email and a 90 second CSV export should never share a queue. Split by business urgency rather than by feature area. A common shape is three queues, high for anything a user is waiting on, default for normal background work, and long for exports and imports with its own supervisor and a much larger timeout.

Set block_for on the Redis connection too. Without it, idle workers poll Redis in a tight loop. With it, a worker blocks until a job arrives or the timeout passes, which removes a large amount of pointless Redis traffic on quiet queues.

Running Laravel Horizon in production

Horizon supervises your workers. Something still has to supervise Horizon. On a normal server that is Supervisor.

[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/horizon.log
stopwaitsecs=3600

Keep stopwaitsecs above your longest job timeout so Supervisor never kills a job mid flight during a restart.

Deploys

Workers hold your code in memory, so a deploy that does not restart them keeps running the previous release. Add the terminate command to the end of every deploy.

php artisan migrate --force
php artisan horizon:terminate

Horizon finishes the job in flight, exits, and Supervisor starts a fresh process on the new code. If you use one-click deployment, confirm that the queue restart is part of the deploy step rather than something you remember to do by hand.

Alerts and long wait notifications

Horizon can notify you when a queue crosses a wait threshold, which is the alert most teams actually need.

// app/Providers/HorizonServiceProvider.php
Horizon::routeSlackNotificationsTo(config('services.slack.webhook'), '#ops');

// config/horizon.php
'waits' => [
    'redis:default' => 60,
    'redis:long' => 300,
],

Watch Redis memory as well. Horizon trims recent and failed job records on the schedule in its trim config, and the defaults keep failed jobs for a week. On a busy app with large payloads that adds up, so review your Redis configuration and shorten retention when memory gets tight. A distinct HORIZON_PREFIX keeps multiple apps sharing one Redis server out of each other's data.

Queue code is repetitive by nature, which makes it good generation material. The Laravel-native intelligence behind StackMivo writes jobs, batches, and Pest tests using the conventions above instead of generic PHP, so what lands in your repo already matches the rest of the app. Try StackMivo Now and keep everything it produces.

Watch the queue before you scale it

Laravel Horizon turns a queue from something you hope is working into something you can see. Install it, put the dashboard behind a real gate, then spend your attention on the four numbers that matter, throughput, max wait time, runtime per job class, and process count.

The rest is a short checklist. Keep retry_after above every job timeout. Size maxProcesses against your database connection limit rather than your patience. Give urgent work its own queue and its own supervisor. Recycle workers with memory, maxJobs, and maxTime. Run horizon:terminate on every deploy and horizon:snapshot on a schedule.

Start with one supervisor, the auto strategy, and a conservative maximum. Watch it for a week under real traffic before tuning anything. Most queue incidents are not capacity problems at all, they are configuration mismatches that a dashboard would have surfaced on day one. Once you can see the queue, scaling it stops being guesswork and becomes arithmetic.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free