crystoline/xpsr-scheduler
Scheduled tasks for XPSR applications: console commands, callbacks, and queued jobs on cron schedules, all run from one cron entry.
composer require crystoline/xpsr-scheduler
Add the provider to app.registry in config/app.php:
Crystoline\Xpsr\Scheduler\SchedulerServiceRegistry::class,
and one entry to the server's crontab, which runs the tasks that are due each minute:
* * * * * cd /var/www/app && php xpsr schedule:run > /dev/nullFailures are written to stderr, so cron mails them, and to the application log. Without cron (in development, or in a container), php xpsr schedule:work does the same in the foreground.
Tasks
Tasks are defined in routes/schedule.php:
<?php
use App\Job\PruneUploads;
use App\Repository\TaskRepository;
use Crystoline\Xpsr\Scheduler\Schedule;
return static function (Schedule $schedule): void {
$schedule->command('session:prune')->daily('03:00');
$schedule->command('report:send --weekly')->weekly('monday', '08:00')->timezone('Africa/Lagos');
$schedule->job(new PruneUploads())->hourly();
$schedule->call(static function (TaskRepository $tasks): void {
$tasks->archiveFinished();
}, 'archive finished tasks')->everyMinutes(30)->weekdays();
};
| Task | Runs |
|---|---|
command('name --option argument') |
A console command, in the schedule:run process. A non-zero exit code is a failure. |
call($callable, ?$name) |
A function or closure, in the schedule:run process. Its parameters are services from the container, by type. |
job($job, ?$queue) |
Queues a crystoline/xpsr-queue job, so a worker runs it. Needs the queue provider. |
The due tasks run one after another. Move long work into a queued job, so it doesn't hold up the tasks after it.
Frequencies
| Method | Cron |
|---|---|
| (none) | * * * * *, every minute |
everyMinute() |
* * * * * |
everyMinutes(15) |
*/15 * * * * (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, or 30) |
hourly(), hourly(30) |
0 * * * *, 30 * * * * |
everyHours(6), everyHours(6, 30) |
0 */6 * * *, 30 */6 * * * (1, 2, 3, 4, 6, 8, or 12) |
daily(), daily('03:00') |
0 0 * * *, 0 3 * * * |
weekly(), weekly('monday', '08:00') |
0 0 * * 0, 0 8 * * 1 |
monthly(), monthly(15, '12:00') |
0 0 1 * *, 0 12 15 * * (months without that day are skipped) |
yearly(), yearly(7, 4, '09:00') |
0 0 1 1 *, 0 9 4 7 * |
cron('30 2 * * 1-5'), cron('@daily') |
as given |
weekdays(), weekends(), and on('monday', 'thursday') limit any of them to days of the week, in either order: daily('08:00')->weekdays() is 0 8 * * 1-5. They cannot be combined with a day of the month, because cron would run the task when either one matches; use when() for that.
Times are 24-hour HH:MM. Schedules are read in app.timezone (or scheduler.timezone), and ->timezone('Africa/Lagos') gives a task its own. Across daylight-saving changes, a time that clocks skip when they go forward runs an hour later, and one that happens twice when they go back runs twice. For tasks that must run exactly once a day, schedule them in UTC or outside the hours clocks change.
Conditions and overlap
$schedule->command('backup:run')->daily('01:00')->withoutOverlapping();
$schedule->command('invoices:remind')->daily('09:00')->when(static fn(Settings $settings): bool => $settings->remindersOn());
$schedule->call($sync, 'sync')->everyMinutes(5)->skip(static fn(Maintenance $maintenance): bool => $maintenance->active());
when() and skip() take callables whose parameters are services. withoutOverlapping() skips a run while the previous one is still going. Its lock is released when the task ends, or when the process holding it dies. On a lock store that expires locks, it lasts at most the period given ('1 day' by default, or seconds).
name('nightly cleanup') sets how a task appears in schedule:list and the log. Commands are named by their command line, jobs by their class, and closures by their file and line.
Commands
php xpsr schedule:run # the tasks due this minute; from cron
php xpsr schedule:list # every task, its cron expression, time zone, and next run
php xpsr schedule:work # schedule:run at the start of each minute, until stopped
php xpsr schedule:work --time-limit=3600
schedule:work starts php xpsr schedule:run as a new process each minute, so it picks up code changes. SIGTERM and SIGINT stop it after the current run.
Configuration
config/scheduler.php is optional:
return [
'files' => ['routes/schedule.php'], // the files that define tasks
'timezone' => 'UTC', // the default: app.timezone
'lock' => env('SCHEDULER_LOCK', 'flock'),
];
lock is where withoutOverlapping() keeps its locks. The default, flock, uses files in var/locks, which works on one server. With several servers running schedule:run, use a store they share: a Symfony Lock DSN such as redis://localhost:6379 (which needs symfony/cache), postgresql+advisory://..., or a PDO DSN.
Services
| Service | Provides |
|---|---|
Crystoline\Xpsr\Scheduler\Schedule |
The tasks from scheduler.files: tasks(), due($time). Shared. |
Requires PHP 8.4+.