Skip to content

Supervisor for Laravel: Queue Workers, Scheduler, Horizon

A

Al Amin Ahamed

Senior Engineer

8 min read
𝕏 in

Supervisor is a process manager. For a Laravel app, it keeps queue workers, the scheduler, and any custom long-running processes alive. systemd can do the same job, but Supervisor's config language is friendlier and its log handling is built-in.

Here's the configuration I run in production.

Why Not Just php artisan queue:work?

If you ssh to your server and run php artisan queue:work, the moment your shell disconnects the worker dies. nohup and & postpone the problem; they don't solve it. When the worker crashes (and it will — OOM, network blip, deploy), nothing restarts it.

Supervisor solves both: it daemonizes processes and auto-restarts them on failure.

Directory Layout

/etc/supervisor/conf.d/ laravel-worker.conf # queue workers laravel-scheduler.conf # scheduler laravel-horizon.conf # if using Horizon instead of queue:work laravel-reverb.conf # if using Reverb websockets

One file per concern. Easier to reason about than one mega-file.

Queue Worker

[program:laravel-worker] process_name=%(program_name)s_%(process_num)02d command=php /var/www/portfolio/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --max-jobs=1000 autostart=true autorestart=true user=deploy numprocs=2 redirect_stderr=true stdout_logfile=/var/www/portfolio/shared/storage/logs/worker.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=10 stopwaitsecs=3600

Key choices:

  • numprocs=2 — two worker processes. Bump up if you have CPU headroom and your queue depth grows.
  • --max-time=3600 — workers exit cleanly after an hour. Supervisor restarts them. This prevents memory leaks from accumulating indefinitely.
  • --max-jobs=1000 — same idea, but counted by jobs processed.
  • stopwaitsecs=3600 — when Supervisor sends SIGTERM, give the worker a full hour to finish its current job before sending SIGKILL. Critical for long jobs.
  • %(process_num)02d — Supervisor expands this to 00, 01 per process. Required when numprocs > 1.

The Scheduler

[program:laravel-scheduler] command=php /var/www/portfolio/current/artisan schedule:work autostart=true autorestart=true user=deploy redirect_stderr=true stdout_logfile=/var/www/portfolio/shared/storage/logs/scheduler.log stdout_logfile_maxbytes=50MB

schedule:work is the modern replacement for the cron entry that runs schedule:run every minute. Supervisor keeps it alive; the command's internal loop ticks every 60 seconds and dispatches scheduled tasks.

If you prefer cron (some teams do for visibility):

* * * * * deploy cd /var/www/portfolio/current && php artisan schedule:run >> /dev/null 2>&1

I prefer Supervisor — one process manager, all logs in one place.

Horizon

If you're using Horizon, replace laravel-worker with:

[program:laravel-horizon] command=php /var/www/portfolio/current/artisan horizon autostart=true autorestart=true user=deploy stopwaitsecs=3600 redirect_stderr=true stdout_logfile=/var/www/portfolio/shared/storage/logs/horizon.log

Horizon spawns its own worker children based on config/horizon.php. Supervisor manages the parent. numprocs=1 (the default) is correct here — Horizon does the multiplexing.

Deploy-Time Restart

Workers cache code. After a deploy, they're running the old code until restarted:

php artisan queue:restart sudo supervisorctl restart laravel-worker:*

queue:restart writes a sentinel to the cache. Workers check the sentinel between jobs and exit gracefully. Supervisor restarts them with the new code.

Critical: restart (not stop then start). Restart is one operation; stop-then-start has a window where workers are completely down.

Permissions

Supervisor runs as root by default. The user=deploy directive drops privileges before exec'ing. The deploy user needs:

  • Read access to the codebase (/var/www/portfolio/)
  • Write access to logs (storage/logs/)
  • Permission to write to Redis and the database
sudo useradd -r -s /bin/bash deploy sudo chown -R deploy:deploy /var/www/portfolio

Status & Logs

sudo supervisorctl status # laravel-worker:laravel-worker_00 RUNNING pid 12345, uptime 2:14:08 # laravel-worker:laravel-worker_01 RUNNING pid 12346, uptime 2:14:08 # laravel-scheduler RUNNING pid 12347, uptime 2:14:08 sudo supervisorctl tail -f laravel-worker:laravel-worker_00

The tail -f is invaluable when debugging a job that misbehaves only in production.

Healthchecks

I run a side script that asserts Supervisor processes are RUNNING and pages me if anything is FATAL:

#!/usr/bin/env bash EXPECTED=("laravel-worker:laravel-worker_00" "laravel-worker:laravel-worker_01" "laravel-scheduler") for proc in "${EXPECTED[@]}"; do status=$(sudo supervisorctl status "$proc" | awk '{print $2}') if [ "$status" != "RUNNING" ]; then curl -X POST "$ALERT_WEBHOOK" -d "Supervisor process $proc is $status" fi done

Cron'd every 5 minutes. Stupid simple, works.

Share 𝕏 in
A

Al Amin Ahamed

Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.

About me →

One email a month. No noise.

What I shipped, what I read, occasional deep dive. Unsubscribe anytime.