Introduction
Jobs fail. Third-party APIs go down, database deadlocks happen, business rules get violated. Laravel's failure machinery gives you the hooks to notify on failure, manually fail a job mid-flight, release it back to the queue for a retry, and clean up state — plus a failed_jobs table for postmortems.
Key Concepts
failed()method: A hook Laravel calls when a job exhausts retries or throws an un-handled exception.$this->fail($e): Mark the current job as permanently failed, skipping remaining retries.$this->release($seconds): Put the job back on the queue with a delay — counts as an attempt.$this->delete(): Silently drop the job without marking it failed.failed_jobstable: Persistent record of every job that ran out of retries, with connection, payload, exception, and timestamp.
Real World Context
A payment job that fails because the card was declined should notify the customer and update the order to payment_failed — that's what failed() is for. A job that hits a rate-limited API should release(60) and try again in a minute, not count as a retry. A job whose target model was deleted should delete() itself without raising an alarm. Each of these is a different failure mode that needs a different tool.
Deep Dive
The failed() Method
Define what happens when a job fails:
phpclass ProcessOrder implements ShouldQueue { public function handle(): void { // Process order... } /** * Handle job failure. */ public function failed(?Throwable $exception): void { // Log the failure Log::error('Order processing failed', [ 'order_id' => $this->order->id, 'exception' => $exception->getMessage(), ]); // Notify someone Notification::send( User::admins()->get(), new JobFailed($this->order, $exception) ); // Update status $this->order->update(['status' => 'failed']); } }
Manual Failure
phppublic function handle(): void { if ($this->order->cancelled) { $this->fail('Order was cancelled.'); return; } // Or fail with exception $this->fail(new OrderCancelledException()); }
Deleting Jobs
phppublic function handle(): void { if ($this->order->cancelled) { $this->delete(); // Remove from queue without failing return; } // Process... }
Releasing Jobs Back to Queue
phppublic function handle(): void { if ($this->serviceUnavailable()) { $this->release(60); // Try again in 60 seconds return; } // Process... }
Global Failed Job Handler
In AppServiceProvider:
phpuse Illuminate\Support\Facades\Queue; public function boot(): void { Queue::failing(function (JobFailed $event) { // $event->connectionName // $event->job // $event->exception Log::channel('slack')->error('Job failed', [ 'job' => $event->job->resolveName(), 'exception' => $event->exception->getMessage(), ]); }); }
Managing Failed Jobs
bash# List failed jobs php artisan queue:failed # Retry specific job php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece # Retry all failed jobs php artisan queue:retry all # Retry jobs that failed in last 24 hours php artisan queue:retry --range=24 # Delete a failed job php artisan queue:forget ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece # Clear all failed jobs php artisan queue:flush # Prune old failed jobs php artisan queue:prune-failed --hours=48
Ignoring Missing Models
phpuse Illuminate\Queue\Middleware\SkipIfModelMissing; class SendWelcomeEmail implements ShouldQueue { use SerializesModels; public function __construct( public User $user ) {} public function middleware(): array { return [new SkipIfModelMissing]; } } // Or on the model property class SendWelcomeEmail implements ShouldQueue { public $deleteWhenMissingModels = true; }
Retry After Transaction
Ensure database transactions complete:
phpclass ProcessPayment implements ShouldQueue { public $afterCommit = true; } // Or when dispatching ProcessPayment::dispatch($payment)->afterCommit();
Example: Robust Job with Full Error Handling
php<?php namespace App\Jobs; use App\Models\Order; use App\Services\PaymentGateway; use App\Notifications\PaymentFailed; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Queue\Middleware\WithoutOverlapping; class ProcessPayment implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; public array $backoff = [30, 60, 120]; public int $timeout = 60; public bool $failOnTimeout = true; public bool $deleteWhenMissingModels = true; public function __construct( public Order $order ) {} public function middleware(): array { return [ new WithoutOverlapping($this->order->id), ]; } public function handle(PaymentGateway $gateway): void { if ($this->order->isPaid()) { $this->delete(); return; } try { $charge = $gateway->charge($this->order); $this->order->markAsPaid($charge->id); } catch (PaymentDeclinedException $e) { $this->fail($e); } catch (GatewayUnavailableException $e) { $this->release(60); } } public function failed(?Throwable $exception): void { $this->order->update(['status' => 'payment_failed']); $this->order->user->notify(new PaymentFailed($this->order, $exception)); } }
Common Pitfalls
- Assuming
failed()runs in the same process ashandle()— it doesn't. Laravel boots a fresh process to run the failed-job hook, so any state you built up inhandle()(local variables, resolved services) is gone. Rely on$this->*properties instead. - Using
release()for permanent failures —release()counts as a retry attempt. A card decline isn't going to fix itself; usefail($e)to short-circuit instead of burning the retry budget.
Best Practices
- Wire
Queue::failing()to your alerting channel — a job that failed silently is worse than a job that crashed loudly. Post to Slack, Sentry, or PagerDuty the moment a job entersfailed_jobs. - Prune the
failed_jobstable on a schedule —queue:prune-failed --hours=168keeps the table from growing without bound. Six months of failure history is plenty.
Summary
failed()is a method-level hook for cleanup and notification on permanent failure.fail($e)short-circuits retries;release($seconds)retries after a delay;delete()drops the job silently.- Failed jobs land in the
failed_jobstable with UUID, payload, and exception. Queue::failing()is a global hook for cross-cutting failure handling.