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
autobalancing with conservativebalanceMaxShiftandbalanceCooldownvalues. - Set
stopwaitsecsin Supervisor to at least your longest expected job runtime. - Always call
horizon:terminatebefore restarting — neversupervisorctl restartcold. - Keep Horizon supervisor
timeoutslightly above the job-level$timeout. - Instrument throughput, runtime, and failure rate; queue depth alone is not enough.
- Separate high-priority work into a dedicated
criticalqueue with its own supervisor block.