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