# 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.

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](https://laravel.com/docs/13.x/queues).

## 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
<?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`:

```text
 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:

```text
# 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.

```php
// 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,
],
```

```bash
php artisan queue:work redis --timeout=300
```

## Work with `failed_jobs`

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

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

List, retry and clean up:

```bash
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`.

```php
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:

```php
// 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](/docs/laravel/) 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](/docs/laravel/) and run one of your jobs: its attempts show up under **Jobs** within seconds.
