Scheduled Tasks & Message Queue in Shopware 6: Cronjobs und asynchrone Verarbeitung richtig aufsetzen
Irgendwann braucht jedes ernsthafte Plugin einen Job, der von selbst läuft: nächtlich Bestände synchronisieren, stündlich einen Feed exportieren, jede Viertelstunde abgelaufene Warenkörbe aufräumen. In Shopware 6 sind dafür zwei Systeme im Spiel, die man ständig verwechselt — der Scheduled Task als Taktgeber und die Message Queue als Arbeitsmaschine. Wer beide durcheinanderbringt, baut Jobs, die im Test funktionieren und in Produktion entweder gar nicht oder viel zu langsam laufen. Wie die zwei zusammenspielen, warum der Handler kurz bleiben muss und welche Worker-Konfiguration am Ende wirklich zuverlässig ist.
1. Zwei Systeme, ein ständiges Missverständnis
Ein Scheduled Task ist kein Cronjob im klassischen Sinn und die Message Queue ist kein Scheduler — auch wenn beide im selben Atemzug genannt werden. Der Scheduled Task beantwortet nur eine Frage: Wann soll etwas passieren? Er trägt einen technischen Namen und ein Intervall, mehr nicht. Ist das Intervall abgelaufen, dispatcht Shopware eine Nachricht auf den Message Bus. Damit endet die Aufgabe des Schedulers — er stößt an, er arbeitet nicht.
Das eigentliche Arbeiten übernimmt die Message Queue, technisch Symfony Messenger. Sie nimmt die dispatchte Nachricht, legt sie in einen Transport und lässt sie von einem Worker abarbeiten — entkoppelt vom Request, der sie ausgelöst hat. Diese Trennung ist der ganze Trick: Der Task hält den Takt, die Queue trägt die Last. Wer versteht, dass es zwei Rollen sind, versteht auch sofort, warum Jobs hängen bleiben — meistens läuft der Taktgeber, aber niemand arbeitet die Queue ab.
2. Der ScheduledTask: nur Name und Intervall
Die Task-Klasse ist bewusst minimal. Sie erbt von Shopware\Core\Framework\MessageQueue\ScheduledTask\ScheduledTask und definiert genau zwei statische Methoden: getTaskName() liefert einen eindeutigen technischen Namen (Konvention: mit Plugin-Präfix, etwa futi.supplier_sync) und getDefaultInterval() gibt das Intervall in Sekunden zurück — 86400 für täglich, 300 für alle fünf Minuten.
Wichtig ist das Wort Default: Das Intervall aus dem Code gilt nur bei der ersten Registrierung. Danach steht der Wert in der Datenbank-Tabelle scheduled_task und ein Shop-Betreiber kann ihn im Admin unter „Einstellungen → System → Aufgaben" überschreiben. Wer sein Intervall im Code ändert und sich wundert, warum sich nichts tut, hat genau das übersehen — der DB-Wert gewinnt. In der Klasse selbst steht keine Logik. Sie ist reiner Datencontainer, eine Nachricht, die durch die Queue reist. Das fühlt sich beim ersten Mal zu leer an, ist aber genau richtig: Alles, was Verhalten hat, gehört in den Handler.
3. Der Handler: kurz halten, schwere Arbeit auslagern
Der Handler ist die Klasse mit der eigentlichen Logik. Er erbt von ScheduledTaskHandler, trägt das Attribut #[AsMessageHandler(handles: SupplierSyncTask::class)] und implementiert die Methode run(). Sobald der Scheduler die Task-Nachricht dispatcht und ein Worker sie aufnimmt, läuft run(). Das #[AsMessageHandler]-Attribut ist der aktuelle Weg (Symfony 6+, ab Shopware 6.5) — ältere Beispiele mit dem messenger.message_handler-Tag in der XML tun dasselbe, sind aber Legacy.
Die wichtigste Regel steht in der offiziellen Doku und wird trotzdem ständig ignoriert: Der Handler soll schnell sein. Ein Task-Handler, der 4.000 Produkte aus einem ERP zieht und einzeln schreibt, blockiert den Worker minutenlang — und solange dieser Worker beschäftigt ist, stauen sich alle anderen Nachrichten dahinter. Der saubere Weg: Im run() nicht selbst arbeiten, sondern die Arbeit in eigene Messages zerlegen und diese auf den Bus geben — eine Nachricht pro Produkt-Batch. Der Handler ist dann in Millisekunden fertig, und die Queue verteilt die Last in verdaubaren Häppchen, parallelisierbar über mehrere Worker.
4. Registrierung und der Lebenszyklus in der Datenbank
Registriert werden beide Klassen in der services.xml. Der Task selbst bekommt den DI-Tag shopware.scheduled.task — daran erkennt Shopware ihn und legt beim Plugin-Install einen Eintrag in der scheduled_task-Tabelle an. Der Handler wird über das #[AsMessageHandler]-Attribut automatisch als Service erkannt; wichtig ist nur, dass der Service als public auffindbar bleibt und Abhängigkeiten (Repositories, den MessageBus) sauber per Constructor Injection bekommt.
Der Lebenszyklus lohnt einen Blick in die Tabelle, wenn ein Job „mysteriös" nicht läuft. Jeder Task hat einen status (scheduled, queued, running, failed, inactive) sowie ein next_execution_time. Wirft ein Handler eine Exception, springt der Task auf failed — und wird beim nächsten Lauf trotzdem neu geplant, statt endlos zu blockieren. Ein Task, der auf inactive steht, läuft dagegen nie wieder, bis jemand ihn reaktiviert. Für gezieltes Debugging gibt es bin/console scheduled-task:run-single <task-name>, das genau einen Task sofort ausführt, ohne auf das Intervall zu warten.
5. Die Queue darunter: Transports und der Doctrine-Default
Sowohl die Task-Nachricht als auch die vom Handler dispatchten Messages landen in einem Transport. Standardmäßig nutzt Shopware den Doctrine-Transport: Die Nachrichten werden in der Datenbank-Tabelle messenger_messages abgelegt. Das läuft ohne zusätzliche Infrastruktur und reicht für viele Shops — hat aber eine Grenze, sobald das Volumen steigt: Jeder Worker-Zyklus pollt die Datenbank, und unter Last wird genau diese Tabelle zum Flaschenhals. Für durchsatzstarke Setups tauscht man den Doctrine-Transport gegen Redis oder RabbitMQ, indem man die MESSENGER_TRANSPORT_DSN umbiegt — die Anwendungslogik bleibt unverändert.
Shopware unterscheidet dabei zwei Warteschlangen: async für den Normalbetrieb und low_priority für Arbeit, die warten darf, ohne wichtigere Jobs zu blockieren — etwa Bulk-Indexierung. Beide Queues sollten von einem Worker konsumiert werden; die low_priority-Queue existiert ab Version 6.5.7.0. Wer nur async konsumiert, wundert sich, warum manche Aufgaben nie fertig werden — sie liegen brav in der zweiten, unbeachteten Queue.
6. Admin Worker vs. dedizierter CLI-Worker — die Produktionsfalle
Hier scheitern die meisten Setups. Standardmäßig ist der Admin Worker aktiv: Solange jemand im Administrations-Backend eingeloggt ist, pollt der Browser periodisch einen Endpunkt, der Scheduler und Queue vorantreibt. Das ist bequem für die lokale Entwicklung und eine Falle in Produktion — denn nachts um drei ist niemand eingeloggt, also läuft weder der Scheduler noch die Queue. Der Job, der „jede Nacht" laufen soll, läuft schlicht nie, und im Test hat trotzdem alles funktioniert, weil das Admin-Tab offen war.
Die Produktions-Lösung: Admin Worker in der shopware.yaml abschalten und stattdessen serverseitige CLI-Worker als Daemon betreiben — via systemd oder supervisor. Zwei Prozesse gehören dauerhaft am Laufen: bin/console scheduled-task:run als Taktgeber-Loop, der fällige Tasks dispatcht, und bin/console messenger:consume async low_priority als Arbeiter, der die Queue leert. Beide sollten mit Grenzen laufen (--time-limit und --memory-limit), damit der Prozessmanager sie regelmäßig frisch startet — ein PHP-Worker, der ewig lebt, sammelt Memory-Leaks. So laufen Jobs unabhängig davon, ob ein Admin eingeloggt ist, und die CPU-Last bleibt planbar statt an Browsersessions gekoppelt.
7. Fazit: Takt und Last sauber trennen
Scheduled Tasks und Message Queue sind zwei Werkzeuge mit klarer Arbeitsteilung: Der Task fragt wann, die Queue erledigt was. Wer diese Trennung ernst nimmt, baut Handler, die in Millisekunden fertig sind und die eigentliche Arbeit als kleine, parallelisierbare Messages auf den Bus geben — statt einen Worker minutenlang zu blockieren. Das ist derselbe Gedanke, der auch hinter den asynchronen Flow-Actions steckt.
Und die häufigste Fehlerquelle liegt gar nicht im Code, sondern im Betrieb: Ein Job, der lokal läuft und in Produktion nicht, hat fast immer keinen laufenden Worker. Wer den Admin Worker abschaltet, scheduled-task:run und messenger:consume als überwachte Daemons aufsetzt und beide Queues konsumiert, hat das Fundament, auf dem sich wiederkehrende Aufgaben verlassen lassen — vom nächtlichen ERP-Sync bis zum Feed-Export, der pünktlich fertig wird, egal ob jemand im Backend sitzt.
Jobs, die lokal laufen — und in Produktion still stehen?
Lass uns in 30 Minuten gemeinsam auf deine Scheduled Tasks und Worker-Konfiguration schauen — wo ein Handler zu viel arbeitet, wie wir die Queue entlasten und welche Daemon-Einrichtung in deinem Hosting wirklich zuverlässig läuft. Kein Sales-Call.