Echtzeit-Updates ohne Polling
Wer Live-Bestellstatus, Benachrichtigungen oder Chat-Nachrichten im Browser anzeigen will, denkt oft sofort an WebSockets — mit allem was dazugehört: eigener Server, Verbindungsmanagement, Protokoll-Handling. Symfony Mercure und Server-Sent Events lösen 80 % dieser Anwendungsfälle einfacher, mit weniger Infrastruktur und nativem Browser-Support ohne externe Bibliotheken.
Inhaltsverzeichnis
- 1. Warum Mercure statt Polling oder WebSockets
- 2. Mercure-Architektur: Hub, Publisher und Subscriber
- 3. Mercure Hub als Docker-Service einrichten
- 4. Symfony-Integration: Publisher-Service konfigurieren
- 5. Updates aus Symfony publizieren
- 6. JWT-Authentifizierung für Topics
- 7. Frontend: EventSource API ohne Bibliotheken
- 8. Konkrete Anwendungsfälle: Bestellstatus und Notifications
- 9. Mercure vs. WebSockets vs. Polling im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Mercure statt Polling oder WebSockets
Polling ist die einfachste Methode für Echtzeit-ähnliche Updates: Der Browser fragt alle fünf Sekunden nach neuen Daten. Das skaliert schlecht, belastet den Server mit nutzlosen Requests und erzeugt trotzdem bis zu fünf Sekunden Verzögerung. Long-Polling verbessert die Latenz, hält aber die HTTP-Verbindung offen — was PHP-Prozesse blockiert und bei vielen gleichzeitigen Benutzern zu Ressourcenproblemen führt. WebSockets lösen beide Probleme, bringen aber eigene Komplexität: Ein separater WebSocket-Server (z.B. Ratchet, ReactPHP oder Node.js) muss betrieben, skaliert und gesichert werden. Nginx-Konfiguration, Load-Balancer-Sticky-Sessions und Firewall-Regeln für den WebSocket-Port kommen hinzu.
Symfony Mercure bietet einen dritten Weg: Server-Sent Events (SSE) über einen spezialisierten Hub-Server, der in Go geschrieben ist und Tausende gleichzeitige Verbindungen mit minimalem Ressourcenverbrauch hält. Der Browser öffnet eine einfache HTTP-Verbindung zum Mercure Hub und erhält Updates als Textstream — ohne WebSocket-Protokoll, ohne Reconnect-Logik (SSE macht das nativ), ohne externe Bibliotheken. Symfony kommuniziert mit dem Hub über eine HTTP-API: Wenn sich etwas ändert, sendet der Symfony-Service einen POST-Request an den Hub, der ihn an alle verbundenen Subscriber weiterleitet. Diese Architektur trennt sauber zwischen PHP (Business-Logik) und dem Hub (Verbindungsmanagement).
2. Mercure-Architektur: Hub, Publisher und Subscriber
Die Symfony Mercure-Architektur besteht aus drei Rollen. Der Hub ist ein Go-Server, der alle SSE-Verbindungen von Browsern hält und eingehende Updates an die richtigen Subscriber verteilt. Publisher sind Symfony-Services, die Updates über eine HTTP-POST-Anfrage an den Hub senden. Subscriber sind Browser, die sich mit dem Hub verbinden und Updates für bestimmte Topics empfangen. PHP kommt nur in der Publisher-Rolle vor — es hält keine langen Verbindungen und blockt keinen Prozess für einzelne Verbindungen.
Topics sind die Adressierungseinheit in Symfony Mercure. Ein Topic ist eine URL oder URI — zum Beispiel https://mironsoft.de/orders/12345 oder /bestellungen/12345/status. Browser abonnieren spezifische Topics, Publisher senden Updates an Topics. Nur Browser, die ein bestimmtes Topic abonniert haben, erhalten Updates dafür. Das erlaubt granulares Targeting: Ein Update für die Bestellung eines Kunden geht nur an den Browser dieses Kunden, nicht an alle verbundenen User. Topic-Pattern wie Wildcards erlauben das Abonnieren einer Gruppe von Topics — zum Beispiel alle Bestellungen eines Kunden.
3. Mercure Hub als Docker-Service einrichten
Der Mercure Hub ist eine einzelne Go-Binary, die als Docker-Container betrieben wird. Das offizielle Docker-Image dunglas/mercure enthält alles — kein zusätzliches Setup nötig. Die Konfiguration erfolgt über Umgebungsvariablen: SERVER_NAME für den Bind-Port, MERCURE_PUBLISHER_JWT_KEY für den JWT-Schlüssel, den Publisher zur Authentifizierung nutzen, und MERCURE_SUBSCRIBER_JWT_KEY für den Schlüssel, den Subscriber-JWTs signieren. In Entwicklung kann MERCURE_EXTRA_DIRECTIVES auf anonymous 1 gesetzt werden, um Subscriber-JWT-Authentifizierung zu deaktivieren — für Produktion ist das niemals empfohlen.
In einem Mark-Shust-Docker-Setup oder einem Standard-compose.yaml wird der Mercure Hub als eigener Service definiert. Der Symfony-Container muss den Hub-Service über das interne Docker-Netz erreichen — die Hub-URL im Symfony-Container ist die interne Docker-Service-URL (z.B. http://mercure/.well-known/mercure), während der Browser den öffentlich erreichbaren Hub-Endpunkt nutzt. Diese Doppelkonfiguration — interne Publisher-URL, öffentliche Subscriber-URL — ist ein wichtiger Aspekt des Mercure-Deployments, der in der Dokumentation manchmal übersprungen wird.
<?php
// docker-compose.yaml mercure service definition:
//
// mercure:
// image: dunglas/mercure
// restart: unless-stopped
// environment:
// SERVER_NAME: ':80 http://:80'
// MERCURE_PUBLISHER_JWT_KEY: '${MERCURE_JWT_SECRET}'
// MERCURE_SUBSCRIBER_JWT_KEY: '${MERCURE_JWT_SECRET}'
// MERCURE_EXTRA_DIRECTIVES: |
// cors_origins "https://mironsoft.de"
// ports:
// - "3000:80"
// .env configuration for Symfony:
// MERCURE_URL=http://mercure/.well-known/mercure # Internal Docker URL for publishing
// MERCURE_PUBLIC_URL=https://mironsoft.de/.well-known/mercure # Public URL for browser
// MERCURE_JWT_SECRET=your-256-bit-secret-key-here
// config/packages/mercure.yaml:
// mercure:
// hubs:
// default:
// url: '%env(MERCURE_URL)%'
// public_url: '%env(MERCURE_PUBLIC_URL)%'
// jwt:
// secret: '%env(MERCURE_JWT_SECRET)%'
// publish: ['*'] # Publisher can publish to all topics
// subscribe: ['*'] # Subscriber JWT claims
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
/**
* Service for publishing real-time updates via the Mercure Hub.
*/
final readonly class MercurePublisher
{
public function __construct(
private HubInterface $hub,
) {}
/**
* Publish a JSON update to a specific topic URI.
*
* @param array<string, mixed> $data
*/
public function publish(string $topicUri, array $data): void
{
$update = new Update(
topics: $topicUri,
data: json_encode($data, JSON_THROW_ON_ERROR),
private: true, // Only authenticated subscribers can receive this
);
$this->hub->publish($update);
}
}
4. Symfony-Integration: Publisher-Service konfigurieren
Die Symfony Mercure-Integration erfolgt über das Paket symfony/mercure-bundle. Nach der Installation per Composer und Flex konfiguriert man den Hub in config/packages/mercure.yaml. Der Service HubInterface ist automatisch im Container und kann per Constructor Injection genutzt werden. Für Projekte mit mehreren Mercure-Hubs — z.B. einem eigenen Hub für interne Admin-Benachrichtigungen und dem öffentlichen Hub für Kunden — können mehrere Hub-Konfigurationen angelegt werden. Per #[Autowire(service: 'mercure.hub.default')] wird der gewünschte Hub-Service selektiv injiziert.
Das Update-Objekt kapselt alle Parameter einer Mercure-Nachricht: Topics (ein String oder Array von Strings), Data (der Payload als String), Private-Flag (nur authentifizierte Subscriber), ID (für Event-Reihenfolge und Reconnect), Retry-Intervall und einen optionalen Event-Type. Für JSON-APIs ist json_encode($data) als Data-String der Standard. Für HTML-Fragmentupdates — z.B. wenn das Backend ein gerendertes Twig-Fragment direkt an den Browser sendet — ist der HTML-String das Data-Payload. Das erlaubt Server-Side Rendering von Updates ohne Client-Side Templating.
5. Updates aus Symfony publizieren
Updates werden in Symfony Mercure typischerweise aus drei Kontexten publiziert: aus einem Symfony Controller nach einer erfolgreichen HTTP-Aktion, aus einem Symfony Messenger Message Handler nach der Verarbeitung einer Queue-Nachricht, oder aus einem Doctrine Event Listener nach einer Datenbankänderung. Der häufigste Ansatz ist der Messenger-Handler: Ein Queue-Job verarbeitet eine Bestellung, und am Ende publisht er ein Mercure-Update an das Order-Topic. Das entkoppelt die Verarbeitung vom Echtzeit-Update — der Browser bekommt das Update, sobald der Job fertig ist, nicht sofort beim HTTP-Request.
Die Topic-URL-Konvention ist wichtig für das Subscription-Design. IRIs (Internationalized Resource Identifiers) — Strings, die wie URLs aussehen, aber keine echten HTTP-Endpunkte sein müssen — sind die empfohlene Form. Ein Topic wie https://mironsoft.de/orders/{id} identifiziert eindeutig eine Bestellung. Browser abonnieren genau dieses Topic für die Bestellungs-ID, die sie gerade anzeigen. Das verhindert, dass ein Benutzer Updates für die Bestellungen anderer Benutzer empfängt — selbst wenn er die Topic-URL kennt, validiert das JWT auf der Subscriber-Seite den Zugriff. Private Mercure-Updates erfordern, dass der Subscriber ein JWT mitschickt, das das Topic in seinen Claims enthält.
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\ProcessOrderMessage;
use App\Repository\OrderRepository;
use App\Service\MercurePublisher;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handles order processing and publishes a Mercure update when done.
*/
#[AsMessageHandler]
final readonly class ProcessOrderMessageHandler
{
public function __construct(
private OrderRepository $orderRepository,
private MercurePublisher $mercurePublisher,
private OrderProcessor $orderProcessor,
) {}
/**
* Process the order and publish a real-time status update to the customer's browser.
*/
public function __invoke(ProcessOrderMessage $message): void
{
$order = $this->orderRepository->find($message->orderId);
if ($order === null) {
return;
}
// Process the order (payment, inventory, etc.)
$result = $this->orderProcessor->process($order);
// Publish real-time update via Mercure — browser receives it instantly
$topicUri = "https://mironsoft.de/orders/{$order->getId()}";
$this->mercurePublisher->publish($topicUri, [
'order_id' => $order->getId(),
'status' => $result->getStatus()->value,
'message' => $result->getStatusMessage(),
'updated_at' => $result->getUpdatedAt()->format(\DateTimeInterface::ATOM),
]);
}
}
6. JWT-Authentifizierung für Topics
Die Sicherheit in Symfony Mercure basiert auf JWTs (JSON Web Tokens). Publisher-JWTs authentifizieren Symfony gegenüber dem Hub — sie werden automatisch von HubInterface erzeugt und mitgesendet, wenn der JWT-Key in der Symfony-Konfiguration gesetzt ist. Subscriber-JWTs authentifizieren den Browser gegenüber dem Hub für private Topics. Diese JWTs enthält in ihren Claims, welche Topics der Subscriber abonnieren darf. Das JWT wird vom Symfony-Backend erzeugt und dem Browser nach der Authentifizierung übergeben — z.B. als Cookie oder im JSON-Response des Login-Endpunkts.
Das Topic-Claim im Subscriber-JWT ist eine Liste von Topic-Patterns, die der Subscriber abonnieren darf. Für einen eingeloggten Benutzer mit User-ID 42 könnte das JWT die Topics https://mironsoft.de/users/42/* enthalten — damit kann der Browser alle Echtzeit-Events für diesen Benutzer empfangen, nicht aber die Events anderer Benutzer. Der Mercure Hub validiert das JWT bei jedem Subscriber-Request und verweigert den Zugriff auf nicht erlaubte Topics. Das Ablaufdatum im JWT sorgt dafür, dass nach einer Session-Ablaufzeit keine Updates mehr empfangen werden — ohne dass der Server aktiv Verbindungen trennen muss.
7. Frontend: EventSource API ohne Bibliotheken
Der Browser-seitige Teil von Symfony Mercure braucht keine Bibliothek. Die native EventSource-API ist in allen modernen Browsern verfügbar und verbindet sich mit dem Mercure Hub. Die URL enthält das abonnierte Topic als Query-Parameter: new EventSource('https://mironsoft.de/.well-known/mercure?topic=...'). Für private Topics wird das Subscriber-JWT als Cookie übergeben — der Mercure Hub liest das Cookie automatisch. EventSource behandelt Verbindungsabbrüche automatisch mit exponentialem Reconnect — keine manuelle Reconnect-Logik nötig.
Für mehrere abonnierte Topics in einer EventSource-Verbindung werden mehrere topic-Query-Parameter übergeben: ?topic=https://...&topic=https://.... Eingehende Events werden über den onmessage-Handler verarbeitet. event.data enthält den JSON-String, den das Symfony-Backend publiziert hat. In modernem JavaScript oder Alpine.js ist das Parsing und Rendering von Mercure-Updates in wenigen Zeilen erledigt. Das ist der Kernvorteil gegenüber WebSockets: kein Protokoll-Handling, kein Handshake, kein Reconnect-Code — der Browser kümmert sich um alles, der PHP-Entwickler schreibt nur den Publisher-Code.
<?php
// Frontend JavaScript for subscribing to Mercure updates (shown as PHP comment)
// No external library needed — native EventSource API
// Subscribe to order status updates for a specific order:
//
// const orderId = '{ { order.id } }'; // Twig template variable
// const mercureHubUrl = '{ { mercure_url } }';
// const topicUri = `https://mironsoft.de/orders/${orderId}`;
//
// const url = new URL(mercureHubUrl);
// url.searchParams.append('topic', topicUri);
//
// const eventSource = new EventSource(url, { withCredentials: true });
//
// eventSource.onmessage = (event) => {
// const data = JSON.parse(event.data);
//
// // Update the order status display
// document.getElementById('order-status').textContent = data.message;
// document.getElementById('order-status-badge').dataset.status = data.status;
//
// // Close connection when order is in a final state
// if (['delivered', 'cancelled'].includes(data.status)) {
// eventSource.close();
// }
// };
//
// eventSource.onerror = (error) => {
// console.warn('Mercure connection lost, browser will reconnect automatically.');
// // No manual reconnect needed — EventSource handles this natively
// };
//
// Alpine.js integration example:
//
// x-data="{
// orderStatus: 'pending',
// initMercure() {
// const url = new URL(mercureHubUrl);
// url.searchParams.append('topic', topicUri);
// const es = new EventSource(url, { withCredentials: true });
// es.onmessage = (e) => { this.orderStatus = JSON.parse(e.data).status; };
// }
// }"
// x-init="initMercure()"
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Renders the order tracking page with the Mercure subscriber JWT as a cookie.
*/
final class OrderTrackingController extends AbstractController
{
public function __construct(
private readonly HubInterface $hub,
) {}
#[Route('/orders/{id}/track', name: 'order_tracking')]
public function track(string $id): Response
{
$topicUri = "https://mironsoft.de/orders/{$id}";
// Generate a subscriber JWT that allows subscribing to this specific topic
$token = $this->hub->generateSubscriberJwt(
topics: [$topicUri],
expiry: new \DateTimeImmutable('+1 hour'),
);
$response = $this->render('order/tracking.html.twig', [
'order_id' => $id,
'topic_uri' => $topicUri,
'mercure_url' => $this->hub->getPublicUrl(),
]);
// Set the JWT as a cookie — Mercure Hub reads it automatically
$response->headers->setCookie(
new \Symfony\Component\HttpFoundation\Cookie(
name: 'mercureAuthorization',
value: $token,
secure: true,
httpOnly: false, // Must be readable by EventSource
sameSite: 'strict',
)
);
return $response;
}
}
8. Konkrete Anwendungsfälle: Bestellstatus und Notifications
Der häufigste Anwendungsfall für Symfony Mercure in E-Commerce-Projekten ist das Live-Tracking von Bestellstatus-Updates. Ohne Echtzeit-Updates fragt der Kunde alle paar Sekunden die Statusseite neu an oder wartet auf eine E-Mail. Mit Mercure sieht der Kunde den Status im Browser sofort, wenn der Fulfillment-Prozess ihn ändert: Zahlung bestätigt, Lager informiert, Versand ausgelöst, Tracking-Nummer zugewiesen. Jeder Schritt triggert einen Messenger-Job, der am Ende ein Mercure-Update publisht. Der Browser aktualisiert die Statusanzeige, ohne die Seite neu zu laden.
Ein zweiter wichtiger Anwendungsfall ist das Admin-Dashboard mit Live-Metriken. Wenn neue Bestellungen eingehen, sollen Verkaufszahlen im Admin-Panel ohne manuelles Neuladen aktualisiert werden. Mit Symfony Mercure publisht ein Doctrine-Event-Listener bei jeder neuen Bestellung ein Update an ein Admin-Topic. Das Admin-Dashboard abonniert dieses Topic und aktualisiert Zähler, Charts und Tabellen in Echtzeit. Das Topic für Admin-Updates ist nur für authentifizierte Admin-Benutzer zugänglich — das Subscriber-JWT enthält die Admin-Topic-Claims, normale Benutzer-JWTs nicht. Ohne Mercure würde man für dasselbe Ergebnis Polling mit Intervallen oder einen komplexen WebSocket-Server brauchen.
9. Mercure vs. WebSockets vs. Polling im Vergleich
Die Wahl zwischen Symfony Mercure, WebSockets und Polling hängt von den Anforderungen an Kommunikationsrichtung, Komplexität und Skalierbarkeit ab.
| Merkmal | Polling | WebSockets | Mercure (SSE) |
|---|---|---|---|
| Richtung | Client → Server | Bidirektional | Server → Client |
| Infrastruktur | Keiner extra | WebSocket-Server | Mercure Hub (Docker) |
| Browser-Support | Alle | Alle modern | Alle modern + nativ |
| Latenz | Hoch (Intervall) | Sehr gering | Gering (<100ms) |
| PHP-Komplexität | Gering | Hoch | Gering (nur Publisher) |
| Skalierung | Schlecht | Aufwendig | Hub skaliert unabhängig |
Symfony Mercure ist nicht für alle Echtzeit-Szenarien die beste Wahl. Chat-Anwendungen, Multiplayer-Games und kollaborative Editoren brauchen bidirektionale Kommunikation — WebSockets oder WebRTC sind dort die richtige Wahl. Für unidirektionale Server-zu-Client-Updates — Status-Updates, Benachrichtigungen, Live-Metriken, Feed-Updates — ist Mercure einfacher, benötigt weniger PHP-Infrastruktur und nutzt native Browser-Funktionen statt Protokoll-Hacks.
Mironsoft
Symfony Mercure, Echtzeit-Features und Event-Driven Architektur
Echtzeit-Features für euer Symfony-Projekt?
Wir implementieren Echtzeit-Updates mit Symfony Mercure — vom Hub-Setup über Publisher-Services und JWT-Authentifizierung bis zur Frontend-Integration für euren Stack.
Mercure-Setup
Hub-Konfiguration, Docker-Integration und Symfony-Publisher-Service
Echtzeit-Features
Bestellstatus, Live-Notifications, Admin-Dashboards und Feed-Updates
Sicherheit
JWT-basierte Topic-Authentifizierung und private Updates für einzelne Benutzer
10. Zusammenfassung
Symfony Mercure macht Echtzeit-Updates in PHP-Projekten zugänglich, ohne die Komplexität eines WebSocket-Servers oder die Last von Polling. Der Mercure Hub hält Tausende Browser-Verbindungen in Go, während PHP ausschließlich als Publisher fungiert — kein blockierender Prozess, kein offener Socket in PHP. Topics als IRIs strukturieren das Adressierungsmodell klar: Jede Ressource hat eine URI, Browser abonnieren die URIs, die sie interessieren. JWT-basierte Authentifizierung stellt sicher, dass private Updates nur die richtigen Subscriber erreichen.
Der Frontend-Code ist minimal: native EventSource-API, ein paar Zeilen JavaScript oder Alpine.js, kein WebSocket-Client, keine externe Bibliothek. Die native Reconnect-Logik von SSE macht die Verbindung resilient gegen Netzwerkunterbrechungen. Für den Backend-Entwickler reduziert sich Echtzeit-Kommunikation auf das Publizieren eines Update-Objekts über HubInterface — dasselbe vertraute Pattern wie das Dispatchen einer Messenger-Message. Symfony Mercure ist die pragmatische Wahl für Server-zu-Client-Echtzeit-Updates in Symfony-Projekten jeder Größe.
Symfony Mercure — Das Wichtigste auf einen Blick
Hub-Architektur
Go-basierter Hub hält Browser-Verbindungen, PHP published nur Updates. Keine langen PHP-Prozesse, keine offenen Sockets in der App.
Topics als IRIs
Topics sind URIs — z.B. https://mironsoft.de/orders/123. Browser abonnieren spezifische Topics, Publisher senden Updates gezielt daran.
JWT-Sicherheit
Subscriber-JWTs mit Topic-Claims kontrollieren den Zugriff. Private Updates erreichen nur Subscriber mit passendem JWT — automatisch vom Hub validiert.
Frontend
Nativ EventSource-API — kein WebSocket-Client, keine Bibliothek. Automatischer Reconnect bei Verbindungsabbruch, cookie-basierte JWT-Übergabe.