Skip to content

Fix: Laravel Queue Job Not Processing — Jobs Stuck in Queue

FixDevs · (Updated: )

Part of:  Database Errors

Quick Answer

How to fix Laravel queue jobs not running — queue worker not started, wrong connection config, failed jobs, job timeouts, horizon setup, and database vs Redis queue differences.

The Problem

A Laravel queued job is dispatched but never executes:

ProcessOrder::dispatch($order);
// Job added to queue... but nothing happens

The job sits in the jobs table indefinitely:

SELECT * FROM jobs;
-- id | queue | payload | attempts | reserved_at | available_at | created_at
-- 1  | default | {...}  | 0        | NULL        | 1711234567  | 1711234567
-- Job never gets picked up

Or the worker runs but jobs fail silently:

php artisan queue:work
# [2026-03-22 10:00:00] Processing: App\Jobs\ProcessOrder
# [2026-03-22 10:00:01] Failed: App\Jobs\ProcessOrder
# No error message visible

Or in a fresh environment, jobs dispatch but workers can’t connect:

[Illuminate\Queue\InvalidPayloadException]
Unable to JSON encode payload. Error code: 5

Why This Happens

Laravel’s queue system requires a separate worker process to poll and execute jobs. The most common cause of “jobs not processing” is simply that no worker is running. Beyond that:

  • No worker runningqueue:work or queue:listen must be running continuously as a separate process. Dispatching a job only writes it to the queue store (database, Redis, SQS, etc.); the worker reads and executes it.
  • Wrong queue connection — the app dispatches to redis but the worker is polling database, or the job specifies a named queue (emails) but the worker only listens to default.
  • Worker stopped after a code changequeue:work caches the application on startup. After deploying new code, workers must be restarted (queue:restart) to pick up changes.
  • Failed job not visible — jobs that fail are moved to the failed_jobs table, not the jobs table. They appear “gone” but actually failed.
  • Job serialization error — Eloquent models in job constructors are serialized using SerializesModels. If the model is deleted before the job runs, the job fails with a ModelNotFoundException.
  • Queue driver not configured.env has QUEUE_CONNECTION=sync (runs jobs immediately in the same request, but only in the web context) instead of database or redis.

The production impact of a stuck queue is rarely obvious at the moment it starts failing. The web tier keeps responding to requests — users see normal HTTP 200 responses. Orders get placed. Sign-ups complete. The problem only surfaces downstream: the welcome email never arrives, the Stripe webhook is never replayed, the nightly report never lands in the analyst’s inbox, the third-party integration never gets notified. Hours can pass before someone notices the queue depth climbing. By the time you discover it, you have a backlog of thousands of jobs that need to be drained before the system catches up to real-time.

The blast radius depends entirely on what your queue does. If background jobs handle only “nice-to-have” tasks like analytics fan-out, an outage is mildly embarrassing but not customer-affecting. If jobs handle critical paths — payment confirmation emails, fraud-check callbacks, password reset tokens — a stuck queue becomes a customer trust incident. The single most important monitoring metric for any Laravel app using queues is queue depth over time. A queue that grows monotonically without ever draining is a paged-incident signal.

Fix 1: Start the Queue Worker

The most common fix — ensure a worker is actually running:

# Start a worker for the default queue on the default connection
php artisan queue:work

# Start with verbose output to see job processing
php artisan queue:work --verbose

# Process a specific connection and queue
php artisan queue:work redis --queue=emails,default

# Process only one job then exit (useful for testing)
php artisan queue:work --once

# Run with a timeout (kills jobs taking longer than N seconds)
php artisan queue:work --timeout=60

# queue:listen vs queue:work:
# queue:listen — restarts the worker after every job (picks up code changes automatically, slower)
# queue:work   — keeps the worker alive (faster, but requires restart after code changes)
php artisan queue:listen

Check if a worker is running:

# Linux
ps aux | grep "queue:work"

# Or check if there's any artisan worker process
ps aux | grep "artisan"

For production — use Supervisor to keep the worker alive:

; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --timeout=90
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2          ; Run 2 worker processes
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log
# Apply the Supervisor config
supervisorctl reread
supervisorctl update
supervisorctl start laravel-worker:*

Fix 2: Verify Queue Configuration

Check that the app’s queue connection matches where the worker is listening:

# .env file
QUEUE_CONNECTION=database   # Must match what the worker polls
# Options: sync, database, redis, beanstalkd, sqs

# For Redis, also set:
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
// config/queue.php — verify the connection is configured correctly
'connections' => [
    'database' => [
        'driver' => 'database',
        'table' => 'jobs',           // Must match the migration table name
        'queue' => 'default',
        'retry_after' => 90,
    ],
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',   // Must match a connection in config/database.php redis section
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => 90,
        'block_for' => null,
    ],
],

Mismatch between dispatch queue and worker queue:

// Job dispatched to 'emails' queue
ProcessOrder::dispatch($order)->onQueue('emails');

// But worker only listens to 'default':
// php artisan queue:work
// Fix: specify the queue explicitly
// php artisan queue:work --queue=emails,default

Check the queue the job was dispatched to:

-- Check what queue the job is waiting in
SELECT queue, COUNT(*) FROM jobs GROUP BY queue;
-- Result: emails | 5  → worker must listen to 'emails'

Fix 3: Restart Workers After Deployment

queue:work bootstraps the Laravel application once on startup and keeps it in memory. New code deployed after the worker started isn’t picked up:

# Signal all workers to gracefully restart after the current job finishes
php artisan queue:restart

# Workers poll for this signal and restart when idle
# The restart command stores a timestamp in the cache — workers check it periodically

Add queue restart to your deploy script:

#!/bin/bash
# deploy.sh
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:restart   # ← Restart workers to pick up code changes

With Supervisor — restart automatically after deploy:

# Or restart Supervisor entirely
supervisorctl restart laravel-worker:*

Note: queue:restart won’t work if you’re using the file or array cache driver (the restart signal is stored in cache). Use redis or database as the cache driver in production.

Fix 4: Debug Failed Jobs

Jobs that fail move to the failed_jobs table. They don’t show in jobs:

# List all failed jobs
php artisan queue:failed

# ID | Connection | Queue | Class                    | Failed At
# 1  | redis      | emails| App\Jobs\ProcessOrder    | 2026-03-22 10:00:01

# Show the exception for a specific failed job
php artisan queue:failed --id=1
# Or view in database:
SELECT id, exception, failed_at FROM failed_jobs ORDER BY failed_at DESC LIMIT 10;
-- The 'exception' column contains the full stack trace

Retry a failed job:

# Retry a specific failed job
php artisan queue:retry 1

# Retry all failed jobs
php artisan queue:retry all

# Delete a failed job
php artisan queue:forget 1

# Clear all failed jobs
php artisan queue:flush

Make sure failed_jobs table exists:

# Create the failed_jobs table if missing
php artisan queue:failed-table
php artisan migrate

Fix 5: Fix Job Serialization and Model Binding

Jobs that store Eloquent models in their constructor use SerializesModels to serialize only the model’s ID. When the job runs, it re-fetches the model from the database. If the model was deleted between dispatch and execution, the job fails:

// WRONG — model may be deleted before job runs
class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public User $user   // Serialized as App\Models\User:123
    ) {}

    public function handle(): void
    {
        // If user was deleted, this throws ModelNotFoundException
        mail($this->user->email, 'Welcome!', '...');
    }
}
// CORRECT — handle the case where the model no longer exists
public function handle(): void
{
    // $this->user is re-fetched automatically via SerializesModels
    // It throws ModelNotFoundException if not found — catch it or use soft deletes

    if (!$this->user) {
        return;   // User deleted — job is no longer relevant
    }

    mail($this->user->email, 'Welcome!', '...');
}

// Or: use the model's ID instead to avoid automatic re-fetching
public function __construct(
    public int $userId
) {}

public function handle(): void
{
    $user = User::find($this->userId);
    if (!$user) return;  // User deleted, skip
    // ...
}

Avoid unserializable data in jobs:

// WRONG — Closures can't be serialized
ProcessOrder::dispatch(function() { /* ... */ });

// WRONG — Resource types (file handles, database connections) can't be serialized
class ProcessFile implements ShouldQueue
{
    public function __construct(
        public $fileHandle   // Can't serialize a resource
    ) {}
}

// CORRECT — pass the file path, open the handle in handle()
class ProcessFile implements ShouldQueue
{
    public function __construct(
        public string $filePath
    ) {}

    public function handle(): void
    {
        $handle = fopen($this->filePath, 'r');
        // ...
        fclose($handle);
    }
}

Fix 6: Handle Job Timeouts and Retries

Jobs that run longer than the worker’s --timeout are killed with a SIGKILL, and the job is marked as failed:

class ProcessLargeReport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // Set a per-job timeout (takes precedence over --timeout flag)
    public int $timeout = 300;     // 5 minutes

    // Number of retry attempts
    public int $tries = 3;

    // Delay between retries
    public int $backoff = 60;      // 60 seconds

    // Only retry until this time
    public function retryUntil(): \DateTime
    {
        return now()->addHours(2);
    }

    public function handle(): void
    {
        // Job logic
    }

    // Called when all retries are exhausted
    public function failed(\Throwable $exception): void
    {
        // Notify team, update database status, etc.
        Log::error('Report generation failed', [
            'exception' => $exception->getMessage(),
        ]);
    }
}

Configure retries with exponential backoff:

// Return different delay per attempt
public function backoff(): array
{
    return [30, 60, 120];  // 30s after 1st failure, 60s after 2nd, 120s after 3rd
}

Fix 7: Use Laravel Horizon for Redis Queue Monitoring

For Redis-backed queues, Laravel Horizon provides a dashboard for monitoring job throughput, failed jobs, and worker status:

composer require laravel/horizon
php artisan horizon:install
php artisan migrate
# Start Horizon (replaces queue:work for Redis queues)
php artisan horizon

# Horizon dashboard available at /horizon
// config/horizon.php — configure worker pools
'environments' => [
    'production' => [
        'supervisor-1' => [
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
        ],
    ],
    'local' => [
        'supervisor-1' => [
            'maxProcesses' => 3,
        ],
    ],
],

Horizon also shows:

  • Real-time job throughput (jobs per minute)
  • Failed jobs with full stack traces
  • Queue depth per queue
  • Worker process count

Fix 8: Monitor Queue Depth as a Production SLI

A stuck queue is silent — it does not throw errors visible to users. The only reliable way to catch a queue outage early is to treat queue depth and worker liveness as first-class production signals.

Set up queue-depth alerts:

// app/Console/Commands/CheckQueueDepth.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;

class CheckQueueDepth extends Command
{
    protected $signature = 'queue:check-depth';

    public function handle(): int
    {
        // Redis queue depth
        $depth = Redis::llen('queues:default');

        // Database queue depth
        // $depth = DB::table('jobs')->where('queue', 'default')->count();

        $threshold = 1000;
        if ($depth > $threshold) {
            // Send to your alerting system (PagerDuty, Slack, Opsgenie)
            $this->error("Queue depth: {$depth} (threshold: {$threshold})");
            return self::FAILURE;
        }

        $this->info("Queue depth: {$depth} (OK)");
        return self::SUCCESS;
    }
}

Schedule this to run every minute. If queue depth exceeds your threshold, the alert fires. Tune the threshold based on normal traffic patterns — a marketing email blast can legitimately push the queue to tens of thousands of jobs, but for a system processing 50 jobs/minute, a depth of 1000 indicates workers are falling behind.

Monitor worker liveness:

A worker that crashed (out-of-memory, segfault, deployment killed it without restart) is the worst case: queue depth grows but no jobs are processed. Detect this by tracking worker heartbeats.

With Supervisor:

# Check that the expected number of workers are running
supervisorctl status laravel-worker:*
# laravel-worker:laravel-worker_00   RUNNING   pid 1234, uptime 2:14:00
# laravel-worker:laravel-worker_01   RUNNING   pid 1235, uptime 2:14:00
# If any are STOPPED or EXITED, page on-call

Real-world scenario: A common production incident pattern is a deploy that introduces a bug in a job. The worker picks up jobs from the queue, fails on each one, moves them to failed_jobs, and continues. The jobs table looks healthy. But hundreds of business-critical jobs are silently dead in failed_jobs. Monitor failed_jobs growth rate, not just total count. A sudden spike in failed jobs per minute is the signature of a regression.

-- Alert if more than 50 jobs failed in the last 10 minutes
SELECT COUNT(*) FROM failed_jobs
WHERE failed_at > NOW() - INTERVAL '10 minutes';

Recovery playbook for a stuck queue:

  1. Confirm a worker is actually running (ps aux | grep queue:work).
  2. Check the queue connection matches the worker’s connection.
  3. Inspect the most recent 5 entries in failed_jobs for a common exception.
  4. Look at storage/logs/worker.log (or your Supervisor stdout) for the last error.
  5. If workers are stuck on a bad job, identify the job class and either fix the bug or queue:forget the poison message.
  6. After fixing, run queue:retry all to drain the failed-job backlog.

Still Not Working?

QUEUE_CONNECTION=sync in .envsync runs jobs immediately in the current process (useful for local development/testing), not asynchronously. Set to database or redis for true async processing. After changing .env, run php artisan config:clear.

Cache config is stale — if you’ve changed queue.php or .env, clear the cached config: php artisan config:clear && php artisan config:cache.

Database queue: jobs table not created — run php artisan queue:table && php artisan migrate to create the jobs table.

Redis connection refused — if using Redis queue, verify Redis is running: redis-cli ping should return PONG. Check the REDIS_HOST and REDIS_PORT in .env.

Job dispatched in a test with Queue::fake() — if Queue::fake() is called in a test, jobs are intercepted and not actually dispatched to a queue. This is intentional for testing, but make sure production code doesn’t have Queue::fake() active:

// In tests
Queue::fake();
ProcessOrder::dispatch($order);
Queue::assertPushed(ProcessOrder::class);  // Verify it was dispatched (not executed)

// For integration tests where you want jobs to run:
// Don't call Queue::fake() — or use a real queue connection

Worker silently killed by OOM — if your jobs allocate large objects (loading thousands of Eloquent models, processing big files), the worker process can exceed available memory and be killed by the OS without a graceful failure. Set memory_limit per job with --memory=512 on queue:work, and use chunked queries (->chunk(100, ...)) to bound memory.

Job dispatched inside a transaction that rolled back — if you dispatch a job from inside DB::transaction(...) and the transaction rolls back, the job is still on the queue but the data it expects no longer exists. Use dispatch_sync for jobs that must observe transaction state, or use the afterCommit() modifier in Laravel 8+ to defer dispatching until the transaction commits.

SQS queue not receiving messages — if using SQS, verify the IAM role has sqs:SendMessage permission. The “stuck” symptom can be a permission denial silently swallowed. Check CloudWatch logs for AccessDenied errors from the Laravel worker.

For related issues, see Fix: Celery Task Not Executing, Fix: Redis Pub/Sub Not Working, Fix: Redis Connection Refused, and Fix: RabbitMQ Connection Refused.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles