Laravel queue workers in production: the setup that does not quit
Why php artisan queue:work belongs under a process supervisor in production, with a supervisor program block, queue:restart at deploy time for graceful worker turnover, the one cron line the scheduler needs, when Horizon earns its complexity, and the failed-jobs table as the setup's honesty mechanism.

By Sam Moreton
First-hand + researched
Laravel queue workers in production need a process supervisor keeping them alive. php artisan queue:work started in a terminal dies the moment the SSH session closes, and a cron fallback buys you a single worker instance that restarts at most once a minute; both setups lose jobs while the queue grows out of sight. The standard answer is supervisor running a fixed pool of workers, paired with php artisan queue:restart in the deploy script so each worker finishes its current job and comes back on the new code. Add one cron line for the scheduler and a failed-jobs table somebody reads, and background work turns into the reliable part of the application.
Why the terminal and cron versions fail
The terminal version fails on logout. The worker is a child of your SSH session; the session ends, the worker ends, and nothing restarts it. Wrapping the command in nohup or screen survives the logout and still dies on the first fatal error or the first server reboot, with nothing watching to notice.
Cron feels like supervision and is worse. An entry launching queue:work every minute gives you one worker at best, cold-starting on a timer, with overlapping instances to guard against and no concept of a pool, so twenty jobs landing at once get eaten serially. The deeper problem is shared with the terminal version: queue:work is built to run for weeks and be managed by something else. It handles retries, sleeps between jobs, and exits cleanly when told. Keeping itself alive was never its job.
Supervisor, the standard answer
Supervisor is a daemon whose job is running other programs. You describe the worker in a program block and supervisor starts it at boot, replaces it within seconds of a crash, and holds the pool at the size you asked for:
[program:app-worker]
command=php /var/www/current/artisan queue:work --tries=3 --backoff=60
autostart=true
autorestart=true
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
numprocs is the width of the pool, so two means two jobs process concurrently. The command line carries the tuning: which queue to eat first, how many attempts a job gets, how long to wait between them, and a maximum runtime per worker so memory growth gets recycled by a clean exit that supervisor turns into a fresh start.
Graceful turnover at deploy
A running worker holds the code it booted with. Deploy a new release and the workers keep executing the old one until they restart, which without a deploy step means jobs run stale code for as long as the workers live, and you end up debugging behavior whose source no longer exists on disk. The fix is one line at the end of the deploy script:
php artisan queue:restart
queue:restart writes a timestamp to the cache. Each worker notices it after finishing its current job, exits, and supervisor starts a replacement that loads the new release. Current jobs complete, no work is lost, and the whole fleet turns over in the time it takes the longest running job to finish. Without a supervisor underneath, the same command kills the workers and leaves them dead, which is why the two pieces ship together.
The scheduler needs one cron line
Laravel's task scheduler does nothing until something invokes it every minute:
* * * * * cd /var/www/current && php artisan schedule:run >> /dev/null 2>&1
One entry, and schedule:run decides each minute what is due. The failure mode is predictable: everything works on the laptop, where the developer ran the scheduler by hand, and in production nothing fires. The cron entry belongs on the day-one checklist, next to confirming that a scheduled job actually ran.
Horizon, when it earns its complexity
Laravel Horizon gives queues a dashboard: throughput, wait times, failures and retries, visible and clickable, with tags and metrics per job class. It is the right tool when queues carry the product, import pipelines, notification fanout, billing runs, and visibility into those is worth running Horizon's own supervisor program and accepting its Redis-only requirement. For an application where the queue handles password resets and the occasional email, supervisor plus the failed-jobs table does the same honest work with less to install, configure and keep updated. Adopting Horizon before the queues justify it mostly adds a dependency to babysit.
The failed-jobs table as your honesty mechanism
Ship the failed-jobs table with every queue setup: generate the migration with queue:failed-table, run it, done. From then on, every job that exhausts its retries is recorded with its payload and the exception that killed it, listed and retriable:
php artisan queue:failed
php artisan queue:retry all
The table earns its keep as an early warning system. Zero failed jobs is a health signal. Growth on a Tuesday means a deploy broke something specific at a specific time, and the exception column names it. Watch the count and alert on it, especially if email or billing jobs run through the queue, because queues fail politely: jobs sit and wait while you fix the cause, and nothing else in the stack will complain.
Panels that supervise for you
The whole arrangement, supervised workers, restart at deploy, one cron line, is configuration rather than code, and some managed platforms ship it as a feature so the supervisor config never gets written by hand. Cloudways treats supervised processes as a core panel capability alongside Git deploys, and the Laravel-specific flow, workers and scheduler included, is walked through in deploying Laravel on Cloudways. For the hosting decision around it, where to host Laravel applications lays out the realistic options.