ZeroPHP

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:run

Run 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-check

For 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 minutes

Hour-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:00

Day-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:00

Filter 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 triggers onFailure and is reported as a failure in schedule:run. Closures attached via call() 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 minutes

Building 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() (optionally withoutOverlapping(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

CadenceHelper(s)
MinuteeveryMinute(), everyTwoMinutes(), everyThreeMinutes(), everyFiveMinutes(), everyTenMinutes(), everyFifteenMinutes(), everyThirtyMinutes(), everyMinutes(n)
Hourhourly(), hourlyAt(minute), twiceDaily(firstHour, secondHour), everyHours(n, minute = 0), everySixHours(), everyTwelveHours(), everyThirtySixHours()
Daydaily(), dailyAt('HH:MM'), weekdays('HH:MM'), weekends('HH:MM')
Weekweekly() (defaults to Monday 00:00), weeklyOn(dayOfWeek, 'HH:MM') where Sunday = 0
Monthmonthly(day = 1, 'HH:MM'), monthlyOn(day, 'HH:MM'), quarterly('HH:MM')
Yearyearly('HH:MM')
Custom workcall(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(…) and minutes(…) 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 format minute 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 be 0 or 7.
  • 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>&1

For 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
done

Press 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.php

The 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:run is safe when testing new tasks—the output is routed through the internal log channel.
  • Listing: php zero schedule:list prints 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:run to detect missed invocations. Or use onSuccess(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. Use onFailure() 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.