I would not count an OOM as a thrown exception on 13.33 — Laravel 13.34 shipped CountCrashesAsExceptions

I would not count an OOM as a thrown exception on 13.33 — Laravel 13.34 shipped CountCrashesAsExceptions

13.34 lets a job opt in so a worker that dies (OOM, segfault, external kill) counts toward maxExceptions. Interruptible jobs receive SIGALRM before a timeout kill, and JobProcessed::$duration is milliseconds from the worker. queue:work --once and the sync driver still miss that timeout path. I would run composer show laravel/framework before I claim any of it.

· 9 min read #laravel #php #queues #jobs

 A worker crash leaves the job-processing cache marker; a thrown exception was already counted

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().

 Timeout SIGALRM notifies the job; SIGTERM also fires WorkerInterrupted

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.

 CountCrashesAsExceptions, the timeout handler, and JobProcessed duration live in laravel/framework

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.

 queue:work --once does not arm the timeout; --stop-when-empty does

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). CountCrashesAsExceptions is under Max Exceptions. “Reacting to Worker Signals” still does not name SIGALRM. Job Events still omit $duration. queue:flush still shows --hours, not --queue. --timeout has 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 key job-processing:{uuid}
  • Framework PR #61651 — notifyJobOfSignal(SIGALRM) before the timeout kill
  • Framework PR #61672 — JobProcessed::$duration in milliseconds
  • Framework PR #61630 — queue:flush --queue=
  • Framework PR #61759 — Schema::getColumn() (same 13.34 release)
  • Worker.php and SyncQueue.php at tag v13.34.0 (those worker methods match v13.35.0)
  • laravel/framework v13.34.0 on Packagist, 29 Sep 2026 12:46 UTC: https://packagist.org/packages/laravel/framework#v13.34.0
  • laravel/framework v13.35.0 on Packagist, 6 Oct 2026 13:52 UTC (latest 13.x at write time)
  • v13.34.0 composer.json suggests ext-pcntl and ext-posix. Laravel 13 .env.example sets CACHE_STORE=database and QUEUE_CONNECTION=database
Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.