Slow Job Alerts in Laravel 13.40: What whenJobRunsLongerThan Catches and What It Misses
I ran Laravel 13.40's new slow job alerts against fast, slow, failed, released and timed out jobs. One of them never triggered an alert.
Laravel 13.40 shipped on 3 November with Queue::whenJobRunsLongerThan(), a one-liner that tells you when a queued job runs slower than it should. The scheduler got a twin, Schedule::whenTaskRunsLongerThan(), in the same release. The queue docs don't cover either one yet, so I tested them.
On a fresh Laravel 13.40.0 install with the database queue driver and PHP 8.3, I pushed five jobs through one worker: a fast one, a slow one, one that fails, one that releases itself, and one that runs past the worker's --timeout. Four did what you'd expect. The fifth is the one to know about before you build alerting on this. All output below is real.
What 13.40 added
PR #62141 adds the queue version. You register it once, in a service provider:
use Carbon\CarbonInterval;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
Queue::whenJobRunsLongerThan(CarbonInterval::seconds(2), function ($job, $duration) {
Log::warning("{$job->resolveName()} took {$duration}ms");
});
}
$duration is in milliseconds. It comes from the JobAttempted event, which gained a duration property in 13.39. And that event is the whole mechanism. This is the core of the method in QueueManager:
$this->app['events']->listen(Events\JobAttempted::class, function ($event) use ($threshold, $handler) {
if (! is_null($event->duration) && $event->duration > $threshold) {
$handler($event->job, $event->duration);
}
});
So it isn't a timer. Nothing happens when a running job crosses the threshold. The check runs once, after the attempt is over, and only if a duration was recorded.
Five jobs through one worker
The test app registers two thresholds side by side. One uses CarbonInterval::seconds(2). The other passes a plain 5, which is easy to type when you mean five seconds:
Queue::whenJobRunsLongerThan(CarbonInterval::seconds(2), function ($job, $duration) {
Log::warning("[2s threshold] {$job->resolveName()} took {$duration}ms");
});
Queue::whenJobRunsLongerThan(5, function ($job, $duration) {
Log::warning("[plain 5] {$job->resolveName()} took {$duration}ms");
});
The job sleeps, then optionally throws or releases itself:
public function handle(): void
{
usleep((int) ($this->seconds * 1_000_000));
match ($this->mode) {
'fail' => throw new RuntimeException('Failed after sleeping'),
'release' => $this->release(60),
default => null,
};
}
The first run is a 0.5 second job, then three 3 second jobs. One finishes, one throws, one releases itself.
$ php artisan queue:work --tries=1 --stop-when-empty
2026-11-06 11:20:35 App\Jobs\SleepJob .. RUNNING
2026-11-06 11:20:36 App\Jobs\SleepJob .. 514.71ms DONE
2026-11-06 11:20:36 App\Jobs\SleepJob .. RUNNING
2026-11-06 11:20:39 App\Jobs\SleepJob .. 3s DONE
2026-11-06 11:20:39 App\Jobs\SleepJob .. RUNNING
2026-11-06 11:20:42 App\Jobs\SleepJob .. 3s FAIL
2026-11-06 11:20:42 App\Jobs\SleepJob .. RUNNING
2026-11-06 11:20:45 App\Jobs\SleepJob .. 3s RELEASED
2026-11-06 11:20:48 Worker STOPPED Queue empty
$ grep WARNING storage/logs/laravel.log
[2026-11-06 11:20:36] local.WARNING: [plain 5] App\Jobs\SleepJob took 513.2ms
[2026-11-06 11:20:39] local.WARNING: [2s threshold] App\Jobs\SleepJob took 3009.45ms
[2026-11-06 11:20:39] local.WARNING: [plain 5] App\Jobs\SleepJob took 3009.45ms
[2026-11-06 11:20:42] local.WARNING: [2s threshold] App\Jobs\SleepJob took 3003.32ms
[2026-11-06 11:20:42] local.WARNING: [plain 5] App\Jobs\SleepJob took 3003.32ms
[2026-11-06 11:20:45] local.WARNING: [2s threshold] App\Jobs\SleepJob took 3005.84ms
[2026-11-06 11:20:45] local.WARNING: [plain 5] App\Jobs\SleepJob took 3005.84ms
The 2 second threshold fired for all three slow jobs, including the one that failed and the one that was released. A job that keeps failing slowly and retrying is exactly what you'd want flagged. (13.40 also changed the worker output to say RELEASED instead of DONE, which you can see on the last job.)
The plain 5 fired for the 514ms job. That's the unit trap. A plain number is milliseconds here, so whenJobRunsLongerThan(5, ...) means five milliseconds, and it will fire for almost every job you run.
And the sync driver behaves the same way. SleepJob::dispatchSync(3) logged took 3006.12ms on both thresholds, because the sync queue dispatches JobAttempted with a duration too.
The scheduler version counts in seconds
PR #62233 adds Schedule::whenTaskRunsLongerThan(). Same shape, different unit:
use Illuminate\Console\Scheduling\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schedule;
Schedule::whenTaskRunsLongerThan(2, function (Event $task, float $runtime) {
Log::warning("{$task->getSummaryForDisplay()} took {$runtime}s");
});
Here a plain 2 means two seconds. So the same number in your queue setup and your scheduler setup means two different things, a thousand times apart. Pass a CarbonInterval to both and the problem goes away. The queue version converts it with totalMilliseconds and the scheduler version with totalSeconds, so CarbonInterval::seconds(30) means 30 seconds in both places.
The job that hits the timeout
The fifth job sleeps for 10 seconds on a worker started with --timeout=5. It's the slowest job in the whole test, by a distance.
$ php artisan queue:work --tries=1 --timeout=5
2026-11-06 11:20:18 App\Jobs\SleepJob .. RUNNING
2026-11-06 11:20:23 App\Jobs\SleepJob .. 5s FAIL
2026-11-06 11:20:23 Worker STOPPED Job timed out
$ echo $?
137
$ grep WARNING storage/logs/laravel.log
[2026-11-06 11:20:23] local.WARNING: [timed out] App\Jobs\SleepJob after 5s
Only the JobTimedOut listener wrote anything. The worker exited with code 137, which is 128 plus signal 9, SIGKILL. The order inside Worker::registerTimeoutHandler() explains it. The SIGALRM handler marks the job as failed (it's in failed_jobs as a TimeoutExceededException), dispatches JobTimedOut, then calls kill(), which sends SIGKILL to its own process. JobAttempted is dispatched from the finally block in Worker::process(), and a process killed with SIGKILL never reaches a finally block. No JobAttempted means no duration, and no duration means no alert.
What I'd register instead
Keep the slow job alert, but don't make it your only timeout signal. This is the setup I'd put in AppServiceProvider:
use Carbon\CarbonInterval;
use Illuminate\Queue\Events\JobTimedOut;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
// Jobs that finish, but slower than they should
Queue::whenJobRunsLongerThan(CarbonInterval::seconds(30), function ($job, $duration) {
Log::warning("Slow job: {$job->resolveName()} took {$duration}ms");
});
// Jobs the worker had to kill
Event::listen(JobTimedOut::class, function (JobTimedOut $event) {
Log::error("Job timed out: {$event->job->resolveName()} after {$event->timeout}s");
});
}
- Pass a
CarbonInterval, never a plain number. It means the same thing in both APIs. - Set the threshold well under your timeout. The alert only sees jobs that finish, so it's an early warning. With the default
--timeout=60, a 30 second threshold tells you a job is drifting toward the limit while it still completes. The timeout itself has to stay belowretry_after, which the 10,000 tasks post explains. - Listen for
JobTimedOutfor the jobs that don't finish. The worker dispatches it on every timeout, before the kill, whether or not the job has retries left. Keep the listener quick, though. It runs inside a process that's about to kill itself.
A timeout isn't the only way a worker dies before it can tidy up. A deploy sends SIGTERM and then SIGKILL after the grace period, which I walked through in the queue worker SIGTERM post.
Background scheduled tasks are never measured
The scheduler version listens for ScheduledTaskFinished and ScheduledTaskFailed. schedule:run times each task by wrapping the call that starts it, and for a task with runInBackground() that call returns as soon as the process is spawned. Two tasks, a 3 second one in the foreground and a 4 second one in the background:
$ php artisan schedule:run
2026-11-06 11:20:51 Running ['artisan' app:slow-task 3] .. 3s DONE
2026-11-06 11:20:55 Running ['artisan' app:slow-task 4] in background 5.56ms DONE
$ grep WARNING storage/logs/laravel.log
[2026-11-06 11:20:55] local.WARNING: [schedule 2] '/opt/homebrew/Cellar/[email protected]/8.3.22/bin/php' 'artisan' app:slow-task 3 > '/dev/null' 2>&1 took 3.23s
The foreground task fired at 3.23s. The background one never did. The scheduler recorded the 5.56ms it took to start the process, not the four seconds the command ran, and nothing compares the real runtime later. If a long task has to run in the background, time it inside the command itself. (Background tasks in the docs covers how they run.)
FAQ
Does whenJobRunsLongerThan fire while the job is still running?
No. It listens for JobAttempted, which the worker dispatches after an attempt ends. A job stuck at minute 20 of a 30 minute timeout produces nothing until it finishes.
What unit does the threshold use?
Milliseconds for Queue::whenJobRunsLongerThan() and seconds for Schedule::whenTaskRunsLongerThan(). Both convert a CarbonInterval correctly, so use one.
Does it alert on a job that hits the worker timeout?
No. The worker kills its own process with SIGKILL before JobAttempted is dispatched. Listen for JobTimedOut instead, which fires just before the kill. The queue timeout docs cover how the timeout itself works.
Does it work with Horizon?
I didn't run Horizon for this test. Horizon's horizon:work command extends Laravel's own queue:work command (I checked in Horizon 5.50.1), so jobs go through the same Worker class and the same timeout path. I'd expect the same results, but confirm on your setup. If you're still choosing between the two, Horizon vs Cloud's managed queues covers the trade-offs.
Does it work with the sync driver?
Yes. The sync queue dispatches JobAttempted with a duration too, so a slow dispatchSync() call triggers it.
A slow alert needs a timeout listener next to it
whenJobRunsLongerThan is a good alert for jobs that finish slowly, and it costs one line. But it can't see the job that never finishes, and that's the one that hurts. And if you use the scheduler version, a task with runInBackground() is invisible to it.
About Hafiz
Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.
Get in touch →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Laravel 13 Queue::route(): One Place to Control Your Entire Queue Topology
Laravel 13 ships Queue::route(), a single method in AppServiceProvider that cont...
Your Queue Worker Gets a SIGTERM on Every Deploy. Here Is What Laravel 13.31 Lets You Do About It
Every deploy sends your workers a SIGTERM. Laravel 13.31 added the events to han...
Your Query Bindings Are in Your Logs: Laravel 13.27 Can Mask Them, and How to Clean Up What Leaked
Every failed query in Laravel writes its real values into the error message. Whe...