>Dudenkoff_
← Lab / Laravel · Queues

Where jobs go when they die

Laravel 13 · PHP 8.5 · database driver · July 6, 2026 · part 4 of 9

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:

terminal
php artisan queue:failed
output
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:

terminal
php artisan queue:forget e6fe09f8-936d-42f0-91c1-edb866b14a32
output
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:

terminal
php artisan queue:failed
output
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.

terminal
php artisan make:job BookCourier
output
INFO Job [app/Jobs/BookCourier.php] created successfully.

Fill the stub in with both decisions:

app/Jobs/BookCourier.php
<?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:

tinker
> App\Jobs\BookCourier::dispatch(53); DB::table('jobs')->count();
= 1

The worker picks it up:

terminal
php artisan queue:work --once
output
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?

tinker
> 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:

tinker
> 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:

terminal
php artisan queue:failed
output
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:

terminal
php artisan queue:retry cd78d29a-c335-460e-9873-6759221b727e
output
INFO Pushing failed queue jobs back onto the queue.

cd78d29a-c335-460e-9873-6759221b727e .. 0.92ms DONE
tinker
> 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:

terminal
php artisan queue:work --once
output
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:

tinker
> 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:

app/Jobs/BookCourier.php
<?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:

terminal
php artisan queue:retry all
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
output
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.)

app/Jobs/BookCourier.php
<?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:

tinker
> 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:

terminal
php artisan queue:work --once
php artisan queue:work --once
output
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:

tinker
> 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:

terminal
php artisan queue:work --once
php artisan queue:work --once
output
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:

terminal
php artisan queue:work --once
output
2026-07-06 09:25:03 App\Jobs\BookCourier .. RUNNING
2026-07-06 09:25:03 App\Jobs\BookCourier .. 2.60ms FAIL
tinker
> 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:

terminal
grep 'giving up' storage/logs/laravel.log
output
[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_jobs keeps the toe tag (uuid ), the cause of death and the payload - intact.
  • queue:retry resurrects 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 same available_at that powers delay() .
  • 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.)


Next
Queue the listener
The series
  1. 01 The minimal job
  2. 02 The model that never rode the queue
  3. 03 Beyond the minimal job
  4. 04 Where jobs go when they die
  5. 05 Queue the listener
  6. 06 The limits of patience
  7. 07 The door before the job
  8. 08 What a group costs
  9. 09 Workers in production