>Dudenkoff_
← Lab / Laravel · Queues

The limits of patience

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

Reopen the morgue. If you followed the job that died in where jobs go when they die, the drawer still has a resident:

terminal
php artisan queue:failed
output
2026-07-06 09:25:03 c90e8b16-1cac-42fa-ae34-9925e800660f database@default App\Jobs\BookCourier

That's BookCourier for order #54, killed by a courier API that never answered. It died on its third knock, because we gave it #[Tries(3)] and the third strike is the morgue. Three tries is a limit. It is not the only one. A job can hang, throw, or give up on its own terms, and Laravel keeps a separate limit for each. This is the tour of them, read straight from the tables. We start with the one that looks like a death and isn't.

Not every pause is a death#

The courier API is back, but the pickup slot for order #57 opens in ten seconds. The job has nothing useful to do yet. It shouldn't fail, and it shouldn't burn an attempt spinning. It should step out and come back. That's release() , and it rides in on the second import the stub always ships with, InteractsWithQueue , folded into Queueable :

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
    {
        $this->release(10);
    }

    public function failed(?\Throwable $exception): void
    {
        Log::error("giving up on order #{$this->orderId}: {$exception->getMessage()}");
    }
}

Dispatch it and run the worker once:

tinker
> App\Jobs\BookCourier::dispatch(57); DB::table('jobs')->count();
= 1
terminal
php artisan queue:work --once
output
2026-07-08 09:01:15 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:01:15 App\Jobs\BookCourier .. 1.03ms DONE

DONE , in green, the same word a job earns by finishing its work. The worker asked the job to run, the job ran, and running chose to release. No exception, no red, no morgue. So where is the job now?

tinker
> DB::table('jobs')->count();
= 1
> DB::table('jobs')->value('attempts');
= 1
> DB::table('jobs')->value('available_at') - DB::table('jobs')->value('created_at');
= 10

Right back in the queue, one row, with attempts ticked up to 1 and its next run set ten seconds out. That number is the argument we passed. And that available_at - created_at is an old acquaintance: the same delayed-availability that powered the cart reminder and the backoff between failed attempts in the morgue article. Release is a delay a job hands itself. Two things to hold onto: it counts as an attempt, so a tries limit still bounds it, and it is a decision the job makes, not a failure the world forces. Wipe the bench and meet the limits that do kill:

tinker
> DB::table('jobs')->delete();
= 1

A job that hangs#

An error is easy: the job throws, the worker catches it, the story is the morgue. Worse is a job that doesn't throw and doesn't finish. The courier API accepts the request and then goes quiet, and handle() sits there holding the line. A worker stuck on one job is a worker doing nothing for every other job behind it. The limit for this is #[Timeout] , a wall clock on a single attempt. Give the job a thirty-second hang and a three-second patience:

app/Jobs/BookCourier.php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Support\Facades\Log;

#[Timeout(3)]
class BookCourier implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function handle(): void
    {
        sleep(30);
    }

    public function failed(?\Throwable $exception): void
    {
        Log::error("giving up on order #{$this->orderId}: {$exception->getMessage()}");
    }
}

The attribute writes itself into the envelope, same as #[Tries] did in the morgue. The class changed, so restart tinker, then dispatch and check:

tinker
> App\Jobs\BookCourier::dispatch(55); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['timeout'];
= 3

Three seconds of patience, thirty seconds of hang. Run the worker the way we always have, and watch the clock:

terminal
php artisan queue:work --once
output
2026-07-08 09:01:41 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:02:11 App\Jobs\BookCourier .. 30s DONE

Thirty seconds, then DONE . The timeout did nothing. This is the first crack in a comfortable assumption. Every worker run in this series has been queue:work --once , and --once does not enforce a timeout. The guarantee lives on the long-running worker. The reason is in the worker's own code: the alarm that kills a slow job (pcntl_alarm , via Illuminate/Queue/Worker.php) is armed inside daemon() , and --once reaches the job through runNextJob() , which walks right past it. Re-dispatch and run the real worker, no flag:

tinker
> App\Jobs\BookCourier::dispatch(55); DB::table('jobs')->count();
= 1
terminal
php artisan queue:work
output
2026-07-08 09:02:18 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:02:21 App\Jobs\BookCourier .. 3s FAIL

Three seconds, then FAIL . The daemon holds the stopwatch, and at the three-second mark it kills the attempt mid-sleep. Read the paperwork:

tinker
> DB::table('failed_jobs')->count();
= 2
> Str::before(DB::table('failed_jobs')->orderByDesc('id')->value('exception'), ' in ');
= "Illuminate\\Queue\\TimeoutExceededException: App\\Jobs\\BookCourier has timed out."

A cause of death the throwing job never had: TimeoutExceededException , written by the worker; your code never threw it. Keep this one filed away. It's the first real reason the series owes you a proper look at workers in production, where --once won't do and the timeout is a promise you depend on. Ctrl-C the worker; the morgue keeps its two.

A deadline, not a count#

Counting attempts is one way to run out of patience. Time is another, and sometimes the honest one. If the courier API is down for maintenance, "try three times" is arbitrary; "keep trying until noon, then give up" matches the actual problem. That's retryUntil() , a method that returns a moment. Ours returns a moment thirty seconds out, so it fits on camera:

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 retryUntil(): \DateTimeInterface
    {
        return now()->addSeconds(30);
    }

    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()}");
    }
}

No #[Tries] this time. Fresh class, fresh tinker; dispatch and open the envelope:

tinker
> App\Jobs\BookCourier::dispatch(56); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['retryUntil'];
= 1783501386
> json_decode(DB::table('jobs')->value('payload'), true)['maxTries'];
= null

The deadline rides along as a plain Unix timestamp, and maxTries is null : no count is watching. Knock twice and it keeps coming back:

terminal
php artisan queue:work --once
php artisan queue:work --once
output
2026-07-08 09:02:40 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:02:40 App\Jobs\BookCourier .. 1.07ms FAIL
2026-07-08 09:02:40 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:02:40 App\Jobs\BookCourier .. 1.83ms FAIL

Two failures on screen. Read the attempt counter, then the drawer:

tinker
> DB::table('jobs')->value('attempts');
= 2
> DB::table('failed_jobs')->count();
= 2

Two attempts logged, and the drawer reads two, but those two are its earlier tenants, the timeout job and old #54; this job never reached it. Nothing counts its attempts, so nothing stops them. A plain #[Tries(3)] would have buried it by now. Only the deadline can. Wait it out and knock one more time:

terminal
php artisan queue:work --once
output
2026-07-08 09:03:16 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:03:16 App\Jobs\BookCourier .. 6.13ms FAIL
tinker
> DB::table('jobs')->count();
= 0
> Str::before(DB::table('failed_jobs')->orderByDesc('id')->value('exception'), ' in ');
= "Illuminate\\Queue\\MaxAttemptsExceededException: App\\Jobs\\BookCourier has been attempted too many times."

Now it's gone, and the cause of death is worth a second look: MaxAttemptsExceededException , "attempted too many times". The same words a #[Tries] limit would have used, though no count was ever set. Once the deadline passes, the worker stops asking how many attempts and calls it exceeded. A deadline is just a limit that reads a clock instead of a counter.

So far it's been a count or a deadline, one or the other. What happens if a class sets both, #[Tries(3)] and a retryUntil() ? They don't combine the way you might guess. The answer sits in the same worker file the timeout came from (Illuminate/Queue/Worker.php):

vendor · Illuminate/Queue/Worker.php
protected function markJobAsFailedIfWillExceedMaxAttempts($connectionName, $job, $maxTries, Throwable $e)
{
    $maxTries = ! is_null($job->maxTries()) ? $job->maxTries() : $maxTries;

    if ($job->retryUntil() && $job->retryUntil() <= Carbon::now()->getTimestamp()) {
        $this->failJob($job, $e);
    }

    if (! $job->retryUntil() && $maxTries > 0 && $job->attempts() >= $maxTries) {
        $this->failJob($job, $e);
    }
}

Read the second if : the attempt count is consulted only when ! $job->retryUntil() . Set a deadline and that branch is skipped, so #[Tries(3)] never fires. The two don't add up and don't race; the deadline wins outright, and the tries limit is dead weight. Pick one.

A budget for exceptions#

Back to counting, but with a sharper pencil. #[Tries] counts every attempt, and we just saw that a release is an attempt. So a job that releases itself while it waits, then finally throws, can spend its whole tries budget on patience and never fail. #[MaxExceptions] counts only the throws. Give this one a generous five tries and a strict two exceptions:

app/Jobs/BookCourier.php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Log;

#[Tries(5)]
#[MaxExceptions(2)]
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()}");
    }
}

Restart tinker for the new class, then dispatch and read both numbers:

tinker
> App\Jobs\BookCourier::dispatch(58); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['maxTries'];
= 5
> json_decode(DB::table('jobs')->value('payload'), true)['maxExceptions'];
= 2

Both numbers in the envelope: five tries, two exceptions. This job only ever throws, so the tighter budget should end it. Knock twice:

terminal
php artisan queue:work --once
php artisan queue:work --once
output
2026-07-08 09:03:35 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:03:35 App\Jobs\BookCourier .. 2.71ms FAIL
2026-07-08 09:03:35 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:03:35 App\Jobs\BookCourier .. 5.83ms FAIL
tinker
> DB::table('jobs')->count();
= 0
> Str::before(DB::table('failed_jobs')->orderByDesc('id')->value('exception'), ' in ');
= "RuntimeException: Courier API is down"

Dead on the second throw, three tries still unspent. And notice the cause of death: the real RuntimeException , "Courier API is down", the last thing the job threw. A tries limit files a generic "attempted too many times"; an exceptions budget keeps the exception. #[Tries] asks how many times we tried. #[MaxExceptions] asks how many times it broke. Set both, and whichever runs out first wins.

Giving up on purpose#

Every limit so far is patience with a shape: a count, a clock, a budget. Sometimes patience is wrong. The courier rejects the order outright, the address does not exist, the payment was refused. Retrying is just failing again on a schedule. When the job knows there is no point, it can end itself on the spot with $this->fail() , tries be damned:

app/Jobs/BookCourier.php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Log;

#[Tries(5)]
class BookCourier implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function handle(): void
    {
        $this->fail(new \RuntimeException('Courier rejected order'));
    }

    public function failed(?\Throwable $exception): void
    {
        Log::error("giving up on order #{$this->orderId}: {$exception->getMessage()}");
    }
}

Five tries on the class, and a handler that fails on the first one. Restart tinker, then dispatch and knock:

tinker
> App\Jobs\BookCourier::dispatch(59); DB::table('jobs')->count();
= 1
terminal
php artisan queue:work --once
output
2026-07-08 09:03:51 App\Jobs\BookCourier .. RUNNING
2026-07-08 09:03:51 App\Jobs\BookCourier .. 2.82ms FAIL
2026-07-08 09:03:51 App\Jobs\BookCourier .. 3.21ms DONE

Look at that pair. One run, two lines: FAIL then DONE . That is the honest tell of what fail() is. It is not a throw. It records the failure in the same breath, then handle() returns like any other method, so the worker also marks the run finished. The job is in the morgue and out of the queue on attempt one:

tinker
> DB::table('jobs')->count();
= 0
> Str::before(DB::table('failed_jobs')->orderByDesc('id')->value('exception'), ' in ');
= "RuntimeException: Courier rejected order"

Five tries on the class, dead on the first, wearing exactly the exception we handed it. fail() is the emergency exit: it skips every limit the class declares and walks the job straight to the drawer. Its quiet sibling $this->delete() does the same without the failure record, for work that is simply no longer needed. Both are the job overriding the queue's patience, because the job knows something the queue doesn't.

When something in a framework feels like patience, it isn't a mood. It's a number in a payload and a comparison in the worker. Read the envelope, run the worker, watch the drawer fill. There is no magic, only limits, and each one is a line you can set.


Six ways a job's patience runs out. Six lines worth keeping:

  • #[Tries(n)] counts attempts. Every run, including a release. The nth strike is the morgue, with a generic "attempted too many times".
  • #[Timeout(n)] is a clock on one attempt. Enforced by the daemon worker, not by --once . It kills the run; the final try files TimeoutExceededException .
  • retryUntil() is a deadline. Retries until the moment regardless of count; past it, the same "attempted too many times". Set it and maxTries steps aside.
  • $this->release() is not a failure. The run ends DONE , the job goes back with a delay and one more attempt spent. A vote for "later".
  • #[MaxExceptions(n)] counts only throws. A tighter bound than tries, and it keeps the real exception on the toe tag.
  • $this->fail($e) is the emergency exit. Straight to the morgue on the spot, every limit skipped. delete() is the same, minus the record.

Every limit here is a property of the job: patience it carries in its own envelope. The next kind is patience you wrap around the job from the outside, one job policing the others: only one ProcessPayout at a time, no more than sixty API calls a minute, back off when a whole service is failing. That's middleware, and it's the next visit.


Next
The door before the job
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