Laravel Horizon in Production: Beyond the Defaults
Most teams install Horizon, push the default config, and move on. That works until traffic spikes, jobs pile up, and the dashboard turns red. This article focuses on the levers that actually matter: supervisor balancing strategies, metric-driven scaling decisions, and graceful shutdown patterns that prevent job loss.
Supervisor Configuration That Reflects Reality
Horizon's config/horizon.php ships with a single supervisor block. In production you almost always need multiple supervisors — one per logical job class or priority tier.
'production' => [
'supervisor-critical' => [
'connection' => 'redis',
'queue' => ['critical'],
'balance' => 'auto',
'minProcesses' => 2,
'maxProcesses' => 10,
'tries' => 3,
'timeout' => 30,
'nice' => 0,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'notifications'],
'balance' => 'simple',
'processes' => 4,
'tries' => 5,
'timeout' => 90,
'nice' => 10, // lower OS priority than critical
],
'supervisor-bulk' => [
'connection' => 'redis',
'queue' => ['bulk-exports'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 3,
'balanceMaxShift' => 1,
'balanceCooldown' => 5,
'timeout' => 600,
'nice' => 15,
],
],
Key decisions here:
balance => autolets Horizon shift processes between queues in the supervisor dynamically. Use it when queue depths fluctuate.balanceMaxShiftlimits how many processes can be added or removed per rebalance cycle — prevents thrashing.nicemaps to the Unix process priority. Bulk jobs should yield CPU to critical ones.- Separate
timeoutper supervisor. A 600-second timeout on the critical supervisor would mask hung jobs.
Reading Horizon Metrics Without Guessing
Horizon stores rolling throughput and runtime snapshots in Redis. You can query them programmatically:
use Laravel\Horizon\Contracts\MetricsRepository;
$metrics = app(MetricsRepository::class);
$throughput = $metrics->throughputForQueue('default'); // jobs/min
$runtime = $metrics->runtimeForQueue('default'); // avg ms
Pipe these into your observability stack (Datadog, Prometheus via a custom exporter, or even a simple scheduled command that writes to a log channel). Alert when throughput drops below your baseline or runtime climbs above your SLA threshold — not when the queue depth crosses an arbitrary number.
Handling Backpressure Without Losing Jobs
When producers outpace consumers, the naive fix is "add more workers." The better fix is to model backpressure explicitly.
// In a high-frequency dispatch path
use Illuminate\Support\Facades\Redis;
$depth = Redis::llen('queues:default');
if ($depth > 5000) {
// Slow the producer, not just scale the consumer
sleep(1); // or return a 429 to the upstream caller
}
Dispatch::dispatch(new ProcessWebhook($payload));
For HTTP-triggered jobs, returning a 202 Accepted with a Retry-After header is more honest than silently queuing millions of jobs that will take hours to drain.
Graceful Shutdown: The Part Everyone Gets Wrong
Horizon sends SIGTERM to workers when you deploy. Workers finish their current job and exit — but only if your jobs respect the signal. Long-running jobs that ignore termination signals will be killed mid-execution.
class ProcessLargeExport implements ShouldQueue
{
public function handle(): void
{
foreach ($this->chunks() as $chunk) {
if (app('queue.worker')->shouldQuit) {
// Checkpoint state, re-dispatch remainder
ProcessLargeExport::dispatch($this->remainingChunks());
return;
}
$this->processChunk($chunk);
}
}
}
Set HORIZON_TERMINATE_WAIT (the --stop-when-empty equivalent) in your deploy script to give workers time to finish:
php artisan horizon:terminate
sleep 30 # match your longest expected job runtime
supervisorctl start horizon
Takeaways
- Split supervisors by job priority and runtime profile, not just queue name.
- Use
balanceMaxShiftandbalanceCooldownto prevent process thrashing underautobalance. - Export Horizon metrics to your observability stack; alert on throughput and runtime, not raw depth.
- Model backpressure at the producer — slowing dispatch is safer than unbounded queue growth.
- Make long-running jobs checkpoint-aware so
SIGTERMresults in a safe re-dispatch, not data loss.