A job that creates shipping labels retries three times against a carrier API that is having a bad morning, then gives up. The row lands in failed_jobs, the worker moves on, and nothing else happens. The order sits there looking perfectly normal in the admin panel, unshipped, until a customer asks where their parcel is.

The queue did its job. Nobody told the application that the work is not coming.

Add a failed() method and Laravel will call it

Any job class can declare a failed() method. When the job finally gives up, Laravel checks whether the method exists and calls it with the exception that killed it:

class CreateShippingLabel implements ShouldQueue
{
    use Queueable, SerializesModels;

    public int $tries = 3;

    public function __construct(public Shipment $shipment) {}

    public function handle(CarrierApi $carrier): void
    {
        $label = $carrier->createLabel($this->shipment);

        $this->shipment->update([
            'label_url' => $label->url,
            'status'    => 'label_created',
        ]);
    }

    public function failed(?Throwable $e): void
    {
        $this->shipment->update(['status' => 'label_failed']);

        Notification::route('slack', config('alerts.ops_webhook'))
            ->notify(new ShipmentNeedsAttention($this->shipment, $e));
    }
}

Now the failure is visible in two places that matter: the shipment record says what happened, and somebody gets told.

When it actually runs

This is the part worth being precise about, because it is easy to assume it fires more often than it does.

failed() runs once, after the final attempt. If $tries is 3, the first two failures do not call it. It is the end of the line, not a per-attempt error handler.

It also runs when:

  • You call $this->fail($exception) inside the job to give up deliberately.
  • The job exceeds its timeout and the worker's alarm handler marks it as failed.
  • The job passes retryUntil() and has no time left.

It does not run when the job merely calls release() to try again later, and it does not run for an attempt that will be retried. If you need to react to every individual failure, that belongs in a try/catch inside handle(), not here.

Keep it small, and keep it safe

failed() is the last code that will run for this job, so treat it accordingly.

Do the cheap, important things: update a status column, notify a human, release a lock, delete a temporary file. Anything that tells the rest of the system the work did not land.

Avoid heavy work. This is not the place to try the operation a different way, call another slow API, or kick off a large recovery process. If the hook itself throws, you get an exception raised while handling an exception, and the useful information is the one you lose.

If a step in there can realistically fail, guard it:

public function failed(?Throwable $e): void
{
    $this->shipment->update(['status' => 'label_failed']);

    rescue(fn () => Notification::route('slack', config('alerts.ops_webhook'))
        ->notify(new ShipmentNeedsAttention($this->shipment, $e)));
}

rescue() keeps a flaky notification channel from taking down the part that actually mattered.

The exception is nullable for a reason

The signature is failed(?Throwable $e). There are paths where Laravel has no exception to hand you, such as a job that was failed without one. If you log $e->getMessage() without checking, the hook itself explodes:

public function failed(?Throwable $e): void
{
    Log::error('Label creation gave up', [
        'shipment' => $this->shipment->id,
        'reason'   => $e?->getMessage() ?? 'no exception recorded',
    ]);
}

Batches have their own hook

If the job runs inside a batch, failed() still fires for that job, but the batch's own catch() callback is usually the better place for anything about the batch as a whole. Keep the job hook for the single unit of work and let the batch handle the set, rather than writing the same alert in both and sending two messages for one incident.

Wrapping up

A failure that nothing reacts to is a silent failure, and silent failures are the ones customers find first. Give any job with a real-world consequence a failed() method, use it to mark state and tell somebody, keep it cheap, and remember it fires once at the very end rather than on every attempt.

Related