Where jobs go when they die
Last time we flipped five switches (delay, transactions, uniqueness, encryption, fast lanes), and every one of them took it for granted that handle()
itself would succeed. Production disagrees. The perfect victim is a job that talks to the outside world: a payment provider, a courier's API, any webhook. Sooner or later the other side stops answering. Laravel keeps a place for work that couldn't be done: a table called failed_jobs
. Think of it as the morgue for jobs: cold storage with good records, not a trash can. Let's break a job and follow it in.
The morgue has a resident#
Before breaking anything, check the morgue itself: if you've been following the series, it is not empty. Ask the front desk:
php artisan queue:failed
2026-07-04 15:51:22 e6fe09f8-936d-42f0-91c1-edb866b14a32 database@default App\Jobs\ShipOrder
One resident. That's Dave, the order that vanished in the model article: ShipOrder
went shopping with its claim tag, found the row deleted, and threw ModelNotFoundException
. The series' first FAIL
, still on file.
The desk shows the card in full: when it failed, a long identifier we'll come back to in a minute, where it came from (database@default
- connection and queue), and what it was. Dave's job is beyond saving: the order row is gone for good, so a retry would only die the same death. For hopeless cases there's a shredder behind the desk:
php artisan queue:forget e6fe09f8-936d-42f0-91c1-edb866b14a32
INFO Failed job deleted successfully.
One drawer, emptied for good. (queue:flush
would empty them all at once - the blunt instrument.) The morgue is clean:
php artisan queue:failed
INFO No failed jobs found.
Break the job#
Now let's fill it properly, with a death we control from the first breath. The story: after ShipOrder
does its part, somebody has to book the courier pickup. The courier company has an API, and today that API is down. Meet BookCourier
: make:job
and the same two decisions every job needs. It carries the order id, a plain int on purpose: the courier API wants a number, and today's death must come from handle()
itself rather than from a claim-tag exchange. And it books the pickup. Except today handle()
can only do what a dead API allows: throw.
php artisan make:job BookCourier
INFO Job [app/Jobs/BookCourier.php] created successfully.
Fill the stub in with both decisions:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class BookCourier implements ShouldQueue
{
use Queueable;
public function __construct(public int $orderId) {}
public function handle(): void
{
throw new \RuntimeException('Courier API is down');
}
}
Dispatch - in tinker:
> App\Jobs\BookCourier::dispatch(53); DB::table('jobs')->count();
= 1
The worker picks it up:
php artisan queue:work --once
2026-07-06 09:22:49 App\Jobs\BookCourier .. RUNNING 2026-07-06 09:22:49 App\Jobs\BookCourier .. 1.11ms FAIL
FAIL
- the second one the series has seen, and this time nothing vanished quietly: the code itself threw, loudly. So where is the job now? Still in the queue, waiting for another go?
> DB::table('jobs')->count();
= 0
> DB::table('failed_jobs')->count();
= 1
Gone from the queue - but not gone. By default the queue's patience runs out after a single attempt. And that default lives on the worker, by the way: queue:work
ships with --tries=1
. The job threw once, and it was moved to the morgue. Open the drawer and read the paperwork:
> DB::table('failed_jobs')->value('uuid');
= "cd78d29a-c335-460e-9873-6759221b727e"
> Str::before(DB::table('failed_jobs')->value('exception'), ' in ');
= "RuntimeException: Courier API is down"
A uuid
- the toe tag, and the long identifier from Dave's card earlier. The id in jobs
died with the row; this one survives death, and it's the name the desk knows it by. The exception
- the cause of death: class and message up front, the full stack trace behind them in the same column. The record also keeps the connection, the queue name, the moment of death, and the payload, intact. That last part is the point: if the payload survived, death is reversible.
Resurrect the job#
The front desk again, now with our own case on file:
php artisan queue:failed
2026-07-06 09:22:49 cd78d29a-c335-460e-9873-6759221b727e database@default App\Jobs\BookCourier
The same card Dave had: when, toe tag, where from, what. And because the payload is intact, one command puts the same job back in line:
php artisan queue:retry cd78d29a-c335-460e-9873-6759221b727e
INFO Pushing failed queue jobs back onto the queue. cd78d29a-c335-460e-9873-6759221b727e .. 0.92ms DONE
> DB::table('jobs')->count();
= 1
> DB::table('failed_jobs')->count();
= 0
Back in the queue. But resurrection is not repair: the courier API is still down, and the worker finds that out the hard way:
php artisan queue:work --once
2026-07-06 09:23:34 App\Jobs\BookCourier .. RUNNING 2026-07-06 09:23:34 App\Jobs\BookCourier .. 0.97ms FAIL
Back in the drawer. Check the toe tag:
> DB::table('failed_jobs')->value('uuid');
= "cd78d29a-c335-460e-9873-6759221b727e"
The same uuid
: literally the same job, through death, resurrection and second death. The fail-retry-fail loop isn't futile, it's telling you something: retry when the world is fixed, not before. So fix the world. In our story the courier API comes back up; on the rig, handle()
stops throwing and does its work:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class BookCourier implements ShouldQueue
{
use Queueable;
public function __construct(public int $orderId) {}
public function handle(): void
{
Log::info("booking courier for order #{$this->orderId}");
}
}
Now retry - all
of them at once, the other form the argument takes. Three commands, three receipts: the retry, the worker, the log's last line:
php artisan queue:retry all
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
INFO Pushing failed queue jobs back onto the queue. cd78d29a-c335-460e-9873-6759221b727e .. 1.58ms DONE 2026-07-06 09:23:59 App\Jobs\BookCourier .. RUNNING 2026-07-06 09:23:59 App\Jobs\BookCourier .. 7.08ms DONE [2026-07-06 09:23:59] local.INFO: booking courier for order #53
Read the log line slowly: order #53 was booked by a job that was dispatched before the fix existed. The job waited out the outage in the morgue, same toe tag through the whole ordeal, and was executed by code deployed after it was queued. The payload carries the state; the code is always today's. (One honest caveat: today's code must still answer to yesterday's payload. Rename a constructor property between dispatch and retry, and the morgue hands back a job the class no longer understands.) That's what the morgue is for: failure parks the work, it doesn't lose it.
Ask for more attempts#
“One strike and you're out” is a harsh rule for a network hiccup that heals in thirty seconds. For failures that fix themselves you want the worker to try again: a bit later, a few times, then give up. Two attributes and one method; the method at the bottom is new too: ignore it for now; it gets the last section. (The courier API is down again for this experiment. Of course it is.)
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Log;
#[Tries(3)]
#[Backoff(5)]
class BookCourier implements ShouldQueue
{
use Queueable;
public function __construct(public int $orderId) {}
public function handle(): void
{
throw new \RuntimeException('Courier API is down');
}
public function failed(?\Throwable $exception): void
{
Log::error("giving up on order #{$this->orderId}: {$exception->getMessage()}");
}
}
#[Tries(3)]
means three attempts in total; #[Backoff(5)]
means five seconds of rest between them. The class changed; restart tinker. Dispatch. Before any strikes, look where the patience physically lives. Remember the payload's panel of dormant switches from the first article? Two of them just flipped on:
> App\Jobs\BookCourier::dispatch(54); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['maxTries'];
= 3
> json_decode(DB::table('jobs')->value('payload'), true)['backoff'];
= "5"
The attributes were read at dispatch and written into the envelope. And the payload's maxTries
outranks the worker's --tries
flag: the class speaks, the worker obeys. Now knock on the queue's door - twice, immediately:
php artisan queue:work --once
php artisan queue:work --once
2026-07-06 09:24:36 App\Jobs\BookCourier .. RUNNING 2026-07-06 09:24:36 App\Jobs\BookCourier .. 1.04ms FAIL
Look carefully: two worker runs, one pair of output lines. The first run failed; the second exited without a word. You've heard this silence before, from the delayed cart reminder. Check the queue and you'll know why:
> DB::table('jobs')->count();
= 1
> DB::table('jobs')->value('attempts');
= 1
> DB::table('jobs')->value('available_at') - DB::table('jobs')->value('created_at');
= 5
The job didn't die. It came back. Not the same row, though: a failed attempt with tries to spare buries the old row and files a fresh one. attempts
carried over and ticked up to 1
, created_at
stamped at the moment of failure, available_at
set five seconds past it (that re-stamp is why the difference reads a clean 5
). Recognize the mechanism? It's the same available_at
that powered delay()
: backoff is just a delay the failure buys for itself, and the silent worker is just skipping a row whose time hasn't come. Wait out the five seconds and knock again, two runs, same pattern:
php artisan queue:work --once
php artisan queue:work --once
2026-07-06 09:24:54 App\Jobs\BookCourier .. RUNNING 2026-07-06 09:24:54 App\Jobs\BookCourier .. 1.77ms FAIL
Strike two, and silence again: the job is resting before its last chance. Five more seconds, and:
php artisan queue:work --once
2026-07-06 09:25:03 App\Jobs\BookCourier .. RUNNING 2026-07-06 09:25:03 App\Jobs\BookCourier .. 2.60ms FAIL
> DB::table('jobs')->count();
= 0
> DB::table('failed_jobs')->count();
= 1
Strike three - and only now, with all three attempts spent, does the job go to the morgue. So much for patience: Tries
counts total attempts, Backoff
spaces them out, and the morgue is the destination of last resort. (Backoff
also accepts a list, #[Backoff([5, 30, 120])]
, for pauses that grow with each failure.)
The last word#
Now, the method you were ignoring. When the failure becomes final - here, the attempt that spends the last try - Laravel gives the job a chance to speak before it's filed away: it calls failed()
and hands over the exception. (There are faster roads to a final failure, $this->fail()
and #[MaxExceptions]
, but they belong to later visits.) Ours left a note:
grep 'giving up' storage/logs/laravel.log
[2026-07-06 09:25:03] local.ERROR: giving up on order #54: Courier API is down
Same timestamp as strike three: the last word is spoken at the moment of death. A log line is the polite minimum; in real code this is where you page the on-call, mark the shipment unbooked, or undo whatever the job half-did. And be clear about what failed()
is not: a fourth attempt. The job is already on its way to the drawer; this is the eulogy, not a rescue. (The exception
we read back at the drawer was only its first line; the failed_jobs
row keeps the whole stack trace.)
That's the full path from FAIL
back to DONE
. Six lines worth keeping:
- One strike by default. A throwing job gets exactly one attempt, then the morgue.
- The morgue is not a trash can.
failed_jobskeeps the toe tag (uuid), the cause of death and the payload - intact. queue:retryresurrects the same job. Same uuid, today's code: the payload carries state, never code.- Retry when the world is fixed. Until then the loop is fail - retry - fail (and for hopeless cases:
queue:forget,queue:flush). #[Tries]counts total attempts,#[Backoff]spaces them. Backoff is a delay the failure buys itself - the sameavailable_atthat powersdelay().failed()is the eulogy, not a rescue. It fires once - on the attempt that makes the failure final - with the exception in hand.
The switch panel still has toggles we haven't touched: how long a single attempt may run (#[Timeout]
), retrying until a deadline instead of a count (retryUntil()
), jobs that must not run twice at once. Those belong to later visits. (And notice the morgue is no longer empty: order #54 lies in its drawer, toe-tagged. Remember it.)
- 01 The minimal job
- 02 The model that never rode the queue
- 03 Beyond the minimal job
- 04 Where jobs go when they die
- 05 Queue the listener
- 06 The limits of patience
- 07 The door before the job
- 08 What a group costs
- 09 Workers in production