Skip to content

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 pointsCronExpression for cron parsing in standalone mode, Schedule for 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 descriptionsdescribe() 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

bash
composer require jardissupport/scheduling

GitHub: jardisSupport/scheduling

CronExpression

Parsing and Evaluating

php
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

PatternExampleResult
Wildcard*Always
Literal30Exactly 30
Range9-179 to 17
List0,15,30,45These values
Step (wildcard)*/15Every 15 (0, 15, 30, 45)
Step (range)1-10/31, 4, 7, 10
Step (value)5/10From 5, every 10 (5, 15, 25, ...)

Fields

Standard (5 fields): min hour day month weekday

PositionFieldRange
1Minute0–59
2Hour0–23
3Day (month)1–31
4Month1–12
5Weekday0–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

AliasEquivalent
@yearly / @annually0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily / @midnight0 0 * * *
@hourly0 * * * *

Timezone

php
$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);  // true

Seconds and Year

php
// 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'));
// true

Schedule — Fluent Task Definition

Defining Tasks

php
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

php
// 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

MethodCron
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

php
->task('sync:orders')
    ->everyFiveMinutes()
    ->between('08:00', '18:00')       // Only between 8am and 6pm
    ->unlessBetween('12:00', '13:00') // Except lunch break

Weekdays

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

php
->task('process:queue')
    ->everyMinute()
    ->when(fn() => QueueService::hasItems())        // Only when queue is not empty
    ->skip(fn() => MaintenanceMode::isActive())     // Not during maintenance

Environment Filters

php
->task('heavy:migration')
    ->monthlyOn(1, '04:00')
    ->environments('production')  // Production only

Tags and Priority

Tags (OR Semantics)

php
$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:

php
$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

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

php
$violations = $schedule->validate();

foreach ($violations as $violation) {
    echo "{$violation->severity}: {$violation->taskName} — {$violation->message}\n";
}
ConditionSeverityMessage
No tasks definedwarningSchedule contains no tasks
Duplicate task nameerrorDuplicate task name: {name}
weekdays() + weekends() on same taskwarningTask {name} has conflicting day constraints

Error Handling

ExceptionCause
InvalidCronExpressionExceptionSyntax error, too few/many fields, value out of range
InvalidScheduleExceptionMissing task name, missing expression, invalid time specification
php
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.php

API Reference

CronExpression

MethodSignatureDescription
parsestatic parse(string $expression, ?DateTimeZone $tz = null): selfFactory
isDueisDue(DateTimeInterface $now): boolDue now?
nextRunnextRun(DateTimeInterface $from): DateTimeInterfaceNext execution
previousRunpreviousRun(DateTimeInterface $from): DateTimeInterfaceLast execution
nextRunsnextRuns(DateTimeInterface $from, int $count): arrayN next executions
describedescribe(): stringHuman-readable description

Schedule

MethodSignatureDescription
createstatic create(string $currentEnvironment = ''): selfFactory
tasktask(string $name): TaskBuilderDefine task
dueNowdueNow(DateTimeInterface $now, array $tags = []): arrayDue tasks
allTasksallTasks(array $tags = []): arrayAll tasks
validatevalidate(): arrayValidate schedule

ScheduledTask

MethodSignatureDescription
namename(): stringTask name
descriptiondescription(): stringDescription
expressionexpression(): CronExpressionInterfaceCron expression
tagstags(): arrayTags
prioritypriority(): intPriority
allowsOverlappingallowsOverlapping(): boolOverlap allowed?
constraintsconstraints(): arrayActive constraints
isDueisDue(DateTimeInterface $now): boolDue including constraints?
nextRunnextRun(DateTimeInterface $from): DateTimeInterfaceNext execution

Complete Example

A production-ready schedule with various intervals, constraints and tag-based querying:

php
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');