Messenger und Event Bus kombinieren
Ein Benutzer registriert sich — die Welcome-E-Mail muss asynchron gesendet, der CRM-Eintrag angelegt und die Analytics aktualisiert werden. Gleichzeitig soll die Domain ein Event feuern, das synchron auf denselben Vorgang reagiert. Symfony Messenger und der Event Dispatcher können beides — wenn man versteht, wie sie zusammenspielen.
Inhaltsverzeichnis
- 1. Messenger vs. Event Dispatcher: Zwei Konzepte, ein Ziel
- 2. Symfony Messenger einrichten: Transports und Routing
- 3. Messages und Handler: typsicher und entkoppelt
- 4. Asynchrone Verarbeitung: Queues und Worker
- 5. Den Event Bus mit Messenger aufbauen
- 6. Domain-Events synchron feuern, async verarbeiten
- 7. Retry-Strategien und Failure Transport
- 8. Middleware für Logging, Tracing und Transaktionen
- 9. Direktvergleich: Messenger-Patterns im Überblick
- 10. Zusammenfassung
- 11. FAQ
1. Messenger vs. Event Dispatcher: Zwei Konzepte, ein Ziel
Viele Symfony-Entwickler setzen Symfony Messenger und den Event Dispatcher synonym ein, dabei haben beide unterschiedliche Stärken und Einsatzbereiche. Der Event Dispatcher ist synchron: Ein Event wird gefeuert, alle Listener werden sofort und in der aktuellen HTTP-Request-Lifecycle ausgeführt, bevor der Code weiterläuft. Das macht ihn ideal für Domain-Events, bei denen das Ergebnis der Listener den weiteren Ablauf beeinflusst — etwa das Abbrechen einer Operation durch einen Listener. Symfony Messenger hingegen ist für Entkopplung und optionale Asynchronität ausgelegt: Eine Message wird auf eine Queue gestellt, ein Worker-Prozess verarbeitet sie später, unabhängig vom ursprünglichen HTTP-Request.
Der entscheidende Architekturvorteil von Symfony Messenger: Eine Message kann synchron oder asynchron verarbeitet werden, je nach Routing-Konfiguration — ohne den Handler-Code zu ändern. Das ermöglicht schrittweise Asynchronisierung: Im Entwicklungsmodus läuft alles synchron im selben Prozess, in der Produktion werden rechenintensive oder fehlertolerante Operationen auf die Queue ausgelagert. Das Beste aus beiden Welten entsteht, wenn man Symfony Messenger als Event Bus konfiguriert: Domain-Events werden über Messenger dispatcht und können je nach Event-Typ synchron oder asynchron verarbeitet werden.
2. Symfony Messenger einrichten: Transports und Routing
Symfony Messenger wird über die config/packages/messenger.yaml konfiguriert. Der zentrale Begriff ist der Transport: Er definiert, wie und wo Messages gespeichert werden — als AMQP-Queue in RabbitMQ, als Datenbankzeile über Doctrine, als Redis-Stream oder im Speicher für synchrone Tests. Jeder Transport hat einen DSN, der die Verbindungsparameter enthält. Das Routing mappt Message-Klassen auf Transports: Kommt eine SendWelcomeEmailMessage an, sendet Symfony Messenger sie auf den async-Transport. Kommt eine CriticalAlertMessage an, geht sie auf einen priorisierten high-priority-Transport.
Mehrere Transports können parallel betrieben werden. Das erlaubt eine differenzierte Priorisierung: E-Mails auf einem langsamen, fehlertoleranten Transport mit vielen Retry-Versuchen, kritische Benachrichtigungen auf einem schnellen Transport mit einem einzelnen Worker, der ausschließlich diese Queue konsumiert. Symfony Messenger unterstützt mit dem Doctrine-Transport auch eine transaktionale Queue: Die Message wird in derselben Datenbanktransaktion gespeichert wie die Hauptoperation. Wenn die Transaktion rollt back, verschwindet auch die Message — keine verwaisten Queue-Einträge mehr für nie gespeicherte Entitäten.
# config/packages/messenger.yaml
framework:
messenger:
# Failure transport: failed messages land here after all retries
failure_transport: failed
transports:
# Async processing via Doctrine DB table — transactional!
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # 1 second initial delay
multiplier: 2 # exponential backoff: 1s, 2s, 4s
max_delay: 60000 # cap at 60 seconds
# High-priority transport — separate worker, low retry count
high_priority:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=high_priority'
retry_strategy:
max_retries: 1
# Failed messages storage for inspection and manual retry
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
# Route by fully-qualified class name
App\Message\SendWelcomeEmailMessage: async
App\Message\GenerateInvoicePdfMessage: async
App\Message\CriticalAlertMessage: high_priority
# Wildcard routing for all domain events
App\Domain\Event\*: async
3. Messages und Handler: typsicher und entkoppelt
Eine Message in Symfony Messenger ist eine einfache PHP-Klasse ohne Abhängigkeiten — ein Value Object, das alle für die Verarbeitung notwendigen Daten trägt. Keine Interfaces, keine Basisklassen, keine Symfony-Abhängigkeiten: Eine Message ist eine reine Datenkapsel. Constructor Property Promotion macht Messages in PHP 8.x kompakt und typsicher. Wichtig: Messages werden serialisiert und gespeichert, was bedeutet, dass sie keine nicht-serialisierbaren Objekte wie Doctrine-Entities oder Services enthalten sollten. Stattdessen übergibt man IDs und lädt Entities im Handler neu aus der Datenbank.
Handler sind Services mit dem Attribut #[AsMessageHandler]. Symfony Messenger findet und registriert sie automatisch per Service-Discovery. Ein Handler kann mehrere Handle-Methoden haben, die jeweils für einen anderen Message-Typ zuständig sind — das erlaubt das Gruppieren verwandter Verarbeitungslogik in einer Klasse, ohne eine God-Klasse zu erzeugen. Mehrere Handler für dieselbe Message sind möglich: Beide werden bei jedem Dispatch aufgerufen. Das entspricht dem Observer-Pattern aus dem Event-Bus-Kontext, aber mit persistenter Queue-Semantik statt synchroner Ausführung.
<?php
declare(strict_types=1);
namespace App\Message;
// Immutable message — only scalar types and value objects, no Doctrine entities
final readonly class SendWelcomeEmailMessage
{
public function __construct(
public readonly int $userId,
public readonly string $email,
public readonly string $locale,
) {}
}
// ---------------------------------------------------------------------------
namespace App\MessageHandler;
use App\Message\SendWelcomeEmailMessage;
use App\Repository\UserRepository;
use App\Service\Mailer\WelcomeMailer;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handles welcome email dispatch after user registration.
* Runs asynchronously via the 'async' transport in production.
*/
#[AsMessageHandler]
final readonly class SendWelcomeEmailHandler
{
public function __construct(
private UserRepository $userRepository,
private WelcomeMailer $mailer,
private LoggerInterface $logger,
) {}
/**
* Load the user fresh from DB — never pass entities through the queue.
*/
public function __invoke(SendWelcomeEmailMessage $message): void
{
$user = $this->userRepository->find($message->userId);
if ($user === null) {
// User deleted between dispatch and processing — not an error
$this->logger->info('User {id} not found, skipping welcome email.', [
'id' => $message->userId,
]);
return;
}
$this->mailer->sendWelcome($user, $message->locale);
$this->logger->info('Welcome email sent to {email}.', ['email' => $message->email]);
}
}
4. Asynchrone Verarbeitung: Queues und Worker
Der Worker-Prozess, der Queue-Messages verarbeitet, wird mit bin/console messenger:consume async gestartet. In der Produktion läuft dieser Prozess dauerhaft unter einem Prozessmanager wie Supervisor oder systemd — Symfony Messenger bringt keine eigene Daemon-Infrastruktur mit. Das --limit-Flag begrenzt die Anzahl verarbeiteter Messages pro Worker-Start, --time-limit begrenzt die Laufzeit in Sekunden. Beide Optionen verhindern Memory-Leaks durch lang laufende PHP-Prozesse: Der Prozessmanager startet den Worker automatisch neu, wenn er sich beendet.
Die Statusüberwachung erfolgt mit bin/console messenger:stats, das Queue-Tiefen und fehlgeschlagene Messages anzeigt. Symfony Messenger integriert sich mit dem Symfony Profiler: Im Entwicklungsmodus zeigt der Profiler alle dispatched Messages, ihre Handler und die Verarbeitungszeit. Fehlgeschlagene Messages landen nach Erschöpfung aller Retry-Versuche auf dem failed-Transport und können mit bin/console messenger:failed:show und messenger:failed:retry inspiziert und manuell nachverarbeitet werden. Das ist erheblich transparenter als ein stilles Verwerfen fehlgeschlagener Queue-Jobs.
5. Den Event Bus mit Messenger aufbauen
Symfony Messenger kann als vollständiger Event Bus konfiguriert werden. Der Unterschied zu einer normalen Message: Ein Event kann mehrere Handler haben, und alle sollen ausgeführt werden — im Gegensatz zu einem Command, der genau einen Handler haben sollte. Die Konfiguration erfolgt über den HandleMessageMiddleware mit allow_no_handlers: true und mehrere Handler für denselben Event-Typ. Symfony empfiehlt, Event Bus und Command Bus als separate Messenger-Instanzen zu konfigurieren, um die unterschiedlichen Regeln (genau ein Handler vs. null bis viele Handler) zu erzwingen.
Der Event Bus in Symfony Messenger kombiniert die Stärken beider Welten: Events können asynchron verarbeitet werden, landen auf einer persistenten Queue, profitieren von Retry-Logik und sind von der HTTP-Request-Lifecycle entkoppelt. Gleichzeitig bleibt die Typsicherheit durch PHP-Klassen erhalten — kein String-basiertes Event-System, keine Magic-Methoden, kein dynamisches Event-Routing. Der Symfony Profiler zeigt jeden dispatched Event, jeden aufgerufenen Handler und Verarbeitungszeiten. Das macht Event-getriebene Architekturen mit Symfony Messenger erheblich debuggbarer als traditionelle Event-Bus-Implementierungen.
6. Domain-Events synchron feuern, async verarbeiten
Das eleganteste Muster kombiniert den Symfony Event Dispatcher für synchrone Domain-Events mit Symfony Messenger für asynchrone Nebeneffekte. Ein Domain-Event wie UserRegisteredEvent wird synchron gefeuert und durch einen Listener empfangen, der eine Symfony Messenger-Message dispatcht. Die eigentliche E-Mail-Versendung, CRM-Aktualisierung und Analytics-Tracking passieren dann asynchron im Worker. Das Ergebnis: Die HTTP-Response wird sofort zurückgegeben, ohne auf externe Services zu warten, aber die Nebeneffekte sind garantiert durch die Queue-Persistenz.
Symfony 6.2 führte das Konzept des Dispatching-Events direkt über Symfony Messenger ein, ohne expliziten Dispatch-Listener. Mit dem DispatchAfterCurrentBusMiddleware-Middleware werden Messages, die während der Verarbeitung einer anderen Message dispatcht werden, erst nach Abschluss der Hauptverarbeitung dispatcht. Das verhindert Race Conditions und stellt sicher, dass alle Datenbankoperationen committed sind, bevor ein Event die Queue erreicht. Dieses Middleware-Pattern ist die sauberste Lösung für das klassische Problem: Einen Event dispatchen, der Daten aus der Datenbank liest, bevor die Transaktion committed ist.
<?php
declare(strict_types=1);
namespace App\Domain\Event;
// Domain event — fired synchronously in the application service
final readonly class UserRegisteredEvent
{
public function __construct(
public readonly int $userId,
public readonly string $email,
public readonly \DateTimeImmutable $registeredAt,
) {}
}
// ---------------------------------------------------------------------------
namespace App\EventListener;
use App\Domain\Event\UserRegisteredEvent;
use App\Message\SendWelcomeEmailMessage;
use App\Message\CreateCrmContactMessage;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Bridges synchronous domain events to async Messenger messages.
* This listener runs synchronously; actual work happens in workers.
*/
#[AsEventListener(event: UserRegisteredEvent::class)]
final readonly class UserRegisteredListener
{
public function __construct(
private MessageBusInterface $bus,
) {}
public function __invoke(UserRegisteredEvent $event): void
{
// Dispatch async messages — they are queued, not executed immediately
$this->bus->dispatch(new SendWelcomeEmailMessage(
userId: $event->userId,
email: $event->email,
locale: 'de',
));
$this->bus->dispatch(new CreateCrmContactMessage(
userId: $event->userId,
email: $event->email,
));
}
}
7. Retry-Strategien und Failure Transport
Netzwerkfehler, externe Service-Ausfälle und vorübergehende Datenbankprobleme sind in asynchronen Systemen die Regel, nicht die Ausnahme. Symfony Messenger implementiert automatische Retry-Logik mit konfigurierbarem Exponential-Backoff: Schlägt ein Handler mit einer Exception fehl, wird die Message nach einer wachsenden Wartezeit erneut verarbeitet. Die Wartezeit verdoppelt sich mit jedem Versuch — 1 Sekunde, 2 Sekunden, 4 Sekunden — bis zum konfigurierten Maximum. Diese Strategie gibt externen Services Zeit zur Erholung, ohne den Worker zu blockieren.
Nicht alle Exceptions sollten zu einem Retry führen. Validierungsfehler, fehlende Entities oder Geschäftslogik-Verletzungen sind permanente Fehler, die auch nach zehn Versuchen nicht verschwinden. Symfony Messenger unterstützt das Konzept des UnrecoverableMessageHandlingException: Wird diese Exception geworfen, überspringt Messenger alle weiteren Retry-Versuche und verschiebt die Message sofort auf den failed-Transport. Das spart Queue-Ressourcen und verhindert, dass bekannte-kaputte Messages stundenlang Retry-Slots blockieren. Benutzerdefinierte Retry-Strategies erlauben noch feinere Kontrolle: Man kann bestimmte Exception-Klassen von Retries ausschließen oder die Delay-Formel anpassen.
8. Middleware für Logging, Tracing und Transaktionen
Die Middleware-Pipeline von Symfony Messenger umhüllt jeden Dispatch- und Handle-Vorgang. Jede Middleware erhält die Message, kann sie transformieren oder anreichern, ruft die nächste Middleware auf und verarbeitet das Ergebnis. Das macht Middleware zum idealen Ort für Querschnittsbelange: Logging aller Messages mit Correlation-IDs, Tracing-Spans für Distributed Tracing mit OpenTelemetry, Datenbankransaktionen die automatisch committed oder gerollt-back werden, abhängig vom Ergebnis des Handlers.
Die DoctrineTransactionMiddleware aus dem symfony/doctrine-messenger-Paket ist ein Paradebeispiel: Sie wraps jeden Handler-Aufruf in eine Doctrine-Transaktion. Schlägt der Handler fehl, rollt die Transaktion automatisch zurück — alle Datenbankoperationen des Handlers verschwinden, als hätten sie nie stattgefunden. Für Symfony Messenger-Queues auf Doctrine-Basis bedeutet das: Message-Verarbeitung und Datenbankoperationen sind transaktional verbunden. Wer eigene Middleware schreibt, implementiert MiddlewareInterface mit einer einzigen handle()-Methode und registriert die Middleware im Service-Container.
| Pattern | Transport | Handler-Anzahl | Typischer Einsatz |
|---|---|---|---|
| Command Bus | sync oder async | Genau 1 | Zustandsändernde Befehle |
| Event Bus | async | 0 bis viele | Domain-Events, Nebeneffekte |
| Query Bus | sync (immer) | Genau 1 | Lesende Abfragen mit Return-Wert |
| Priority Queue | high_priority async | Genau 1 | Zeitkritische Benachrichtigungen |
| Event Dispatcher | sync (immer) | 0 bis viele | HTTP-Events, Kernel-Events |
9. Direktvergleich: Messenger-Patterns im Überblick
Die Tabelle zeigt, wie sich verschiedene Messaging-Patterns in Symfony Messenger unterscheiden. Command Bus, Event Bus und Query Bus sind drei verschiedene Verantwortlichkeiten, die alle über Symfony Messenger abgebildet werden können — aber unterschiedliche Konfigurationen und Erwartungen haben. Der Query Bus ist ein besonderer Fall: Er muss immer synchron laufen, weil der Aufrufer auf ein Ergebnis wartet. Ihn auf einen asynchronen Transport zu routen würde die Anwendung fehlerhaft machen — daher empfiehlt sich ein dedizierter Query-Bus ohne async-Routing.
Für Teams, die gerade mit Symfony Messenger beginnen, ist der pragmatische Einstieg: Ein einzelner Bus für alle Messages, synchron im Entwicklungsmodus, ein async-Transport in der Produktion für alle lang laufenden Operationen. Die Trennung in Command Bus, Event Bus und Query Bus kann schrittweise eingeführt werden, wenn die Anforderungen komplexer werden. Das Routing in der messenger.yaml ist der einzige Ort, der geändert werden muss — Handler-Code bleibt unverändert, wenn eine Message von synchron auf asynchron wechselt.
Mironsoft
Symfony Backend-Architektur, Messenger-Integration und Event-getriebene Systeme
Asynchrone Symfony-Architektur aufbauen?
Wir entwerfen und implementieren Event-getriebene Symfony-Systeme mit Messenger — von der Message-Architektur über Worker-Infrastruktur bis zu Retry-Strategien und Monitoring für euren Produktionsbetrieb.
Architektur
Command/Event/Query-Bus-Trennung und Message-Design für skalierbare Symfony-Systeme
Worker-Infrastruktur
Supervisor/systemd-Setup, Monitoring und Deployment-Strategie für Messenger-Worker
Fehlertoleranz
Retry-Strategien, Failure Transport und Alerting für zuverlässige Message-Verarbeitung
10. Zusammenfassung
Symfony Messenger und der Event Dispatcher lösen verwandte, aber unterschiedliche Probleme. Der Event Dispatcher feuert synchrone Domain-Events, die sofort im selben Request verarbeitet werden. Symfony Messenger persistiert Messages auf einer Queue und verarbeitet sie asynchron in Worker-Prozessen — mit automatischem Retry, Failure Transport und konfigurierbarem Exponential-Backoff. Das stärkste Architekturmuster kombiniert beide: Domain-Events werden synchron gefeuert, Listener dispatchen asynchrone Messenger-Messages für Nebeneffekte wie E-Mails, CRM-Updates und Analytics.
Die DoctrineTransactionMiddleware verbindet Message-Verarbeitung und Datenbankoperationen transaktional. UnrecoverableMessageHandlingException verhindert sinnlose Retry-Schleifen für permanente Fehler. Separate Transports für unterschiedliche Prioritäten ermöglichen differenzierte SLA-Garantien. Wer Symfony Messenger konsequent einsetzt, baut ein System, in dem HTTP-Requests schnell antworten und rechenintensive, fehlertolerante Operationen zuverlässig im Hintergrund laufen.
Symfony Messenger + Event Bus — Das Wichtigste auf einen Blick
Async ohne Code-Änderung
Routing in messenger.yaml bestimmt sync/async — Handler-Code bleibt unverändert. Schrittweise Asynchronisierung ohne Refactoring.
Retry & Failure Transport
Exponential-Backoff mit max_retries und delay. Fehlgeschlagene Messages landen auf dem failed-Transport für manuelle Inspektion.
Domain-Events + Messenger
Event Dispatcher feuert synchron, Listener dispatchen Messenger-Messages für asynchrone Nebeneffekte. Beste beider Welten.
Transaktionale Queue
Doctrine-Transport + DoctrineTransactionMiddleware: Message-Dispatch und DB-Operationen in einer Transaktion — kein verwaister Queue-Eintrag.