Inside a Laravel queued job's handle() method, what is the difference between calling $this->release(), calling $this->fail() and throwing an exception?
answer
- temporary versus permanent problem
- release(seconds) still uses an attempt
- fail() skips remaining tries
- a string becomes ManuallyFailedException
- 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
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
Recall that release puts the job back, fail stops it for good, and a thrown exception is retried if attempts remain.
Explain that release consumes an attempt and ignores backoff, that fail accepts an exception or a string, and why you return after either call.
Classify failures as temporary or permanent and map each to release, fail, FailOnException or dontRetry, knowing what failed() will receive.
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.