DEV Community

Cover image for Debugging Laravel Queue Failures in Production Without Losing Messages
Deploynix
Deploynix

Posted on • Originally published at deploynix.io

Debugging Laravel Queue Failures in Production Without Losing Messages

Queue workers are the workhorses of production Laravel applications. They send emails, process payments, generate reports, resize images, sync data with third-party APIs, and handle every operation too slow for a web request. When they fail, the consequences range from annoying (delayed notifications) to catastrophic (lost payment confirmations, corrupted data).

The challenge with queue failures in production is that they happen asynchronously. There is no browser to show an error page. The user who triggered the job has already moved on. The failure sits silently in a database table or log file until someone notices the downstream effects.

This guide teaches you how to proactively detect, inspect, debug, and recover from queue failures without losing a single message.

Understanding How Laravel Queue Failures Work

When a queued job fails, Laravel follows a specific sequence:

  1. The job throws an exception during execution
  2. Laravel catches the exception and checks the job's $tries property
  3. If attempts remain, the job is released back to the queue with a delay
  4. If all attempts are exhausted, the job is moved to the failed_jobs table
  5. The JobFailed event is dispatched

This sequence means there are two distinct categories of failures:

  • Retryable failures: Temporary issues (API timeout, database deadlock, rate limit hit) where the job will succeed on retry
  • Permanent failures: Logic errors, invalid data, or missing resources where retrying will never help

Your debugging strategy must handle both categories differently.

Setting Up Failure Infrastructure

Before you can debug failures, you need infrastructure to capture them.

The Failed Jobs Table

Ensure you have the failed_jobs migration:

php artisan make:queue-failed-table
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

This creates a table that stores every permanently failed job with its connection, queue name, payload, exception message, and the timestamp of failure. This table is your primary forensic tool.

Job Failure Notifications

Configure immediate notification when jobs fail. In your AppServiceProvider or a dedicated service provider:

use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;

Event::listen(JobFailed::class, function (JobFailed $event) {
    Log::error('Job failed', [
        'job' => $event->job->resolveName(),
        'exception' => $event->exception->getMessage(),
        'queue' => $event->job->getQueue(),
    ]);
});
Enter fullscreen mode Exit fullscreen mode

For critical jobs (payment processing, order fulfillment), send a Slack notification or email to your operations team. You want to know about these failures within minutes, not hours.

Deploynix Health Monitoring

Deploynix's real-time monitoring tracks your server's resource utilization. Queue worker failures caused by memory exhaustion or CPU saturation show up as server-level alerts. Configure disk usage alerts too — if your failed_jobs table grows rapidly, something systemic is wrong.

Inspecting Failed Jobs

Viewing Failed Jobs

List all failed jobs:

php artisan queue:failed
Enter fullscreen mode Exit fullscreen mode

This shows a table with the job ID, connection, queue, class name, and failure time. For more detail on a specific failure:

php artisan queue:failed --json
Enter fullscreen mode Exit fullscreen mode

The JSON output includes the full exception trace and the serialized job payload, which contains the exact data the job was processing when it failed.

Decoding the Payload

The payload column in the failed_jobs table contains a JSON-encoded structure. The data.command key holds a serialized PHP object — your actual job class with all its properties at the time of dispatch.

To inspect a failed job's data:

// In tinker or a debug command
$failedJob = DB::table('failed_jobs')->find($id);
$payload = json_decode($failedJob->payload, true);
$command = unserialize($payload['data']['command']);
Enter fullscreen mode Exit fullscreen mode

This gives you the exact job instance with all constructor arguments, public properties, and any state that was serialized. You can inspect what data the job was working with, which is often the key to understanding why it failed.

Reading the Exception

The exception column stores the full exception message and stack trace. Common patterns:

  • "Trying to get property of non-null": A model relationship or property was null when expected to have a value. The related record was likely deleted between dispatch and execution.
  • "Connection refused": A third-party service (API, SMTP server, Redis) was unreachable.
  • "Deadlock found": Multiple queue workers tried to update the same database rows simultaneously.
  • "Maximum execution time exceeded": The job ran longer than PHP's max_execution_time. This is separate from the job's $timeout property.

Common Queue Failure Patterns

Pattern 1: Model Not Found

The most common queue failure in Laravel applications. A job is dispatched with a model ID. Between dispatch and execution, the model is deleted. The job tries to load the model and gets null or throws a ModelNotFoundException.

Fix: Use the DeleteWhenMissingModels trait on your job class:

use Illuminate\Queue\Attributes\DeleteWhenMissingModels;

#[DeleteWhenMissingModels]
class ProcessOrder implements ShouldQueue
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This tells Laravel to silently delete the job if the model cannot be found, instead of failing it.

Pattern 2: Third-Party API Failures

External APIs fail temporarily — rate limits, maintenance windows, network issues. Your job should be configured to retry with increasing delays:

public int $tries = 5;

public function backoff(): array
{
    return [10, 30, 60, 300, 900]; // seconds
}
Enter fullscreen mode Exit fullscreen mode

This retries at 10 seconds, 30 seconds, 1 minute, 5 minutes, and 15 minutes. The exponential backoff gives the external service time to recover.

Pattern 3: Memory Exhaustion

A job processes a large dataset (CSV import, bulk email, report generation) and runs out of memory. The worker process is killed by the OOM killer, and the job may be left in an indeterminate state.

Fix: Process data in chunks:

public function handle(): void
{
    User::query()
        ->where('needs_notification', true)
        ->chunk(100, function ($users) {
            foreach ($users as $user) {
                $user->notify(new WeeklyDigest());
            }
        });
}
Enter fullscreen mode Exit fullscreen mode

For very large operations, dispatch multiple smaller jobs instead of one large one. A ProcessCsvImport job might read the file, split it into chunks of 1,000 rows, and dispatch a ProcessCsvChunk job for each.

Pattern 4: Serialization Failures

Jobs are serialized when dispatched and deserialized when processed. If a job's constructor or public properties contain non-serializable objects (closures, database connections, file handles), serialization fails.

Symptoms: The job fails immediately with a serialization error, or it fails on deserialization with corrupted data.

Fix: Only pass serializable data to job constructors — model IDs (not model instances, unless using SerializesModels), primitive types, arrays, and simple objects. Use the SerializesModels trait for Eloquent models, which stores the model's ID and class during serialization and re-fetches it during deserialization.

Pattern 5: Database Deadlocks

When multiple queue workers process jobs that modify overlapping database rows, MySQL deadlocks occur. Laravel retries deadlocked queries automatically (once), but if the retry also deadlocks, the job fails.

Fix: Reduce contention by using database locks strategically:

public function handle(): void
{
    DB::transaction(function () {
        $account = Account::lockForUpdate()->find($this->accountId);
        $account->balance += $this->amount;
        $account->save();
    });
}
Enter fullscreen mode Exit fullscreen mode

Or use Laravel's atomic locks to ensure only one job processes a given resource at a time:

public function handle(): void
{
    $lock = Cache::lock('process-account-' . $this->accountId, 30);

    if ($lock->get()) {
        try {
            // Process the job
        } finally {
            $lock->release();
        }
    } else {
        $this->release(10); // Try again in 10 seconds
    }
}
Enter fullscreen mode Exit fullscreen mode

Retrying Failed Jobs

Retry a Specific Job

php artisan queue:retry {id}
Enter fullscreen mode Exit fullscreen mode

This moves the job from failed_jobs back to its original queue for reprocessing.

Retry All Failed Jobs

php artisan queue:retry all
Enter fullscreen mode Exit fullscreen mode

Use this cautiously. If 500 jobs failed because of a bug, retrying all of them before fixing the bug just creates 500 more failures.

Retry Jobs by Class

php artisan queue:retry --class=App\\Jobs\\SendWelcomeEmail
Enter fullscreen mode Exit fullscreen mode

This is safer than retrying everything because you can target specific job types that you know are now fixable.

Flushing Failed Jobs

php artisan queue:flush
Enter fullscreen mode Exit fullscreen mode

This deletes all failed jobs permanently. Only use this after you have investigated the failures and either retried the recoverable ones or determined they are no longer relevant.

Daemon Management with Deploynix

Queue workers in production should run as daemons — long-lived processes managed by a process supervisor. Deploynix manages daemons through Supervisor, providing:

  • Automatic restart: If a worker crashes (memory limit, unhandled exception), Supervisor restarts it immediately
  • Deployment restart: Deploynix restarts all daemons after deployment so workers load new code
  • Multiple workers: Configure the number of worker processes per daemon
  • Queue priority: Route different queues to different workers

Configuring a Queue Daemon in Deploynix

Through the Deploynix dashboard, create a daemon with:

  • Command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
  • Number of processes: 2-4 depending on your workload
  • Auto-restart: Enabled

The --max-time=3600 flag tells the worker to exit after one hour, even if it is idle. Supervisor immediately restarts it. This prevents memory leaks from accumulating in long-running PHP processes.

Monitoring Worker Health

Watch for these signals that your queue workers are unhealthy:

  • Jobs processing time increasing: Workers are overloaded or contending for resources
  • Failed job count climbing: A systematic issue, not isolated failures
  • Worker memory usage growing: Memory leak in a job or dependency
  • Workers frequently restarting: Check Supervisor logs for the cause

Deploynix's real-time monitoring shows CPU and memory utilization. If your worker server consistently runs above 80% memory, either optimize your jobs or upgrade the server.

Preventing Message Loss

The worst outcome is losing queued messages entirely — a job that was dispatched but never processed and never recorded as failed. This happens when:

  1. A worker dies mid-job and the queue driver does not requeue the message
  2. Redis loses data due to a crash without persistence configured
  3. The database table or queue storage is purged accidentally

Prevention strategies:

Use database or Redis with persistence: For critical jobs, use the database queue driver. It provides the strongest durability guarantee because jobs live in a MySQL table with ACID properties. If you use Redis (Valkey), ensure RDB or AOF persistence is enabled.

Implement idempotent jobs: Design jobs so that processing them twice produces the same result as processing them once. If a payment job runs twice, it should detect the duplicate and skip the second execution. This makes retry-after-failure safe.

Log job dispatch and completion: For critical business processes, log when a job is dispatched and when it completes. If you see a dispatch log without a corresponding completion log, investigate.

public function handle(): void
{
    Log::info('Processing order', ['order_id' => $this->orderId]);

    // Process...

    Log::info('Order processed', ['order_id' => $this->orderId]);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Queue failures in production are inevitable. Third-party services go down, data gets into unexpected states, and race conditions surface under real load. The goal is not to prevent every failure — it is to detect failures quickly, understand their cause, and recover without losing data.

Set up your failure infrastructure before you need it: the failed_jobs table, failure event listeners, and monitoring alerts. Configure your Deploynix daemons with appropriate retry policies and memory limits. Design your jobs to be idempotent and resilient.

When failures do occur, the failed_jobs table gives you the payload and exception trace to diagnose the root cause. Fix the underlying issue, retry the failed jobs, and move on. This is the reality of production queue management: not perfection, but controlled, observable, recoverable failure handling.

Top comments (0)