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 Einstiegspunkte —
CronExpressionfür Cron-Parsing im Standalone-Betrieb,Schedulefü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 Descriptions —
describe()ü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
composer require jardissupport/schedulingGitHub: jardisSupport/scheduling
CronExpression
Parsen und auswerten
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
| Pattern | Beispiel | Ergebnis |
|---|---|---|
| Wildcard | * | Immer |
| Literal | 30 | Exakt 30 |
| Range | 9-17 | 9 bis 17 |
| Liste | 0,15,30,45 | Diese Werte |
| Step (Wildcard) | */15 | Alle 15 (0, 15, 30, 45) |
| Step (Range) | 1-10/3 | 1, 4, 7, 10 |
| Step (Wert) | 5/10 | Ab 5, alle 10 (5, 15, 25, ...) |
Felder
Standard (5 Felder): min hour day month weekday
| Position | Feld | Bereich |
|---|---|---|
| 1 | Minute | 0–59 |
| 2 | Stunde | 0–23 |
| 3 | Tag (Monat) | 1–31 |
| 4 | Monat | 1–12 |
| 5 | Wochentag | 0–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
| Alias | Entspricht |
|---|---|
@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); // trueSekunden und Jahr
// 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'));
// trueSchedule — Fluente Task-Definition
Tasks definieren
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
// 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
| Methode | 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 * * * *') | 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
->task('sync:orders')
->everyFiveMinutes()
->between('08:00', '18:00') // Nur zwischen 8 und 18 Uhr
->unlessBetween('12:00', '13:00') // Außer MittagspauseWochentage
->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
->task('process:queue')
->everyMinute()
->when(fn() => QueueService::hasItems()) // Nur wenn Queue nicht leer
->skip(fn() => MaintenanceMode::isActive()) // Nicht im Maintenance-ModusUmgebungs-Filter
->task('heavy:migration')
->monthlyOn(1, '04:00')
->environments('production') // Nur in ProductionTags und Priorität
Tags (OR-Semantik)
$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:
$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() 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
$violations = $schedule->validate();
foreach ($violations as $violation) {
echo "{$violation->severity}: {$violation->taskName} — {$violation->message}\n";
}| Bedingung | Schwere | Meldung |
|---|---|---|
| Keine Tasks definiert | warning | Schedule contains no tasks |
| Doppelter Task-Name | error | Duplicate task name: {name} |
weekdays() + weekends() am selben Task | warning | Task {name} has conflicting day constraints |
Fehlerbehandlung
| Exception | Ursache |
|---|---|
InvalidCronExpressionException | Syntaxfehler, zu wenige/viele Felder, Wert außerhalb des Bereichs |
InvalidScheduleException | Fehlender Task-Name, fehlende Expression, ungültige Zeitangabe |
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.phpAPI-Referenz
CronExpression
| Methode | Signatur | Beschreibung |
|---|---|---|
parse | static parse(string $expression, ?DateTimeZone $tz = null): self | Factory |
isDue | isDue(DateTimeInterface $now): bool | Jetzt fällig? |
nextRun | nextRun(DateTimeInterface $from): DateTimeInterface | Nächste Ausführung |
previousRun | previousRun(DateTimeInterface $from): DateTimeInterface | Letzte Ausführung |
nextRuns | nextRuns(DateTimeInterface $from, int $count): array | N nächste Ausführungen |
describe | describe(): string | Human-Readable Beschreibung |
Schedule
| Methode | Signatur | Beschreibung |
|---|---|---|
create | static create(string $currentEnvironment = ''): self | Factory |
task | task(string $name): TaskBuilder | Task definieren |
dueNow | dueNow(DateTimeInterface $now, array $tags = []): array | Fällige Tasks |
allTasks | allTasks(array $tags = []): array | Alle Tasks |
validate | validate(): array | Schedule validieren |
ScheduledTask
| Methode | Signatur | Beschreibung |
|---|---|---|
name | name(): string | Task-Name |
description | description(): string | Beschreibung |
expression | expression(): CronExpressionInterface | Cron-Expression |
tags | tags(): array | Tags |
priority | priority(): int | Priorität |
allowsOverlapping | allowsOverlapping(): bool | Overlap erlaubt? |
constraints | constraints(): array | Aktive Constraints |
isDue | isDue(DateTimeInterface $now): bool | Fällig inkl. Constraints? |
nextRun | nextRun(DateTimeInterface $from): DateTimeInterface | Nächste Ausführung |
Vollständiges Beispiel
Ein produktionsreifes Schedule mit verschiedenen Intervallen, Constraints und Tag-basiertem Querying:
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');