A queue worker exits and Supervisor starts a new one. The log shows jobs processing, then nothing, then jobs processing again. Whether that gap was a clean queue:restart, a memory limit, or a lost database connection, the output looked the same.
Laravel 13.30 has queue:work write the reason on the way out:
2026-09-01 13:20:40 Worker STOPPED Memory limit exceeded
What Was Already There
The worker has known why it was stopping for a while. WorkerStopping carries a WorkerStopReason along with the exit status, the number of jobs processed, the timestamp of the last job, and the memory in use at the moment it quit:
use Illuminate\Queue\Events\WorkerStopping; Event::listen(function (WorkerStopping $event) { Log::info('Worker stopped', [ 'reason' => $event->reason?->value, 'status' => $event->status, 'jobs' => $event->jobsProcessed, 'memory' => $event->memoryUsage, ]);});
That works, and it is still the right tool when the destination is a metrics backend. It is a lot of ceremony for the common case, which is wanting to see in a terminal or a log why the process you were watching went away.
The Output
queue:work now listens for the event itself and writes a final line. In a terminal:
2026-09-01 13:20:40 Worker STOPPED Queue empty
With --json, it goes out as a record alongside the per-job output, so a log pipeline that already parses one parses the other:
{"level":"info","status":"stopped","reason":"empty","exit_code":0,"jobs_processed":12,"memory":34.0,"timestamp":"2026-09-01T13:20:40.118273+00:00"}
reason is the enum's backing value, stable to match on. memory is megabytes, rounded to one decimal. level follows the exit code: info for a clean exit, warning for anything else, which in practice means the memory limit and a job timeout.
Nothing is written under --quiet or --silent, or when the reason is null. That last case is worth knowing: a worker killed from outside, by an OOM killer or a SIGKILL, never reaches the code that dispatches the event. Silence still means the process died without being asked to.
The Nine Reasons
WorkerStopReason gained a description() method holding the human-readable strings, so the enum is now the single place both the console output and your own listeners can read them from:
| Value | Description | Exit code |
|---|---|---|
empty |
Queue empty | 0 |
empty_for |
Queue empty for the configured duration | 0 |
max_jobs |
Maximum jobs exceeded | 0 |
max_time |
Maximum run time exceeded | 0 |
restart_signal |
Received restart signal | 0 |
interrupted |
Interrupted | 0 |
lost_connection |
Lost connection | 0 |
memory |
Memory limit exceeded | 12 |
timed_out |
Job timed out | 1 |
The two non-zero codes are the ones to alert on, and they mean different things:
memory is the worker noticing its own memory usage passed --memory between jobs, which is the mechanism working as designed. It becomes a signal when it happens after two jobs instead of two thousand, because that points at a job holding a large result set or a leak in something the worker loads once. Exit code 12 is Worker::EXIT_MEMORY_LIMIT, and it is configurable through Worker::$memoryExceededExitCode if your process manager treats specific codes differently.
timed_out is different in kind. The other eight are the worker deciding to stop between jobs; this one is the parent process killing a child that ran past --timeout mid-job. That job's failed() may not have run the way it would for an ordinary failure, so a run of timed_out exits usually means one job needs a longer $timeout or a smaller unit of work.
The zero-status reasons are mostly informational, with one exception. lost_connection exits cleanly because reconnecting is the process manager's job, but a steady stream of them says the database or Redis connection is being dropped underneath the worker, often by an idle timeout on a firewall or proxy rather than by the database itself.
The rest describe intent: restart_signal is someone running queue:restart, typically a deploy. empty and empty_for are --stop-when-empty and --stop-when-empty-for doing their job, expected on a container that scales to zero. max_jobs and max_time are --max-jobs and --max-time, the deliberate recycle that keeps a long-lived PHP process from accumulating state. interrupted is a SIGINT or SIGTERM, which is what Ctrl-C and most container shutdowns send.
Reading It in Production
The value of the JSON format is that "why did the worker restart" stops being a question you answer by correlating timestamps. Filter on the reason and the answer is in one field:
php artisan queue:work --json --max-time=3600 2>&1 | \ jq -c 'select(.status == "stopped")'
Under a process manager writing to a file, the same query runs against the log after the fact. A worker recycling on max_time every hour is the configuration working. The same worker recycling on memory every four minutes is a leak, and previously both looked like a restart.
The event listener is still there for anything beyond reading, such as incrementing a counter per reason so a dashboard shows the mix over time rather than one line per exit.
Contributed by @jackbayliss in #61339.