Task Scheduling
Most applications need work to happen on a clock: prune expired tokens overnight, send a digest every Monday, rebuild a sitemap every hour. Adding a crontab line for each of those spreads the schedule across a server you cannot see from your editor, cannot review in a pull request, and cannot test.
Maat keeps the whole schedule in Dart. The server gets one cron entry, and that entry never changes again:
* * * * * cd /srv/app && ./bin/maat schedule:run >> /dev/null 2>&1Cron wakes schedule:run once a minute. The command asks the schedule which
tasks are due at that minute and runs them.
Defining the Schedule
Scheduled tasks are declared in bootstrap/app.dart, alongside middleware and
exception handling:
Future<Application> createApp({Map<String, String>? environment}) => Application.configure(basePath: ..., environment: environment) .withConfig(appConfig) .withProviders([...]) .withSchedule((schedule) { schedule.command('cartouche:prune --hours=48').dailyAt('03:00'); schedule.call(rebuildSitemap).hourly(); schedule.exec('pg_dump', ['app']).daily().appendOutputTo('storage/logs/backup.log'); }) .create();There are three kinds of task.
schedule.command()
Runs a console command, written exactly as you would type it:
schedule.command('cartouche:prune --hours=48').dailyAt('03:00');schedule.command('queue:prune-failed').weekly();The command runs inside the schedule:run process, against the
application that process already booted. It is not forked. See
Why tasks are not forked.
schedule.call()
Runs a Dart callback. Anything that returns void or Future<void> works,
and the container is available through app<T>():
schedule.call(() async { await app<ReportBuilder>().rebuildDaily();}, description: 'Rebuild daily reports').dailyAt('05:30');Pass description: — it is what schedule:list shows and what the overlap
lock is named after. Without it the task is listed as Closure.
To queue a job rather than run it inline, dispatch from the callback:
schedule.call(() => dispatch(SendWeeklyDigest())).mondays().at('08:00');This keeps the scheduler process short-lived and lets the queue worker do the
slow part, which is the same reason Laravel offers Schedule::job().
schedule.exec()
Runs an external program in its own process:
schedule.exec('pg_dump', ['--file=/backups/app.sql', 'app']).daily();Use this when a task must be genuinely isolated from the application process.
Frequencies
Every task starts as * * * * * and each frequency method overwrites part of
that expression. The methods chain, and the last one to touch a field wins.
| Method | Expression |
|---|---|
everyMinute() |
* * * * * |
everyTwoMinutes() / everyThreeMinutes() / everyFourMinutes() |
*/2 … */4 |
everyFiveMinutes() / everyTenMinutes() / everyFifteenMinutes() |
*/5, */10, */15 |
everyThirtyMinutes() |
0,30 * * * * |
hourly() |
0 * * * * |
hourlyAt([15, 45]) |
15,45 * * * * |
everyOddHour() |
0 1-23/2 * * * |
everyTwoHours(), everyThreeHours(), everyFourHours(), everySixHours() |
0 */n * * * |
daily() |
0 0 * * * |
dailyAt('13:30') |
30 13 * * * |
twiceDaily(1, 13) |
0 1,13 * * * |
twiceDailyAt(1, 13, 15) |
15 1,13 * * * |
weekly() |
0 0 * * 0 |
weeklyOn(1, '08:00') |
0 8 * * 1 |
monthly() |
0 0 1 * * |
monthlyOn(15, '09:00') |
0 9 15 * * |
twiceMonthly(1, 16) |
0 0 1,16 * * |
lastDayOfMonth() |
0 0 L * * |
quarterly() |
0 0 1 1-12/3 * |
quarterlyOn(4, '02:00') |
0 2 4 1-12/3 * |
yearly() |
0 0 1 1 * |
yearlyOn(12, 25) |
0 0 25 12 * |
Day-of-week constraints combine with any of the above:
schedule.command('reports:send').daily().weekdays();schedule.command('backups:run').dailyAt('02:00').weekends();schedule.command('digest:send').weekly().mondays();days([1, 3, 5]), weekdays(), weekends(), and sundays() through
saturdays() are all available. Days are numbered Sunday 0 to Saturday 6,
as cron numbers them — not as DateTime.weekday does.
Set the time of day on any of them with at('HH:mm'):
schedule.command('cleanup:run').weekly().at('06:45'); // 45 6 * * 0Or write the expression yourself:
schedule.command('odd:job').cron('7 4 * * 2');cron() validates what you give it, so a typo fails at boot rather than
becoming a task that silently never runs. The five fields also accept JAN
through DEC, SUN through SAT, ranges, */n steps, lists, and L in the
day-of-month field for “the last day of this month”.
Constraints
Time windows
schedule.command('emails:send').everyFifteenMinutes().between('09:00', '17:00');schedule.command('reports:run').hourly().unlessBetween('22:00', '04:00');A window whose end is earlier than its start wraps midnight, so
between('22:00', '04:00') means the overnight window a reader expects.
Conditions
schedule.command('sync:partners') .hourly() .when(() => config('partners.sync_enabled') == true) .skip(() => app<Maintenance>().isActive);when() must return true for the task to run; skip() must return false.
Both are evaluated at the moment the task comes due, not when the schedule is
defined — so they see current config and current state.
Environments
schedule.command('billing:charge').monthly().environments(['production']);The comparison is against APP_ENV.
Preventing Overlap
A task that sometimes runs longer than its own interval will otherwise start a second copy on top of the first:
schedule.command('reports:rebuild').everyFiveMinutes().withoutOverlapping();The lock is a file under storage/framework/schedule/. It needs no expiry:
the operating system releases it when the process ends, however it ends — a
crash, a kill -9, a deploy — so a task cannot lock itself out.
Give two tasks the same name to serialise them against each other:
schedule.command('reports:daily').dailyAt('01:00').name('reports').withoutOverlapping();schedule.command('reports:weekly').weekly().at('01:00').name('reports').withoutOverlapping();One machine, not a cluster
The lock is local to the machine. Two servers each running the cron entry will
each run the task. Until Maat has a shared cache, run schedule:run on exactly
one machine, or gate cluster-wide tasks with a when() that checks a row in
your own database.
Running Tasks Concurrently
Tasks run one after another by default, in the order they were defined. A slow one holds up the rest of that minute’s run:
schedule.exec('pg_dump', ['app']).daily().runInBackground();runInBackground() lets the remaining tasks start without waiting for this
one. schedule:run still waits for every background task before it exits — a
Dart process that returned early would take its own unfinished work down with
it.
Task Output
schedule.command('reports:build').daily().sendOutputTo('storage/logs/reports.log');schedule.command('reports:build').daily().appendOutputTo('storage/logs/reports.log');sendOutputTo replaces the file each run; appendOutputTo adds to it. The
output captured is whatever the command wrote through info(), line(),
warn(), and error(), or whatever an exec() process wrote to stdout and
stderr.
Hooks
schedule.command('billing:charge') .monthly() .before(() => Log.info('Charging subscriptions')) .after(() => Log.info('Done charging')) .onSuccess((output) => Log.info('Charged: $output')) .onFailure((error, stack) => Log.error('Charging failed', error, stack));before and after always run as a pair — after runs even when the task
throws. onSuccess receives the captured output; onFailure receives the
error and its stack trace.
Events
Every run dispatches events you can listen for in a service provider, which is where a global failure alert belongs:
| Event | When |
|---|---|
ScheduledTaskStarting |
A due task passed its filters and is about to run |
ScheduledTaskFinished |
A task ran, successfully or not |
ScheduledTaskSkipped |
A filter, an environment, or the overlap lock stopped it |
ScheduledTaskFailed |
A task threw or returned a non-zero status |
class ScheduleServiceProvider extends ServiceProvider { ScheduleServiceProvider(super.app);
@override void boot() { Event.listen<ScheduledTaskFailed>((e) async { await app<Notifier>().alert('Scheduled task failed: ${e.result.task.description}'); }); }}Console Commands
| Command | What it does |
|---|---|
schedule:run |
Run every task due this minute. This is what cron calls. |
schedule:list |
Show every task with its expression and next due time. |
schedule:test {task?} |
Run one task now, whether or not it is due. |
schedule:work |
Run the schedule in the foreground, once a minute. |
maat schedule:listExpression Task Next Due---------- ---- --------0 3 * * * cartouche:prune --hours=48 2026-09-08T03:00 (in 12h)0 * * * * Rebuild sitemap 2026-09-07T16:00 (in 23m)schedule:test takes part of a description, or a task’s index from
schedule:list, and runs it immediately:
maat schedule:test cartouche:prunemaat schedule:test 0Filters and the overlap lock still apply, which is usually what you are trying
to find out — a task that will not run in production because of a when() will
not run here either, and it tells you why.
schedule:work loops in the foreground for local development, so you can watch
a schedule work without touching crontab. Production uses cron and
schedule:run.
maat schedule:workSetting Up Cron
Add the entry once, as the user that owns the application:
crontab -e* * * * * cd /srv/app && ./bin/maat schedule:run >> /dev/null 2>&1Notes that save an afternoon:
- Use an absolute path. Cron’s
PATHis nearly empty.cdinto the application directory and call the compiled binary by path. - The application must find its
.env.cdinto the application root, or setAPP_BASE_PATHin the crontab. - Run it as the deploy user, not root, so
storage/stays writable by the same user the HTTP process uses. - One machine only — see the note under Preventing Overlap.
In a container, run the schedule as its own service rather than adding cron to the application image:
services: scheduler: image: my-app:latest command: ["./bin/server", "schedule:work"]schedule:work is the right process there: a container supervisor already
restarts it if it dies, and it needs no cron daemon inside the image.
Time Zones
The schedule is evaluated against the process’s local time. Run the server in UTC — the usual production default — and every expression means UTC.
For a task that must fire at a local wall-clock time on a UTC server:
schedule.command('reports:daily').dailyAt('09:00').utcOffset(Duration(hours: 3));That is a fixed offset, and it is named so you cannot mistake it for anything else. Dart ships no IANA time-zone database, so Maat cannot follow a zone through a daylight-saving change. For a zone that observes DST, either run the process in that zone or adjust the offset when it changes.
Why Tasks Are Not Forked
Laravel’s scheduler runs each Schedule::command() by shelling out to a fresh
php artisan process, because PHP has no application to reuse between
requests. Maat’s schedule:run is already a booted application: the container
is built, providers are registered, the database pool is open. Forking
dart run per task would pay full start-up every minute and gain nothing.
So schedule.command() runs in-process. Two consequences worth knowing:
- A task that calls
exit()ends the whole run. Do not callexit()from a command you schedule. - A task that leaks state into a singleton leaks it into the tasks after it in the same run.
Use schedule.exec() when a task genuinely needs its own process.
Testing the Schedule
Schedule takes an injectable clock, so a test can drive it to any minute
instead of waiting for one:
test('the prune task runs overnight', () async { final schedule = Schedule( storagePath: tempDir.path, environment: 'testing', clock: () => DateTime(2026, 9, 7, 3), ); schedule.command('cartouche:prune').dailyAt('03:00');
expect(schedule.dueTasks(), hasLength(1)); expect(schedule.dueTasks(DateTime(2026, 9, 7, 4)), isEmpty);});Against a booted application, resolve the real schedule and check the expression a task ended up with:
final schedule = app.make<Schedule>();expect(schedule.tasks.single.expression, '0 3 * * *');And run a single task without going near the clock:
final result = await schedule.tasks.single.run();expect(result.outcome, TaskOutcome.success);