spatie/laravel-short-schedule
Schedule Artisan commands at sub-minute frequencies—every second or half-second—using a long-running ReactPHP event loop kept separate from Laravel's native scheduler, so short tasks never delay your regular schedule.
Three steps to a working sub-minute schedule:
composer require spatie/laravel-short-schedule
Then register tasks in routes/console.php (Laravel 11+) — this is the first place to look, and the shortest path to value:
use Spatie\ShortSchedule\Facades\ShortSchedule;
ShortSchedule::command('outbox:flush')->everySecond();
Finally, run the worker:
php artisan short-schedule:run
That command is a long-running ReactPHP event loop, entirely separate from schedule:run. It never touches app/Console/Kernel::schedule(), so your existing one-minute schedule keeps working untouched. In production it goes behind Supervisor (or systemd) with autorestart=true.
Legacy apps (L10 and below) use the kernel method instead — same builder, different entry point:
// app/Console/Kernel.php
protected function shortSchedule(\Spatie\ShortSchedule\ShortSchedule $shortSchedule)
{
$shortSchedule->command('outbox:flush')->everySecond();
}
First use case to reach for: anything genuinely blocked on the 60-second floor of the native scheduler — a webhook/outbox poller, an external API or socket drain, cache pre-warming ahead of traffic, a lightweight health probe. If once-a-minute is acceptable, use Laravel's native scheduler instead; this package is a separate operational process to babysit and shouldn't be adopted for convenience.
Decision rule before you start: each tick spawns a fresh php artisan process. At everySecond(), that's a full framework boot every second. If the task body is a few milliseconds of work, the boot dwarfs it — in that case write one long-running Artisan command with its own internal loop and schedule that at a coarse interval, or push the work onto a queue.
Frequency vocabulary — everySecond(), everySeconds(30), and sub-second values like everySeconds(0.5). Sub-second only makes sense for trivial shell/exec calls; the process spawn cost makes it pointless for Artisan commands.
Command by signature vs. by class. Passing a class resolves it from the container, which is the clean way to keep schedule files readable and to get IDE navigation:
ShortSchedule::command('sync:orders')->everySeconds(5);
ShortSchedule::command(\App\Console\Commands\SyncOrders::class)->everySeconds(5);
Shell commands. exec() takes any bash string — useful for redis-cli, log rotation, or a curl ping where a full framework boot would be waste:
ShortSchedule::exec('redis-cli ping')->everySecond();
Composite constraints. All constraints must pass; chain them in the order you'd read them, but be aware the ordering has no effect on evaluation cost:
ShortSchedule::command('sync:orders')
->environments(['staging', 'production'])
->between('09:00', '17:00') // overflow-safe: between('21:00', '01:00') works
->when(fn () => Cache::get('api_healthy'))
->everySeconds(5)
->withoutOverlapping()
->onOneServer();
withoutOverlapping() is the one most people forget. Default behaviour is fire-and-forget: a slow invocation does not block the next tick, which is the package's main advantage over the native scheduler but also the fastest way to stack up forty concurrent copies of the same job. For anything touching a shared resource, add it.
Multi-server fan-out. With several app servers behind Supervisor, every server runs its own loop. Use onOneServer() to collapse to a single runner (requires a lock-capable cache store — Redis, Memcached, database; not array or file on shared-NFS setups).
Events → queue, never inline. ShortScheduledTaskStarting and ShortScheduledTaskStarted fire inside the loop, both exposing $command and the Symfony\Component\Process\Process instance. Use them for lightweight observability — a counter increment, a log line — and immediately hand anything heavier to a job:
Event::listen(ShortScheduledTaskStarting::class, function ($event) {
Log::channel('short-schedule')->info('starting', ['command' => $event->command]);
});
The Process instance on the event is the extension point for anything richer: you can inspect the command line, or capture exit behaviour in a wrapper command of your own.
Memory discipline via lifetime. Under Supervisor:
[program:short-schedule]
command=php /var/www/artisan short-schedule:run --lifetime=3600
autostart=true
autorestart=true
numprocs=1
The lifetime argument terminates the worker and its child processes after N seconds, releasing any leaked memory; Supervisor brings it straight back. Note the changelog: no default lifetime is set in production (1.4.1), so you must opt in.
Testing. Don't test the schedule itself. Test the underlying Artisan command in isolation; if you must assert on the wiring, swap the ShortSchedule facade (available since 1.6.0) and assert on the recorded commands. The upstream suite runs on Pest 4.
routes/console.php on a live server changes nothing until short-schedule:run restarts. This burns people during hotfixes — put the restart in your deploy script next to queue:restart.when(fn () => Http::get(...)->json()) evaluated every second will stall every other short-scheduled task. Cache the truth test result, or compute the condition from a value the loop can read for free.ShortScheduledTaskStarted/Starting events for visibility while debugging. There is no short-schedule:list equivalent for inspection; verify your wiring by temporarily adding a Log::info() or writing to a file in the task body.short-schedule:run processes (a stale Supervisor child plus a manual run) mean every task fires twice. numprocs=1 plus onOneServer() if multiple hosts exist.config:clear-free deploys, bad if you expected shared in-memory state), and each task opens its own DB connections. At high frequency, watch connection churn on MySQL.down. If a task must keep running (draining a queue, for instance), add runInMaintenanceMode(). This surprises people precisely when they're debugging a deploy.between() timezone behaviour isn't spelled out in the README. Assume it follows the app timezone for the worker process, but verify against your Supervisor environment — a container with TZ=UTC and an app configured for Europe/Brussels will produce windows four hours off. Set TZ explicitly in the Supervisor env if your schedule windows are business-hours-sensitive.shortSchedule method — useful if you're rolling it out across a fleet of apps gradually.ShortSchedule facade and the container-bound Spatie\ShortSchedule\ShortSchedule object (swap either in tests), the two events' Process handle, and exec() as an escape hatch when you want to bypass the framework boot entirely for a hot-path shell task.How can I help you explore Laravel packages today?