The limits of patience
Reopen the morgue. If you followed the job that died in where jobs go when they die, the drawer still has a resident:
php artisan queue:failed
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
:
<?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:
> App\Jobs\BookCourier::dispatch(57); DB::table('jobs')->count();
= 1
php artisan queue:work --once
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?
> 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:
> 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:
<?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:
> 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:
php artisan queue:work --once
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:
> App\Jobs\BookCourier::dispatch(55); DB::table('jobs')->count();
= 1
php artisan queue:work
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:
> 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:
<?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:
> 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:
php artisan queue:work --once
php artisan queue:work --once
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:
> 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:
php artisan queue:work --once
2026-07-08 09:03:16 App\Jobs\BookCourier .. RUNNING 2026-07-08 09:03:16 App\Jobs\BookCourier .. 6.13ms FAIL
> 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):
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:
<?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:
> 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:
php artisan queue:work --once
php artisan queue:work --once
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
> 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:
<?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:
> App\Jobs\BookCourier::dispatch(59); DB::table('jobs')->count();
= 1
php artisan queue:work --once
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:
> 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 filesTimeoutExceededException.retryUntil()is a deadline. Retries until the moment regardless of count; past it, the same "attempted too many times". Set it andmaxTriessteps aside.$this->release()is not a failure. The run endsDONE, 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.
- 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