Laravel · Laravel

Laravel Horizon Monitoring Patterns

How Laravel Horizon's dashboard, metrics, and alerting work for Redis-backed queues, and the configuration patterns that keep queue monitoring useful once a job backlog actually happens.

John Kihiu12 min read

Laravel's default queue worker gives you almost no visibility: is a job stuck, is one queue backed up while another sits idle, which job type is actually slow. Horizon replaces that blind spot with a dashboard and a supervisor configuration for Redis-backed queues specifically — it doesn't work with the database or SQS queue drivers, which is the first thing to check before reaching for it.

What Horizon actually is: a supervisor plus a dashboard

Horizon is two things bundled together: a configuration-driven process supervisor that manages your queue:work processes (starting, balancing, and restarting workers per queue), and a dashboard that reads job metrics out of Redis to show throughput, wait times, and failed jobs in something readable, instead of grepping worker logs. It replaces hand-rolled Supervisor/systemd configs for queue workers with a single config/horizon.php file that defines how many processes run per queue and how they should balance.

PHP · config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['high', 'default'],
            'balance' => 'auto',
            'minProcesses' => 2,
            'maxProcesses' => 12,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
            'tries' => 3,
        ],
    ],
],

Auto-balancing: what it does and doesn't solve

'balance' => 'auto' shifts worker processes between queues based on each queue's current wait time — if the high queue starts backing up, Horizon reallocates processes from default toward it, within the min/max bounds you set. This solves the common problem of a fixed worker count per queue leaving one queue idle while another falls behind, but it doesn't fix a queue that's slow because individual jobs are slow (an external API call with a long timeout, for example) — auto-balancing shifts capacity, it doesn't make any single job execute faster.

A stuck job looks identical to a slow burst from the dashboard alone

Horizon's dashboard shows wait time and throughput, but distinguishing "temporarily busy, will clear" from "a job is hung and blocking the worker slot" requires looking at job duration, not just queue depth. Set a sensible --timeout per worker and pair it with the retryAfter on the job class — without both, a genuinely stuck job (waiting on a dead external connection, for instance) occupies a worker slot indefinitely while Horizon's dashboard just shows normal-looking throughput for everything else.

Failed jobs, retries, and the dashboard's actual value

Horizon's failed-jobs view is the practical reason most teams adopt it: a searchable list of failed jobs with the full exception and stack trace, plus one-click retry, instead of querying the failed_jobs table by hand. Combined with tries and backoff on the job class, this is where you actually learn whether a job type is failing intermittently (network blip, safe to retry) or consistently (a bug, retrying won't help and just wastes worker time).

PHP · app/Jobs/SyncInventory.php
class SyncInventory implements ShouldQueue
{
    public $tries = 3;
    public $backoff = [10, 60, 300];
    public $timeout = 120;

    public function failed(Throwable $exception): void
    {
        Log::error('Inventory sync failed permanently', [
            'sku' => $this->sku,
            'error' => $exception->getMessage(),
        ]);
    }
}

Metrics snapshots and wiring up alerts

Horizon periodically snapshots job and queue metrics (via a scheduled horizon:snapshot command you need to add to your scheduler) that back the dashboard's throughput graphs. Horizon itself doesn't send alerts — it has no built-in Slack or PagerDuty integration — so production setups typically pair it with a scheduled check against Horizon::getQueueWait() or similar, firing a notification when wait time crosses a threshold, rather than relying on someone noticing the dashboard.

Wrapping up

Horizon's real value is turning "is the queue okay" from a question you answer by tailing worker logs into one you answer by looking at a dashboard — but it's specifically a Redis-queue tool, its auto-balancing solves queue-starvation problems rather than slow-job problems, and it has no alerting of its own. Add the horizon:snapshot scheduled command and your own threshold-based alert on top, or the dashboard is only useful to whoever remembers to open it.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.