You dispatch a job, nothing happens, and there is no error. Or the job ran once, then ran again, or sits in the queue while the worker idles. "Queue not working" is one symptom with about a dozen causes, and most of them can be told apart in a few minutes if you check in the right order. This guide goes from the cheapest checks to the subtle ones, with the commands to confirm each.
Quick diagnosis: start here
-
Is the job really queued? If
QUEUE_CONNECTION=sync, jobs run inline during the request and no worker is involved. -
Is a worker running, for the right connection and queue name? Check
ps aux | grep queue:workand compare its--queuevalue with the queue the job was dispatched to. - Where is the job right now? Waiting, delayed, reserved (running or lost), or failed. Each state has a different fix.
- Is the worker running current code and config? Workers keep the application in memory.
Step 3 is where most guessing stops, so do it first.
Find out where the job is
On the Redis driver, a queue named default uses three keys. These come from the RedisQueue class:
| Redis key | Type | Meaning |
|---|---|---|
queues:default |
list | Jobs waiting to be picked up |
queues:default:delayed |
sorted set | Jobs scheduled for later (delay(), retries with backoff) |
queues:default:reserved |
sorted set | Jobs a worker has taken and not finished or released |
redis-cli llen queues:default
redis-cli zcard queues:default:delayed
redis-cli zcard queues:default:reserved
redis-cli keys 'queues:*'
If your app uses a Redis prefix, include it in the key names. The last command lists every queue that has data, which is how you spot a job sitting in a queue nobody listens to. On the database driver, the same information is in the jobs table: reserved_at is null for waiting jobs and set for reserved ones, and available_at in the future means delayed. Failed jobs are in failed_jobs (see the failed section below).
Cause 1: Nobody is working that queue
A worker only reads the queues it is told about. php artisan queue:work reads the default queue of the default connection. A job dispatched with onQueue('emails') goes to a different list. I tested this with the real RedisQueue class:
push job to 'emails'
worker on 'default' pops: NULL
redis keys: queues:emails:notify, queues:emails | llen queues:emails = 1
The job sits in queues:emails forever and nothing reports an error. The fix is to tell the worker which queues to read, in priority order:
php artisan queue:work redis --queue=emails,default
The --queue option takes a comma-separated list, and queues earlier in the list are processed first. The same applies to a different connection: if the job was dispatched to redis and the worker runs against database, they never meet. Look at the first argument of queue:work and at QUEUE_CONNECTION in the environment the web app uses.
Cause 2: No worker is running at all
This is the usual one after a server reboot, a deploy that rebuilt containers, or a crashed process. A worker is a long-running PHP process. It does not start by itself. In production, run it under a process manager so it restarts after a crash. A typical Supervisor program:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/laravel-worker.log
stopwaitsecs=3600
Adjust the path, user and connection. After editing:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
--max-time=3600 makes each worker exit after an hour and lets Supervisor start a fresh one, which keeps memory from growing forever. stopwaitsecs should be longer than your longest job, so a stop does not kill a job in the middle of its work. The output of supervisorctl status should say RUNNING. If it says FATAL or keeps restarting, read the log file named in the program section, since the worker is crashing at boot, typically on a bad .env or a missing PHP extension.
Cause 3: QUEUE_CONNECTION is sync, or points somewhere else
The default in the Laravel 13 application skeleton is QUEUE_CONNECTION=database in .env.example and 'default' => env('QUEUE_CONNECTION', 'database') in config/queue.php. With sync, the job runs immediately inside the request, so there is nothing to process later and a slow job slows the page. If you changed .env and nothing changed, you are probably serving a cached config:
php artisan config:show queue.default
php artisan config:clear
If you deploy with php artisan config:cache, the .env file is no longer read at runtime, so edits to it have no effect until you cache again. Also remember to restart the workers (next section).
Cause 4: The worker is running old code
A queue:work process boots the framework once and keeps it in memory. After a deploy, it keeps running the old job classes and the old config until it is restarted. This is the classic "I fixed the bug and the queue still fails" report. Add this to every deploy, after the new code is in place:
php artisan queue:restart
The command does not kill anything. It writes a timestamp to the cache under the key illuminate:queue:restart, and each worker checks that key between jobs and exits after finishing its current job. Your process manager then starts a new one. That design has two consequences worth knowing:
- It needs a cache store that the CLI deploy command and the workers share. With the
arraycache driver, or afilecache on a different machine from the workers, the signal never arrives and nothing restarts. Use the database, Redis or Memcached store for this. - It needs a process manager to start the new worker. Without Supervisor or an equivalent, the workers exit and nothing replaces them. That turns "stale code" into "no worker at all".
During local development, php artisan queue:listen reloads the code for every job at the cost of speed. The Laravel 13 skeleton uses it in its composer dev script. Do not use it in production.
Cause 5: The queue is paused, or the app is in maintenance mode
The framework has queue:pause and queue:resume commands. A paused queue is not processed, and the pause state is stored in the cache under keys like illuminate:queue:paused:redis:default. In the framework source I read, pause() uses forever(), so a pause stays until someone resumes. If workers are running and healthy but a queue does not move, run:
php artisan queue:resume redis:default
# or everything:
php artisan queue:resume --all
Separately, a worker does not process jobs while the application is in maintenance mode (php artisan down) unless it was started with --force. If a deploy script runs down and then crashes before up, the queue stops with it. Check with php artisan up.
Cause 6: A delayed job that is not due yet
Jobs dispatched with delay() sit in the delayed set until their time arrives. Mixing this up with a stuck queue is common, especially because the queue size includes delayed jobs. I pushed a job with a 60 second delay:
later(60): pop = NULL delayedSize = 1 size = 1
The worker sees nothing to do, while size() reports 1. Check zcard queues:default:delayed before assuming the worker is broken. Jobs that failed and are waiting for a retry with backoff are in the same state. Server clock drift between the web server and the worker machine produces a similar picture, so keep NTP running.
Cause 7: retry_after is shorter than the job runtime (jobs run twice)
This one looks like a stuck queue and a duplicate-job bug at the same time. When a worker takes a job, it moves into the reserved set with an expiry of retry_after seconds. If the job is still running when that time passes, the queue assumes the worker died and makes the job available again. A second worker picks it up while the first is still working. I reproduced it with retry_after set to 2 seconds:
worker 1 reserved the job: attempts = 1 reservedSize = 1 (job "still running")
worker 2 pops right away: NULL
worker 2 pops after 3s: the same job, attempts = 2
The rule is that retry_after must be longer than the longest time a job can run, and the worker's --timeout (60 seconds by default) must be shorter than retry_after. The skeleton sets retry_after to 90 for the database, Redis and Beanstalkd connections through environment variables such as REDIS_QUEUE_RETRY_AFTER. A job that takes five minutes needs both raised:
php artisan queue:work redis --timeout=300
# config/queue.php, or the env var REDIS_QUEUE_RETRY_AFTER
'retry_after' => 360,
You can also set a per-job timeout on the job class with the $timeout property. The --timeout option is enforced through the pcntl extension: in the worker source, the timeout handler is only registered when pcntl is loaded. Without it, a hung job is never killed by the worker, so check php -m | grep pcntl for the CLI binary that runs your workers.
The effect of getting this wrong gets worse with --tries=1. When the job comes back after retry_after, its attempt count is 2, which exceeds the limit. In the source, the worker then marks it failed and raises MaxAttemptsExceededException with the message <JobClass> has been attempted too many times. That message in failed_jobs usually means a worker died or was killed mid-job, or retry_after was too short, not that your code threw it.
Cause 8: The job is stuck because of a worker that died
When a worker is killed during a job (out of memory, kill -9, a container stop, a timeout), nothing records that the job failed. The job stays in the reserved set until retry_after expires and is then retried. So "stuck" for exactly retry_after seconds is normal behaviour after a crash. If a job is stuck much longer than that:
- Confirm that a worker is running at all, because expired reserved jobs are only moved back when some worker polls that queue (the Redis queue migrates expired jobs on each pop).
- Look for out-of-memory kills in
dmesgor the container runtime. The worker's--memoryoption defaults to 128 MB and makes the worker exit cleanly when it is exceeded. A kernel OOM kill is different and leaves no log line from Laravel. - Check the unique or overlap locks (next section).
Cause 9: A lock that was never released
Jobs that implement ShouldBeUnique and jobs with the WithoutOverlapping middleware use locks stored in the cache. If a worker dies while holding such a lock, new dispatches of the same job are silently skipped until the lock expires. The default is to hold a unique lock until the job finishes or fails, so set an expiry that matches the job (uniqueFor on unique jobs, expireAfter() on WithoutOverlapping). Since these locks live in the cache, php artisan cache:clear on a shared store also clears them, with the side effects of clearing everything else, so prefer removing the one key. This section follows the framework's documented behaviour. I did not reproduce lock expiry in a test.
Cause 10: The job is failing, not stuck
If the worker output shows jobs as FAIL, the job exists in the failed jobs store. The commands, from the framework source:
php artisan queue:failed # list failed jobs
php artisan queue:retry all # retry everything
php artisan queue:retry 5d3b... # retry one by ID
php artisan queue:retry --queue=emails # retry one queue
php artisan queue:flush # delete all failed records
php artisan queue:prune-failed --hours=48 # delete failed records older than 48 hours
The exception column of each failed job holds the full message and stack trace, which is where the real answer usually is. The skeleton stores failed jobs with the database-uuids driver, so the IDs are UUIDs. If failed_jobs does not exist, run php artisan make:queue-failed-table followed by php artisan migrate, and if you are on the database driver, check that the jobs table exists too (php artisan make:queue-table).
Common reasons a job fails only on the worker: a missing environment variable because the worker was started without the right user or .env, a model deleted before the job ran (ModelNotFoundException when the serialized model is restored), and a job dispatched inside a transaction that had not committed yet. The last one is covered in Laravel queued notification runs before the transaction commits.
Cause 11: Jobs are queued but the dispatch never happens
If the queue is empty and the worker is healthy, the job might never have been pushed. Reasons include the code path not running, an exception thrown before dispatch(), Queue::fake() left active in a test environment, or dispatchAfterResponse() being used when you expected a normal push. A direct check is to dispatch from php artisan tinker and watch the store:
php artisan tinker
>>> App\Jobs\ProcessOrder::dispatch(1);
# then, in another shell:
redis-cli llen queues:default
If the length goes up, the push works and the problem is on the worker side. If it does not, the application is writing somewhere else (compare config('queue.default') and the Redis prefix between tinker and the web app).
Monitoring so you notice sooner
The queue:monitor command checks queue sizes and fires an event when a queue exceeds a threshold. Schedule it and attach a notification listener:
php artisan queue:monitor redis:default,redis:emails --max=100
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:monitor redis:default,redis:emails --max=100')->everyMinute();
It dispatches a QueueBusy event when a queue grows past the limit, and you listen for that event to send a Slack message or email. Note that in Laravel 13 the event exposes $connectionName rather than $connection, as covered in the Laravel 12 to 13 upgrade guide. The command needs a working scheduler, so an unhealthy cron entry takes the alarm down with the queue. If you run Redis queues in production, Laravel Horizon adds a dashboard with throughput, wait times and failed jobs.
Checklist for a stuck queue
-
ps aux | grep "queue:work": is a worker running, with the connection and--queuelist you expect? -
php artisan config:show queue.default: is the app using the connection you think it is? - Count the waiting, delayed and reserved jobs (Redis keys above, or the
jobstable). -
php artisan queue:failed: read the latest exception. -
php artisan queue:resume --allandphp artisan up: rule out a pause and maintenance mode. - Compare
retry_after, the worker--timeoutand your longest job. -
php artisan queue:restartafter any deploy, and confirm the cache store is shared between the deploy command and the workers. - Read the worker log (Supervisor's
stdout_logfile) for crashes at boot.
Sources
- Laravel Documentation: Queues
- laravel/framework: Illuminate\Queue source (13.x)
- Laravel Documentation: Horizon
- Redis documentation: ZCARD
Originally published on DEV Talk.
Top comments (0)