Most Common Failure Points
After working with Laravel queues across many production systems, these are the failure causes that appear most frequently:
1. Missing Model (ModelNotFoundException)
// The model was deleted between dispatch and processing
// Fix: set $deleteWhenMissingModels = true
class ProcessPayment implements ShouldQueue
{
public bool $deleteWhenMissingModels = true;
public function __construct(protected Order $order) {}
}
2. Timeout (Process Killed)
// Job runs longer than $timeout — worker process is killed by the OS
// The job appears "reserved" in the DB but never finishes
// Fix: set $timeout and match the --timeout flag on the worker
class ProcessVideoUpload implements ShouldQueue
{
public int $timeout = 300; // 5 minutes
}
// Worker:
// php artisan queue:work --timeout=310 // slightly higher than job timeout
3. Memory Limit Exceeded
// PHP fatal error: Allowed memory size exhausted
// Worker process dies — job is "reserved" and never completed
// Fix 1: Increase PHP memory limit for workers
// php artisan queue:work --memory=512
// Fix 2: Find the leak — use cursor() instead of get() for large datasets
// ❌ User::all() — loads all users into memory
// ✅ User::cursor() — lazy loads one at a time
4. Unserializable Data
// Passing a Closure, a resource, or a non-serializable object causes:
// "Serialization of 'Closure' is not allowed"
// ❌ Can't serialize a closure
class BadJob implements ShouldQueue
{
public function __construct(protected \Closure $callback) {} // crash!
}
// ✅ Pass serializable data only
class GoodJob implements ShouldQueue
{
public function __construct(protected int $userId, protected string $action) {}
}
5. Queue Driver Not Running
Jobs dispatch successfully (they're in the database/Redis) but never execute. The worker process isn't running. This is the most common "it's not working" issue for beginners.
Structured Queue Logging
Generic log messages make queue debugging painful. Structure your logs so you can filter, search, and correlate them effectively.
// ❌ Unstructured — impossible to filter by job or order
\Log::error('Something went wrong: ' . $e->getMessage());
// ✅ Structured — filter by job_class, order_id, attempt
\Log::error('Queue job failed', [
'job_class' => static::class,
'order_id' => $this->order->id,
'attempt' => $this->attempts(),
'queue' => $this->queue,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// Log job lifecycle events in a reusable middleware
class LogJobLifecycle
{
public function handle(object $job, \Closure $next): void
{
$ctx = [
'job' => class_basename($job),
'attempt' => method_exists($job, 'attempts') ? $job->attempts() : 1,
'queue' => $job->queue ?? 'default',
];
\Log::debug('Job started', $ctx);
$start = microtime(true);
try {
$next($job);
\Log::debug('Job completed', array_merge($ctx, [
'duration_ms' => round((microtime(true) - $start) * 1000),
]));
} catch (\Throwable $e) {
\Log::error('Job failed', array_merge($ctx, [
'error' => $e->getMessage(),
'duration_ms' => round((microtime(true) - $start) * 1000),
]));
throw $e;
}
}
}
Use a Dedicated Queue Log Channel
// config/logging.php
'channels' => [
'queue' => [
'driver' => 'daily',
'path' => storage_path('logs/queue.log'),
'level' => 'debug',
'days' => 14,
],
],
// In jobs — use the queue channel
\Log::channel('queue')->error('Payment failed', ['order_id' => $this->order->id]);
Silent Failures – The Hardest Bug
A silent failure is when a job appears to succeed (no exception, no failed_jobs record) but doesn't produce the expected result. These are the hardest bugs to diagnose.
Common Causes of Silent Failures
// Cause 1: Empty return without doing work
public function handle(): void
{
$user = User::find($this->userId);
if (! $user) return; // ← silent — nothing happened, no log, no alert
// Fix: log when skipping
if (! $user) {
\Log::warning('Job skipped — user not found', ['user_id' => $this->userId]);
return;
}
}
// Cause 2: Exception caught and swallowed
public function handle(): void
{
try {
$this->callApi();
} catch (\Exception $e) {
// ❌ swallowed — job "succeeds" but API was never called
\Log::info('API call failed: ' . $e->getMessage());
// Fix: rethrow so the job actually fails and retries
throw $e;
}
}
// Cause 3: Wrong queue — job is dispatched but no worker listens to that queue
SendEmail::dispatch($user)->onQueue('emails');
// If no worker is running for 'emails' queue, jobs pile up silently
Debugging Serialization Issues
When a job fails immediately without running handle(), it's usually a serialization problem. Check the failed_jobs table — the exception will say "Serialization of..." or "Trying to get property of non-object."
// Debug: manually serialize the job to see what the payload looks like
$job = new ProcessPayment($order);
$payload = serialize($job);
echo strlen($payload) . ' bytes'; // check payload size too
// Common serialization mistakes:
// 1. Passing unserializable types
public function __construct(
protected \SplFileInfo $file, // ❌ not serializable
protected \Closure $callback, // ❌ not serializable
protected $resource, // ❌ PHP resources are not serializable
) {}
// 2. Circular references in Eloquent models
// If $order->load('user.orders') creates circular references,
// SerializesModels may produce corrupt payloads
// Fix: don't load relationships in the constructor — load them inside handle()
// 3. Model not persisted
$user = new User(['email' => 'test@test.com']); // not in DB!
SendEmail::dispatch($user); // SerializesModels stores ID=null, re-fetch returns null
Replaying & Inspecting Failed Jobs
# List all failed jobs (shows UUID, connection, queue, failed_at, exception)
php artisan queue:failed
# Inspect a specific failed job — see its full payload and exception
php artisan queue:failed | grep "SendPayment"
# Query the failed_jobs table directly for deeper inspection
SELECT uuid, queue, payload, exception, failed_at
FROM failed_jobs
WHERE failed_at >= NOW() - INTERVAL 1 HOUR
ORDER BY failed_at DESC;
# Extract the job class from payload (MySQL JSON)
SELECT
JSON_UNQUOTE(JSON_EXTRACT(payload, '$.displayName')) as job_class,
COUNT(*) as failure_count,
MIN(failed_at) as first_failure,
MAX(failed_at) as last_failure
FROM failed_jobs
GROUP BY job_class
ORDER BY failure_count DESC;
Retry with Modified Payload
# Retry a specific job
php artisan queue:retry 550e8400-e29b-41d4-a716-446655440000
# Retry all failed jobs on a specific queue
php artisan queue:retry --queue=payments
# Retry all failed jobs of a specific class (query DB then retry)
DB::table('failed_jobs')
->where('payload', 'like', '%ProcessPayment%')
->pluck('uuid')
->each(fn ($uuid) => Artisan::call('queue:retry', ['id' => [$uuid]]));
Worker Not Running – Diagnostic Steps
The #1 complaint: "I dispatched the job but nothing happened." Follow this diagnostic checklist:
# Step 1: Is the job in the queue?
SELECT COUNT(*) FROM jobs;
# If 0 — job was never dispatched. Check QUEUE_CONNECTION in .env is not 'sync'.
# Step 2: Is a worker running?
ps aux | grep "queue:work"
# If nothing — start a worker: php artisan queue:work
# Step 3: Is the worker listening to the right queue?
# Check the --queue flag. Worker must listen to the queue the job was dispatched to.
# php artisan queue:work --queue=emails # only processes 'emails' queue
# If your job is on 'default' queue, this worker ignores it.
# Step 4: Is the job reserved (stuck)?
SELECT * FROM jobs WHERE reserved_at IS NOT NULL;
# reserved_at set but job not completing = worker crashed mid-job
# These jobs auto-release after retry_after seconds (default 90s in queue.php)
# Step 5: Check worker logs
tail -f storage/logs/worker.log
tail -f storage/logs/laravel.log | grep -i "queue\|job\|fail"
# Step 6: Run a worker in verbose mode
php artisan queue:work --verbose
Conclusion
The most common failures: missing model, timeout, memory limit, unserializable data, worker not running
Use structured logging with job context — filter logs by job class, entity ID, and attempt number
Never swallow exceptions silently — rethrow so the job actually fails and gets recorded
Always log when a job skips work — silent no-ops are impossible to debug without logs
Use the failed_jobs table as your primary debugging tool — query it for patterns
When a worker isn't processing: verify job is in the queue, worker is running, and it's listening to the right queue