Skip to content

Scheduling

Cron-Expressions und Task-Scheduling mit fluenter API: definieren, nicht ausführen.

Einführung

Scheduling-Libraries in PHP versuchen oft, alles auf einmal zu sein: Cron-Parser, Task-Runner, Process-Manager und Queue-Dispatcher in einem Package. Das Ergebnis: Komplexität, Framework-Kopplung und Annahmen darüber, wie Tasks ausgeführt werden sollen.

jardissupport/scheduling macht bewusst nur eines: definieren, welche Tasks wann laufen sollen. Die Ausführung ist Sache des Aufrufers: das Package liefert die Antwort auf "Was ist jetzt fällig?" und überlässt den Rest.

  • Zwei EinstiegspunkteCronExpression für Cron-Parsing im Standalone-Betrieb, Schedule für eine fluente Task-Definition mit Tags, Prioritäten und Constraints
  • Über Cron hinaus — Zeitfenster (between/unlessBetween), Wochentage (weekdays/weekends), Callable-Bedingungen (when/skip) und Umgebungsfilter (environments)
  • Human-Readable Descriptionsdescribe() übersetzt Cron-Expressions in natürliche Sprache
  • Timezone-aware — Auswertung immer in der konfigurierten Zeitzone, unabhängig vom Server-Clock
  • Voll testbar — alle Methoden akzeptieren DateTimeInterface, keine Systemuhr-Abhängigkeit

Installation

bash
composer require jardissupport/scheduling

GitHub: jardisSupport/scheduling

CronExpression

Parsen und auswerten

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 heute, 10:30 morgen, 10:30 übermorgen]

$cron->describe();
// 'Daily at 10:30'

Unterstützte Syntax

PatternBeispielErgebnis
Wildcard*Immer
Literal30Exakt 30
Range9-179 bis 17
Liste0,15,30,45Diese Werte
Step (Wildcard)*/15Alle 15 (0, 15, 30, 45)
Step (Range)1-10/31, 4, 7, 10
Step (Wert)5/10Ab 5, alle 10 (5, 15, 25, ...)

Felder

Standard (5 Felder): min hour day month weekday

PositionFeldBereich
1Minute0–59
2Stunde0–23
3Tag (Monat)1–31
4Monat1–12
5Wochentag0–7 (0 und 7 = Sonntag)

6 Felder (Sekunden-Präfix): sec min hour day month weekday
7 Felder (Sekunden-Präfix + Jahr-Suffix): sec min hour day month weekday year

Sekunden-Bereich: 0–59 · Jahres-Bereich: 1970–2099

Vordefinierte Aliase

AliasEntspricht
@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

Sekunden und Jahr

php
// Alle 30 Sekunden
CronExpression::parse('30 * * * * *')->isDue(new DateTimeImmutable('2026-04-05 10:00:30'));
// true

// Nur im Jahr 2026
CronExpression::parse('0 0 12 1 1 * 2026')->isDue(new DateTimeImmutable('2026-01-01 12:00:00'));
// true

Schedule — Fluente Task-Definition

Tasks definieren

php
use JardisSupport\Scheduling\Schedule;

$schedule = Schedule::create('production')
    ->task('cleanup:expired')
        ->dailyAt('03:00')
        ->description('Abgelaufene Datensätze entfernen')
        ->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');

Fällige Tasks abfragen

php
// Alle jetzt fälligen Tasks
$due = $schedule->dueNow(new DateTimeImmutable());

// Nur Tasks mit bestimmten Tags (OR-Semantik)
$syncTasks = $schedule->dueNow(new DateTimeImmutable(), ['sync']);

// Alle definierten Tasks (unabhängig von Fälligkeit)
$all = $schedule->allTasks();
$emailTasks = $schedule->allTasks(['email']);

// Task inspizieren
foreach ($due as $task) {
    $task->name();                                     // 'cleanup:expired'
    $task->description();                              // 'Abgelaufene Datensätze entfernen'
    $task->expression()->describe();                   // 'Daily at 03:00'
    $task->nextRun(new DateTimeImmutable());            // nächste Ausführung
    $task->priority();                                 // 10
    $task->allowsOverlapping();                        // true/false
    $task->tags();                                     // ['maintenance']
}

Zeit-Helper

MethodeCron
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 * * * *')Beliebige Expression

Constraints

Constraints schränken die Ausführung über die Cron-Expression hinaus ein. Alle Constraints werden per AND verknüpft: alle müssen erfüllt sein.

Zeitfenster

php
->task('sync:orders')
    ->everyFiveMinutes()
    ->between('08:00', '18:00')       // Nur zwischen 8 und 18 Uhr
    ->unlessBetween('12:00', '13:00') // Außer Mittagspause

Wochentage

php
->task('daily:report')
    ->dailyAt('09:00')
    ->weekdays()                      // Mo–Fr

->task('weekend:cleanup')
    ->dailyAt('02:00')
    ->weekends()                      // Sa–So

->task('tuesday-thursday')
    ->dailyAt('10:00')
    ->days(2, 4)                      // Di + Do (0=So, 6=Sa)

Callable-Bedingungen

php
->task('process:queue')
    ->everyMinute()
    ->when(fn() => QueueService::hasItems())        // Nur wenn Queue nicht leer
    ->skip(fn() => MaintenanceMode::isActive())     // Nicht im Maintenance-Modus

Umgebungs-Filter

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

Tags und Priorität

Tags (OR-Semantik)

php
$schedule = Schedule::create()
    ->task('email:digest')
        ->dailyAt('08:00')
        ->tag('email', 'notifications')
    ->task('email:welcome')
        ->everyMinute()
        ->tag('email', 'onboarding');

// Alle Tasks mit Tag 'email'
$emailTasks = $schedule->dueNow($now, ['email']);

// Leere Tags → alle Tasks
$allDue = $schedule->dueNow($now);

Tags werden dedupliziert: .tag('a', 'b')->tag('b', 'c')['a', 'b', 'c'].

Priorität

Höherer Wert = wird zuerst zurückgegeben:

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() setzt ein Flag: das Package selbst implementiert kein Locking. Der Task-Runner muss $task->allowsOverlapping() prüfen und bei false einen eigenen Lock-Mechanismus verwenden.

Validierung

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

foreach ($violations as $violation) {
    echo "{$violation->severity}: {$violation->taskName} — {$violation->message}\n";
}
BedingungSchwereMeldung
Keine Tasks definiertwarningSchedule contains no tasks
Doppelter Task-NameerrorDuplicate task name: {name}
weekdays() + weekends() am selben TaskwarningTask {name} has conflicting day constraints

Fehlerbehandlung

ExceptionUrsache
InvalidCronExpressionExceptionSyntaxfehler, zu wenige/viele Felder, Wert außerhalb des Bereichs
InvalidScheduleExceptionFehlender Task-Name, fehlende Expression, ungültige Zeitangabe
php
use JardisSupport\Scheduling\Exception\InvalidCronExpressionException;

try {
    CronExpression::parse('invalid');
} catch (InvalidCronExpressionException $e) {
    echo $e->getMessage();
}

Architektur

Zwei Orchestratoren mit spezialisierten Handlern im Closure-Orchestrator-Pattern:

CronExpression                         ← Orchestrator
├── ParseExpression                    ← Tokenisierung → Field-Arrays
├── MatchFields                        ← Field-Arrays vs. DateTime
├── FindNextRun / FindPreviousRun      ← Iteration bis Match
├── DescribeExpression                 ← Human-Readable Text
└── ResolveTimezone                    ← Timezone-Konvertierung

Schedule                               ← Orchestrator
├── TaskBuilder                        ← Fluente Konfiguration
│   └── ScheduledTask                  ← Immutable Value Object
├── ValidateSchedule                   ← Validierungsregeln
└── Constraints
    ├── TimeWindow                     ← between / unlessBetween
    ├── DayOfWeek                      ← weekdays / weekends / days()
    ├── CallableCondition              ← when / skip
    └── EnvironmentMatch               ← environments()

Verzeichnisstruktur

src/
├── CronExpression.php              ← Orchestrator
├── Schedule.php                    ← Orchestrator
├── TaskBuilder.php                 ← Fluenter 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-Referenz

CronExpression

MethodeSignaturBeschreibung
parsestatic parse(string $expression, ?DateTimeZone $tz = null): selfFactory
isDueisDue(DateTimeInterface $now): boolJetzt fällig?
nextRunnextRun(DateTimeInterface $from): DateTimeInterfaceNächste Ausführung
previousRunpreviousRun(DateTimeInterface $from): DateTimeInterfaceLetzte Ausführung
nextRunsnextRuns(DateTimeInterface $from, int $count): arrayN nächste Ausführungen
describedescribe(): stringHuman-Readable Beschreibung

Schedule

MethodeSignaturBeschreibung
createstatic create(string $currentEnvironment = ''): selfFactory
tasktask(string $name): TaskBuilderTask definieren
dueNowdueNow(DateTimeInterface $now, array $tags = []): arrayFällige Tasks
allTasksallTasks(array $tags = []): arrayAlle Tasks
validatevalidate(): arraySchedule validieren

ScheduledTask

MethodeSignaturBeschreibung
namename(): stringTask-Name
descriptiondescription(): stringBeschreibung
expressionexpression(): CronExpressionInterfaceCron-Expression
tagstags(): arrayTags
prioritypriority(): intPriorität
allowsOverlappingallowsOverlapping(): boolOverlap erlaubt?
constraintsconstraints(): arrayAktive Constraints
isDueisDue(DateTimeInterface $now): boolFällig inkl. Constraints?
nextRunnextRun(DateTimeInterface $from): DateTimeInterfaceNächste Ausführung

Vollständiges Beispiel

Ein produktionsreifes Schedule mit verschiedenen Intervallen, Constraints und Tag-basiertem Querying:

php
use JardisSupport\Scheduling\Schedule;
use JardisSupport\Scheduling\CronExpression;

$schedule = Schedule::create('production')
    // Tägliche Bereinigung um 3 Uhr morgens
    ->task('cleanup:sessions')
        ->dailyAt('03:00')
        ->description('Abgelaufene Sessions entfernen')
        ->tag('maintenance')
        ->priority(5)

    // ERP-Sync alle 5 Minuten, nur werktags 8–18 Uhr
    ->task('sync:erp')
        ->everyFiveMinutes()
        ->between('08:00', '18:00')
        ->weekdays()
        ->tag('sync', 'erp')
        ->withoutOverlapping()
        ->priority(10)

    // Monatlicher Report am 1. um 7 Uhr
    ->task('report:monthly')
        ->monthlyOn(1, '07:00')
        ->timezone('Europe/Berlin')
        ->tag('reports')
        ->environments('production')

    // Queue nur verarbeiten wenn Items vorhanden
    ->task('queue:process')
        ->everyMinute()
        ->when(fn() => QueueService::count() > 0)
        ->skip(fn() => MaintenanceMode::isActive())
        ->tag('queue');

// Validieren
$violations = $schedule->validate();
if (count($violations) > 0) {
    foreach ($violations as $v) {
        echo "[{$v->severity}] {$v->taskName}: {$v->message}\n";
    }
}

// Im Cron-Job: fällige Tasks abfragen
$now = new DateTimeImmutable();
$dueTasks = $schedule->dueNow($now);

foreach ($dueTasks as $task) {
    if (!$task->allowsOverlapping() && Lock::isHeld($task->name())) {
        continue;  // Overlap-Guard: Runner-Verantwortung
    }

    Lock::acquire($task->name());
    try {
        $runner->execute($task->name());
    } finally {
        Lock::release($task->name());
    }
}

// Standalone CronExpression für eigene Zwecke
$cron = CronExpression::parse('*/15 9-17 * * 1-5');
$cron->describe();  // 'Every 15 minutes'
$cron->nextRun($now)->format('Y-m-d H:i');