Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 027 / 100

Laravel Horizon: PHP & Laravel best practices explained

A dashboard for Redis queues.

Expona Admin8 min read

Introduction

Redis queues power millions of background jobs across the web, but without visibility, they become a black box. Laravel Horizon: PHP & Laravel best practices explained gives you real-time insight into what's actually happening with your queued work—how fast jobs process, where bottlenecks live, and which ones fail. In this guide, we'll walk through setting up and operating Horizon to keep your queue infrastructure healthy and predictable.

What is Laravel Horizon?

Horizon is a beautifully designed dashboard and code-driven supervisor for Redis queues in Laravel. Rather than writing shell scripts or systemd units to manage your queue workers, Horizon lets you define supervisor configurations directly in PHP, then monitor everything through a web interface. It's the bridge between your application's background work and operational visibility.

Think of it as having an always-on operations person watching your queues, tracking metrics, and alerting you when something goes wrong. Without it, failed jobs silently pile up, workers crash unnoticed, and you only discover problems when customers complain about missing notifications or incomplete reports.

Monitor Throughput and Wait Times

The first value Horizon delivers is visibility. From the dashboard, you see exactly how many jobs your system processes per minute, how long they sit in the queue before a worker picks them up, and how long they take to complete.

Install Horizon via Composer and publish its assets:

php
composer require laravel/horizon
php artisan horizon:install

Visit /horizon on your application and you'll see real-time metrics. The "Jobs" card shows throughput—how many jobs per minute are being processed. The "Processes" card displays how many worker processes are active. These numbers matter because they tell you whether your queue capacity matches demand.

In a real scenario: your email queue normally processes 500 jobs/minute, but during a marketing campaign, it spikes to 3,000/minute. The dashboard immediately shows jobs backing up. Wait time climbs from milliseconds to seconds. You see this happening before users notice delayed emails, giving you time to scale workers.

Horizon stores per-queue metrics, so you can monitor different queues independently. If your email queue is healthy but your image-processing queue is drowning, you know exactly where to allocate resources.

Balance Workers Across Queues

Not all jobs are equal. Some queues handle high-volume, low-effort work (sending emails), while others process fewer but heavier tasks (generating reports). Horizon lets you configure how many workers handle each queue, and it even supports dynamic scaling based on load.

Configuration lives in config/horizon.php. After running horizon:install, you'll find this file. Here's a typical multi-queue setup:

php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['emails', 'notifications'],
            'balance' => 'simple',
            'processes' => 10,
            'tries' => 3,
        ],
        'supervisor-2' => [
            'connection' => 'redis',
            'queue' => ['reports', 'exports'],
            'balance' => 'auto',
            'processes' => 4,
            'tries' => 1,
            'maxTime' => 3600,
        ],
    ],
]

In this config, supervisor-1 handles emails and notifications with 10 worker processes. These are fast, idempotent jobs, so retrying them three times is safe. Supervisor-2 handles reports and exports with only 4 processes—these are slower and longer-running, so we increase maxTime to 3600 seconds and reduce retry attempts to avoid duplicate reports.

The balance option controls how workers distribute across queues. simple means each queue gets equal attention in round-robin fashion. auto means workers prioritize whichever queue has the most jobs waiting. For the emails queue, simple balance works because both queues have similar throughput. For reports, auto balance ensures we don't starve that queue when notifications spike.

You can also use the balanceMaxShift and balanceCooldown options to prevent workers thrashing between queues due to temporary spikes:

php
'supervisor-2' => [
    'connection' => 'redis',
    'queue' => ['reports', 'exports'],
    'balance' => 'auto',
    'processes' => 4,
    'balanceMaxShift' => '3',
    'balanceCooldown' => 3,
]

This tells Horizon to shift at most 3 processes between queues and to wait 3 seconds between shifts, preventing jittery behavior.

See Failed Jobs and Retry Them

Jobs fail. Networks timeout, APIs return errors, databases lock up. Horizon captures every failure and stores it for inspection. From the dashboard, click "Failed Jobs" and you see a list: the job class, the queue, the exception message, and when it failed.

More importantly, Horizon lets you retry failed jobs directly from the UI. You don't need to craft artisan commands or manually query Redis. Select the jobs you want to retry and click "Retry"—Horizon pushes them back onto the queue.

This is invaluable during incidents. If your payment processor went down and 500 charge jobs failed, you wait for the provider to recover, then click one button in Horizon to retry all 500. No manual intervention, no risk of missing any.

Behind the scenes, Horizon uses Redis to store failed job data. When a job's retry count exceeds the supervisor's tries limit, it moves to the failed table. You can configure how long Horizon retains this data:

php
'trim' => [
    'recent' => 60,
    'pending' => 60,
    'completed' => 60,
    'failed' => 10080, // 7 days for failed jobs
],

Keep failed jobs longer than completed ones. You'll want to investigate failures hours or days later, but you don't need to review every successful job from a week ago.

For jobs that consistently fail, you can set up failure callbacks. In your job class:

php
class ProcessPayment implements ShouldQueue
{
    public function failed(Throwable $exception)
    {
        Log::error('Payment processing failed', [
            'exception' => $exception->getMessage(),
            'job_id' => $this->job->getJobId(),
        ]);
        
        // Alert ops team
        Notification::send(OpsteamChannel::class, 
            new JobFailedAlert($exception)
        );
    }
}

Now every time this job fails, your ops team is notified immediately, not hours later when someone reviews the dashboard.

Configure Supervisors in Code

Before Horizon, managing queue workers meant writing systemd unit files or supervisor configuration files in YAML—versions that had to be deployed separately from your application code. If you wanted to add a new queue or adjust worker counts, you edited a config file, redeployed, and restarted services. It was error-prone and disconnected from your app.

Horizon inverts this. Your supervisor configuration lives in PHP, versioned alongside your code. When you deploy, Horizon reads the config and adjusts worker pools automatically. No manual restart needed.

Here's a more complete production config:

php
'environments' => [
    'production' => [
        'supervisor-high-priority' => [
            'connection' => 'redis',
            'queue' => ['critical', 'urgent'],
            'balance' => 'simple',
            'processes' => 8,
            'tries' => 5,
            'timeout' => 60,
            'sleep' => 1,
        ],
        'supervisor-standard' => [
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'auto',
            'processes' => 15,
            'tries' => 3,
            'timeout' => 90,
            'sleep' => 3,
        ],
        'supervisor-long-running' => [
            'connection' => 'redis',
            'queue' => ['exports', 'reports', 'backups'],
            'balance' => 'auto',
            'processes' => 2,
            'tries' => 1,
            'timeout' => 3600,
            'sleep' => 10,
            'maxTime' => 3600,
        ],
    ],
],

Notice the sleep parameter. It controls how long a worker waits after checking an empty queue before checking again. High-priority queues sleep 1 second (responsive), standard ones sleep 3 seconds (balanced), and long-running ones sleep 10 seconds (reducing Redis chatter for slow jobs). This simple parameter tunes CPU and Redis load without touching supervisor code.

When you deploy this config, Horizon detects changes and adjusts the supervisor pools. Workers are gracefully stopped and restarted with the new configuration. No manual intervention.

Common Mistakes

Setting tries too high: If a job consistently fails—say, because it depends on external data that doesn't exist—retrying it 10 times won't help. It wastes workers and delays other jobs. Use tries to handle transient failures (network hiccups), not logic errors. For jobs that genuinely might fail, set tries to 2 or 3.

Ignoring failed job callbacks: Without failure notifications, failed jobs pile up silently until someone notices. Always implement failed() methods and integrate them with your alerting system (PagerDuty, Slack, email). Silent failures are the worst failures.

Not tuning worker count per queue: Allocating the same number of workers to every queue ignores reality. Email jobs are fast; report generation is slow. Over-allocating to reports starves email. Under-allocating causes backlog. Use Horizon's metrics to guide allocation, then adjust based on real load patterns.

Forgetting to run horizon:install in production: The dashboard won't work if you skip the install step. Make sure your deployment process includes php artisan horizon:install and that the config file is published.

Setting maxTime too low: maxTime tells a worker to exit after running that many seconds. If you set it to 600 (10 minutes) but you have jobs taking 45 minutes, they'll be killed mid-execution. Balance between preventing memory leaks (workers recycled periodically) and allowing long jobs to finish.

Key Takeaways

  • Horizon provides real-time visibility into Redis queue metrics—throughput, wait times, and job counts—helping you spot bottlenecks before they affect users.
  • Configure multiple supervisors for different queue types, adjusting worker count, retry behavior, and timeout based on job characteristics.
  • Use the balance option to distribute workers intelligently: simple for similar queues, auto for variable load.
  • Capture and monitor failed jobs through the UI and implement failure callbacks to alert your team immediately.
  • Keep supervisor configuration in code, versioned with your application, so queue changes deploy seamlessly without manual restarts.
  • Tune the sleep parameter per supervisor to balance responsiveness and resource usage.
  • Implement proper tries and maxTime` settings to handle both transient failures and long-running work.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles