Laravel 13.35's Scheduler Upgrades: One Flag, Safe Interrupts

 Laravel 13.35's Scheduler Upgrades: One Flag, Safe Interrupts

Laravel 13.35 landed on October 6 — a weekly minor, but one with genuinely practical upgrades for apps running in production. A new video walks through the highlights: QUERY route support plus two scheduler improvements that make multi-server fleets and deploys calmer. This article goes deep on the scheduler pair, because that's where the real day-to-day wins are.


1. Schedule::alwaysOnOneServer() — one flag for the whole fleet

If you run schedule:run on more than one server, every scheduled task runs once per server. That's fine for clearing a local cache. It's a disaster for the daily report email that just went out three times.

Laravel has had onOneServer() for years — the first server to start the task grabs a cache lock, and the rest skip it for that run:

Schedule::command('reports:send')->daily()->onOneServer();

The problem was never the API. It was remembering it. Every new task needed the call, and the one you forgot only announced itself when the duplicates showed up in production. The workaround was wrapping the whole schedule in a group:

Schedule::onOneServer()->group(function () {
Schedule::command('reports:send')->daily();
Schedule::command('invoices:generate')->monthly();
});

13.35 gives you a global switch. Drop it in a service provider and every task in the schedule gets onOneServer() behavior:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Schedule;
 
public function boot(): void
{
Schedule::alwaysOnOneServer();
}

The flag is applied when the schedule reads its events, so tasks defined later — including ones added after the call in routes/console.php — are still covered. It's off by default, and Schedule::alwaysOnOneServer(false) turns it back off.

Three caveats worth knowing:

  • Shared cache is mandatory. Like onOneServer(), it needs a cache store every server can reach (Redis, Memcached, DynamoDB…). File cache on each box won't cut it.
  • Unnamed closure tasks are skipped. The lock is built from the task's name, so a closure without one can't participate — give it a name if you want it covered.
  • It's all-or-nothing. If one task genuinely needs to run per server (local cache clearing), you'll need to restructure around that.

Bonus: schedule:list --json now includes an onOneServer field, so you can audit which tasks hold locks straight from the terminal.


2. Long-running commands can finally hear schedule:interrupt

Deploys and long-running scheduled commands have always been awkward together. php artisan schedule:interrupt tells schedule:run to stop its sub-minute loop — useful when a deploy is rolling out — but a command that was already running never saw the signal. It just kept churning, sometimes against half-deployed code.

13.35 changes the mechanism. The interrupt is now stored as a timestamp — the same trick queue:restart has used for years — and a new method lets your command check it at safe points:

use Illuminate\Console\Scheduling\Schedule;
 
public function handle(Schedule $schedule)
{
$startedAt = now();
 
foreach ($this->batches() as $batch) {
if ($schedule->hasBeenInterruptedSince($startedAt)) {
$this->info('Deploy interrupt received — stopping at a safe point.');
return;
}
 
$this->process($batch);
}
}

The pattern is simple: record when the command started, then ask "has anyone interrupted the schedule since then?" at points where stopping is safe — between batches, between pages of a cursor, between files. The command finishes its current unit of work and exits cleanly instead of being killed mid-write.

One subtle fix rides along with this: schedule:run itself now uses the same timestamp check, so an interrupt fired earlier in the minute no longer stops a fresh run that starts after it.


3. Quick corrections and extras from 13.35

A few details from this release deserve a second look:

  • queue:work --memory=75% is a percentage of PHP's memory_limit, not the machine's RAM. With memory_limit at 512M, the worker restarts after a job once it crosses roughly 307 MB. Watch out: the PHP CLI often ships with memory_limit set to -1, where the percentage resolves to a negative number and the worker never stops for memory. Set memory_limit explicitly on the worker process if you use a percentage.
  • Model defaults() in Laravel 13 needs the HasDefaultAttributes trait to run — it's opt-in, not a plain method override. Values from defaults() win over $attributes, and they're raw: they skip casts and mutators.
  • response()->markdown($post->markdown) is new — it returns a text/markdown response and pairs with the request's existing wantsMarkdown() check. Handy for LLM-friendly content endpoints.
  • lazy_root_creation on local disks delays creating the root directory until the first file is actually written, instead of when the disk is built.

⬆️ Upgrading

Same as always for a weekly minor:

composer update laravel/framework

No breaking changes, everything opt-in. If you're on the 13.x line, 13.35 is the boring, safe kind of upgrade — the best kind.


Sources: Laravel News — Running Laravel's Scheduler on Multiple Servers, Laravel News — Laravel 13.35, laravel/framework v13.35.0 changelog

Dinesh Uprety

WRITTEN BY

Senior Software Engineer • Writer @ Laranepal • PHP, Laravel, Livewire, TailwindCSS & VueJS • CEO @ Laranepal & Founder @ laracodesnap

Discussion

Login or register to comment or ask questions

No comments yet

Be the first to share your thoughts or ask a question.

Join the conversation

Sign in to share your thoughts with the community.