Monitoring Laravel queues in production with Horizon
You shipped a Laravel app with queued jobs. Jobs start failing silently. Workers fall behind. You only find out when a customer complains. Laravel Horizon gives you a real-time dashboard and metrics — but only if you configure it properly.
Setting up Laravel Horizon queue monitoring
Install Horizon and publish its config:
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
This creates config/horizon.php and registers HorizonServiceProvider. Commit both.
Configuring supervisors per queue
The default config runs a single supervisor watching the default queue. In production, separate your queues by workload type so a spike in email sending doesn't starve your critical jobs:
// config/horizon.php
'environments' => [
'production' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'balanceMaxShift' => 1, // max new workers spawned per rebalance cycle
'balanceCooldown' => 3, // seconds between rebalance checks
'tries' => 3,
],
'supervisor-emails' => [
'connection' => 'redis',
'queue' => ['emails'],
'balance' => 'simple', // fixed process count — no auto-scaling
'processes' => 3,
'tries' => 3,
],
],
],
Three balance strategies:
auto— scales workers up and down based on queue depth. Use this on queues with variable load.simple— distributes a fixed process count evenly across queues. Use this where you want predictable throughput.false— processes queues in the listed order with no balancing. Rarely the right choice.
Auto-balancing workers
With balance: auto, Horizon watches queue depth and adjusts worker count within your minProcesses / maxProcesses bounds. Two scaling strategies exist:
'autoScalingStrategy' => 'time', // scale by estimated time to clear the queue (default)
// or
'autoScalingStrategy' => 'size', // scale by number of jobs in the queue
time accounts for job duration — 100 short jobs don't need as many workers as 10 slow ones. It's the better default.
Set balanceMaxShift to 1 in production. Without it, a sudden burst of jobs can cause Horizon to spawn the full maxProcesses count at once, hammering your database before the load normalises.
The Horizon dashboard
Start Horizon with php artisan horizon and hit /horizon. The key widgets to watch:
- Throughput — jobs completed per minute, per queue
- Runtime — average job execution time
- Wait time — how long jobs queue before a worker picks them up
- Failed jobs — searchable list with full stack traces
Wait time is the most actionable metric. If it's climbing, workers can't keep up — increase maxProcesses. If runtime is high, your jobs are doing too much and should be split into smaller units.
Failed jobs include the serialised exception, making them far more useful than digging through storage/logs.
Alerting on failures
Wire up notifications in AppServiceProvider:
use Laravel\Horizon\Horizon;
public function boot(): void
{
Horizon::routeMailNotificationsTo('[email protected]');
Horizon::routeSlackNotificationsTo(
env('HORIZON_SLACK_WEBHOOK'),
'#alerts'
);
}
Horizon triggers these when queue wait time exceeds a configured threshold. Set the threshold in horizon.php:
'waits' => [
'redis:default' => 60, // alert if the default queue wait exceeds 60 seconds
],
This is the single most useful thing you can add. Without it, you're relying on customers to tell you your queue is backed up.
Protecting the Horizon dashboard
By default /horizon is only accessible in the local environment. In production, restrict it by email inside HorizonServiceProvider:
protected function gate(): void
{
Gate::define('viewHorizon', function (User $user) {
return in_array($user->email, config('horizon.allowed_emails', []));
});
}
Add allowed_emails to horizon.php:
'allowed_emails' => explode(',', env('HORIZON_ALLOWED_EMAILS', '')),
Then in .env:
HORIZON_ALLOWED_EMAILS=steven@example.com,ops@example.com
I prefer this over hardcoding emails directly in the gate — it keeps access control out of version control and lets you update it without a deployment.
Running Horizon under Supervisor in production
Don't run php artisan horizon manually — it dies when your SSH session closes. Use Supervisor to keep it alive:
; /etc/supervisor/conf.d/horizon.conf
[program:horizon]
process_name=%(program_name)s
command=php /var/www/html/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/horizon.log
stopwaitsecs=3600
stopwaitsecs=3600 gives running jobs up to an hour to finish before Supervisor force-kills the process. Tune this to your longest expected job runtime.
During deploys, terminate Horizon gracefully rather than killing it:
php artisan horizon:terminate
This signals Horizon to finish its current batch and exit cleanly. Supervisor restarts it with your new code. Kill it directly and you risk jobs that are half-executed with no indication of where they failed.
For maintenance windows where you want to pause processing without a restart:
php artisan horizon:pause # stop accepting new jobs, keep process alive
php artisan horizon:continue # resume
Gotchas and Edge Cases
Redis Cluster is not supported. Horizon assumes a single Redis node or Sentinel setup. If you're on Redis Cluster, you need a different queue monitoring solution.
The horizon Redis connection name is reserved. Don't create a connection named horizon in config/database.php — Horizon uses it internally and you'll get connection conflicts.
Timeout must be less than retry_after. If your supervisor's timeout exceeds the retry_after value in config/queue.php, timed-out jobs get re-queued and executed a second time. Check both values match up.
Environment key matching is exact. Horizon uses APP_ENV to pick the right environments block. 'production' and 'prod' are different keys — if your env is production, you need 'production' => in the config, not 'prod' =>.
horizon:terminate vs horizon:pause. Terminate exits the process (Supervisor restarts it). Pause keeps the process running but stops processing. Use terminate on deploys, pause on maintenance.
Wrapping Up
Install Horizon, split your queues into named supervisors with appropriate balance strategies, wire up Slack alerts on wait time, protect the dashboard with a gate, and run it under Supervisor. Check wait time and throughput for the first few days to establish a baseline — after that, the alerts will tell you when something needs attention.
Full config reference: laravel.com/docs/12.x/horizon.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.