8 min read

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.

Slow Job Alerts in Laravel 13.40: What whenJobRunsLongerThan Catches and What It Misses

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.

Before you read on

The worker runs with --tries=1 --timeout=5. The job sleeps for 10 seconds. The app registers Queue::whenJobRunsLongerThan(CarbonInterval::seconds(2), ...) and a listener for JobTimedOut.

What ends up in the log?

$ 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.

When Queue::whenJobRunsLongerThan fires Two job attempts on one worker with a 2 second alert threshold and a 5 second timeout. A job that finishes after 3 seconds dispatches JobAttempted and the alert fires. A job that needs 10 seconds is stopped at 5 seconds: the worker dispatches JobTimedOut, writes the job to failed_jobs and kills itself with SIGKILL, so the finally block that dispatches JobAttempted never runs and no alert fires. alert threshold 2s --timeout=5 Job finishes at 3s SleepJob(3) 2s passes, no alert yet attempt ends: JobAttempted 3009ms > 2000ms, alert fires Job needs 10s SleepJob(10) never runs SIGALRM JobTimedOut dispatched row written to failed_jobs kill(): SIGKILL, exit 137 finally never runs: no JobAttempted, no alert 0s 2s 3s 5s 10s time into the attempt LEGEND attempt running never runs alert fires worker killed at the timeout

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");
    });
}
  1. Pass a CarbonInterval, never a plain number. It means the same thing in both APIs.
  2. 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 below retry_after, which the 10,000 tasks post explains.
  3. Listen for JobTimedOut for 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.

Share: X/Twitter | LinkedIn | | RSS
Hafiz Riaz

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