laravel/framework: Pull request #61789: [13.x] Add Schedule::alwaysOnOneServer()
Contributed by jackbayliss
Part of the framework v13.35.0 release
Laravel 13.x adds a one-line, opt-in switch for multi-server scheduling: Schedule::alwaysOnOneServer(). Once enabled, every event registered on the schedule is automatically marked to run on only one server per cron expression — no group wrapper, no per-task method calls, no changes to routes/console.php. Because the behavior is off by default, upgrading from v13.34.0 is a no-op until you explicitly opt in.
The problem: every replica runs the scheduler
The classic multi-server setup — several app replicas, each with a schedule:run entry in its crontab — has an unpleasant side effect: every due task fires on every machine. Ten replicas means ten copies of the nightly report and ten parallel model:prune runs, and at some point you get that "hold on, why are we sending 50 of these?" moment.
Laravel already had the tool to prevent this: onOneServer(), which uses a cache-based mutex so only one server executes a due event for a given cron expression. The catch was ergonomics. Applying it globally meant wrapping your entire schedule in a group:
// routes/console.php
Schedule::onOneServer()->group(function () {
Schedule::command('reports:generate')->daily();
Schedule::command('model:prune')->daily();
// ...and every other task, forever
});
That works only as long as everyone remembers it. One task added outside the group, or one teammate who doesn't know the convention, and the duplicates quietly return. As the PR author puts it, the convention made people the weakest link.
The new API: one line, in one place
Instead of relying on discipline, you can now flip a switch in a service provider:
// app/Providers/AppServiceProvider.php
use Illuminate\Console\Scheduling\Schedule;
public function boot(): void
{
Schedule::alwaysOnOneServer();
}
That's the entire setup. From that point on, artisan commands, queued jobs, and named closures are all scheduled with one-server semantics, no matter where in the application they were registered. The method is static, so a single call covers the whole process, and it accepts a boolean if you ever need to disable it (Schedule::alwaysOnOneServer(false)) — the PR's own tests use exactly that to reset state.
There's one deliberate exception worth knowing about: unnamed closures are skipped rather than being forced onto one server. One-server locking is keyed off an event's name, and an anonymous Schedule::call(fn () => ...) has none — previously, applying onOneServer to such a closure would throw. If you want your closures covered, give them a name:
Schedule::call(fn () => Cleanup::run())->hourly()->name('cleanup');
Under the hood: events() becomes the source of truth
The biggest internal change is small but meaningful: dueEvents() no longer reads the $events property directly — it now calls events(). That matters because events() is where the new flag is applied. When alwaysOnOneServer is on, events() walks the registered events and calls onOneServer() on each eligible one, lazily, right before the scheduler needs them. Since schedule:run discovers its work through dueEvents(), this is what actually makes the flag effective at runtime.
If you extend Illuminate\Console\Scheduling\Schedule, or if any of your code reads the $events property directly, take note: events() is now the authoritative accessor, and only readers who go through it will see one-server mode applied.
Upgrading from v13.34.0
Upgrade impact is minimal because the feature is opt-in and the default (static::$alwaysOnOneServer = false) preserves existing behavior exactly. If you do enable it:
- Make sure every replica shares a central cache (Redis, Memcached, database, DynamoDB, and so on). One-server locking is implemented with cache mutexes, so a per-server local cache won't actually deduplicate anything.
- Name any scheduled closures you want covered, as shown above.
- Audit extensions or helpers that bypass
events()when reading the schedule. - Remember the flag is static and persists for the life of the process — in tests, reset it with
Schedule::alwaysOnOneServer(false)inside atry/finally.
If you're already wrapping everything in onOneServer()->group(...), you can keep it as-is or migrate to the new flag; both produce the same runtime behavior.
Takeaways
Schedule::alwaysOnOneServer()is a new, opt-in way to make every scheduled task run on a single server per cron expression.- It removes the fragile "wrap everything in a group" convention required for multi-replica deployments.
- Unnamed scheduled closures are deliberately skipped; add
->name()to include them. dueEvents()now reads throughevents(), which is where the flag is applied — relevant if you extend or override scheduling internals.- Off by default, so upgrading from v13.34.0 requires no action unless you opt in.