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 handle it properly, and one common flag turns them all off.
Every time you deploy, something sends your queue workers a SIGTERM. Supervisor does it on restart. Docker does it on docker stop. Kubernetes does it before it evicts a pod. And somewhere in the middle of that, a job is halfway through importing 40,000 rows.
What happens to that job?
The comfortable answer is that Laravel handles it. The worker finishes the current job before exiting, so nothing is lost. That is true right up until the job takes longer than your process manager is willing to wait, at which point SIGKILL arrives and the job dies wherever it happens to be.
Laravel 13.31 shipped a JobInterrupted event, and 13.7 shipped the Interruptible contract it depends on. Together they let a job notice the signal and stop cleanly at a safe point. I spent a morning testing both against a real 13.31 app, and two things turned out differently from how the release notes describe them. One of them silently disables the whole feature.
The problem is the gap between SIGTERM and SIGKILL
When your process manager wants a worker gone, it does not kill it outright. It sends SIGTERM, waits, and then escalates to SIGKILL if the process is still alive.
Laravel's worker catches that SIGTERM and sets an internal shouldQuit flag. It does not abandon the job it is running. It lets the current job finish, then exits before reserving another one. So for a job that takes two seconds, this is a non-issue.
For a job that takes nine minutes, it is the whole issue. Supervisor's stopwaitsecs defaults to 10 seconds, and the Laravel docs recommend raising it past the length of your longest job, with an explicit warning: "You should ensure that the value of stopwaitsecs is greater than the number of seconds consumed by your longest running job. Otherwise, Supervisor may kill the job before it is finished processing."
The sample Supervisor config in the docs sets it to 3600. An hour. That is the honest cost of the "worker finishes its current job" guarantee, and it means a deploy can hang for an hour waiting on one import.
Interruptible jobs are the other way out. Instead of making the deploy wait for the job, you let the job hear the signal and wind itself down.
What actually happens, in order
Here is the part where testing beat reading. I wrote a job that logs each step, implemented Interruptible, registered listeners on both events, and sent a real SIGTERM to a running worker mid-job.
The observed log, in the order it was written:
SlowReport: step 1
SlowReport: step 2
SlowReport: step 3
SlowReport: step 4
EVENT WorkerInterrupted signal=15 queue=default
SlowReport::interrupted() called with signal 15
EVENT JobInterrupted signal=15 conn=database
SlowReport: stopping cleanly at step 5
Worker STOPPED Interrupted
Signal 15 is SIGTERM. The ordering is not what you would guess from the changelog.
The interrupted() method on the job runs before the JobInterrupted event fires, not after. If you are writing a listener that assumes the job has already reacted to the signal by the time your listener runs, that assumption holds. If you assumed the reverse, that your listener runs first and can influence what the job does, it does not.
In Illuminate\Queue\Worker, the signal handler sets shouldQuit, dispatches WorkerInterrupted, then calls notifyJobOfSignal(), which calls $job->interrupted($signal) and only then dispatches JobInterrupted. Four steps, one after the other, inside the signal handler itself.
And notice the last line. Worker STOPPED Interrupted comes from the stop-reason output added in 13.30, which tells you why a worker exited instead of leaving you guessing.
The flag that turns all of this off
This is the one that will actually bite people.
php artisan queue:work --once never installs the signal handlers at all.
Not "handles them differently". Does not install them. In Worker, the call to listenForSignals() lives inside the daemon() method. The --once flag routes through runNextJob() instead, which never touches signal handling. You can see the branch in WorkCommand:
->{$this->option('once') ? 'runNextJob' : 'daemon'}(
I ran the same SIGTERM test with --once and the log stopped dead at step 4. No WorkerInterrupted. No interrupted() call. No JobInterrupted. The process died mid-loop with the job still reserved in the table, waiting for its timeout to expire before anything retries it.
This matters more than it sounds, because --once is everywhere. It is the standard pattern for running workers under cron. It shows up in Docker setups that prefer one job per container. It is what a lot of people reach for in Kubernetes when they want a job runner rather than a long-lived process. Every one of those setups gets SIGTERM on shutdown, and none of them will run your cleanup code.
If you rely on interruptible jobs, run the daemon. That is the requirement, and nothing in the release notes says so.
Writing a job that stops cleanly
The contract is one method:
namespace Illuminate\Contracts\Queue;
interface Interruptible
{
public function interrupted(int $signal): void;
}
The important thing to understand is that interrupted() is cooperative, not preemptive. Laravel calls it, and that is the entire extent of Laravel's involvement. It does not unwind your stack, throw an exception, or stop your loop. If your handle() method never checks anything, the job keeps running exactly as before and your interrupted() method accomplished nothing.
So the pattern is always two halves. A flag set by the signal, and a check inside the work loop:
namespace App\Jobs;
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SlowReport implements ShouldQueue, Interruptible
{
use Queueable;
protected bool $stopping = false;
public function handle(): void
{
foreach (range(1, 10) as $step) {
if ($this->stopping) {
logger()->info("SlowReport: stopping cleanly at step {$step}");
return;
}
logger()->info("SlowReport: step {$step}");
sleep(1);
}
logger()->info('SlowReport: finished all steps');
}
public function interrupted(int $signal): void
{
$this->stopping = true;
logger()->info("SlowReport::interrupted() called with signal {$signal}");
}
}
Where you put the check decides how quickly the job gives up the process. A check at the top of a loop that iterates once per second means you stop within a second. A check outside a loop that runs for four minutes means you stop in four minutes, which defeats the point.
Three practical notes on the real version of this:
- Save progress before returning. Stopping cleanly is only useful if the next run can pick up where this one left off. A checkpoint column, a cursor, a last-processed ID. Returning without recording anything just means you redo the work.
- Return, do not throw. Throwing marks the job failed and sends it down the retry path, which is not what happened. A clean return lets you re-dispatch on your own terms.
- Put the check where the work is. Inside the chunk loop, not around it.
If your jobs process large batches, the checkpointing side of this pairs with the batching approach I wrote about in processing 10,000 tasks without breaking, where the same idea keeps a failed run from restarting at zero.
The two events do different jobs
Both events exist and they are not interchangeable.
JobInterrupted carries connectionName, job and signal. It fires only for jobs that implement Interruptible. The check in notifyJobOfSignal() returns early unless there is a current job, its resolved handler is a CallQueuedHandler, and the underlying command implements the contract. Miss any of those and the event never fires.
WorkerInterrupted carries signal, connectionName, queue and the WorkerOptions. It fires whenever a worker catches a termination signal, whatever job happens to be running and whether or not that job implements anything.
That difference makes WorkerInterrupted the better observability hook. It tells you a worker on a given queue got a signal, and it tells you for every worker, not just the ones running jobs you have retrofitted. If you want a count of how often deploys are interrupting in-flight work, listen to that one:
use Illuminate\Queue\Events\WorkerInterrupted;
use Illuminate\Support\Facades\Event;
Event::listen(function (WorkerInterrupted $event) {
logger()->warning('Worker interrupted', [
'signal' => $event->signal,
'queue' => $event->queue,
'connection' => $event->connectionName,
]);
});
Use JobInterrupted when you need the job instance, for per-job cleanup or for recording which specific job was cut short.
Neither event appears in the queue documentation as of this writing. The Interruptible contract is documented under Reacting to Worker Signals, but the events are release-note material only, which is part of why the ordering surprise above is easy to miss.
totalSize() and the number you were probably alerting on
The other half of 13.31 is queue measurement, and it fixes a real footgun.
size($queue) counts one queue. If you call size() with no argument, you get the default queue and nothing else. Plenty of monitoring code calls that, names the metric something like "queue depth", and quietly ignores every other queue in the system.
totalSize() counts all of them. I dispatched three jobs onto three different queues and checked:
size("default"): 1
size("emails"): 1
totalSize(): 3
There are three companion methods that break the number down, and they are the ones worth graphing:
$queue = app('queue')->connection('database');
$queue->totalPendingSize(); // waiting, available now
$queue->totalDelayedSize(); // waiting, scheduled for later
$queue->totalReservedSize(); // picked up by a worker, still running
$queue->totalSize(); // all of the above
A clean run with two immediate jobs and one delayed job returned 2, 1, 0 and 3 respectively.
The split matters because the three numbers mean different things when something is wrong. Pending climbing means you are not consuming fast enough. Delayed climbing is usually just scheduled work and is often fine. Reserved climbing while pending stays flat means jobs are being picked up and not finishing, which is the shape of a hung worker or a job stuck on an external call.
Two caveats worth knowing before you wire this into a dashboard.
These methods are implemented per driver rather than on the base Queue class, and that matters more than it sounds. DatabaseQueue and RedisQueue return real numbers. SqsQueue, SyncQueue and BeanstalkdQueue implement the same methods and return a hardcoded 0:
// SqsQueue.php
public function totalSize()
{
return 0;
}
So on SQS the call succeeds, returns zero, and never errors. Wire that to a dashboard and you get a flat line that looks like a healthy queue. Check what your driver actually returns rather than whether the method exists.
And the implementations are not equivalent in cost. DatabaseQueue::totalSize() is a single unfiltered count() against the jobs table. RedisQueue::totalSize() enumerates every known queue name and sums size() across them, so the work scales with how many queues you have. On Redis with a lot of queues, that is not a call to make every second from a hot path.
For a fuller picture of what to watch beyond raw depth, the trade-offs in managed queues versus Horizon cover where each approach puts the monitoring burden.
What I would actually change
If you run long jobs, do these three things in order.
Check whether you are running --once. Everything else is moot if signal handlers were never installed. Look at your Supervisor config, your Dockerfile CMD, your Kubernetes manifests and your crontab. If --once is there and you have jobs that run for minutes, you are losing work on every deploy and it is not being logged anywhere.
Add Interruptible to your longest job, not all of them. Find the job with the worst p99 duration and start there. Most jobs finish in under a second and gain nothing from this. The import that runs for six minutes is the one holding up your deploys.
Listen to WorkerInterrupted before you do anything else. It costs one listener and it tells you whether this is a real problem in your app. If it fires twice a month, drop it and move on. If it fires forty times during a deploy window, you have found something.
Then, once you know a job can be interrupted safely, you can lower stopwaitsecs from the hour the docs suggest to something that matches how fast your jobs actually wind down. That is the payoff. Not just cleaner shutdowns, but deploys that do not have to choose between waiting an hour and killing work in progress.
The multi-tenant version of this gets messier, because a job stopping cleanly still has to stop cleanly in the right tenant context. I wrote about three bugs that only show up in multi-tenant queues if you are running that setup, and the two-minute server migration covers the deploy side when workers are part of the cutover.
FAQ
Does implementing Interruptible mean my job gets killed when a deploy happens?
No. Laravel calls your interrupted() method and nothing else. The job keeps running until your own code decides to stop it, which is why the flag-and-check pattern matters. A job that implements the contract but never checks the flag behaves exactly as it did before.
Why did nothing happen when I sent SIGTERM to my worker?
The most likely cause is --once. That flag routes through runNextJob() instead of daemon(), and signal handlers are only installed in daemon(). The other possibility is that the pcntl extension is not loaded, since Laravel checks for it before installing handlers and silently skips them if it is missing.
What is the difference between JobInterrupted and WorkerInterrupted?
WorkerInterrupted fires whenever a worker receives a termination signal, regardless of what it is running. JobInterrupted fires only when the job currently being processed implements Interruptible. Use the worker event for observability across everything, and the job event when you need the job instance itself.
Does totalSize() work on every queue driver?
Every driver implements it, but only the database and Redis drivers return real numbers. SqsQueue, SyncQueue and BeanstalkdQueue return a hardcoded 0, so the call works and tells you nothing. On SQS, queue depth has to come from CloudWatch instead.
Should I still set stopwaitsecs high in Supervisor?
Until your long jobs handle interruption, yes, because the alternative is SIGKILL partway through. Once a job stops cleanly within a known window, you can bring the value down to match that window instead of padding it to cover the full job duration.
The thing worth remembering
The feature here is small. One interface, two events, four counting methods. What makes it worth an afternoon is that the failure it prevents is invisible: jobs dying partway through on deploy, leaving half-written state, and nothing in your logs saying that is what happened.
Before you write a single listener, go and grep your infrastructure config for --once. If it is there alongside jobs that take minutes, that one flag is quietly discarding every cleanup path Laravel offers, and it will keep doing it however many contracts you implement.
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 Cloud vs Forge vs Hetzner: What I'd Actually Pick at Each Stage
Three options, three very different trade-offs. Here is what Laravel Cloud, Forg...
What's Coming to Laravel Cloud: $5/month Plan, Spend Caps, and Instant Scale-to-Zero
Laravel Cloud is getting five major updates: a new $5/month plan, spend caps, mi...
Scotty vs Laravel Envoy: Spatie's New Deploy Tool Is Worth the Switch
Spatie just released Scotty, a drop-in replacement for Laravel Envoy with plain...