skip to content

Inside a Laravel queued job's handle() method, what is the difference between calling $this->release(), calling $this->fail() and throwing an exception?

level: middleimportance: should knowfreq 44%

answer

  1. temporary versus permanent problem
  2. release(seconds) still uses an attempt
  3. fail() skips remaining tries
  4. a string becomes ManuallyFailedException
  5. neither call returns for you

basics

~20 s

$this->release($delay) requeues the job for a later attempt, $this->fail() marks it failed immediately whatever tries remain, and throwing lets the worker retry with backoff until tries run out. Neither call stops handle(), so return afterwards.

solid answer

~40 s

`$this->release(60)` pushes the job back onto its queue after the given seconds; the next pickup counts as an attempt and the job's `backoff` is not used. `$this->fail()` ends it now: the job is deleted, `failed()` runs, `JobFailed` fires and a `failed_jobs` row is written, even if tries remain; pass a caught exception, or a string that becomes a `ManuallyFailedException`. Throwing hands the decision to the worker, which fails the job if it was the last attempt, if `#[MaxExceptions]` is reached or if the exception is on the `dontRetry()` list, and otherwise releases it with its backoff. Neither `release()` nor `fail()` throws, so you `return` right after them. `failed()` runs on a freshly unserialized instance, so state set in `handle()` is gone.

code

php · 33 lines
php
<?php

use App\Exceptions\AddressNotFound;
use App\Models\Listing;
use App\Services\Geocoder;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class GeocodeListing implements ShouldQueue
{
    use Queueable;

    public $tries = 5;

    public function __construct(public Listing $listing) {}

    public function handle(Geocoder $geocoder): void
    {
        if ($geocoder->inMaintenance()) {
            $this->release(600); // temporary: try again in 10 minutes
            return;
        }

        try {
            $point = $geocoder->lookup($this->listing->address);
        } catch (AddressNotFound $e) {
            $this->fail($e); // permanent: retrying cannot help
            return;
        }

        $this->listing->update(['lat' => $point->lat, 'lng' => $point->lng]);
    }
}

go deeper

for a junior

Recall that release puts the job back, fail stops it for good, and a thrown exception is retried if attempts remain.

for a middle

Explain that release consumes an attempt and ignores backoff, that fail accepts an exception or a string, and why you return after either call.

for a senior

Classify failures as temporary or permanent and map each to release, fail, FailOnException or dontRetry, knowing what failed() will receive.

for a principal

Set conventions so every job states which errors are retryable, keeping failed_jobs a list of real problems rather than noise.

## Three ways out of a failing handle() A queued job in Laravel has three ways to react when something goes wrong inside `handle()`. They look similar but have very different effects on the job's **attempts**, its **backoff** and whether it ends in `failed_jobs`. | Action | Consumes an attempt | Uses `backoff` | Ends in `failed_jobs` | `failed()` receives | |---|---|---|---|---| | `$this->release(60)` | Yes, on the next pickup | No, uses the delay you pass | Only if a later pickup finds no attempts left | `MaxAttemptsExceededException` in that case | | `$this->fail($e)` | No further attempts | No | Yes, immediately | The exception passed, or `ManuallyFailedException` for a string | | Throwing | Yes | Yes | When no retry is allowed: last attempt, `MaxExceptions` reached or `dontRetry()` | The thrown exception | ## release(): try again later `$this->release()` comes from the `InteractsWithQueue` trait, which the `Queueable` trait on every generated job includes. It pushes the job back onto its queue: - `release()` with no argument makes it available immediately; - `release(60)` or `release(now()->addMinute())` delays it by seconds or until a date. The next pickup counts as a new attempt. With the default of one try, a released job is failed with `MaxAttemptsExceededException` as soon as it is picked up again, so jobs that release themselves need `tries` above one or a `retryUntil()` deadline. Use `release()` for **expected, temporary** conditions: a dependency in maintenance, a resource not ready yet. ## fail(): stop now `$this->fail()` marks the job failed at once, whatever attempts remain. It accepts: 1. nothing, in which case `failed()` receives `null`; 2. a caught `Throwable`, which is passed through; 3. a string, which Laravel wraps in `Illuminate\Queue\ManuallyFailedException`. The job is deleted from the queue, `failed()` runs, the `JobFailed` event fires and the worker records it in `failed_jobs`. Use it for **permanent** conditions where another attempt cannot succeed, such as an address the geocoder says does not exist. ## Throwing: let the worker decide An uncaught exception goes to the worker, which checks in order: 1. would another attempt exceed `tries`, or has the `retryUntil()` deadline passed? Then the job fails; 2. has the job reached its `#[MaxExceptions]` count? Then it fails; 3. is the exception listed with `dontRetry()` in `bootstrap/app.php`? Then it fails. Otherwise the job is released with its `backoff` delay. Either way the exception is rethrown so the exception handler reports it. ## Neither call stops handle() `release()` and `fail()` are ordinary method calls: they change the job's state and **return**. Code after them keeps running, so a `$this->fail(...)` followed by more work will still perform that work. Always `return` straight after either call. ## failed() runs on a new instance Laravel unserializes a **fresh copy** of the job from its payload before calling `failed()`, so anything `handle()` stored on `$this` is gone. Models in `SerializesModels` properties are fetched from the database again. The exception it receives tells you why the job ended: - the exception thrown on the final attempt; - `MaxAttemptsExceededException` when the job was picked up with no attempts left; - `TimeoutExceededException` (a subclass of `MaxAttemptsExceededException`) after a timeout; - `ManuallyFailedException` when `fail()` was given a string. ## Choosing between them Ask one question about each failure: **could another attempt succeed?** 1. **Yes, later, and you know when**: a maintenance window, a record another job has not written yet. Call `release()` with a delay and give the job enough `tries` or a `retryUntil()` deadline. 2. **Maybe, and you cannot tell**: a timeout, a connection reset, a 500 from an upstream API. Let it throw; the worker applies `backoff` and stops at `tries` or `#[MaxExceptions]`. 3. **No**: invalid input, a deleted customer, a permission that was revoked. Call `fail()` with the exception, or declare it with `FailOnException` or `dontRetry()`, so the job does not burn attempts and delays on something that cannot change. Getting this wrong has visible costs. Throwing on a permanent error wastes every remaining attempt and fills logs with the same trace; calling `release()` on an unexpected error hides a bug behind requeues until the attempts run out; calling `fail()` on a transient blip sends work to `failed_jobs` that one more try would have finished. ## Declarative alternatives Instead of `try`/`catch` plus `fail()`, the `FailOnException` job middleware fails the job for listed exception classes, and `$exceptions->dontRetry([...])` inside `withExceptions()` in `bootstrap/app.php` does the same application-wide.

  • What does failed() receive when $this->fail() is called with no argument?
    `null`, which is why the signature is `failed(?Throwable $exception)`. The `JobFailed` event still carries an exception, because Laravel substitutes a `ManuallyFailedException` for the missing one there. Pass the caught exception or a message if `failed()` needs to know why.
  • How can a Laravel job fail immediately on a specific exception without try/catch in handle()?
    Attach the `FailOnException` job middleware with the exception classes, for example `new FailOnException([AuthorizationException::class])`, or register them application-wide with `$exceptions->dontRetry([...])` in `withExceptions()` in `bootstrap/app.php`. Other exceptions keep retrying normally.

A courier with an undeliverable parcel: when the recipient is out, the parcel goes back on the van for another round (release); when the address does not exist, it goes straight to the returns desk however many rounds are left (fail); when the van breaks down, the depot decides whether to send it out again (throwing).

saying these in an interview costs you the question

  • Calling $this->fail() throws, so code after it never runs.
  • Manual $this->release() calls do not count toward the job's tries.
  • A released job waits for its configured $backoff before running again.
  • failed() can read properties that handle() set earlier on $this.
  • $this->fail() only takes effect after the remaining tries are used up.