Caption: cache->add('job-processing:{uuid}') at the start of an opted-in attempt. A thrown exception already increments job-exceptions:{uuid}. An OOM, segfault, or kill leaves the marker. The next attempt treats that leftover as one exception — only if maxExceptions is set and the cache store outlives the dead process.
Introduction
I keep a throwaway Laravel 13 app for queue work. Not a client box. A place I can bump the framework and see whether a dead worker is the same thing as a thrown exception.
This week I cared about three shapes I still get wrong: an import that OOMs until retryUntil(), a timeout I listen for on WorkerInterrupted, and a duration I compute by pairing JobProcessing with JobProcessed.
On 13.33 a thrown exception already counts toward maxExceptions. A worker that dies mid-handle() does not. A timeout kills the process without calling interrupted().
Laravel 13.34.0 shipped 29 Sep 2026 (Packagist 2026-09-29T12:46:21+00:00). Laravel News covered it 30 Sep 2026. As of 7 Oct 2026, the latest 13.x on Packagist is v13.35.0 (2026-10-06T13:52:37+00:00). These three APIs landed in 13.34.0. I diffed the worker methods against 13.35.0 and the bodies match. 13.35 only added a regression test (#61787).
This is a lab note from tag v13.34.0, the 13.x queue docs retrieved 7 Oct 2026, and Laravel News. I did not kill a worker on the VPS this morning.
About the feature
#[CountCrashesAsExceptions] is an empty attribute. Queue::createObjectPayload() copies it onto the payload with getAttributeValue(), which reads the attribute or the public $countCrashesAsExceptions property. No constructor arguments means true. The 13.x docs show the attribute beside #[MaxExceptions(3)]. The property is in that helper and in PR #61737, not in the docs snippet.
markJobAsFailedIfAlreadyExceedsMaxExceptions() is the only check. No cache, flag false, missing uuid, or maxExceptions() null: it returns. Otherwise:
if ($this->cache->add('job-processing:'.$uuid, true, Carbon::now()->addDay())) {
return;
}
add() returns true when this attempt just planted the marker, and false when the previous marker is still there. The false path increments job-exceptions:{uuid} — the same counter a thrown exception already uses — and fails the job at maxExceptions.
Worker::process() forgets the marker in finally. The timeout handler forgets it again before kill(), so a worker-managed timeout is not later scored as a crash. Two cache calls per opted-in attempt is why this is opt-in.
Timeout notification is separate (PR #61651). registerTimeoutHandler() runs from daemon() only when ext-pcntl is loaded. The SIGALRM handler calls notifyJobOfSignal(SIGALRM), reports anything interrupted() throws, then still runs the attempt checks, dispatches JobTimedOut, forgets the crash marker, and kill()s. Worker::$killOnTimeout defaults to true.
notifyJobOfSignal() is the 13.31 SIGTERM method. It returns unless the running command implements Interruptible. Then it calls interrupted($signal) and dispatches JobInterrupted. listenForSignals() still only registers SIGQUIT, SIGTERM, and SIGINT for WorkerInterrupted. A timeout does not dispatch that event.
runNextJob() — the --once path — never calls registerTimeoutHandler(). The queue docs say the same thing: --timeout has no effect with --once.
JobProcessed::$duration (PR #61672) is milliseconds: round((hrtime(true) / 1e9 - $startedAt) * 1000, 2). The constructor default is null. SyncQueue::raiseAfterJobEvent() still dispatches JobProcessed with no third argument. The Job Events docs still only list connectionName, job, and payload().
Caption: The alarm handler calls notifyJobOfSignal(SIGALRM), forgets job-processing:{uuid}, dispatches JobTimedOut, and kills the worker. SIGTERM still goes through listenForSignals(), which dispatches WorkerInterrupted first. A timeout does not.
Why I picked it
The queue item is week 2026-W40. I am writing on 7 Oct 2026 (2026-W41). Nothing newer was waiting, so this is the lowest-priority undone feature.
I still ship retryUntil() jobs behind a rate limiter. Every release() increments attempts, so $tries is useless, and maxExceptions did not move when the worker segfaulted. I also tail WorkerInterrupted and call that “the import saved progress.” On a timeout, that event never fires.
Where it can be used
This is not “any Laravel app.” It is a retryUntil() media job that can OOM, a long import that already implements Interruptible and should checkpoint on timeout, and a log line that wants JobProcessed::$duration without a second listener.
I would not stamp the attribute on every job. Without maxExceptions the worker returns before it writes the marker. I would not point workers at the array cache: that store dies with the process. The Laravel 13 skeleton still ships CACHE_STORE=database and QUEUE_CONNECTION=database (.env.example on 13.x, retrieved 7 Oct 2026). Database and Redis outlive the worker.
Same release, if the crash lab is thin: php artisan queue:flush --queue=sftp-sync (optionally --hours=48) and Schema::getColumn('users', 'email'), which returns one column array or []. The queue docs I retrieved still show queue:flush and --hours, not --queue. I did not invent a CVE.
Benefit
I get a cap on ungraceful deaths for retryUntil() jobs that already set maxExceptions, a timeout hook on interrupted(int $signal), and a duration on the event the worker dispatches.
I do not composer require illuminate/queue. laravel/framework replaces it. v13.34.0 composer.json suggests ext-pcntl and ext-posix for the queue worker. Neither is a hard require. Without ext-pcntl, supportsAsyncSignals() is false and SIGALRM never reaches the job.
Caption: ext-pcntl and ext-posix are suggestions in laravel/framework v13.34.0 composer.json. The crash marker needs a cache that survives the worker. The skeleton default is database.
Practical example from a recent application
Lab shape from the queue notes and the v13.34.0 worker, not a named client. Confirm composer show laravel/framework is 13.34.0+. The flag still needs maxExceptions and a shared cache. SIGALRM is not the 13.31 SIGTERM lab. --once will not fire a timeout. The worker still fails the job after interrupted() returns. Duration is null on sync.
composer show laravel/framework | sed -n '1,8p'
# name : laravel/framework
# versions : * v13.35.0 # 6 Oct 2026 on Packagist. These APIs landed in 13.34.0.
php -m | grep -i pcntl
If that version line still says 13.33.x, stop. The attribute class is missing. If php -m has no pcntl, the timeout half of this example will not run. The crash marker can still work on a daemon, because it does not need the alarm.
use DateTime;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\CountCrashesAsExceptions;
use Illuminate\Queue\Attributes\MaxExceptions;
#[MaxExceptions(3), CountCrashesAsExceptions]
class TranscodeClip implements ShouldQueue
{
public function retryUntil(): DateTime
{
return now()->addHour();
}
public function handle(): void
{
// RateLimited middleware releases this job. $tries never caps it.
}
}
public $maxExceptions = 3; and public $countCrashesAsExceptions = true; are the property form from the same PR.
For the timeout lab I keep the docs’ ImportProducts shape and branch on the signal:
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Timeout;
#[Timeout(3)]
class ImportProducts implements ShouldQueue, Interruptible
{
public function interrupted(int $signal): void
{
if ($signal === SIGALRM) {
$this->import->saveProgress();
}
}
}
php artisan queue:work database --stop-when-empty --timeout=3
That has to be the daemon, not --once. After interrupted() returns, the handler still applies the attempt limits, dispatches JobTimedOut, and kills the process.
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
Event::listen(function (JobProcessed $event) {
if ($event->duration === null) {
return;
}
Log::info($event->job->resolveName().' took '.$event->duration.'ms');
});
Failure I would hit on 13.33 — and still hit on 13.34 with the wrong cache or --once. The attribute class is missing on 13.33. On 13.34 with CACHE_STORE=array, the OOM takes the marker with it, so the next cache->add() succeeds and maxExceptions never moves. php artisan queue:work --once --timeout=3 never registers the alarm, and the alarm path never dispatches WorkerInterrupted. On sync, $event->duration stays null.
Fix. Bump laravel/framework to 13.34.0 or newer (today that is 13.35.0). Set maxExceptions and a shared cache (database or redis). Run a daemon with ext-pcntl. Branch interrupted() on SIGALRM. Keep WorkerInterrupted for process signals, not timeouts. Ignore a null duration on sync.
Caption: --once calls runNextJob() and the alarm is never registered. --stop-when-empty stays in daemon(), which calls registerTimeoutHandler() when ext-pcntl is loaded. Sync JobProcessed still has a null duration.
Conclusion
What I would keep: laravel/framework at 13.34.0+ (today that is 13.35.0), the crash attribute only on retryUntil() jobs that already set maxExceptions, a shared cache, ext-pcntl on the daemon, and $duration only when the event came from Worker::process().
Once that bump is in place, a leftover job-processing:{uuid} increments job-exceptions:{uuid} on the next pop, a timeout calls interrupted(SIGALRM) and still kills the worker, --once still ignores --timeout, and sync still passes no duration. Next I would grep for WorkerInterrupted listeners that think they saw a timeout, and for CACHE_STORE=array on any box with more than one worker.
Did you hit the same wall?
I got stuck on #[CountCrashesAsExceptions] missing on 13.33, then a crash marker that vanished because CACHE_STORE=array, plus queue:work --once --timeout=3 never calling interrupted(SIGALRM) while I was listening for WorkerInterrupted. Did you hit the same thing — a maxExceptions you set and still never saw increment, a timeout with no pcntl, or a JobProcessed duration that stayed null on the sync driver? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan
References
- Laravel queues docs (retrieved 7 Oct 2026).
CountCrashesAsExceptionsis under Max Exceptions. “Reacting to Worker Signals” still does not nameSIGALRM. Job Events still omit$duration.queue:flushstill shows--hours, not--queue.--timeouthas no effect with--once: https://laravel.com/docs/13.x/queues - Laravel News, 30 Sep 2026, “Count Worker Crashes as Job Exceptions in Laravel 13.34”: https://laravel-news.com/laravel-13-34-0
- Framework PR #61737 —
#[CountCrashesAsExceptions]/$countCrashesAsExceptions, cache keyjob-processing:{uuid} - Framework PR #61651 —
notifyJobOfSignal(SIGALRM)before the timeout kill - Framework PR #61672 —
JobProcessed::$durationin milliseconds - Framework PR #61630 —
queue:flush --queue= - Framework PR #61759 —
Schema::getColumn()(same 13.34 release) Worker.phpandSyncQueue.phpat tagv13.34.0(those worker methods matchv13.35.0)laravel/frameworkv13.34.0 on Packagist, 29 Sep 2026 12:46 UTC: https://packagist.org/packages/laravel/framework#v13.34.0laravel/frameworkv13.35.0 on Packagist, 6 Oct 2026 13:52 UTC (latest 13.x at write time)- v13.34.0
composer.jsonsuggestsext-pcntlandext-posix. Laravel 13.env.examplesetsCACHE_STORE=databaseandQUEUE_CONNECTION=database