Skip to content

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>&1

Cron 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 * * 0

Or 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.
Terminal window
maat schedule:list
Expression 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:

Terminal window
maat schedule:test cartouche:prune
maat schedule:test 0

Filters 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.

Terminal window
maat schedule:work

Setting Up Cron

Add the entry once, as the user that owns the application:

Terminal window
crontab -e
* * * * * cd /srv/app && ./bin/maat schedule:run >> /dev/null 2>&1

Notes that save an afternoon:

  • Use an absolute path. Cron’s PATH is nearly empty. cd into the application directory and call the compiled binary by path.
  • The application must find its .env. cd into the application root, or set APP_BASE_PATH in 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 call exit() 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);