Why Laravel queued jobs fail, and how to find out

A Laravel job fails when it throws on its last attempt, times out or runs out of attempts. It lands in failed_jobs and nobody is told unless you add a hook.

Updated View as Markdown

A queued job fails when it throws on its last attempt, runs longer than its timeout, or is picked up more often than its tries allow. Laravel then writes it to the failed_jobs table and moves on: nobody gets an email, and the worker log only says FAIL.

Everything below was run on Laravel 13.40 with the database queue driver, against the Laravel 13.x queue docs.

The three ways a job fails

It throws on its last attempt

By default Laravel attempts a job once; an exception releases it back onto the queue only while it has attempts left. Laravel 13 sets this with attributes on the job class:

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
use RuntimeException;

#[Tries(3)]
#[Backoff([1, 2])]
class SyncInvoices implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        throw new RuntimeException('Billing API returned 503');
    }
}

#[Backoff([1, 2])] waits 1 second before the second attempt and 2 before the third. Without it a released job is retried immediately, which rarely helps against a rate limit. For a whole worker: php artisan queue:work --tries=3 --backoff=3.

It times out

A job that runs past its timeout (60 seconds by default; set it with #[Timeout(120)] or --timeout) makes the worker exit. Timeouts need the pcntl extension. With no attempts left, the job fails with Illuminate\Queue\TimeoutExceededException:

 2026-11-07 23:43:32 App\Jobs\ExportReport .. RUNNING
 2026-11-07 23:43:35 App\Jobs\ExportReport .. 3s FAIL
 2026-11-07 23:43:35 Worker STOPPED Job timed out

Supervisor or Forge restarts the worker; a queue:work in a terminal stays stopped.

It runs longer than retry_after

Each connection in config/queue.php has a retry_after: 90 seconds for database and redis (DB_QUEUE_RETRY_AFTER, REDIS_QUEUE_RETRY_AFTER). A job still running after that is handed to another worker, which sees too many attempts and fails it with MaxAttemptsExceededException while the first worker finishes it.

We reproduced it with a job that sleeps 6 seconds, #[Tries(1)], #[Timeout(30)], DB_QUEUE_RETRY_AFTER=2 and two workers:

# worker 1
 2026-11-07 23:44:16 App\Jobs\ImportProducts .. RUNNING
 2026-11-07 23:44:22 App\Jobs\ImportProducts .. 6s DONE

# worker 2
 2026-11-07 23:44:20 App\Jobs\ImportProducts .. RUNNING
 2026-11-07 23:44:20 App\Jobs\ImportProducts .. 4.15ms FAIL

failed_jobs now holds MaxAttemptsExceededException: App\Jobs\ImportProducts has been attempted too many times. for a job that did its work. The fix: keep the timeout several seconds below retry_after, so a stuck job is killed before it's handed out again.

// config/queue.php, connections
'redis' => [
    'driver' => 'redis',
    'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 330),
    'block_for' => null,
    'after_commit' => false,
],
php artisan queue:work redis --timeout=300

Work with failed_jobs

New Laravel apps have the migration. If yours doesn't:

php artisan make:queue-failed-table
php artisan migrate

List, retry and clean up:

php artisan queue:failed
php artisan queue:retry all
php artisan queue:retry --queue=QUEUE_NAME
php artisan queue:forget FAILED_JOB_UUID
php artisan queue:prune-failed --hours=48

QUEUE_NAME is the queue to retry, FAILED_JOB_UUID an ID from queue:failed. queue:retry pushes the original payload back, so fix the cause first. Under Horizon, use horizon:forget. Synchronously dispatched jobs never reach this table.

Clean up in failed()

A failed method runs once after the last attempt, with the exception: yours, MaxAttemptsExceededException or TimeoutExceededException.

use Illuminate\Support\Facades\Log;
use Throwable;

public function failed(?Throwable $exception): void
{
    Log::error('SyncInvoices failed', ['exception' => $exception?->getMessage()]);
}

Use it to undo partial work or tell the user. It's per class, so it's the wrong place to alert the team.

Get notified when any job fails

Queue::failing runs for every failed job. Send it where a person reads it, such as a Slack incoming webhook:

// app/Providers/AppServiceProvider.php
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::failing(function (JobFailed $event) {
        Http::post(config('services.slack.failed_jobs_webhook'), [
            'text' => sprintf(
                'Job failed: %s on %s. %s',
                $event->job->resolveName(),
                $event->connectionName,
                $event->exception->getMessage(),
            ),
        ]);
    });
}

For the SyncInvoices job above, that posts Job failed: App\Jobs\SyncInvoices on database. Billing API returned 503.

This catches failures, not patterns: a job that fails 2 of its 3 attempts every night never shows up. For that you need every attempt.

How Lampwick shows job attempts

The Lampwick Laravel package records every job attempt. Jobs lists each job class with its processed, released and failed counts and p95 duration; each attempt opens a timeline of its queries, logs and exceptions. An exception in a job becomes an issue in Issues, with a push notification when it's new or comes back after you resolved it. There is no separate alert for a failed job.

Next step

Connect a Laravel app and run one of your jobs: its attempts show up under Jobs within seconds.