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_jobs table: 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:

php
class 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

php
public function handle(): void
{
    if ($this->order->cancelled) {
        $this->fail('Order was cancelled.');
        return;
    }

    // Or fail with exception
    $this->fail(new OrderCancelledException());
}

Deleting Jobs

php
public function handle(): void
{
    if ($this->order->cancelled) {
        $this->delete();  // Remove from queue without failing
        return;
    }

    // Process...
}

Releasing Jobs Back to Queue

php
public function handle(): void
{
    if ($this->serviceUnavailable()) {
        $this->release(60);  // Try again in 60 seconds
        return;
    }

    // Process...
}

Global Failed Job Handler

In AppServiceProvider:

php
use 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

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

php
class 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

  1. Assuming failed() runs in the same process as handle() — it doesn't. Laravel boots a fresh process to run the failed-job hook, so any state you built up in handle() (local variables, resolved services) is gone. Rely on $this->* properties instead.
  2. Using release() for permanent failures — release() counts as a retry attempt. A card decline isn't going to fix itself; use fail($e) to short-circuit instead of burning the retry budget.

Best Practices

  1. 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 enters failed_jobs.
  2. Prune the failed_jobs table on a schedule — queue:prune-failed --hours=168 keeps 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_jobs table with UUID, payload, and exception.
  • Queue::failing() is a global hook for cross-cutting failure handling.
✓ Completed