What this usually means
The worker process is alive but blocked—either by a PHP timeout that silently kills the loop, a memory exhaustion that causes the daemon to exit without log, a database deadlock from a failed job that never releases, or a supervisor misconfiguration that respawns the worker too fast without proper signal handling. In production, the most common culprit is the `timeout` setting in `queue:work` or the `process_timeout` in supervisor causing repeated SIGTERM without graceful shutdown.
The first ten minutes — establish facts before touching code.
- 1Run `php artisan queue:work --once` to see if a single job processes manually—if yes, the daemon loop is breaking.
- 2Check PHP error log: `tail -f /var/log/php-fpm/error.log` for segfaults or memory exhaustion messages.
- 3Inspect supervisor logs: `sudo tail -f /var/log/supervisor/supervisord.log` for 'FATAL' or 'EXITED' events.
- 4Run `php -r "echo memory_get_usage(true);"` from the worker user to confirm available memory is not exhausted.
- 5Verify the queue driver connection: `php artisan tinker` then `Queue::size('default')` to confirm the queue is accessible.
- 6Look for stuck jobs in the database: `SELECT * FROM jobs WHERE reserved_at IS NOT NULL AND attempts > 0;`
The specific files, logs, configs, and dashboards that usually own this bug.
- search/var/log/supervisor/supervisord.log — supervisor events and child exit codes
- searchstorage/logs/laravel.log — Laravel exceptions (especially if workers log to stack)
- search/etc/supervisor/conf.d/laravel-worker.conf — the exact command and process_timeout
- searchphp artisan queue:work --help — check default timeout and sleep values
- search.env file — QUEUE_DRIVER, REDIS_HOST, DB_CONNECTION settings
- searchDatabase `jobs` and `failed_jobs` tables — inspect reserved_at and attempts columns
- searchphpinfo() output or `php -i | grep memory_limit` for PHP memory limit
Practical causes, not theory. These are the things you will actually find.
- warning`queue:work --timeout=0` or missing timeout: default is 60s, but if a job runs longer the worker is killed silently.
- warningSupervisor `process_timeout` set too low: forces SIGKILL before the job finishes, worker restarts but never completes.
- warningPHP `memory_limit` set too low (e.g., 128M): worker exhausts memory after processing a few jobs and exits.
- warningRedis connection timeout: the worker blocks indefinitely on BLPOP with no timeout, never returning to loop.
- warningStuck job with infinite loop or uncaught exception: worker hangs on that job and never picks up the next one.
- warningHorizon worker paused: `php artisan horizon:pause` was called, or supervisor killed horizon and it auto-paused.
Concrete fix directions. Pick the one that matches your root cause.
- buildAdd explicit timeout to queue:work: `php artisan queue:work --timeout=120` and ensure jobs finish within that window.
- buildSet supervisor `process_timeout=300` and `stopwaitsecs=60` to give graceful shutdown time.
- buildIncrease PHP memory_limit: `memory_limit=512M` in php.ini or via `-d memory_limit=512M` in command.
- buildUse `php artisan queue:work --sleep=3` to avoid tight polling loops that hit database/redis too hard.
- buildImplement job middleware for rate limiting or timeout to protect against stuck jobs.
- buildSwitch to Horizon for better monitoring and automatic worker restart on failure.
A fix you cannot prove is a guess. Close the loop.
- verifiedRun `php artisan queue:work --once --timeout=120` and confirm your failing job completes.
- verifiedWatch supervisor log for 'WARN' or 'EXITED' messages after the fix: `sudo supervisorctl tail -f laravel-worker`.
- verifiedMonitor memory: `top -p $(pgrep -f queue:work)` and verify it stays stable over 1000 jobs.
- verifiedUse `php artisan queue:listen` temporarily to see per-job output and catch hidden exceptions.
- verifiedAdd logging to a test job: `Log::info('Job started at '.now())` and `Log::info('Finished')` to confirm execution.
- verifiedSimulate load with `php artisan queue:work --queue=default --tries=3 --timeout=30` and check `failed_jobs` table.
Things that make this bug worse or harder to find.
- warningRestarting supervisor without checking logs: you'll hide the error temporarily.
- warningSetting `process_timeout` too high: it can mask genuinely stuck jobs.
- warningUsing `queue:work` without `--daemon` (deprecated) but not understanding that the `--queue` parameter is positional.
- warningEditing php.ini globally instead of per-worker: other services may break.
- warningForgot to restart supervisor after config change: `sudo supervisorctl reread && sudo supervisorctl update`.
- warningRelying on `php artisan queue:restart` alone when supervisor is managing the process: you need `supervisorctl restart`.
The Silent Queue Worker: 10K Unprocessed Orders
Timeline
- 09:00Deploy new order processing job to production.
- 10:15Customer support reports orders not being processed for 30 minutes.
- 10:20Check Horizon dashboard: all workers show 'idle', jobs pending.
- 10:22Run php artisan queue:work --once: job processes fine. Daemon mode fails.
- 10:25Check supervisor logs: 'laravel-worker: ERROR (abnormal termination)' every 60s.
- 10:28Check PHP error log: 'Allowed memory size of 134217728 bytes exhausted'.
- 10:32Increase memory_limit to 512M in supervisor command, restart.
- 10:35Orders start processing. Root cause: new job loaded large dataset without chunking.
At 09:00 we pushed a new order fulfillment job that fetches all line items for an order and sends them to an external API. The job didn't use chunking—it loaded every line item into an array. For a typical order with 5 items, memory usage was fine. But one order had 2,000 line items. That job ballooned PHP memory to 300MB, hitting the 128MB limit.
The worker (a daemon managed by supervisor) would process the first few small orders fine. Then it picked up the 2,000-item order, hit the memory limit, and crashed with a fatal error. Supervisor saw the process exit unexpectedly and respawned it. The new worker started again but eventually hit the same order and crashed again. This cycle repeated every 60 seconds, never processing any other jobs.
The fix was two-fold: increase the PHP memory limit to 512MB for the worker, and refactor the job to use chunking (`Order::with('lineItems')->chunk(100)`) to keep memory low. We also added a `--timeout=300` to the worker command to prevent future timeouts from large jobs. After the change, the backlog cleared in 5 minutes.
Root cause
PHP memory exhaustion from a job that loaded too many Eloquent models without chunking.
The fix
Increased memory_limit to 512M via `-d memory_limit=512M` in supervisor command and refactored job to use chunking.
The lesson
Always profile memory usage of new jobs in staging with production-like data. Set both PHP memory_limit and queue timeout explicitly.
When you run `php artisan queue:work --daemon` (or without --daemon in modern Laravel), the worker enters an infinite loop. It tries to pop a job from the queue, processes it, then loops back for the next job. The loop only breaks on a fatal error, intentional stop (SIGTERM/SIGQUIT), or if the queue is empty and --stop-when-empty is set.
The `--timeout` option is critical: it specifies how many seconds the worker should allow a job to run before killing it. This is implemented via `pcntl_alarm()` — a signal is sent after the timeout. If the job doesn't finish, the worker terminates with a SIGALRM. Without proper signal handling, this looks like a crash. Supervisor then respawns, and the cycle repeats.
A common misconfiguration is setting `process_timeout` in supervisor. This is the time after which supervisor sends SIGKILL if the child process hasn't exited after SIGTERM. If set too low (e.g., 10 seconds), the worker can't gracefully shut down and loses the current job. The worker restarts but the job remains reserved, causing staleness.
The correct approach: set `stopwaitsecs=60` (or higher than your longest job), and set `process_timeout=300` (or higher). Also ensure `autorestart=true` so the worker comes back after a crash. Use `stdout_logfile` and `stderr_logfile` to capture worker output.
When using the database queue driver, a worker reserves a job by updating `reserved_at` and `attempts`. If the worker crashes without releasing the job, that job stays reserved. The next worker picks up a different job, but the reserved job remains stuck until `reserved_at` is cleared manually or a timeout fires.
Laravel's database queue has a `--timeout` that releases reserved jobs after the timeout plus some margin. But if the worker is killed by supervisor before that timeout, the job may remain locked. The fix: run `php artisan queue:prune-batches` and manually update `reserved_at = NULL` for old jobs. Also consider using Redis for better reliability.
Even if PHP memory_limit is set high, a worker processing thousands of jobs can gradually leak memory due to closures, static variables, or packages that cache data. Over hours, memory grows until the limit is hit, crashing the worker. This is why memory_limit per worker should be generous (512M+) and workers should be restarted periodically.
Horizon handles this by automatically restarting workers after a configurable number of jobs (`--max-jobs`). For queue:work, you can simulate this with a shell wrapper that kills the process after N jobs. Or use `php artisan queue:work --max-jobs=1000`.
Frequently asked questions
Why does `php artisan queue:work --once` work but daemon mode doesn't?
`--once` processes a single job and exits, so memory is freed after each job. Daemon mode reuses the process, accumulating memory. If a single job consumes too much memory or if there's a leak, daemon mode will eventually exhaust memory. Also, timeout settings apply per job; daemon mode may hit a timeout on a long job that `--once` never encounters.
How do I debug a queue worker that exits silently without logs?
First, capture stdout and stderr in supervisor: set `stdout_logfile=/var/log/laravel-worker.log` and `stderr_logfile=/var/log/laravel-worker-error.log`. Then check those logs. If still silent, run the worker manually in foreground: `php artisan queue:work --daemon --timeout=120 2>&1 | tee /tmp/worker.log`. Watch for PHP fatal errors. Also check system logs: `dmesg | grep -i php` for OOM killer.
What's the difference between `queue:work` and `queue:listen`?
`queue:listen` starts a new PHP process for each job, so memory is freed after every job, but it's slower and uses more CPU. `queue:work` (daemon) reuses the same process, which is faster but can leak memory. For production, use `queue:work` with Horizon or a periodic restart. For debugging a single job, `queue:listen` can be useful because it logs per-job output.
Can a job with an infinite loop cause the worker to stop?
Yes. If a job contains an infinite loop or a blocking operation (e.g., a HTTP request that never responds), the worker will hang on that job and never process the next. The worker won't crash—it just appears idle. The only way to recover is to kill the worker process manually or set a timeout (`--timeout`) that will force-kill the job after the specified seconds.
Why does Horizon show workers as 'paused' even though I never paused?
Horizon automatically pauses workers when it detects supervisor has stopped them. This can happen if supervisor restarts the process too fast (crash loop). Check supervisor logs for 'FATAL' or 'EXITED'. Also check that the Horizon supervisor configuration matches the actual queue:work command. Run `php artisan horizon:continue` to resume.