5 min read

Graceful Queue Shutdowns: Laravel's Interruptible Job Interface

Laravel's Interruptible interface lets queued jobs catch SIGTERM during deploys, so long-running work can stop loops, release locks, and save state cleanly.

Featured image for "Graceful Queue Shutdowns: Laravel's Interruptible Job Interface"

If you have ever deployed a Laravel app while a long-running job was mid-flight, you have probably felt the quiet dread that follows. The worker gets a SIGTERM, the process disappears, and the job is left in whatever half-finished state it happened to be in. Maybe it was looping through ten thousand records and got through six thousand. Maybe it grabbed a cache lock it never released. For years the standard answer was “keep your jobs short” or “drain the queue before deploying,” which is fine advice until reality refuses to cooperate. Laravel 13.7.0 added a cleaner option: the Interruptible interface, which lets a job find out it is about to be killed and react before the lights go off.

What Actually Happens on a Deploy

When you deploy, your process manager (Horizon, Supervisor, a Kubernetes pod, whatever you use) sends the worker a SIGTERM. Laravel’s worker has always tried to be polite about this: it finishes the current job, then exits before picking up the next one. The problem is the word “finishes.” If your current job is a tight loop processing a large batch, Laravel cannot magically pause it. It either waits for the loop to complete or, once the configured timeout elapses, the job gets SIGKILLed and dies right where it stood.

That is the gap. The worker knew the signal arrived, but the job running inside it had no way to ask “am I about to be cut off?” The Interruptible interface closes that gap by giving the job a callback that fires the moment the signal lands.

Implementing Interruptible

The interface is small on purpose. You implement it alongside ShouldQueue, add a stop flag, and check that flag at a safe point in your work loop.

use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class GenerateMonthlyReport implements ShouldQueue, Interruptible
{
    use Queueable;

    protected bool $stop = false;

    public function handle(): void
    {
        foreach ($this->accounts() as $account) {
            if ($this->stop) {
                $this->checkpoint($account);
                return;
            }

            $this->process($account);
        }
    }

    public function interrupted(int $signal): void
    {
        $this->stop = true;
    }
}

When the worker receives SIGTERM, Laravel calls interrupted() on the running job, passing the signal number. Your job sets $stop = true, and on the next pass through the loop it exits cleanly. The key insight is that you control where the work stops. You are not interrupted in the middle of a database write or a half-sent API call. You are interrupted at the top of the next iteration, which is exactly where you can save progress and bail safely.

That checkpoint() call is the part that earns its keep. Because you know the job is stopping, you can record where you got to, then re-dispatch the job so it resumes from that point. The work that was already done stays done, and nothing gets reprocessed or lost.

Releasing Locks and Saving State

The interface is not only for loops. A lot of long jobs hold something they should not leak: a cache lock, a temporary file, a row marked as “processing” in the database. If the job is killed outright, that resource stays stuck and the next run has to clean up after a corpse. With interrupted(), you get a deterministic place to release it.

public function interrupted(int $signal): void
{
    $this->stop = true;

    Cache::lock('report:monthly')->release();

    $this->report->update(['status' => 'interrupted']);
}

Pair this with the isLocked() method that also landed in 13.7.0 on the Lock class, and your locking logic gets noticeably easier to reason about. You can check whether a lock is currently held before deciding how to recover, instead of guessing based on side effects.

Observability With WorkerInterrupted

There is a companion piece worth knowing about. Laravel now dispatches a WorkerInterrupted event when a worker receives a signal, before it shuts down. This fires regardless of whether the running job implements Interruptible, which makes it the right hook for observability rather than recovery.

use Illuminate\Queue\Events\WorkerInterrupted;

Event::listen(function (WorkerInterrupted $event) {
    Log::info('Worker received signal, shutting down', [
        'connection' => $event->connectionName,
    ]);
});

I find this genuinely useful for understanding deploy behavior in production. If you are seeing jobs retried more often around deploy windows, listening for this event tells you exactly when workers are being told to stop, which you can line up against your deployment logs. Even if you never implement Interruptible on a single job, the event gives you a window into something that used to be invisible.

When to Reach for It

Not every job needs this. A job that finishes in fifty milliseconds will almost never be mid-flight when a signal arrives, and wrapping it in interruption logic is wasted effort. The interface earns its place in three situations: jobs that loop over large datasets, jobs that hold locks or external resources for a meaningful stretch of time, and jobs that are expensive enough that reprocessing from scratch is painful. Report generation, bulk imports, media processing, and long-running sync tasks are the obvious candidates.

The practical pattern that has worked well for me is checkpoint-and-redispatch. Process in chunks, persist your position, and when you are interrupted, save the cursor and queue a fresh copy of the job that picks up where you left off. The job becomes resumable almost for free, and deploys stop being something you schedule around.

It is a small interface, but it addresses a real category of production pain that has been with Laravel queues for a long time. If you run anything heavier than trivial jobs, it is worth a look the next time you touch your queue code.

Sources