Laravel Queues: Horizon Metrics, Supervisor Tuning, and Reliable Job Throughput
#laravel #queues #horizon #devops #performance

Laravel Queues: Horizon Metrics, Supervisor Tuning, and Reliable Job Throughput

3 min read Mohamed Said Mohamed Said

Beyond php artisan queue:work: Running Queues Seriously in Production

Most Laravel developers know how to push a job and run a worker. Far fewer have thought carefully about how many workers to run, which balancing strategy to use, or what happens when a deploy kills a long-running job mid-execution. This article covers the practical decisions that separate a fragile queue setup from a production-grade one.


Horizon Balancing Strategies

Horizon ships with three balancing strategies: simple, auto, and false (manual). The choice has real consequences.

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['critical', 'default', 'low'],
            'balance' => 'auto',
            'minProcesses' => 2,
            'maxProcesses' => 20,
            'balanceMaxShift' => 3,
            'balanceCooldown' => 3,
            'tries' => 3,
            'timeout' => 90,
        ],
    ],
],

auto measures throughput per queue and redistributes workers every balanceCooldown seconds, shifting at most balanceMaxShift processes at a time. This is the right default for mixed-priority workloads.

simple divides workers evenly across queues — predictable but blind to actual queue depth.

Keep balanceMaxShift low (2–3) to avoid thrashing. A sudden spike that shifts 10 workers at once starves other queues for the cooldown window.


Supervisor: Zero-Downtime Deploys

Horizon itself is managed by Supervisor. The critical piece most teams miss is graceful termination on deploy.

; /etc/supervisor/conf.d/horizon.conf
[program:horizon]
process_name=%(program_name)s
command=php /var/www/artisan horizon
autostart=true
autorestart=true
stopwaitsecs=3600
stopsignal=SIGTERM
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/horizon.log

stopwaitsecs=3600 tells Supervisor to wait up to one hour for Horizon to finish in-flight jobs before force-killing. Without this, a deploy during a long job causes a silent partial execution.

In your deploy script:

php artisan horizon:terminate
sudo supervisorctl restart horizon

horizon:terminate signals Horizon to stop accepting new jobs and wait for current workers to finish. Supervisor then restarts the process cleanly with the new code.


Job Timeouts and the SIGKILL Problem

PHP's pcntl extension allows Horizon to send SIGTERM to a worker when its timeout is exceeded. The worker can catch this and clean up. But if timeout in horizon.php is shorter than the job's own $timeout property, the worker is killed before the job can handle it.

class GenerateReport implements ShouldQueue
{
    public int $timeout = 120; // job-level
    public int $tries = 2;

    public function handle(): void
    {
        // long-running work
    }

    public function failed(Throwable $e): void
    {
        // cleanup, notify, etc.
    }
}

Rule: the Horizon supervisor timeout should always be slightly higher than the longest job $timeout in that queue. A 10-second buffer is sufficient.


Observability: Metrics That Matter

Horizon exposes a /horizon/api/stats endpoint and stores metrics in Redis. Expose these to your monitoring stack:

// In a scheduled command or Prometheus exporter
$stats = app(\Laravel\Horizon\Contracts\MetricsRepository::class);

$throughput = $stats->throughput(); // jobs/min across all queues
$runtime = $stats->runtimeForQueue('default'); // avg ms
$failRate = $stats->failedJobsPerMinute();

Alert on:

  • Queue depth growing faster than throughput (workers undersized)
  • Average runtime increasing over time (memory leak or slow dependency)
  • Failed jobs per minute spiking (upstream service degradation)

Practical Takeaways

  • Use auto balancing with conservative balanceMaxShift and balanceCooldown values.
  • Set stopwaitsecs in Supervisor to at least your longest expected job runtime.
  • Always call horizon:terminate before restarting — never supervisorctl restart cold.
  • Keep Horizon supervisor timeout slightly above the job-level $timeout.
  • Instrument throughput, runtime, and failure rate; queue depth alone is not enough.
  • Separate high-priority work into a dedicated critical queue with its own supervisor block.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between Horizon's `auto` and `simple` balancing strategies?
`auto` dynamically redistributes workers based on measured queue throughput, shifting processes toward busier queues within the configured cooldown window. `simple` divides workers evenly across all queues regardless of actual depth, which is predictable but inefficient under uneven load.
Q02 Why does `stopwaitsecs` in Supervisor matter for Laravel Horizon?
Without a sufficient `stopwaitsecs`, Supervisor force-kills the Horizon process before in-flight jobs finish when you restart during a deploy. Setting it to at least your longest expected job duration gives `horizon:terminate` time to drain workers gracefully.
Q03 How do I prevent jobs from being silently killed mid-execution on deploy?
Run `php artisan horizon:terminate` in your deploy script before restarting Supervisor. This signals Horizon to stop accepting new jobs and wait for current workers to complete, then Supervisor restarts the process with fresh code.

Continue reading

More Articles

View all