Scheduler & Cron
Scheduler & Cron
Zero Framework ships with a lightweight scheduler that mirrors Laravel's ergonomics while staying dependency-free. All scheduled work is funnelled through a single CLI entry point:
php zero schedule:runRun this command every minute (or at the cadence you prefer) and the framework evaluates the schedule, runs due tasks, and skips everything else.
Example Schedule (routes/cron.php)
<?php
declare(strict_types=1);
use Zero\Lib\Console\Scheduling\Schedule;
return static function (Schedule $schedule): void {
// Dispatch queued emails every five minutes without overlap.
$schedule->command('emails:dispatch')
->everyFiveMinutes()
->withoutOverlapping()
->description('Dispatch queued transactional emails');
// Sync analytics dashboards five past the hour on weekdays.
$schedule->command('reports:sync')
->hourlyAt(5)
->daysOfWeek(1, 2, 3, 4, 5)
->description('Refresh BI dashboards');
// Generate invoices nightly at 00:30.
$schedule->command('billing:invoice')
->dailyAt('00:30')
->description('Generate customer invoices');
// Rotate database backups every six hours at the 10th minute.
$schedule->command('backups:rotate')
->everySixHours(10)
->description('Rotate database backups');
// Run Sunday maintenance at 03:00.
$schedule->command('maintenance:weekly')
->sunday()
->hours(3)
->minutes(0)
->description('Weekly maintenance window');
// Quarterly statement export on the 1st and 15th at 02:00 (Jan/Apr/Jul/Oct).
$schedule->command('finance:statements')
->months(1, 4, 7, 10)
->datesOfMonth(1, 15)
->hours(2)
->minutes(0)
->description('Quarterly financial statements');
// Closure helper for cache refresh at 02:00 daily.
$schedule->call(fn () => cache_clear())
->dailyAt('02:00')
->description('Refresh application cache');
};The repository already ships with routes/cron.php; edit that file directly (or copy the example above) to tailor the schedule to your project.
Local Test Harness
The repository includes an app:test command that appends entries to storage/logs/cron-sample.log and emits a debug message via the internal log channel. Use it to verify cadence helpers without wiring real workloads.
php zero app:test --type=manual-checkFor automated smoke tests, append a block like this to your schedule and watch the log file:
$schedule->command('app:test', ['--type=every-minute'])
->everyMinute()
->description('Cron sanity check: every minute');
$schedule->command('app:test', ['--type=every-three-minutes'])
->everyThreeMinutes()
->description('Cron sanity check: every three minutes');Chain additional helpers (everySixHours(), daysOfWeek(), etc.) to exercise the combinations you rely on. Remove the block once you finish testing.
Schedule API
use Zero\Lib\Console\Scheduling\Schedule;Each task is registered with $schedule->command(...) or $schedule->call(...), then chained with cadence + filter helpers.
Defining tasks
$schedule->command(string $signature, array $args = []): Event
Schedule a CLI command.
$schedule->command('emails:dispatch')->everyFiveMinutes();
$schedule->command('app:test', ['--type=manual'])->everyMinute();$schedule->call(callable $callback, ?string $description = null): Event
Schedule any closure / invokable.
$schedule->call(fn () => cache_clear())->dailyAt('02:00');Cadence helpers
Every helper returns the Event, so you can keep chaining.
Minute-level
$schedule->command('a')->everyMinute();
$schedule->command('a')->everyTwoMinutes();
$schedule->command('a')->everyThreeMinutes();
$schedule->command('a')->everyFiveMinutes();
$schedule->command('a')->everyTenMinutes();
$schedule->command('a')->everyFifteenMinutes();
$schedule->command('a')->everyThirtyMinutes();
$schedule->command('a')->everyMinutes(7); // every 7 minutesHour-level
$schedule->command('a')->hourly();
$schedule->command('a')->hourlyAt(15); // every hour at :15
$schedule->command('a')->everyHours(2); // every 2 hours, on the hour
$schedule->command('a')->everyHours(2, 30); // every 2 hours at :30
$schedule->command('a')->everySixHours(10);
$schedule->command('a')->everyTwelveHours();
$schedule->command('a')->twiceDaily(1, 13); // 01:00 and 13:00Day-level
$schedule->command('a')->daily();
$schedule->command('a')->dailyAt('00:30');
$schedule->command('a')->weekdays('09:00');
$schedule->command('a')->weekends('10:00');Week-level
$schedule->command('a')->weekly(); // Mondays 00:00
$schedule->command('a')->weeklyOn(0, '09:00'); // Sundays 09:00
$schedule->command('a')->sunday(); // Sun 00:00
$schedule->command('a')->monday();
$schedule->command('a')->tuesday();
$schedule->command('a')->wednesday();
$schedule->command('a')->thursday();
$schedule->command('a')->friday();
$schedule->command('a')->saturday();Month / year
$schedule->command('a')->monthly(); // 1st 00:00
$schedule->command('a')->monthlyOn(15, '02:00'); // 15th at 02:00
$schedule->command('a')->quarterly('03:00'); // Jan/Apr/Jul/Oct 1st 03:00
$schedule->command('a')->yearly('00:00'); // Jan 1 00:00Filter helpers
Stack these on top of a cadence to narrow execution.
daysOfWeek(int ...$days) — Sun=0..Sat=6
$schedule->command('reports:sync')->hourlyAt(5)->daysOfWeek(1, 2, 3, 4, 5);datesOfMonth(int ...$dates) — 1..31
$schedule->command('finance:statements')->dailyAt('02:00')->datesOfMonth(1, 15);months(int ...$months) — 1..12
$schedule->command('audit')->monthly()->months(1, 4, 7, 10);hours(int ...$hours) / minutes(int ...$minutes)
$schedule->command('a')->everySixHours()->hours(0, 6, 12, 18);
$schedule->command('a')->hourly()->minutes(15, 45);Concurrency & metadata
withoutOverlapping(?int $expiresAfter = null): Event
Skip the run if the previous one is still going. Optional file-lock TTL in seconds.
$schedule->command('reports:sync')->hourly()->withoutOverlapping();
$schedule->command('reports:sync')->hourly()->withoutOverlapping(900);mutexName(string $name): Event
Share a lock key across multiple events.
$schedule->command('a')->hourly()->withoutOverlapping()->mutexName('reports');
$schedule->command('b')->hourly()->withoutOverlapping()->mutexName('reports');description(string $text): Event
$schedule->command('a')->daily()->description('Nightly clean-up');Conditional helpers
Stack arbitrary predicates on top of cadence/filter rules. Predicates receive the current DateTimeInterface.
when(callable $predicate): Event
Run only when the predicate returns truthy.
$schedule->command('reports:sync')
->hourly()
->when(fn () => env('APP_ENV') === 'production');skip(callable $predicate): Event
Skip the run when the predicate returns truthy.
$schedule->command('emails:dispatch')
->everyFiveMinutes()
->skip(fn () => env('DISABLE_EMAILS') === 'true');Lifecycle hooks
Attach side effects without changing the task itself. Hooks run after the cadence/when/skip checks pass. Each hook receives the Event instance. Hook exceptions are logged but never break the scheduler.
before(callable $callback): Event
Runs immediately before execute().
$schedule->command('reports:sync')
->hourly()
->before(fn ($event) => logger('starting ' . $event->getDescription()));after(callable $callback): Event
Always runs after execute(), success or failure.
$schedule->command('reports:sync')
->hourly()
->after(fn ($event) => logger('finished ' . $event->getDescription()));onSuccess(callable $callback): Event
Runs only when the task completes successfully (no throw, exit code 0 or void).
$schedule->command('billing:invoice')
->dailyAt('00:30')
->onSuccess(fn () => Http::get('https://hc-ping.com/abc-123'));onFailure(callable $callback): Event
Runs only when the task throws or returns a non-zero exit code.
$schedule->command('billing:invoice')
->dailyAt('00:30')
->onFailure(fn ($event) => logger('billing failed: ' . $event->lastException()?->getMessage()));Exit codes.
command()events now propagate the exit code returned by the underlying console command. A non-zero exit triggersonFailureand is reported as a failure inschedule:run. Closures attached viacall()continue to be treated as successful unless they throw.
Raw cron expressions
cron(string $expression): Event
Five fields: minute hour day-of-month month day-of-week. Supports lists (1,15), ranges (1-5), steps (*/10), month/weekday aliases (jan, mon, …), and ?.
$schedule->command('a')->cron('0 9 ? * mon-fri'); // 09:00 weekdays
$schedule->command('a')->cron('*/10 * * * *'); // every 10 minutesBuilding Your Schedule
- Keep tasks idempotent so a run that fires twice in the same window does not produce duplicate side effects.
- Use descriptive command signatures (
reports:sync,emails:dispatch) so scheduler logs stay readable. - Delegate heavy work to services or dedicated commands—helpers should remain thin.
- Chain
withoutOverlapping()(optionallywithoutOverlapping(900)) to guard long-running jobs. - Provide
description()text when you want human-friendly log entries. - Use
mutexName()when multiple helpers must share a locking key.
Frequency Helper Reference
| Cadence | Helper(s) |
|---|---|
| Minute | everyMinute(), everyTwoMinutes(), everyThreeMinutes(), everyFiveMinutes(), everyTenMinutes(), everyFifteenMinutes(), everyThirtyMinutes(), everyMinutes(n) |
| Hour | hourly(), hourlyAt(minute), twiceDaily(firstHour, secondHour), everyHours(n, minute = 0), everySixHours(), everyTwelveHours(), everyThirtySixHours() |
| Day | daily(), dailyAt('HH:MM'), weekdays('HH:MM'), weekends('HH:MM') |
| Week | weekly() (defaults to Monday 00:00), weeklyOn(dayOfWeek, 'HH:MM') where Sunday = 0 |
| Month | monthly(day = 1, 'HH:MM'), monthlyOn(day, 'HH:MM'), quarterly('HH:MM') |
| Year | yearly('HH:MM') |
| Custom work | call(callable, description?) |
Each helper returns the underlying event so you can keep chaining (->withoutOverlapping()->description('...')).
Additional Constraints
daysOfWeek(…)or shorthand helpers (monday(),tuesday(), …) filter execution to specific weekdays.datesOfMonth(…)restricts runs to specific calendar dates.months(…)limits runs to the supplied months (1–12).hours(…)andminutes(…)accept lists of allowed clock values.
Chain these alongside cadence helpers to express complex schedules (for example, ->everySixHours(30)->daysOfWeek(1,3,5)->months(1,4,7,10)).
Cron Expressions
cron()accepts five fields in the formatminute hour day-of-month month day-of-week.- Supported syntax includes lists (
1,15), ranges (1-5), and steps (*/10). - Month aliases (
jan,feb, …) and weekday aliases (sun,mon, …) are recognised; Sunday may be0or7. - Use
?in day-of-month or day-of-week when the other field drives the schedule (e.g.,0 9 ? * mon). - Combine cron expressions with helper guards if you need multi-condition logic.
Registering the Cron Entry
Add a single cron line so the scheduler runs every minute. Substitute your PHP binary, project root, and user:
* * * * * www-data /usr/bin/php /var/www/zero-framework/zero schedule:run >> /var/log/zero-schedule.log 2>&1For full deployment steps (choosing the cron user, log destination, and verifying the install), follow the scheduler section in docs/deployment.md.
For quick local smoke tests, open a second terminal and run a tiny loop that calls the scheduler every minute:
while true; do
php zero schedule:run --path=routes/cron.php
sleep 60
donePress Ctrl+C when you are done. Adjust the sleep duration or inline additional logic to align with your workflow.
Container platforms (Kubernetes CronJob, ECS Scheduled Task, etc.) should invoke the same command on their own scheduler.
Listing the schedule
Inspect which tasks are registered (and which are due right now) without executing them:
php zero schedule:list
php zero schedule:list --path=routes/cron.phpThe Due now column reflects every active rule — cadence helpers, daysOfWeek/hours/minutes filters, and any when()/skip() predicates. Useful for verifying that a new schedule will fire when you expect.
Draining the queue from cron
Hosts without supervisord can drain the queue by scheduling queue:work --once:
$schedule->command('queue:work', ['--once', '--queue=default'])
->everyMinute()
->withoutOverlapping()
->description('Drain default queue');See queue.md for the full queue reference.
Operating & Monitoring
- Manual runs:
php zero schedule:runis safe when testing new tasks—the output is routed through the internal log channel. - Listing:
php zero schedule:listprints every registered task and whether it would fire at the current minute. - Logging: each run emits “running/completed/failed” log events; redirect cron stdout/stderr to an aggregated log file or service.
- Health checks: append a heartbeat (DB row, log ping, uptime monitor) at the end of
schedule:runto detect missed invocations. Or useonSuccess(fn () => Http::get($pingUrl))per-task for fine-grained healthcheck.io / cronitor pings. - Failure handling: the command exits with a non-zero status if any task throws or a scheduled
command()returns a non-zero exit code. UseonFailure()to wire alerts.
Roadmap Ideas
- Introduce per-task timezone overrides (e.g.
$event->timezone('UTC')). - Background execution (
runInBackground()) so a slow task does not block subsequent ones. - Per-task output capture (
sendOutputTo($file)/appendOutputTo($file)). - Built-in healthcheck-ping helpers (
pingBefore($url)/thenPing($url)). - Support alternative mutex stores beyond the file-based lock.
- Document best practices for multi-environment schedules and feature toggles.
With the scheduler wired up, you consolidate recurring jobs behind one cron entry and keep operational visibility in a single place.