Laravel Horizon in Production: Beyond the Pretty Dashboard
Horizon ships with a slick UI, but the real value for a senior engineer is in its programmatic metrics API, fine-grained supervisor configuration, and the ability to drain workers cleanly during deployments. This article focuses on those three areas with concrete examples.
Querying the Metrics API
Horizon stores throughput and runtime snapshots in Redis. You can read them directly via the Horizon\Contracts\MetricsRepository binding rather than scraping the UI.
use Laravel\Horizon\Contracts\MetricsRepository;
$metrics = app(MetricsRepository::class);
// Throughput (jobs/min) for the last recorded snapshot
$throughput = $metrics->throughput();
// Runtime in milliseconds for a specific job class
$runtime = $metrics->runtimeForJob(ProcessInvoice::class);
// All measured queues
$queues = $metrics->measuredQueues();
This is useful for custom alerting: pipe $throughput into a Prometheus pushgateway or a Slack webhook when it drops below a threshold, without depending on a third-party APM.
Supervisor Configuration for Mixed Workloads
A single supervisor with a high maxProcesses count is the most common Horizon misconfiguration. Different job classes have wildly different runtimes and resource profiles. Separate them.
// config/horizon.php
'environments' => [
'production' => [
'supervisor-critical' => [
'connection' => 'redis',
'queue' => ['critical'],
'balance' => 'auto',
'minProcesses' => 2,
'maxProcesses' => 10,
'tries' => 3,
'timeout' => 30,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'simple',
'processes' => 4,
'tries' => 3,
'timeout' => 90,
],
'supervisor-heavy' => [
'connection' => 'redis',
'queue' => ['exports', 'reports'],
'balance' => 'simple',
'processes' => 2,
'tries' => 1,
'timeout' => 600,
],
],
],
Balance Strategies
simple— distributes processes evenly across queues in the list. Good when queue depths are predictable.auto— scales processes toward queues with the longest wait time. Use this forcriticalwhere latency matters.false— no balancing; each process works the queues in order. Rarely correct in production.
Set balanceCooldown (seconds) when using auto to prevent thrashing:
'balanceCooldown' => 3,
'minProcesses' => 1,
'maxProcesses' => 15,
Safe Deployments Without Dropping Jobs
The naive php artisan horizon:terminate in a deploy script kills workers immediately, potentially mid-job. The correct sequence:
# 1. Tell Horizon to finish current jobs then exit
php artisan horizon:pause
# 2. Run your deployment steps (composer, migrations, asset build)
# ...
# 3. Restart Horizon — supervisor (systemd/Forge) brings it back
php artisan horizon:terminate
horizon:pause stops workers from picking up new jobs but lets in-flight jobs complete. horizon:terminate then sends SIGTERM to the master process, which waits for workers to finish before exiting.
Systemd Unit Example
[Unit]
Description=Laravel Horizon
After=network.target
[Service]
User=forge
ExecStart=/usr/bin/php /var/www/app/artisan horizon
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=3600
[Install]
WantedBy=multi-user.target
Setting TimeoutStopSec=3600 gives long-running export jobs up to an hour to finish before systemd force-kills the process. Tune this to your longest expected job runtime.
Horizon in Docker / Kubernetes
In a container environment, trap SIGTERM and delegate to Horizon's own signal handler:
CMD ["php", "artisan", "horizon"]
Kubernetes sends SIGTERM on pod termination. Horizon's master process catches it and begins a graceful shutdown. Set terminationGracePeriodSeconds in your pod spec to match TimeoutStopSec.
Takeaways
- Use
MetricsRepositorydirectly for custom alerting instead of polling the UI. - Separate supervisors by job profile (latency-sensitive, long-running, bulk) rather than one fat supervisor.
- Use
balance => autowith a cooldown for variable-load queues; usesimplefor predictable ones. - Always
horizon:pausebefore deploying, thenhorizon:terminateafter assets are in place. - Match
TimeoutStopSec/terminationGracePeriodSecondsto your longest job timeout to avoid mid-job kills.