The junior pastes a Supervisor screenshot. One queue:work line vanished. The failed_jobs table is empty. Horizon still shows a running supervisor. They file a pool outage: “the workers are dead.”
I stop the run there. A timed-out job is not a killed worker. Official Laravel queue docs are blunt: the default timeout is 60 seconds, and if a job runs longer than that number, the worker processing that job exits with an error. A process manager on the server then starts another worker. That is one job hitting a clock, not a pool that died. [Source: https://laravel.com/docs/13.x/queues]
I already refused to treat one matching glob as a sandbox exemption for the rest of the line in One Matching Glob Is Not a Sandbox Exemption for the Rest of the Line. I already refused to treat a hostname handed to a forward proxy as a missing host in Handing the Hostname to the Forward Proxy Is Not a Missing Host. I already refused to treat ubuntu-latest as a runner image I already tested in Ubuntu-latest Is Not a Runner Image You Already Tested. This post is the same desk rule for queues. Print the timeout. Print the kill. Name who owns queue:work.
The question is not whether a long job demos on your laptop. The question is whether the named owner can tell a job clock from a worker death before paging the pool.

The ticket that looks like a dead pool
Juniors treat a vanished queue:work process the way they treat a crashed PHP-FPM pool. The process is gone. The job is still on the queue or already reserved. They page infra.
Two jobs collide on that line.
- Stop a job that ran past its clock. Laravel’s timeout exists so a frozen
handle()does not sit forever. Official docs: the worker processing that job exits with an error. [Source: https://laravel.com/docs/13.x/queues] - Keep the rest of the pool on the same supervisor. One worker exiting is the designed path. A process manager starts another worker. That is not “all workers died.” [Source: https://laravel.com/docs/13.x/queues]
If you only screenshot “process missing,” you will file a pool outage. You will not file the clock.
I do not invent a fake overnight outage. I use the public contract. The timeout page is the ticket, not a version pin in the title.
What the timeout field actually is
Official docs, Timeout section: you specify how long a queued job is allowed to run. Default is 60 seconds. If the job runs longer, the worker processing it exits with an error. Typically a process manager restarts that worker. [Source: https://laravel.com/docs/13.x/queues]
You set the clock in two places. Do not mix them.
- Worker flag.
php artisan queue:work --timeout=30sets the worker’s maximum seconds. [Source: https://laravel.com/docs/13.x/queues] - Job attribute.
#[Timeout(120)]on the job class wins over the command line. [Source: https://laravel.com/docs/13.x/queues]
Copy the docs example as a probe, not as a bypass recipe.
1<?php
2
3namespace App\Jobs;
4
5use Illuminate\Contracts\Queue\ShouldQueue;
6use Illuminate\Queue\Attributes\FailOnTimeout;
7use Illuminate\Queue\Attributes\Timeout;
8
9#[Timeout(120)]
10#[FailOnTimeout]
11class ProcessPodcast implements ShouldQueue
12{
13 public function handle(): void
14 {
15 // work that must finish inside 120 seconds
16 }
17}
[Source: https://laravel.com/docs/13.x/queues]
FailOnTimeout is a separate field. Official docs: by default a timed-out job consumes one attempt and is released back to the queue if retries remain. If you put FailOnTimeout on the class, the job is marked failed and is not retried, regardless of tries. [Source: https://laravel.com/docs/13.x/queues]
Three more sentences from the same page that belong on the ticket:
- The PCNTL PHP extension must be installed or you cannot specify job timeouts. [Source: https://laravel.com/docs/13.x/queues]
- A job’s timeout must be less than its
retry_aftervalue. Otherwise the job is re-attempted before it finished or timed out. Default Redisretry_afterin the docs sample is 90 seconds. [Source: https://laravel.com/docs/13.x/queues] --timeouthas no effect whenqueue:workis invoked with--once. [Source: https://laravel.com/docs/13.x/queues]- IO that blocks in sockets or HTTP clients does not always respect PHP’s timeout. Guzzle needs its own connection and request timeouts. [Source: https://laravel.com/docs/13.x/queues]
That last line is why a junior files “the worker hung.” The PHP alarm fired. The HTTP client did not. The process still looks busy until the worker exits.
Pins are evidence, not the hook

Three columns on the ticket
I keep one table on the ticket.
| What you saw | What it is | What it is not |
|---|---|---|
Job ran past Timeout / --timeout | That job hit its clock | The pool is dead |
| Worker process gone after timeout | Designed worker exit, then supervisor restart | All workers died |
JobTimedOut in logs | Timeout handler ran | A PHP fatal or an OOM |
Empty failed_jobs after a timeout | Default: one attempt consumed, job released if retries remain | The job succeeded |
| Supervisor restarted one program | Process manager did its job | You need a new cluster |
The junior screenshot is the first row. They want the third column. Print the second column first.
Name the owner. I use QUEUE_WORK_OWNER the same way I use a migration owner. The person who owns php artisan queue:work on this desk also owns “did this job hit its clock, or did the worker die for another reason.” A coding agent does not get to file a pool outage because one process vanished.
1php artisan queue:work redis --timeout=60
2php artisan queue:work --queue=high,default
[Source: https://laravel.com/docs/13.x/queues]
If Horizon is in play, remember Horizon manages Redis queues. Official docs: if your failover list includes database, run a regular php artisan queue:work database process alongside Horizon. Mixing those two screenshots is a second ticket. [Source: https://laravel.com/docs/13.x/queues]
Do not heading-clone the ubuntu-latest post. That post is a moving CI label. This post is a queue clock.
Opting out of the kill is not the default
Public pull request 61591, merged 15 September 2026 into 13.x, says the quiet part out loud. Today, if a job times out, it takes the whole worker down with it. Infra has to ask why the process stopped. The framework re-bootstraps. Connections are grabbed again. The author wanted an opt-out. [Source: https://github.com/laravel/framework/pull/61591]
The flag they shipped:
1<?php
2
3namespace App\Providers;
4
5use Illuminate\Queue\Worker;
6use Illuminate\Support\ServiceProvider;
7
8class AppServiceProvider extends ServiceProvider
9{
10 public function boot(): void
11 {
12 // Evidence of the opt-out. Default remains true.
13 // Do not flip this in production from a screenshot.
14 // Worker::$killOnTimeout = false;
15 }
16}
[Source: https://github.com/laravel/framework/pull/61591]
Read the PR twice.
Worker::$killOnTimeoutdefaults to true. Nothing changes unless you opt in. [Source: https://github.com/laravel/framework/pull/61591]- With it off, the timeout path throws
TimeoutExceededExceptionrather than killing the worker. [Source: https://github.com/laravel/framework/pull/61591] - One benefit: a timing-out job can tidy up, and can catch
TimeoutExceededException. [Source: https://github.com/laravel/framework/pull/61591] - The named risk: if
handle()wraps work in a try/catch, that catch swallows the exception. That is why the flag is opt-in. [Source: https://github.com/laravel/framework/pull/61591]
This is not a recipe to keep a frozen job alive. This is a field: kill on timeout is still the default. If your screenshot is “the worker vanished,” you are looking at the default path, not a broken supervisor.
I already wrote the sibling rule for a matching glob that is not a whole-line exemption. This is the sibling for a missing process that is not a dead pool.

An exit code you can actually read
Public pull request 61622, merged 17 September 2026, is the other half of the same desk. Worker::$timedOutExitCode existed from an earlier change and had no effect. Worker::kill() sends SIGKILL to itself, then calls exit($status). SIGKILL is immediate. The exit() line never runs. A parent waiting on the worker sees a signal death, which carries no exit status. Measured in that PR on PHP 8.5.8 with ext-posix: the process reports 137 (128+9) and never prints the line before exit(). [Source: https://github.com/laravel/framework/pull/61622] [Source: https://github.com/laravel/framework/pull/60072]
That is why a junior files “OOM” or “PHP fatal.” The supervisor saw a signal death. It did not see a timeout code.
The PR is careful about why SIGKILL stays. exit() would run shutdown functions and destructors on a frozen, mid-timeout job. A destructor that hangs would hang the worker. A destructor that flushes or commits would now do work the kill existed to prevent. [Source: https://github.com/laravel/framework/pull/61622]
The change: Worker::killUsing() registers a callback before the SIGKILL. A worker can pcntl_exec and replace its process image. execve runs no shutdown functions and no destructors, so the kill semantics stay, while a real exit status reaches the parent. Off by default. If exec fails, it falls through to posix_kill. [Source: https://github.com/laravel/framework/pull/61622]
Laravel Cloud registers that callback and sets Worker::$timedOutExitCode = 124 so a managed timeout is a restartable exit, not container exit 255 mixed with fatals and OOM. That is their supervisor contract, not a VPS default. [Source: https://github.com/laravel/framework/pull/61622]
$killOnTimeout = false short-circuits before kill(). Throwing keeps the worker alive only if PHP can reach a VM instruction boundary. A job wedged in a blocking call never gets there. An exec callback still dies, and only changes how the death is reported. [Source: https://github.com/laravel/framework/pull/61622]
Print that distinction on the ticket:
- Job timeout — the clock.
- Worker kill — default: process dies; opt-out: throw; Cloud path: reachable exit 124.
- Named owner — who is allowed to change either flag.
Do not paste 124 into Supervisor because a blog mentioned Cloud. Signal death and timeout exit are different fields.
Probe before you page the pool
I keep a probe in the app root the coding agent uses, not in a docs folder on a laptop. It does not dispatch jobs. It does not disable timeouts. It classifies the last supervisor event.
1#!/usr/bin/env python3
2"""Classify a vanished queue:work line. Not a timeout bypass."""
3
4from __future__ import annotations
5
6import os
7import re
8from pathlib import Path
9
10
11def main() -> int:
12 owner = os.environ.get("QUEUE_WORK_OWNER", "").strip()
13 log = Path(os.environ.get("SUPERVISOR_LOG", "storage/logs/worker.out.log"))
14 job_timeout = os.environ.get("JOB_TIMEOUT", "60")
15 worker_timeout = os.environ.get("WORKER_TIMEOUT", "60")
16 text = log.read_text(encoding="utf-8", errors="replace") if log.exists() else ""
17
18 print(f"QUEUE_WORK_OWNER={owner or 'MISSING'}")
19 print(f"JOB_TIMEOUT={job_timeout}")
20 print(f"WORKER_TIMEOUT={worker_timeout}")
21 print(f"LOG={log}")
22
23 if not owner:
24 print("VERDICT=NO_OWNER")
25 return 2
26
27 timed_out = bool(re.search(r"JobTimedOut|has timed out", text))
28 signal_kill = bool(re.search(r"\b(SIGKILL|signal 9|exit status 137)\b", text))
29 exit_124 = bool(re.search(r"\b(exit status 124|exited with 124)\b", text))
30 pool_down = bool(re.search(r"FATAL|gave up: .*queue:work", text, re.I))
31
32 if pool_down and not timed_out:
33 print("VERDICT=SUPERVISOR_GAVE_UP")
34 return 1
35 if timed_out and signal_kill:
36 print("VERDICT=JOB_TIMEOUT_THEN_DEFAULT_KILL")
37 return 0
38 if timed_out and exit_124:
39 print("VERDICT=JOB_TIMEOUT_REACHABLE_EXIT")
40 return 0
41 if timed_out:
42 print("VERDICT=JOB_TIMEOUT_SEEN")
43 return 0
44 if signal_kill:
45 print("VERDICT=SIGNAL_DEATH_NOT_YET_A_TIMEOUT")
46 return 2
47 print("VERDICT=INSUFFICIENT_LOG")
48 return 2
49
50
51if __name__ == "__main__":
52 raise SystemExit(main())
Run it in the app that actually ships.
1export QUEUE_WORK_OWNER="shinjae"
2export SUPERVISOR_LOG="storage/logs/worker.out.log"
3export JOB_TIMEOUT="120"
4export WORKER_TIMEOUT="60"
5python3 scripts/probe_queue_timeout.py
6php artisan --version
The script does not talk to Redis. It does not start Horizon. VERDICT=JOB_TIMEOUT_THEN_DEFAULT_KILL means you do not have a pool outage. Then open Supervisor. Copy the program name. Copy the last exit. Put those four lines on the ticket: owner, job timeout, worker timeout, verdict.
A second probe is the job class itself. Print #[Timeout], #[FailOnTimeout], $timeout if the class still uses the property, --timeout on the artisan line, and retry_after in config/queue.php. If the job clock is 120 and retry_after is 90, you are looking at the docs warning, not a dead pool. [Source: https://laravel.com/docs/13.x/queues]

What you must not do
Forbidden:
- File a “worker pool is dead” ticket without printing the job timeout, the worker
--timeout,retry_after, the supervisor last exit, and one human name onqueue:work. - Put a Laravel version in the title or the first line. The pin is evidence after the decision.
- Mix this field with an
ubuntu-latestrunner label, a hostname-to-proxy DNS ticket, or a compound-glob sandbox ticket. Those are other posts. - Flip
Worker::$killOnTimeout = falsefrom a screenshot. Default still kills. The PR says a try/catch inhandle()swallowsTimeoutExceededException. [Source: https://github.com/laravel/framework/pull/61591] - Paste
Worker::$timedOutExitCode = 124because Cloud uses 124. That number is their supervisor contract. Off by default. [Source: https://github.com/laravel/framework/pull/61622] - Raise
--timeoutuntil the long job “succeeds,” then call that a fix. Raise the clock only when the named owner can state why the work needs that many seconds, and keep timeout less thanretry_after. [Source: https://laravel.com/docs/13.x/queues] - Dispatch a live job that
sleep()s past the clock to “demo” the kill. Print logs. Do not freeze production. - Recommend buying Horizon, Cloud, a plan, or a seat because one worker exited on timeout.
- Treat an empty
failed_jobstable as success. Default timeout consumes an attempt and releases the job if retries remain. [Source: https://laravel.com/docs/13.x/queues] - Ignore Guzzle or socket timeouts because PHP’s alarm exists. Official docs: blocking IO does not always respect the job timeout. [Source: https://laravel.com/docs/13.x/queues]
Allowed:
- Print
#[Timeout],#[FailOnTimeout],queue:work --timeout, andretry_after. - Read Supervisor’s last exit for that one program, not the whole group.
- Listen for
JobTimedOutand write the seconds on the ticket. An earlier change put the timeout value on that event so you can tell a job clock from a worker--timeout. [Source: https://github.com/laravel/framework/pull/61060] - Keep
FailOnTimeouton jobs that must not retry after a clock. - Name one human as
QUEUE_WORK_OWNER. - Print
php artisan --versionafter the decision, in a details block, not in the title.
GSC this week still has no striking-distance query on the compound-glob post, the hostname-to-proxy post, or the ubuntu-latest post. I am not refreshing those URLs. This is a new field, not a synonym of Wednesday’s glob or Saturday’s runner label.
If you need the broader habit, start at /ai-agent-operations/. Tooling notes live under /developer-tools/. Laravel plus Vue notes live under /laravel-vue-saas/. A first-week map is at /start-here/.
A changelog bullet about a worker kill is not permission to skip the clock.
What you should do Monday morning
- Open the repo that actually ships. Export
QUEUE_WORK_OWNERto a human name. Runprobe_queue_timeout.pyagainst last week’s supervisor log. Write the verdict on the ticket next to that name. - Open the job class from the incident. Copy
#[Timeout]or$timeout. Copy#[FailOnTimeout]if present. Copy thequeue:workline from Supervisor, including--timeout. Copyretry_afterfromconfig/queue.php. If the job clock is not less thanretry_after, file that mismatch, not a pool outage. [Source: https://laravel.com/docs/13.x/queues] - If the log shows
JobTimedOutand Supervisor restarted one program, do not page the pool. File “that job hit its clock.” Decide whether the work is too slow, the clock is too low, or Guzzle has no request timeout. - If someone pasted
Worker::$killOnTimeout = falsein a review, reject it until the named owner can state what a swallowedTimeoutExceededExceptiondoes to the next job on the same process. Default remains kill. [Source: https://github.com/laravel/framework/pull/61591] - Print
php artisan --version. If the laptop is behind the 22 September 2026 framework release that named the opt-out and the reachable exit code, do not treat the local pin as those fields. Compare, then decide an upgrade as change control, not as a social post. [Source: https://github.com/laravel/framework/releases/tag/v13.33.0] - Confirm coding-agent instructions on this desk name the same owner and forbid “the worker pool is dead” without the four lines: owner, job timeout, worker timeout, supervisor last exit.
The question is not whether a timeout demos well in a gist. The question is whether the named owner can still tell a job clock from a worker death after handoff.
Further reading
Source Laravel Docs — queues (timeout, FailOnTimeout, queue:work)
Source GitHub — opt out of killing the worker on job timeout
Source GitHub — make the worker timed-out exit code reachable
