Why the Default Horizon Config Will Bite You in Production
The horizon.php stub ships with a single default supervisor, auto balance mode, and maxProcesses: 1. That is fine for a demo. In production it means one slow job can stall your entire queue, memory leaks accumulate silently, and you have no visibility into which queue is the bottleneck.
This article walks through the decisions that actually matter.
Supervisor Strategies: Separate Concerns by Queue
Horizon's supervisor config is the most important lever you have. Group queues by their latency contract, not by convenience.
// config/horizon.php
'environments' => [
'production' => [
'supervisor-critical' => [
'connection' => 'redis',
'queue' => ['critical'],
'balance' => 'simple',
'processes' => 10,
'tries' => 3,
'timeout' => 30,
'memory' => 256,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'notifications'],
'balance' => 'auto',
'minProcesses' => 2,
'maxProcesses' => 20,
'balanceMaxShift' => 5,
'balanceCooldown' => 3,
'tries' => 5,
'timeout' => 90,
'memory' => 256,
],
'supervisor-heavy' => [
'connection' => 'redis',
'queue' => ['exports', 'reports'],
'balance' => 'simple',
'processes' => 3,
'tries' => 2,
'timeout' => 600,
'memory' => 512,
],
],
],
simple vs auto balance: simple distributes processes evenly across queues in the list. auto watches queue depth and shifts workers toward the busiest queue. Use auto when queue depths are unpredictable; use simple when you need deterministic allocation.
balanceMaxShift and balanceCooldown prevent thrashing — Horizon will move at most 5 workers per 3-second cycle.
Long-Running Jobs: Timeout, Memory, and the Daemon Loop
The timeout value in Horizon config maps directly to the --timeout flag passed to queue:work. When a job exceeds this, the worker process is killed via SIGKILL — not a graceful exception. Your job must be idempotent.
class GenerateExportJob implements ShouldQueue
{
public int $timeout = 540; // job-level override
public int $tries = 2;
public bool $failOnTimeout = true;
public function handle(): void
{
// Checkpoint progress so a retry can resume
Cache::put("export:{$this->exportId}:cursor", $this->cursor);
// ... heavy work ...
}
public function retryUntil(): \DateTime
{
return now()->addHours(2);
}
}
Set $failOnTimeout = true (Laravel 10+) so the job is marked failed rather than silently retried forever.
Memory Leak Mitigation
Horizon workers are long-lived PHP processes. Every static cache, event listener registered inside a job, or Eloquent model that holds a reference will accumulate.
// In a job that processes many models
public function handle(): void
{
User::query()
->lazyById(200)
->each(function (User $user) {
$this->process($user);
// Detach the model from the identity map
});
// Force GC after heavy work
gc_collect_cycles();
}
Set memory in the supervisor config to a value your server can sustain multiplied by maxProcesses. Horizon will restart a worker that exceeds its memory limit after the current job finishes.
Instrumenting Queues: Events and Metrics
Horizon emits first-class events. Hook into them for custom alerting without polling the dashboard.
// app/Providers/AppServiceProvider.php
use Laravel\Horizon\Events\JobFailed;
use Laravel\Horizon\Events\LongWaitDetected;
public function boot(): void
{
Event::listen(LongWaitDetected::class, function (LongWaitDetected $event) {
Log::warning('Long queue wait', [
'connection' => $event->connection,
'queue' => $event->queue,
'seconds' => $event->seconds,
]);
// Push to your alerting system
});
Event::listen(JobFailed::class, function (JobFailed $event) {
Metrics::increment('queue.job_failed', [
'job' => class_basename($event->payload['displayName']),
]);
});
}
LongWaitDetected fires when a queue's wait time exceeds the threshold set in horizon.waits. Configure it per queue:
'waits' => [
'redis:critical' => 5, // alert after 5 seconds
'redis:default' => 60,
],
Takeaways
- Separate supervisors by latency contract — never mix sub-second and multi-minute jobs in the same supervisor.
autobalance is not free — tunebalanceMaxShiftandbalanceCooldownto prevent worker thrashing.- Set
$failOnTimeout = trueon long-running jobs so failures surface in the failed jobs table. - Memory limits are per-worker, not per-supervisor — size them against your actual job footprint.
- Hook
LongWaitDetectedfor proactive alerting instead of discovering queue backlogs after user complaints. retryUntilbeats$triesfor time-sensitive jobs where absolute deadline matters more than attempt count.