Horizon is a dashboard and code-driven configuration layer for Laravel’s Redis queues. It replaces a queue:work command buried in a supervisor config with a config file in your repository, and it replaces guessing about job throughput with a page that shows it.
The problem it solves is one most Laravel projects acquire quietly. Queues get added for one job, the worker is started with a command line somebody wrote once, and six months later nobody knows how many workers are running, which queues they cover, or how many jobs failed last week. Horizon makes all of that visible and puts the configuration under version control, which is where it belonged.
Table of contents
- What it actually gives you
- Installing and configuring
- The three balancing strategies
- Running it in production
- Securing the dashboard
- Whether you need it
- How this fits the rest of the stack
- FAQ
What it actually gives you
- A dashboard showing job throughput, runtime, and failures, with a recent-jobs list and a failed-jobs list you can retry from.
- Code-driven worker configuration. Supervisors, queues, and process counts defined in
config/horizon.phpand committed rather than living in a server-side supervisor file. - Automatic worker balancing across queues, so a busy queue gets more processes without you rebalancing by hand.
- Metrics over time — jobs per minute, average runtime per job class — retained as snapshots.
- Tags on jobs, so you can filter to everything relating to one user or one order.
- Failure notifications when a queue’s wait time exceeds a threshold.
The one that changes day-to-day work most is the failed-jobs view. Without Horizon, a failed job is a row in the failed_jobs table that somebody has to know to query. With it, failures are on a page with the exception, the payload, and a retry button. That difference is the gap between having queues and operating them.
Two constraints to know up front: Horizon requires Redis as the queue driver, and it requires the PHP process control extension. Neither is exotic, but a project on a database queue driver has to move first.
Installing and configuring
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
horizon:install publishes the config and assets. php artisan horizon starts the master process, which supervises the workers described in your config.
The config is where the thinking happens:
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'tries' => 3,
'timeout' => 60,
],
],
'local' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'maxProcesses' => 3,
],
],
],
Queue order matters. Listed as ['high', 'default', 'low'], a worker drains high completely before touching default. That is priority, and it is what you want for anything user-facing competing with bulk work — but it also means a permanently busy high queue starves the others entirely.
Separate supervisors are the answer when starvation is a risk. One supervisor dedicated to high with its own processes, another covering the rest, means the slow bulk queue always has workers even during a spike.
The three balancing strategies
This is Horizon’s most distinctive feature and the one worth understanding rather than leaving at the default.
auto— Horizon monitors queue depth and shifts processes between queues, scaling up tomaxProcesseswhen work piles up and back down when it clears. The right default for most applications.simple— processes split evenly across the listed queues, fixed. Predictable, and it does not react to load.false— no balancing. Workers process the queues in the listed order, exactly like plainqueue:work.
With auto, balanceMaxShift and balanceCooldown control how aggressively it moves. The default cooldown prevents thrashing, and raising the max shift makes it respond faster to a sudden burst at the cost of more churn.
Set minProcesses above zero on any queue that must respond immediately. With a floor of zero, a queue that has been idle has no workers, and the first job after a quiet period waits for the balancer to notice — which turns into a latency complaint that is hard to reproduce.
maxProcesses needs an eye on your database. Ten workers each holding a connection, times however many application instances, adds up against the database’s connection limit faster than people expect.
Running it in production
php artisan horizon is a long-running process. Something has to keep it alive.
[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
user=www-data
stopwaitsecs=3600
stopwaitsecs=3600 is the important line and it is easy to miss. It gives Horizon an hour to finish in-flight jobs before being killed. Without it, a deploy kills workers mid-job, and jobs that are not idempotent get half-applied.
The deploy sequence has its own required step:
php artisan horizon:terminate
This tells Horizon to finish current jobs and shut down gracefully, so the supervisor restarts it with the new code. Skip it and your workers keep running the previous release indefinitely — a genuinely confusing bug where the web tier is on the new version and background jobs are on the old one, producing behaviour that matches neither.
Other commands worth knowing: horizon:pause and horizon:continue to stop and resume processing without stopping the process, and horizon:clear to purge a queue — with --queue=emails to target one.
Securing the dashboard
The dashboard shows job payloads. Payloads frequently contain email addresses, user IDs, order details, and occasionally tokens. It is accessible at /horizon and by default only in the local environment.
In production it is gated by a gate you define, in HorizonServiceProvider:
protected function gate(): void
{
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, [
'ops@example.com',
]);
});
}
Do not skip this. A publicly reachable Horizon dashboard is an information disclosure and, since it can retry and delete jobs, a way to affect your system’s behaviour. Checking that /horizon returns a 403 for an anonymous request should be part of any deployment checklist.
Snapshot retention is worth configuring too. Horizon trims job and queue metrics to a set number of snapshots, and the defaults are reasonable — the point is to be aware Redis is holding this data and it is not free.
Whether you need it
Honestly: not always.
Horizon requires Redis, adds a dashboard to secure, and puts another long-running process under supervision. On a project running three jobs a day, queue:work in a supervisor config with an occasional look at failed_jobs is adequate and simpler.
It earns its place when any of these are true:
- Multiple queues with genuinely different priorities.
- Enough job volume that throughput and wait time are questions you ask.
- Failures you want visible rather than discovered.
- More than one person operating the system, so configuration in the repository beats configuration on a server.
- Load that varies, where automatic balancing beats a fixed worker count.
One thing worth doing regardless of Horizon: make jobs idempotent. Running the same job twice should be harmless. Queues retry, workers die mid-job, and deploys interrupt things. A job that sends an email twice or charges a card twice under retry is a bug that no dashboard prevents — it just makes it easier to see afterwards.
How this fits the rest of the stack
Horizon’s deployment step — terminating workers so they restart on the new release — is a reminder that background workers are a second deployable that has to move with the web tier, not after it. That is much easier when both come from the same repository and the same build, with logs attached to the deploy and the previous version available to roll back to. Services on RunxBuild deploy from GitHub with build logs, a live route, environment variables, metrics, and rollback, and a managed Redis is not something we offer — but the PHP application and its managed MySQL or Postgres are. Deploying from GitHub on RunxBuild covers the connection, and the RunxBuild hosting calculator prices the web service, a separate worker service, the database, and bandwidth as individual lines.
Useful related references:
- Redis Default Port: Why 6379, and the Three Other Ports a Developer Should Know
- Redis Persistence: RDB, AOF, and the Right Defaults
- PHP Composer: Dependency Management, and the Two Files That Matter
- Services on RunxBuild
FAQ
What does Laravel Horizon do?
It provides a dashboard and code-driven configuration for Laravel’s Redis queues — job throughput and runtime metrics, a failed-jobs view with retry, tags for filtering, and automatic worker balancing across queues. Worker configuration lives in config/horizon.php rather than in a server-side supervisor file.
Does Horizon require Redis?
Yes. Horizon only works with the Redis queue connection, and it also needs the PHP process control extension. A project using the database queue driver has to move to Redis before Horizon is an option.
What is the difference between the auto, simple and false balancing strategies?
auto shifts worker processes between queues based on queue depth, scaling within your min and max process settings. simple splits processes evenly across queues and does not react to load. false disables balancing so workers process queues strictly in the listed order.
Why do my queue workers keep running old code after a deploy?
You need php artisan horizon:terminate as part of the deploy. It tells Horizon to finish in-flight jobs and exit so the supervisor restarts it with the new release. Without it, workers keep the previously loaded code indefinitely while the web tier serves the new version.
Is the Horizon dashboard secure by default?
Only in the local environment. In production you must define a viewHorizon gate in HorizonServiceProvider restricting access. The dashboard exposes job payloads and can retry or delete jobs, so verifying that /horizon returns 403 anonymously belongs on your deployment checklist.