Scheduling
Cron expressions and task scheduling with fluent API: define, don't execute.
Introduction
Scheduling libraries in PHP often try to be everything at once: cron parser, task runner, process manager and queue dispatcher in one package. The result: complexity, framework coupling and assumptions about how tasks should be executed.
jardissupport/scheduling deliberately does only one thing: define which tasks should run when. Execution is the caller's responsibility: the package delivers the answer to "what is due now?" and leaves the rest to them.
- Two entry points —
CronExpressionfor cron parsing in standalone mode,Schedulefor fluent task definition with tags, priorities and constraints - Beyond cron — time windows (
between/unlessBetween), weekdays (weekdays/weekends), callable conditions (when/skip) and environment filters (environments) - Human-readable descriptions —
describe()translates cron expressions into natural language - Timezone-aware — evaluation always in the configured timezone, independent of the server clock
- Fully testable — all methods accept
DateTimeInterface, no system clock dependency
Installation
composer require jardissupport/schedulingGitHub: jardisSupport/scheduling
CronExpression
Parsing and Evaluating
use JardisSupport\Scheduling\CronExpression;
$cron = CronExpression::parse('30 10 * * *');
$cron->isDue(new DateTimeImmutable('2026-04-05 10:30:00')); // true
$cron->isDue(new DateTimeImmutable('2026-04-05 10:31:00')); // false
$cron->nextRun(new DateTimeImmutable('2026-04-05 09:00:00'));
// → DateTimeImmutable '2026-04-05 10:30:00'
$cron->previousRun(new DateTimeImmutable('2026-04-05 12:00:00'));
// → DateTimeImmutable '2026-04-05 10:30:00'
$cron->nextRuns(new DateTimeImmutable('2026-04-05 09:00:00'), 3);
// → [10:30 today, 10:30 tomorrow, 10:30 the day after]
$cron->describe();
// 'Daily at 10:30'Supported Syntax
| Pattern | Example | Result |
|---|---|---|
| Wildcard | * | Always |
| Literal | 30 | Exactly 30 |
| Range | 9-17 | 9 to 17 |
| List | 0,15,30,45 | These values |
| Step (wildcard) | */15 | Every 15 (0, 15, 30, 45) |
| Step (range) | 1-10/3 | 1, 4, 7, 10 |
| Step (value) | 5/10 | From 5, every 10 (5, 15, 25, ...) |
Fields
Standard (5 fields): min hour day month weekday
| Position | Field | Range |
|---|---|---|
| 1 | Minute | 0–59 |
| 2 | Hour | 0–23 |
| 3 | Day (month) | 1–31 |
| 4 | Month | 1–12 |
| 5 | Weekday | 0–7 (0 and 7 = Sunday) |
6 fields (seconds prefix): sec min hour day month weekday
7 fields (seconds prefix + year suffix): sec min hour day month weekday year
Second range: 0–59 · Year range: 1970–2099
Predefined Aliases
| Alias | Equivalent |
|---|---|
@yearly / @annually | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily / @midnight | 0 0 * * * |
@hourly | 0 * * * * |
Timezone
$cron = CronExpression::parse('0 12 * * *', new DateTimeZone('Europe/Berlin'));
// 10:00 UTC = 12:00 Berlin (CEST)
$utcTime = new DateTimeImmutable('2026-04-05 10:00:00', new DateTimeZone('UTC'));
$cron->isDue($utcTime); // trueSeconds and Year
// Every 30 seconds
CronExpression::parse('30 * * * * *')->isDue(new DateTimeImmutable('2026-04-05 10:00:30'));
// true
// Only in 2026
CronExpression::parse('0 0 12 1 1 * 2026')->isDue(new DateTimeImmutable('2026-01-01 12:00:00'));
// trueSchedule — Fluent Task Definition
Defining Tasks
use JardisSupport\Scheduling\Schedule;
$schedule = Schedule::create('production')
->task('cleanup:expired')
->dailyAt('03:00')
->description('Remove expired records')
->tag('maintenance')
->priority(10)
->task('sync:inventory')
->everyFiveMinutes()
->between('08:00', '18:00')
->weekdays()
->tag('sync', 'erp')
->withoutOverlapping()
->task('report:monthly')
->monthlyOn(1, '07:00')
->timezone('Europe/Berlin')
->tag('reports')
->task('monitor:uptime')
->everyMinute()
->environments('production', 'staging');Querying Due Tasks
// All currently due tasks
$due = $schedule->dueNow(new DateTimeImmutable());
// Only tasks with specific tags (OR semantics)
$syncTasks = $schedule->dueNow(new DateTimeImmutable(), ['sync']);
// All defined tasks (regardless of due status)
$all = $schedule->allTasks();
$emailTasks = $schedule->allTasks(['email']);
// Inspect a task
foreach ($due as $task) {
$task->name(); // 'cleanup:expired'
$task->description(); // 'Remove expired records'
$task->expression()->describe(); // 'Daily at 03:00'
$task->nextRun(new DateTimeImmutable()); // next execution
$task->priority(); // 10
$task->allowsOverlapping(); // true/false
$task->tags(); // ['maintenance']
}Time Helpers
| Method | Cron |
|---|---|
everyMinute() | * * * * * |
everyFiveMinutes() | */5 * * * * |
everyFifteenMinutes() | */15 * * * * |
everyThirtyMinutes() | */30 * * * * |
hourly() | 0 * * * * |
hourlyAt(15) | 15 * * * * |
daily() | 0 0 * * * |
dailyAt('14:30') | 30 14 * * * |
weekly() | 0 0 * * 0 |
weeklyOn(1, '09:00') | 0 9 * * 1 |
monthly() | 0 0 1 * * |
monthlyOn(15, '08:00') | 0 8 15 * * |
yearly() | 0 0 1 1 * |
cron('*/3 * * * *') | Any expression |
Constraints
Constraints restrict execution beyond the cron expression. All constraints are AND-linked: all must be satisfied.
Time Windows
->task('sync:orders')
->everyFiveMinutes()
->between('08:00', '18:00') // Only between 8am and 6pm
->unlessBetween('12:00', '13:00') // Except lunch breakWeekdays
->task('daily:report')
->dailyAt('09:00')
->weekdays() // Mon–Fri
->task('weekend:cleanup')
->dailyAt('02:00')
->weekends() // Sat–Sun
->task('tuesday-thursday')
->dailyAt('10:00')
->days(2, 4) // Tue + Thu (0=Sun, 6=Sat)Callable Conditions
->task('process:queue')
->everyMinute()
->when(fn() => QueueService::hasItems()) // Only when queue is not empty
->skip(fn() => MaintenanceMode::isActive()) // Not during maintenanceEnvironment Filters
->task('heavy:migration')
->monthlyOn(1, '04:00')
->environments('production') // Production onlyTags and Priority
Tags (OR Semantics)
$schedule = Schedule::create()
->task('email:digest')
->dailyAt('08:00')
->tag('email', 'notifications')
->task('email:welcome')
->everyMinute()
->tag('email', 'onboarding');
// All tasks with tag 'email'
$emailTasks = $schedule->dueNow($now, ['email']);
// Empty tags → all tasks
$allDue = $schedule->dueNow($now);Tags are deduplicated: .tag('a', 'b')->tag('b', 'c') → ['a', 'b', 'c'].
Priority
Higher value = returned first:
$schedule = Schedule::create()
->task('low')->everyMinute()->priority(1)
->task('high')->everyMinute()->priority(10)
->task('medium')->everyMinute()->priority(5);
$due = $schedule->dueNow($now);
// $due[0]->name() === 'high'
// $due[1]->name() === 'medium'
// $due[2]->name() === 'low'Overlap Guard
->task('long:running')
->everyMinute()
->withoutOverlapping()withoutOverlapping() sets a flag: the package itself does not implement locking. The task runner must check $task->allowsOverlapping() and use its own lock mechanism when false.
Validation
$violations = $schedule->validate();
foreach ($violations as $violation) {
echo "{$violation->severity}: {$violation->taskName} — {$violation->message}\n";
}| Condition | Severity | Message |
|---|---|---|
| No tasks defined | warning | Schedule contains no tasks |
| Duplicate task name | error | Duplicate task name: {name} |
weekdays() + weekends() on same task | warning | Task {name} has conflicting day constraints |
Error Handling
| Exception | Cause |
|---|---|
InvalidCronExpressionException | Syntax error, too few/many fields, value out of range |
InvalidScheduleException | Missing task name, missing expression, invalid time specification |
use JardisSupport\Scheduling\Exception\InvalidCronExpressionException;
try {
CronExpression::parse('invalid');
} catch (InvalidCronExpressionException $e) {
echo $e->getMessage();
}Architecture
Two orchestrators with specialized handlers in the Closure-Orchestrator-Pattern:
CronExpression ← Orchestrator
├── ParseExpression ← Tokenization → field arrays
├── MatchFields ← Field arrays vs. DateTime
├── FindNextRun / FindPreviousRun ← Iteration until match
├── DescribeExpression ← Human-readable text
└── ResolveTimezone ← Timezone conversion
Schedule ← Orchestrator
├── TaskBuilder ← Fluent configuration
│ └── ScheduledTask ← Immutable Value Object
├── ValidateSchedule ← Validation rules
└── Constraints
├── TimeWindow ← between / unlessBetween
├── DayOfWeek ← weekdays / weekends / days()
├── CallableCondition ← when / skip
└── EnvironmentMatch ← environments()Directory Structure
src/
├── CronExpression.php ← Orchestrator
├── Schedule.php ← Orchestrator
├── TaskBuilder.php ← Fluent builder
├── Data/
│ └── ScheduledTask.php ← Immutable Value Object
├── Exception/
│ ├── InvalidCronExpressionException.php
│ └── InvalidScheduleException.php
└── Handler/
├── ParseExpression.php
├── MatchFields.php
├── FindNextRun.php
├── FindPreviousRun.php
├── DescribeExpression.php
├── ResolveTimezone.php
├── ValidateSchedule.php
├── TimeWindow.php
├── DayOfWeek.php
├── CallableCondition.php
└── EnvironmentMatch.phpAPI Reference
CronExpression
| Method | Signature | Description |
|---|---|---|
parse | static parse(string $expression, ?DateTimeZone $tz = null): self | Factory |
isDue | isDue(DateTimeInterface $now): bool | Due now? |
nextRun | nextRun(DateTimeInterface $from): DateTimeInterface | Next execution |
previousRun | previousRun(DateTimeInterface $from): DateTimeInterface | Last execution |
nextRuns | nextRuns(DateTimeInterface $from, int $count): array | N next executions |
describe | describe(): string | Human-readable description |
Schedule
| Method | Signature | Description |
|---|---|---|
create | static create(string $currentEnvironment = ''): self | Factory |
task | task(string $name): TaskBuilder | Define task |
dueNow | dueNow(DateTimeInterface $now, array $tags = []): array | Due tasks |
allTasks | allTasks(array $tags = []): array | All tasks |
validate | validate(): array | Validate schedule |
ScheduledTask
| Method | Signature | Description |
|---|---|---|
name | name(): string | Task name |
description | description(): string | Description |
expression | expression(): CronExpressionInterface | Cron expression |
tags | tags(): array | Tags |
priority | priority(): int | Priority |
allowsOverlapping | allowsOverlapping(): bool | Overlap allowed? |
constraints | constraints(): array | Active constraints |
isDue | isDue(DateTimeInterface $now): bool | Due including constraints? |
nextRun | nextRun(DateTimeInterface $from): DateTimeInterface | Next execution |
Complete Example
A production-ready schedule with various intervals, constraints and tag-based querying:
use JardisSupport\Scheduling\Schedule;
use JardisSupport\Scheduling\CronExpression;
$schedule = Schedule::create('production')
// Daily cleanup at 3am
->task('cleanup:sessions')
->dailyAt('03:00')
->description('Remove expired sessions')
->tag('maintenance')
->priority(5)
// ERP sync every 5 minutes, weekdays 8am–6pm only
->task('sync:erp')
->everyFiveMinutes()
->between('08:00', '18:00')
->weekdays()
->tag('sync', 'erp')
->withoutOverlapping()
->priority(10)
// Monthly report on the 1st at 7am
->task('report:monthly')
->monthlyOn(1, '07:00')
->timezone('Europe/Berlin')
->tag('reports')
->environments('production')
// Process queue only when items are present
->task('queue:process')
->everyMinute()
->when(fn() => QueueService::count() > 0)
->skip(fn() => MaintenanceMode::isActive())
->tag('queue');
// Validate
$violations = $schedule->validate();
if (count($violations) > 0) {
foreach ($violations as $v) {
echo "[{$v->severity}] {$v->taskName}: {$v->message}\n";
}
}
// In the cron job: query due tasks
$now = new DateTimeImmutable();
$dueTasks = $schedule->dueNow($now);
foreach ($dueTasks as $task) {
if (!$task->allowsOverlapping() && Lock::isHeld($task->name())) {
continue; // Overlap guard: runner responsibility
}
Lock::acquire($task->name());
try {
$runner->execute($task->name());
} finally {
Lock::release($task->name());
}
}
// Standalone CronExpression for custom purposes
$cron = CronExpression::parse('*/15 9-17 * * 1-5');
$cron->describe(); // 'Every 15 minutes'
$cron->nextRun($now)->format('Y-m-d H:i');