Laravel 13.31 Finishes the Queue Drain You Have Been Faking in Bash
Four releases across 2026 quietly assembled a real queue drain gate for deploys. Here is how pauseAll, allReservedJobs, and totalSize fit together.
Every Laravel deploy script I have ever inherited has the same lie in it somewhere. A line that runs php artisan queue:restart, followed by a sleep 10, followed by a comment that says something like “give workers time to finish.” That sleep is a guess. Nobody measured it, and nobody has revisited it since the day the job that made them add it stopped failing.
The reason the guess persisted is that Laravel never gave you the three things you needed to replace it: a way to stop workers from taking new work, a way to see whether anything is still in flight, and a way for a long job to bail out cleanly when asked. As of 13.31, released on September 8, 2026, all three exist. They arrived in four separate releases spread across a little over four months, which is probably why nobody has connected them yet.
The three pieces
Stop taking new work. Laravel 13.25 added a global pause switch, contributed by Jack Bayliss in #61126. The queue argument on queue:pause and queue:resume became optional, so you can now say:
php artisan queue:pause --all
php artisan queue:resume --all
Or from PHP, Queue::pauseAll() and Queue::resumeAll(). Mechanically it is a single cache key, illuminate:queues:paused, written with forever() and cleared with forget(). Workers already read their per-queue pause keys in one batched many() call each loop, and the global key rides along in that same call, so the check adds no extra round trip.
Worth knowing if you run Redis Cluster or ElastiCache Serverless: folding the global key into that batch produced a cross-slot MGET that crash-looped workers, reported in #61138. The cluster-safety work landed in 13.31 alongside everything else here, so take the whole set or none of it.
A paused worker does not die. It stays alive and keeps looping, it just sleeps instead of popping. Producers are unaffected, so SomeJob::dispatch() keeps writing to Redis or the database while you are paused, and those jobs wait.
The important design decision is that pauseAll() and the per-queue pause() write different keys and do not know about each other. If someone parked your imports queue an hour ago to investigate a bad payload, a deploy script calling resumeAll() will not quietly un-park it. That is the behavior you want, and it is worth knowing before you write a rollback path that assumes otherwise.
See what is in flight. Laravel 13.8 added allReservedJobs(), allDelayedJobs(), and allPendingJobs() on the queue connection (#59997). Before that, inspection required naming a queue, which meant your deploy script had to know your queue topology and stay in sync with it. Each method returns a collection of InspectedJob instances carrying uuid, name, attempts, and a createdAt Carbon instance recording when the job was queued.
Then 13.31 added Queue::totalSize() (#61373), which collapses the arithmetic everyone was writing by hand:
// Before
Queue::totalPendingSize() + Queue::totalDelayedSize() + Queue::totalReservedSize();
// After
Queue::totalSize();
Both of these are driver dependent. The inspection methods cover the database, Redis, and fake drivers; totalSize() covers database, Redis, Cloud, and fake. On SQS or Beanstalkd they throw rather than returning zero, so if that is your stack, stop reading here and keep your sleep.
Let long jobs bail out. Laravel 13.7 introduced the Interruptible contract (#59833). A job implementing it gets an interrupted(int $signal) callback when the worker receives a signal such as SIGTERM, which gives it a chance to set a stop flag rather than being killed at an arbitrary point.
Putting it together
Here is the drain gate, as an Artisan command you call from your deploy script instead of the sleep:
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Queue;
final class DrainQueues extends Command
{
protected $signature = 'deploy:drain {--timeout=120}';
protected $description = 'Pause every queue and wait for in-flight jobs to finish.';
/** @var list<string> */
private array $connections = ['redis', 'redis_long'];
public function handle(): int
{
Queue::pauseAll();
$this->info('All queues paused.');
$deadline = time() + (int) $this->option('timeout');
while (($running = $this->reserved())->isNotEmpty()) {
if (time() >= $deadline) {
$this->error("Timed out with {$running->count()} job(s) still running:");
$this->table(
['Connection', 'Job', 'Attempts', 'Queued'],
$running->map(fn (array $row) => [
$row['connection'],
$row['job']->name,
$row['job']->attempts,
$row['job']->createdAt?->toDateTimeString(),
])->all(),
);
Queue::resumeAll();
return self::FAILURE;
}
sleep(1);
}
$waiting = collect($this->connections)
->sum(fn (string $name) => Queue::connection($name)->totalSize());
$this->info("Drained. {$waiting} job(s) waiting for resume.");
return self::SUCCESS;
}
private function reserved(): Collection
{
return collect($this->connections)->flatMap(
fn (string $name) => Queue::connection($name)
->allReservedJobs()
->map(fn ($job) => ['connection' => $name, 'job' => $job]),
);
}
}
Four things to notice. First, the connection list is explicit. This is the trap in the whole exercise: pauseAll() really does pause every connection, but Queue::allReservedJobs() and Queue::totalSize() proxy through QueueManager to the default connection only. Call them bare and the gate will cheerfully report a clean drain while a second connection is still chewing on something.
Second, the timeout path resumes rather than leaving the queues parked, because a deploy that aborts should not also silently halt your background processing. Third, the failure output names the jobs that would not finish, which is the piece of information you actually want at 2am. Fourth, the total at the end tells you how much backlog accumulated while you were paused, which is a useful number to send to your metrics backend and watch over time.
The deploy script then reads roughly:
php artisan deploy:drain --timeout=90 || exit 1
# swap the symlink, run migrations, warm caches
php artisan queue:restart
php artisan queue:resume --all
Making a slow job cooperate
The drain only works if your long jobs actually stop. A PDF render for a php[architect] issue that walks two hundred pages will blow past any reasonable timeout unless it checks in:
namespace App\Jobs;
use App\Models\Page;
use App\Support\PageRenderer;
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
final class RenderIssuePdf implements ShouldQueue, Interruptible
{
use Queueable;
private bool $stop = false;
public function __construct(
private readonly int $issueId,
private readonly int $fromPage = 1,
) {}
public function handle(PageRenderer $renderer): void
{
$pages = Page::where('issue_id', $this->issueId)
->where('number', '>=', $this->fromPage)
->orderBy('number')
->cursor();
foreach ($pages as $page) {
if ($this->stop) {
self::dispatch($this->issueId, $page->number);
return;
}
$renderer->render($page);
}
}
public function interrupted(int $signal): void
{
$this->stop = true;
}
}
The job checkpoints by redispatching itself from the page it did not get to. The redispatched job lands on a paused queue and sits there until resumeAll(), which is exactly right.
For observability, 13.31 added a JobInterrupted event (#61412) that fires when a job holding the Interruptible contract is interrupted, alongside the WorkerInterrupted event from 13.7 (#59848) and the QueuesPaused and QueuesResumed events from 13.25. Wire the first one to a counter and you will find out quickly whether your drain timeout is generous or optimistic.
Where this leaves the sleep
Nowhere useful. If you are on 13.31 or later, the sleep in your deploy script is measuring nothing that a while loop over allReservedJobs() cannot measure properly, and it is almost certainly either too short on your worst day or too long on every other one.
The one caveat worth flagging: pausing does not stop dispatch. If your app produces jobs faster than a 90 second window can absorb, you are trading failed jobs for a backlog spike, and totalSize() is how you find out how big. That is still a much better problem than deserializing an old payload into new code.
Sources: Pause All Laravel Queues During a Deploy, Laravel News, Queue totalSize() and JobInterrupted Event in Laravel 13.31, Laravel News, Queue-Wide Inspection Methods in Laravel 13.8.0, Laravel News, Interruptible Jobs in Laravel 13.7.0, Laravel News, laravel/framework CHANGELOG, Queues, Laravel documentation.