The site's scheduled jobs, kept in the repository. The host runs one cron entry every minute, and that entry works out which jobs are due and runs them:
* * * * * php craft schedule-it/run
The schedule itself is config/schedule-it.php, so it is reviewed like any other code, it is
the same on every environment that deploys the commit, and a new environment gets every job
the moment that one entry exists — instead of jobs living only in a hosting dashboard,
invisible to the repository and missing wherever someone forgot to re-add them.
Built on omnilight/yii2-scheduling, with the defaults that library needs to work under Craft already applied.
Craft CMS 5, PHP 8.2+. A shared mutex (Craft 5's default database mutex is one) unless the host can only ever run one server — see Overlap.
composer require webdna/schedule-it
php craft plugin/install schedule-it
Copy vendor/webdna/schedule-it/src/config.php to config/schedule-it.php, add the jobs,
then add the one cron entry on each environment (see Servd for that host).
Each job is written either in words or as a cron expression, and the two mix freely:
<?php
// config/schedule-it.php
use craft\helpers\App;
use webdna\scheduleit\Job;
return [
'jobs' => [
// In words.
Job::command('gc/run')->dailyAt('03:00'),
Job::command('reports/send')->weekdays()->at('08:30'),
Job::command('feeds/sync')->everyFifteenMinutes(),
Job::command('my-module/products/import')->hourly()->background(),
Job::exec('bin/geoip-update.sh')->weekly(),
Job::command('my-module/reminders/send')
->dailyAt('08:00')
->description('Membership reminders — sends real email')
->enabled(App::env('CRAFT_ENVIRONMENT') === 'production'),
// Or the console command, arguments included => a cron expression.
'utils/prune-revisions --max-revisions=50' => '30 2 * * 1',
'my-module/listings/archive --dryRun=0' => '30 2 * * *',
// Or an array, for a description, a timezone, or a per-environment switch.
'my-module/reminders/send' => [
'cron' => '0 8 * * *',
'description' => 'Membership reminders — sends real email',
'enabled' => App::env('CRAFT_ENVIRONMENT') === 'production',
],
// One command on two schedules: list entries that name their command.
['command' => 'feeds/sync', 'cron' => '0 6 * * 1-5'],
['command' => 'feeds/sync', 'cron' => '0 12 * * 6,0'],
// A shell command is a list entry with `exec`.
['exec' => 'bin/rotate-backups.sh', 'cron' => '0 4 * * *'],
],
];Job::command('<route and arguments>'), then methods that each set part of the expression —
so they compose, the way Laravel's scheduler does: ->weekdays()->at('08:30') is
30 8 * * 1-5, and so is ->at('08:30')->weekdays(). schedule-it/list shows the expression
each one became.
| Minutes | everyMinute(), everyTwoMinutes(), everyFiveMinutes(), everyTenMinutes(), everyFifteenMinutes(), everyThirtyMinutes(), everyNMinutes(n) |
| Hours | hourly(), hourlyAt(minute), everyTwoHours(), everySixHours(), everyNHours(n, minute = 0) |
| Time of day | daily() (midnight), dailyAt('HH:MM'), at('HH:MM') (sets only the time, so it follows a day method), twiceDaily(1, 13, minute = 0) |
| Days | weekdays(), weekends(), mondays() … sundays(), days('monday', 3, …) (names, or 0 = Sunday) |
| Longer | weekly() (Sunday midnight), weeklyOn('tuesday', 'HH:MM'), monthly(), monthlyOn(day, 'HH:MM'), lastDayOfMonth('HH:MM'), quarterly(), yearly() |
| Raw | cron('30 2 * * *') — methods after it change parts of it |
| Other | timezone('Europe/London'), description('…'), enabled(bool or '$ENV_VAR'), background() (see Long jobs) |
Job::exec('<shell line>') starts a job that is not a Craft command — a script, a binary, a
pipeline — and takes the same methods. It runs through the shell from the project root, and
its output, lock and exit code are handled exactly as a console command's. The whole line is
logged, a && b included.
Times are 24-hour. A nonsensical schedule — at('24:00'), days('mon'),
everyNMinutes(0) — is refused with a message naming the job.
| Job key | |
|---|---|
cron |
Required. A five-field cron expression. |
timezone |
The timezone cron is read in. Defaults to the schedule's (UTC). |
description |
What list and the run output call the job. Defaults to the command. |
enabled |
true, false, or a string Craft reads as a boolean ('$SCHEDULE_REMINDERS'). A disabled job is shown by list and never run. |
background |
true to run the job as its own process; see Long jobs. The same values as enabled. |
command |
Only for list entries; otherwise the key is the command. |
exec |
A shell command, in place of command, in a list entry. |
Anything the plugin cannot read is an error, not a skipped job. A misspelt key
('enable' => false), a bad expression or an unknown timezone stops schedule-it/run with a
message and exit code 78, because the alternative is a job that silently never runs.
It never takes the site down. config/schedule-it.php is the plugin's settings file, so
Craft loads it on every request, web included. A mistake in it — at('24:00'), even a
misspelt method like ->daly() — is only reported by schedule-it/run and schedule-it/list;
every other request carries on. (A PHP syntax error is the exception, as in any config file.)
Top-level settings:
| Setting | Default | |
|---|---|---|
timezone |
'UTC' |
Keep UTC: in a timezone with summer time, a job at a time of day moves an hour against UTC twice a year, and a job at 01:30 on the night the clocks change runs twice or not at all. schedule-it/list shows each job's next run in Craft's timezone beside its own, so dailyAt('07:00') reads as 08:00 or 09:00 in Zagreb. Set it, or a job's own timezone, when a job must follow the local clock. |
logFile |
storage/logs/schedule-it.log |
Aliases and env vars are parsed. |
singleServer |
false |
See Overlap. |
configure |
null |
function (\webdna\scheduleit\Schedule $schedule) {} — for anything the array cannot say. $schedule->job('a/b', '0 * * * *')->weekdays() and $schedule->execJob('bin/x.sh', '0 * * * *') get every default below; the library's call(), when(), skip() and thenPing() are all there. |
Never schedule queue/run or queue/listen. The host or Craft already runs the queue,
and a second consumer runs jobs twice or takes them mid-flight. queue/listen never exits,
so it would also block every job after it. Leave db/backup to the host's backups too.
php craft schedule-it/run — runs every job due this minute, one after another, then
exits non-zero if any of them failed. Each job is reported as it ends:
Running scheduled command: gc/run
…the job's own output…
✓ gc/run — exit 0 in 1.4s
– feeds/import — skipped: still running from an earlier start
↗ my-module/products/import — started in the background (pid 4182); its output goes to …/schedule-it.log
A skipped job is not a failure. still running from an earlier start means its lock is held;
already started for this minute by another runner means a second runner, on another
server or started by hand, got there first.
Most minutes nothing is due, and it prints No scheduled commands are ready to run. — that
is evidence the schedule loaded, not a problem.
php craft schedule-it/list — every job, its expression, timezone and next due time —
in the job's timezone and in Craft's — whether it runs in turn or in the background, the
disabled ones, and whether the mutex would let run start. Where a job has a description,
the command is shown beneath it, ready to copy. Runs nothing.
Each of these is a way the library, used directly under Craft, fails without saying so:
- It runs through
craft. The library spawns jobs through a script calledyii, which a Craft project does not have — so nothing runs and nothing is reported. The path is also made absolute, because cron does not guarantee a working directory. - It keeps stderr. Every job's stdout and stderr go to the log file; without that, a fatal in a job leaves no trace.
- It echoes job output to stdout. See Servd — on a host whose task runner throws its filesystem away, the log file alone is worthless. Output is read from the job's own process as it is written, so two runners sharing a log never print each other's.
- It reports exit codes. The library discards them, so its runner exits 0 whatever happened.
- It refuses a lock other servers cannot see. See Overlap.
- It is console-only. The web app has no route to the runner;
/actions/schedule-it/runis a 404.
Every job takes a lock through Craft's mutex component just before it starts, and releases
it the moment it ends. A job still running from an earlier minute is skipped rather than
started twice, and holds back nothing but itself: the other jobs due that minute run as
normal.
With the lock held, the runner also records the minute it started the job in Craft's
cache. A second runner in the same minute — another server, or a run started by hand —
finds the job already started and skips it, even if the first has already finished. That is
only as shared as the cache: Craft's default file cache belongs to one server, so on several
servers give the cache a shared driver (Redis, or the database) as well as the mutex.
That only works if the lock is somewhere every server can see. Craft 5's default is the
database (yii\mutex\MysqlMutex / PgsqlMutex), which is. A FileMutex is not: it lives on
one server's disk. The library's own guard tests Craft's wrapper component rather than the
driver inside it, so it never catches this; the plugin checks the driver, and schedule-it/run
refuses to start on a file (or null) mutex. On a host that can never run two servers, set
'singleServer' => true to run anyway.
Jobs run one after another, so a ten-minute import holds back the jobs listed after it in the same minute (never those of later minutes — see Overlap). Two ways round that:
- List the long job last.
- Run it in the background:
->background(), or'background' => true. The runner starts it as its own process,craft schedule-it/run/job <id> <minute>, and moves straight on. That process takes the job's lock and records its start as the runner would, and writes the job's output and its✓/✗line to the log file — not to the runner's output, which has moved on. Its exit code is not the runner's either.
Not on Servd, or any host that runs the scheduled task in a container removed when the command exits: the background process goes with it. List the long job last there.
- Servd dashboard → the project → the environment → Scheduled Tasks.
- Add one task:
php craft schedule-it/run, every minute (* * * * *). Match the form Servd's existing tasks use. - Repeat per environment — environments do not share tasks. Nothing else is added there, ever; the jobs come from the repository.
Two things specific to Servd:
- Servd already runs the queue and the backups. Do not schedule
queue/*ordb/backup. - The log file does not survive. A scheduled task runs in Servd's task runner, not the web
container, with a filesystem that goes when the task ends; Servd collects stdout only. That
is why the runner echoes each job's output: read it in Servd's log for the task, and do
not look for
storage/logs/schedule-it.logthere. Locally the file is the thing to read. - Background jobs do not survive either, for the same reason. See Long jobs.
Think about staging before adding its task. Once the entry exists, every job runs there,
including any that send email. Point staging's mailer somewhere harmless, give those jobs an
enabled switch, or leave staging without the task.
There is no local cron under DDEV. Run a job directly with ddev craft <command>, or the
runner itself with ddev craft schedule-it/run, which runs whatever is due at that minute in
UTC. ddev craft schedule-it/list shows when that is.
If the project already maps schedule to omnilight\scheduling\ScheduleController in
config/app.console.php with a config/schedule.php file:
- Install the plugin, and move each job into
config/schedule-it.php. Chained->cron()->timezone('UTC')->onOneServer()->appendOutputTo()->then(...)lines become one line each —Job::command('gc/run')->dailyAt('03:00'), or'gc/run' => '0 3 * * *'. - Remove the
schedulecomponent and theschedulecontrollerMapentry, and deleteconfig/schedule.php. - Change the host's one cron entry from
schedule/runtoschedule-it/runin the same deploy. Until it changes, nothing runs — andschedule/runfails with a message saying so, rather than succeeding. (The library registers that command on every console app by itself, and left alone it would run an empty schedule and exit 0 every minute.)
Before 2.0.0 this plugin was webdna/scheduler, handle scheduler. It was renamed because
the Plugin Store already lists Verbb's Scheduler under that handle, and Craft's update check
matches plugins by handle — so the control panel offered every install Verbb's updates.
The plugin keeps nothing in the database, so the move is the old one out and the new one in:
php craft plugin/uninstall scheduler
composer remove webdna/scheduler
composer require webdna/schedule-it
php craft plugin/install schedule-it
git mv config/scheduler.php config/schedule-it.php
- In
config/schedule-it.php, changeuse webdna\scheduler\Job;touse webdna\scheduleit\Job;. - Change the host's one cron entry from
scheduler/runtoschedule-it/runin the same deploy. Until it changes nothing runs, andscheduler/runfails with a message saying so. - Commit
composer.json,composer.lock, the config file andconfig/project/. On each environment, applying project config uninstallsschedulerand installsschedule-ittogether; nothing else needs running there.
A schedule left in config/scheduler.php is never read by the new plugin, so schedule-it/run
and schedule-it/list refuse to start until it is renamed, rather than running nothing.