ralphjsmit/laravel-horizon-cron-supervisor
Run Laravel's scheduled tasks through Horizon instead of system cron. Your schedule is dispatched as Horizon jobs, so it's supervised, monitored, and retried automatically and visible on the Horizon dashboard.
This package is for teams already running Horizon on Redis who want to keep queue workers alive with cron instead of Supervisor/systemd. You declare when each Horizon supervisor should be active, and a cron-driven command reconciles the running Horizon processes with that schedule. The practical first use case is splitting queues: keep a small always-on supervisor for critical jobs, and schedule a bulk/reporting supervisor to run only during business hours.
Minimal path:
php artisan horizon manually, confirm the dashboard is reachable and a test job processes. Do not debug Horizon and the cron supervisor at the same time.composer require ralphjsmit/laravel-horizon-cron-supervisor
Pin the version in composer.json. This is small, single-maintainer glue on the job-processing path.config/horizon.php. The exact key is documented by the package; the shape is typically a cron expression on each supervisor entry:
'supervisors' => [
'default' => [
'connection' => 'redis',
'queue' => ['default', 'notifications'],
'balance' => 'auto',
'processes' => 2,
'cron' => '* * * * *', // always eligible
],
'reports' => [
'connection' => 'redis',
'queue' => ['reports', 'exports'],
'balance' => 'simple',
'processes' => 1,
'cron' => '0 8-18 * * 1-5', // weekdays, 08:00–18:59
],
],
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
If the README gives a direct artisan command, call that every minute instead. Do not run it as root or as the web user unless that is the same user Horizon runs as.php artisan config:cache, terminate any existing Horizon master, and let the next cron tick start workers.php artisan horizon:status, the Horizon dashboard, and actual queue depth. A green dashboard with a stalled queue is the failure mode to watch for.Where to look first:
config/horizon.php — the supervisors and queues you already know.Always-on critical supervisor + time-windowed bulk supervisor. This is the safest production pattern. Keep default on a * * * * * schedule so transactional jobs never wait for a window, and schedule reports/exports only during business hours. It limits the blast radius: if the cron reconciliation misbehaves, only the scheduled queues are affected.
'default' => [
'queue' => ['default', 'mail'],
'processes' => 2,
'cron' => '* * * * *',
],
'reports' => [
'queue' => ['reports'],
'processes' => 1,
'cron' => '0 7-19 * * 1-5',
],
Business-hours-only environments. Staging, QA, and demo environments are strong fits. Run Horizon only during working hours to cut idle worker cost, and accept that a job submitted at 02:00 waits until 07:00. This is usually fine when there is no SLA and no customer-facing latency requirement.
Nightly batch windows. Put heavy exports and report generation on a dedicated queue and supervisor, and schedule it for a narrow window such as 0 1-3 * * *. Keep it off the default queue so batch load cannot starve interactive jobs.
Deploy pipeline integration. The common failure is calling php artisan horizon:terminate during deploy and waiting for the next cron tick. Add an explicit reconcile step after terminate so workers come back immediately:
php artisan horizon:terminate
# If the package exposes a direct command:
php artisan horizon:cron-supervisor
# If it uses Laravel's scheduler:
php artisan schedule:run
Sequence this after config:cache and after new code is in place. Without it, every deploy can stall the queue for up to a minute.
Independent health checks. Do not let the supervisor be the only thing watching the supervisor. Alert separately on queue depth, job age, and whether a Horizon master exists during an expected window. A simple scheduled check can assert that horizon:status succeeds when the schedule says workers should be active.
Local development stays normal. Run php artisan horizon directly in local development. Do not add cron entries locally; you want immediate feedback, not a one-minute reconciliation loop.
Rollback is intentionally boring. Remove the cron line, re-enable the Supervisor unit or systemd service, run php artisan horizon:terminate, and let the normal process manager restart Horizon. There is no schema change or queue migration to reverse.
Cron granularity is your real restart SLA. Cron is a polling supervisor. On a good host the tick is one minute; on some shared hosts it is five or fifteen minutes. Measure it by killing the Horizon master on staging and timing recovery. Do not read the documented interval and assume it is the effective one.
Confirm the reconciliation model before relying on it. The package description and community write-ups describe slightly different internals: a liveness poll (“start Horizon if it is not running”) versus a bounded run (“start Horizon, drain for a window, exit, repeat”). They have different failure modes. Read the command source once and confirm:
That is a one-hour review that prevents a production surprise.
Overlap and duplicate masters. If the package uses a bounded-run model, a max runtime longer than the cron interval can start a second Horizon master while the first is still draining. Confirm the lock behavior and keep max runtime comfortably below the cron interval. Two masters against the same queues can double process counts, double Redis connections, and make the dashboard confusing.
Deploy stall is real. horizon:terminate stops the master; under cron nothing restarts it until the next tick. If you deploy frequently, wire the reconcile command into the pipeline as described above. Test it on staging with a deploy while jobs are flowing.
Config cache catches people. Horizon reads supervisor configuration at boot. After editing config/horizon.php, run php artisan config:cache and terminate/restart Horizon. A stale config cache is a common reason a schedule change appears to do nothing.
Timezone discipline. Cron expressions are evaluated against either the server timezone or the application timezone depending on implementation. Set it explicitly, document it in the runbook, and avoid schedules that silently shift when a server is rebuilt in a different region.
Run cron as the right user. The cron user needs write access to storage/, read access to .env, and network access to Redis. Running as root causes log and cache ownership problems; running as the web user can conflict with deploy permissions.
Log the cron output. Silent cron failures are the worst kind. Redirect stdout and stderr to a log file and alert on repeated failures. Also capture horizon:status exit codes in your health check.
Test graceful shutdown with a long job. Dispatch a job that sleeps for 30–60 seconds, trigger a terminate, and confirm the job is released back to the queue rather than lost. Horizon normally handles this well, but the cron supervisor’s shutdown path is the part you are trusting.
Dashboard and metrics will show gaps. If the master exits every cycle, Horizon’s metrics and process list will have gaps. That is expected, but it means “no data” in the dashboard is not automatically an incident. Alert on queue depth and job age instead.
Do not use this where latency is on an SLA. Any workload with a strict job-latency target, high throughput, or multi-node autoscaling should use Supervisor, systemd, Kubernetes, ECS, Vapor, or Forge daemons. Cron is a liveness mechanism, not an event-driven process manager.
Keep a fallback. This is a small package with a single maintainer. Pin the version, keep a tested Supervisor unit or a hand-written scheduled command ready, and make sure at least one other person knows how to switch back. The fallback should be exercised once on staging, not written down and forgotten.
How can I help you explore Laravel packages today?