Cron Jobs mit dem Messenger
Klassische Cron Jobs in der crontab sind schwer zu testen, nicht versionierbar und außerhalb der Anwendung definiert. Der Symfony Scheduler bringt periodische Aufgaben direkt ins PHP-Projekt: testbar, typsicher, Messenger-integriert und mit voller Dependency Injection.
Inhaltsverzeichnis
- 1. Warum klassische Cron Jobs Probleme verursachen
- 2. Das Scheduler-Konzept in Symfony
- 3. Installation und erste Schedule-Klasse
- 4. Trigger: Cron, Interval, Jitter und Custom
- 5. Messages und Handler im Scheduler-Kontext
- 6. Den Scheduler Worker betreiben
- 7. Fehlerbehandlung und Retry-Strategien
- 8. Scheduler-Aufgaben testen
- 9. Scheduler vs. klassische Cron Jobs im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum klassische Cron Jobs Probleme verursachen
Klassische Cron Jobs, definiert in der Crontab des Servers, haben einen grundlegenden Nachteil: Sie existieren außerhalb der Anwendung. Die Logik liegt in einem Symfony-Command, aber der Zeitplan liegt auf dem Server. Wenn die Anwendung auf einen neuen Server migriert wird, müssen alle Cron-Einträge manuell übertragen werden. Wenn ein Entwickler den Zeitplan ändert, ist diese Änderung nirgendwo im Repository sichtbar. Code-Reviews erfassen nur den Command-Code, nicht den Auslösezeitpunkt. Das sind genau die Szenarien, in denen Symfony Scheduler den Unterschied macht.
Ein weiteres Problem: Cron Jobs laufen unabhängig vom Messenger-Stack. Retry-Logik, Dead-Letter-Queues und Monitoring sind für Cron-basierte Aufgaben schwer nachzurüsten. Schlägt ein Cron Job fehl, ist das oft erst beim nächsten geplanten Lauf sichtbar. Mit dem Symfony Scheduler sind periodische Aufgaben vollständig in den Messenger integriert: sie profitieren von automatischen Retries, Failure-Transports und allen Middleware-Features, die der Messenger bietet. Das Ergebnis ist eine Hintergrundverarbeitung mit einheitlichem Monitoring und klarer Verantwortlichkeit im PHP-Code.
2. Das Scheduler-Konzept in Symfony
Der Symfony Scheduler, eingeführt in Symfony 6.3 und in 7.x erheblich erweitert, basiert auf zwei Kernkonzepten: der Schedule-Klasse und dem RecurringMessage. Eine Schedule-Klasse implementiert das Interface ScheduleProviderInterface und definiert in ihrer getSchedule()-Methode, welche Messages wann gesendet werden sollen. Jede Message ist ein normales Symfony-Messenger-Message-Objekt, das von einem Handler verarbeitet wird. Der Scheduler selbst ist ein spezieller Messenger-Transport, der intern einen eigenen Worker betreibt.
Das Besondere: Der Symfony Scheduler ist vollständig in den Messenger integriert. Schedule-Messages durchlaufen dieselbe Middleware-Pipeline wie reguläre Messages, können dasselbe Retry-System nutzen und landen bei Fehler im selben Failure-Transport. Das bedeutet, dass bestehende Monitoring-Tools, Dashboards und Alerting-Regeln automatisch auch für geplante Aufgaben gelten. Entwickler müssen kein separates System für Cron-Monitoring aufbauen – das Messenger-Ökosystem übernimmt diese Aufgabe.
<?php
declare(strict_types=1);
namespace App\Scheduler;
use App\Message\CleanupExpiredTokensMessage;
use App\Message\GenerateDailyReportMessage;
use App\Message\SyncProductCatalogMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Main application schedule — replaces all cron jobs with PHP-native configuration.
* The #[AsSchedule] attribute registers this class as a Scheduler transport.
*/
#[AsSchedule('default')]
final class AppSchedule implements ScheduleProviderInterface
{
public function __construct(
private readonly CacheInterface $cache,
) {}
public function getSchedule(): Schedule
{
return (new Schedule())
// Run every 5 minutes via interval trigger
->add(RecurringMessage::every('5 minutes', new CleanupExpiredTokensMessage()))
// Cron expression: every day at 03:00
->add(RecurringMessage::cron('0 3 * * *', new GenerateDailyReportMessage()))
// Every hour with 300-second jitter to prevent thundering herd
->add(RecurringMessage::every('1 hour', new SyncProductCatalogMessage(), jitter: 300))
// Use cache to persist schedule state across worker restarts
->stateful($this->cache);
}
}
4. Trigger: Cron, Interval, Jitter und Custom
Der Symfony Scheduler bietet mehrere eingebaute Trigger-Typen. RecurringMessage::every() akzeptiert eine lesbare Intervall-Angabe wie '5 minutes', '1 hour' oder '30 seconds'. RecurringMessage::cron() nimmt einen vollständigen Cron-Ausdruck entgegen und unterstützt damit jede beliebige zeitliche Spezifikation inklusive Wochentagen und Monatsdaten. Für Aufgaben, bei denen mehrere Worker gleichzeitig denselben Task starten würden, ist der Jitter-Parameter entscheidend: Er fügt eine zufällige Verzögerung hinzu und verhindert das "Thundering Herd"-Problem, bei dem viele Prozesse gleichzeitig aufwachen und die Datenbank überlasten.
Für komplexe Zeitpläne, die sich mit keinem Standard-Trigger abbilden lassen, implementiert man das Interface TriggerInterface. Ein Custom-Trigger definiert die Methode getNextRunDate(), die ausgehend vom letzten Ausführungszeitpunkt den nächsten Termin berechnet. Das ermöglicht Zeitpläne wie "jeden letzten Freitag des Monats" oder "nur an Werktagen zwischen 08:00 und 18:00 Uhr". Der Symfony Scheduler ruft getNextRunDate() nach jedem Lauf erneut auf, sodass der Trigger dynamisch entscheiden kann, wann die nächste Ausführung fällig ist.
5. Messages und Handler im Scheduler-Kontext
Messages im Symfony Scheduler sind normale Symfony-Messenger-Message-Objekte: einfache PHP-Klassen, die Daten tragen. Sie müssen kein Interface implementieren und keine Basisklasse erweitern. Das macht es einfach, bestehende Messenger-Messages wiederzuverwenden oder neue spezifisch für den Scheduler zu erstellen. Der Handler ist ebenfalls ein normaler Messenger-Handler: eine Klasse mit dem Attribut #[AsMessageHandler] und einer __invoke()-Methode, die die Message-Klasse als Parameter nimmt.
Ein wichtiger Aspekt: Der Handler läuft im Kontext des Worker-Prozesses, nicht im HTTP-Request-Kontext. Doctrine-EntityManager, Caches und andere Services sind verfügbar, aber HTTP-spezifische Services wie RequestStack haben keinen aktiven Request. Das bedeutet, dass Handler für den Symfony Scheduler keine Request-Daten verarbeiten sollten und keine Response-Objekte zurückgeben müssen. Der Handler führt die Aufgabe aus, loggt das Ergebnis und gibt nichts zurück – oder wirft eine Exception, wenn etwas schiefgeht.
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\GenerateDailyReportMessage;
use App\Service\ReportGeneratorService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler for the daily report generation task scheduled via Symfony Scheduler.
* Runs in worker context — no HTTP request available, full DI support.
*/
#[AsMessageHandler]
final readonly class GenerateDailyReportHandler
{
public function __construct(
private ReportGeneratorService $reportGenerator,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
) {}
/**
* Generate the daily sales report and persist the result.
*/
public function __invoke(GenerateDailyReportMessage $message): void
{
$this->logger->info('Starting daily report generation', [
'triggered_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339),
]);
try {
$report = $this->reportGenerator->generateDailyReport(new \DateTimeImmutable('yesterday'));
$this->entityManager->persist($report);
$this->entityManager->flush();
$this->logger->info('Daily report generated successfully', [
'report_id' => $report->getId(),
'row_count' => $report->getRowCount(),
]);
} catch (\Throwable $e) {
// Throwing here triggers the Messenger retry mechanism
$this->logger->error('Daily report generation failed', ['error' => $e->getMessage()]);
throw $e;
}
}
}
6. Den Scheduler Worker betreiben
Der Symfony Scheduler läuft als Messenger-Worker, der über den bekannten Befehl bin/console messenger:consume scheduler_default gestartet wird. Der Name des Transports entspricht dem Namen des Schedules aus dem #[AsSchedule]-Attribut, mit dem Präfix scheduler_. In Produktionsumgebungen läuft dieser Worker dauerhaft unter Supervisor, systemd oder einem Container-Prozessmanager. Anders als klassische Cron Jobs, die jeden Lauf einen neuen Prozess starten, ist der Scheduler-Worker ein langlebiger Prozess, der intern den Zeitplan prüft und Messages zum richtigen Zeitpunkt erzeugt.
Der Worker-Neustart bei Codeänderungen ist wichtig: Wenn die Schedule-Klasse geändert wird, muss der Worker neu gestartet werden, damit die neuen Zeiten wirksam werden. Das --time-limit-Flag begrenzt die Laufzeit des Workers und ermöglicht regelmäßige Neustarts. In Kombination mit stateful($this->cache) in der Schedule-Klasse bleibt der Ausführungsstatus über Worker-Neustarts hinaus erhalten – der Scheduler weiß, welche Tasks bereits gelaufen sind und überspringt sie nicht versehentlich nach einem Neustart.
7. Fehlerbehandlung und Retry-Strategien
Wenn ein Symfony Scheduler-Handler eine Exception wirft, greift das Retry-System des Messengers. In config/packages/messenger.yaml konfiguriert man für den Scheduler-Transport eigene Retry-Strategien: Anzahl der Versuche, Verzögerung zwischen Versuchen und maximale Wartezeit. Nach Erschöpfung der Retries landet die gescheiterte Message im konfigurierten Failure-Transport – typischerweise einer Datenbanktabelle oder einem gesonderten Queue-Eintrag. Von dort kann sie manuell oder automatisch erneut verarbeitet werden.
Für kritische Scheduler-Tasks empfiehlt sich eine differenzierte Strategie: leichte Fehler wie Netzwerk-Timeouts werden mit exponentiellem Backoff mehrfach wiederholt, während schwere Fehler wie fehlende Datenstruktur sofort in den Failure-Transport wandern. Das multiplier-Feld in der Retry-Konfiguration des Symfony Schedulers erhöht die Wartezeit zwischen jedem Versuch. Ein Monitoring-Tool wie Sentry fängt die Exceptions bereits beim ersten Fehler ab, bevor das Retry-System greift – so ist das Team informiert, bevor alle Retries aufgebraucht sind.
<?php
// config/packages/messenger.yaml — retry and failure transport for Symfony Scheduler
// framework:
// messenger:
// transports:
// scheduler_default:
// dsn: 'schedule://default'
// retry_strategy:
// max_retries: 3
// delay: 1000 # 1 second initial delay
// multiplier: 2 # exponential: 1s, 2s, 4s
// max_delay: 30000 # max 30 seconds between retries
//
// failed:
// dsn: 'doctrine://default?queue_name=failed'
// retry_strategy:
// max_retries: 0 # no auto-retry from the failure transport
//
// failure_transport: failed
// Routing — scheduler messages use the scheduler transport automatically
// All other messages can use a separate async transport:
// framework:
// messenger:
// routing:
// 'App\Message\GenerateDailyReportMessage': scheduler_default
// 'App\Message\SyncProductCatalogMessage': scheduler_default
// Start the Scheduler worker (keep alive with Supervisor/systemd):
// bin/console messenger:consume scheduler_default --time-limit=3600 -vv
8. Scheduler-Aufgaben testen
Einer der größten Vorteile des Symfony Schedulers gegenüber klassischen Cron Jobs ist die Testbarkeit. Da Zeitplan und Handler normale PHP-Klassen sind, lassen sie sich mit PHPUnit testen. Den Handler testet man wie jeden anderen Messenger-Handler: mit gemockten Dependencies und einer konkreten Message-Instanz. Den Zeitplan testet man über die ScheduleTestCase-Basisklasse aus dem Symfony Scheduler-Paket, die Methoden bereitstellt, um die nächsten N Ausführungszeitpunkte einer Message zu überprüfen.
Integrationstests für den kompletten Symfony Scheduler-Fluss nutzen den InMemoryTransport: Eine Message wird in den Scheduler eingereiht, der Worker verarbeitet sie synchron, und das Ergebnis wird im Test verifiziert. Dabei helfen Symfonys KernelTestCase und der MessengerTestTrait, der Assertions für gesendete Messages und Handler-Aufrufe bereitstellt. Dieser Ansatz stellt sicher, dass Zeitplan, Message und Handler korrekt zusammenarbeiten – und zwar vor dem Deployment in die Produktion.
9. Scheduler vs. klassische Cron Jobs im Vergleich
Der direkte Vergleich zeigt die konkreten Unterschiede zwischen klassischen Cron Jobs in der crontab und dem Symfony Scheduler.
| Kriterium | Klassischer Cron Job | Symfony Scheduler | Vorteil |
|---|---|---|---|
| Versionierung | Außerhalb des Repos | PHP-Klasse im Repo | Zeitplan im Code-Review sichtbar |
| Testbarkeit | Schwer – nur Command testbar | Vollständig mit PHPUnit | Zeitplan und Handler testbar |
| Fehlerbehandlung | Manuell per Exit-Code | Messenger Retry + Failure | Automatisch mit Backoff |
| Monitoring | Nur via Log-Datei | Messenger-Monitoring | Einheitliches Dashboard |
| Deployment | Manuelle crontab-Pflege | Worker-Neustart reicht | Kein Server-Zugriff nötig |
Ein Nachteil des Symfony Schedulers gegenüber Cron: Er benötigt einen dauerhaft laufenden Worker-Prozess. Klassische Cron Jobs werden vom Betriebssystem gestartet und brauchen keinen Hintergrundprozess. In Umgebungen ohne Prozessmanager oder Container-Orchestrierung kann das eine Hürde sein. Für die meisten modernen PHP-Projekte, die bereits Messenger-Worker für asynchrone Verarbeitung betreiben, ist das jedoch kein zusätzlicher Aufwand – der Scheduler nutzt dieselbe Infrastruktur.
Mironsoft
Symfony Messenger, Scheduler und Hintergrundverarbeitung
Cron Jobs durch Symfony Scheduler ersetzen?
Wir migrieren bestehende Cron-basierte Prozesse auf den Symfony Scheduler – mit vollständiger Messenger-Integration, Retry-Logik und Monitoring für euren Produktionsbetrieb.
Migration
Crontab-Analyse und Migration auf Symfony Scheduler mit PHP-nativen Schedule-Klassen
Worker-Setup
Supervisor/systemd-Konfiguration, Health Checks und automatischer Neustart für Scheduler Worker
Monitoring
Failure-Transport, Retry-Strategie und Alerting für gescheiterte Scheduler-Aufgaben
10. Zusammenfassung
Der Symfony Scheduler löst das fundamentale Problem klassischer Cron Jobs: Die Trennung von Zeitplan und Anwendungscode. Mit ScheduleProviderInterface und RecurringMessage sind Zeitpläne versionierbare PHP-Klassen im Repository. Cron-Ausdrücke, Intervall-Trigger und Jitter-Konfiguration decken alle gängigen Szenarien ab. Custom-Trigger erlauben beliebig komplexe Zeitpläne. Die vollständige Messenger-Integration bringt automatisches Retry, Failure-Handling und einheitliches Monitoring ohne zusätzlichen Aufwand.
Der wichtigste Schritt für bestehende Projekte ist die Migration: Für jeden Cron-Eintrag eine Message-Klasse und einen Handler anlegen, den Zeitplan in einer Schedule-Klasse definieren, den Worker unter Supervisor oder systemd einrichten und den alten Cron-Eintrag entfernen. Das Ergebnis ist ein System, bei dem Zeitpläne denselben Review-Prozess durchlaufen wie Anwendungscode – und bei dem Fehler in geplanten Aufgaben sofort sichtbar und automatisch behandelt werden.
Symfony Scheduler — Das Wichtigste auf einen Blick
Schedule-Klasse
ScheduleProviderInterface + #[AsSchedule] – der gesamte Zeitplan lebt als PHP-Klasse im Repository, testbar und versionierbar.
Trigger-Typen
RecurringMessage::every() für Intervalle, ::cron() für Cron-Ausdrücke, Jitter für verteilte Starts. Custom-Trigger für beliebige Zeitpläne.
Messenger-Integration
Automatisches Retry, Failure-Transport und Middleware-Pipeline – Scheduler-Aufgaben nutzen dieselbe Infrastruktur wie asynchrone Messages.
Stateful Schedule
->stateful($cache) persistiert den Ausführungsstatus über Worker-Neustarts hinaus – kein doppelter Lauf nach einem Restart.