Echtzeit-Features mit Symfony Mercure: Eine Produktionsalternative zu WebSockets

Sandor Farkas
Gründer & Lead Developer
Experte für Softwareentwicklung und Legacy-Code-Optimierung
LinkedInDie meisten Teams fügen ihren Anwendungen Echtzeit-Features auf die schwierige Art hinzu. Sie greifen zu WebSockets, richten einen dedizierten Node.js-Service ein, verdrahten Socket.io, stellen fest, dass ihr PHP-Backend das Protokoll nicht nativ sprechen kann, und verbringen zwei Wochen damit, eine fragile Brücke zwischen ihrer Symfony-Anwendung und ihrer neuen Echtzeit-Schicht zu bauen. Dann verbringen sie die nächsten sechs Monate damit, sie zu warten.
Symfony Mercure existiert, weil das für die meisten Anwendungen der falsche Weg ist. Mercure ist ein offenes Protokoll, das auf Server-Sent Events (SSE) aufbaut, nativ in Symfony und API Platform integriert ist und genau die Anwendungsfälle behandelt, die Teams in Richtung WebSocket-Komplexität treiben: Live-Dashboards, Benachrichtigungen, Präsenz-Indikatoren, kollaboratives Editieren, Feed-Updates. Wenn deine Echtzeit-Anforderungen einem Server-zu-Client-Push-Modell entsprechen - und die meisten tun das - bringt dich Mercure schneller in Produktion, mit weniger Infrastruktur und deutlich weniger operativer Angriffsfläche. Für diese Fälle ist Mercure die schlankere Produktionsalternative zu WebSockets.
Dieser Beitrag führt durch eine realistische Produktionsarchitektur: Wie Mercure funktioniert, wofür es genuinen Mehrwert bietet, wo WebSockets noch die richtige Wahl sind und wie man Authentifizierung, Skalierung und Backpressure behandelt, bevor der erste echte Nutzer dein System trifft.
Wie Mercure funktioniert (und warum es anders ist als WebSockets)
WebSockets sind bidirektional. Client und Server können beide jederzeit Nachrichten über eine persistente Verbindung senden. Diese Flexibilität ist genuinen Nutzen für Anwendungen, bei denen der Client hochfrequente Daten an den Server senden muss - Spielzustand, kollaborative Cursor, Audio - aber sie hat ihren Preis: Dein Backend muss offene Socket-Verbindungen pflegen, dein Load-Balancer benötigt Sticky Sessions oder eine geteilte Pub/Sub-Schicht, und jede Standard-HTTP-Middleware (Authentifizierung, Rate Limiting, Caching) muss entweder umgangen oder für die WS-Schicht neu implementiert werden.
Mercure verwendet unter der Haube Server-Sent Events, was bedeutet, dass es unidirektional ist: Der Server pusht zum Client, und der Client kommuniziert zurück über normale HTTP-Anfragen. Das ist keine Einschränkung - es ist ein Feature. SSE ist HTTP. Es funktioniert durch Proxies, CDNs und Unternehmens-Firewalls, die WebSocket-Upgrades blockieren. Clients verbinden sich nach Netzwerkunterbrechungen automatisch neu. Die EventSource-API des Browsers übernimmt Reconnection, Lückenfüllung mit dem Last-Event-ID-Header und Content-Type-Aushandlung ohne jede clientseitige Bibliothek.
Der Mercure Hub ist das Element, das das skalierbar macht. Deine Symfony-Anwendung veröffentlicht Updates an den Hub (ein HTTP-POST). Der Hub sendet diese Updates an alle abonnierten Clients über ihre SSE-Verbindungen. Deine PHP-Anwendung ist zustandslos - sie hält keine offenen Verbindungen - also skaliert sie horizontal ohne jegliche Koordination. Der Hub übernimmt den Fan-out.
Browser --SSE-Abonnement--> Mercure Hub <--POST-Update-- Symfony App
Browser --SSE-Abonnement--> Mercure Hub
Browser --SSE-Abonnement--> Mercure Hub
Der offizielle Mercure Hub ist in Go geschrieben und wird als einzelne Binärdatei ausgeliefert. Es gibt eine verwaltete gehostete Version, ein Dockerfile und eine Symfony-Binärdatei, die den Hub für die lokale Entwicklung einbettet. In Produktion betreibst du den Hub als separaten Service - typischerweise eine Instanz hinter einem Load-Balancer oder ein kleiner Cluster, wenn du Redundanz benötigst.
Mercure in einer Symfony-Anwendung installieren und konfigurieren
Die Symfony Mercure-Komponente installieren:
composer require symfony/mercure-bundle
Hub-URL und JWT-Secret in der Umgebung konfigurieren:
# .env.local (niemals Secrets committen)
MERCURE_URL=http://mercure-hub:3000/.well-known/mercure
MERCURE_PUBLIC_URL=https://deinedomain.de/.well-known/mercure
MERCURE_JWT_SECRET=dein-sehr-langes-zufaelliges-secret-mindestens-256-bit
In config/packages/mercure.yaml:
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
Das ist das vollständige serverseitige Setup. Das Bundle übernimmt JWT-Generierung für deine Publish-Anfragen und stellt den HubInterface-Service zum Veröffentlichen von Updates bereit.
Updates aus deiner Anwendung veröffentlichen
Das Symfony Mercure-Bundle stellt ein HubInterface bereit, das du überall injectest, wo du veröffentlichen musst. Topics sind URI-Templates - sie müssen nichts auflösen, sie sind nur Bezeichner:
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
class OrderService
{
public function __construct(
private readonly HubInterface $hub,
private readonly OrderRepository $orders,
) {}
public function markShipped(string $orderId): void
{
$order = $this->orders->find($orderId);
$order->ship();
$this->orders->save($order);
// An ein bestellungsspezifisches Topic veroeffentlichen
$this->hub->publish(new Update(
topics: "https://deinedomain.de/orders/{$orderId}",
data: json_encode([
'status' => 'versandt',
'tracking' => $order->getTrackingNumber(),
'updated_at' => $order->getUpdatedAt()->format(\DateTimeInterface::ATOM),
]),
));
}
}
Der Update-Konstruktor akzeptiert einen einzelnen Topic-String oder ein Array von Topics. Das Veröffentlichen an mehrere Topics ermöglicht einer Sendung, Abonnenten verschiedener Topic-Muster zu erreichen - nützlich, um gleichzeitig an den globalen Benachrichtigungs-Feed eines Nutzers und an eine bestimmte Bestellungsseite zu senden:
$this->hub->publish(new Update(
topics: [
"https://deinedomain.de/orders/{$orderId}",
"https://deinedomain.de/users/{$userId}/notifications",
],
data: json_encode($payload),
));
Für Hochdurchsatz-Anwendungen veröffentliche aus einem asynchronen Symfony Messenger-Handler heraus, anstatt direkt im Request-Zyklus. Das hält deine HTTP-Antwortzeiten schnell und gibt dir Retry-Logik, wenn der Hub vorübergehend nicht verfügbar ist.
Clientseitig abonnieren
Die integrierte EventSource-API des Browsers übernimmt die SSE-Verbindung. Für öffentliche Topics ist die Abonnement-URL nur die Hub-URL mit einem Topic-Query-Parameter:
const url = new URL('https://deinedomain.de/.well-known/mercure');
url.searchParams.append('topic', 'https://deinedomain.de/orders/12345');
const es = new EventSource(url);
es.onmessage = (event) => {
const data = JSON.parse(event.data);
updateOrderStatus(data.status, data.tracking);
};
es.onerror = (error) => {
// EventSource verbindet sich automatisch neu - dies wird bei Verbindungsabbruechen ausgeloest,
// nicht nur bei fatalen Fehlern. Loggen, aber nicht in Panik geraten.
console.warn('Mercure-Verbindung unterbrochen, Neuverbindung...', error);
};
EventSource verbindet sich nach Netzwerkunterbrechungen automatisch neu. Beim Neuverbinden sendet es den Last-Event-ID-Header mit der ID des zuletzt empfangenen Events, und der Hub spielt alle verpassten Events aus seinem Verlauf ab. Diese Lückenfüllung ist im Protokoll eingebaut - du bekommst sie kostenlos.
Für React- oder Next.js-Frontends in einem benutzerdefinierten Hook verpacken:
import { useEffect, useRef } from 'react';
export function useMercure<T>(
topic: string,
onMessage: (data: T) => void,
): void {
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
const url = new URL(process.env.NEXT_PUBLIC_MERCURE_URL!);
url.searchParams.append('topic', topic);
const es = new EventSource(url, { withCredentials: true });
es.onmessage = (event) => {
onMessageRef.current(JSON.parse(event.data) as T);
};
return () => es.close();
}, [topic]);
}
Private Topics mit JWT-Autorisierung absichern
Öffentliche Topics funktionieren ohne Authentifizierung. Für private Daten - nutzerspezifische Benachrichtigungen, Admin-Dashboards, interner Bestellstatus - benötigst du JWT-basierte Autorisierung.
Der Mercure Hub validiert zwei JWTs: ein Publisher-JWT (von deiner Symfony-Anwendung zum Posten von Updates verwendet, automatisch vom Bundle behandelt) und ein Subscriber-JWT (vom Browser gesendet, um zu autorisieren, welche Topics er abonnieren kann). Du generierst Subscriber-JWTs in deinen Symfony-Controllern und gibst sie an das Frontend:
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
class DashboardController extends AbstractController
{
public function __construct(
private readonly string $mercureJwtSecret,
) {}
#[Route('/dashboard', name: 'dashboard')]
public function index(): Response
{
$user = $this->getUser();
// Ein JWT erstellen, das das Abonnieren nur der Topics dieses Nutzers erlaubt
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText($this->mercureJwtSecret),
);
$token = $config->builder()
->withClaim('mercure', [
'subscribe' => [
"https://deinedomain.de/users/{$user->getId()}/*",
],
])
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->render('dashboard/index.html.twig', [
'mercure_token' => $token,
'mercure_url' => $this->getParameter('mercure.public_url'),
]);
}
}
Clientseitig übergibst du den Token als Cookie (der empfohlene Ansatz - der Hub validiert ihn automatisch) oder als Authorization-Header. Der Cookie-Ansatz ist für Browser-Clients einfacher:
// Token als Cookie setzen, bevor EventSource geoeffnet wird
document.cookie = `mercureAuthorization=${mercureToken}; path=/.well-known/mercure; SameSite=Strict; Secure`;
const url = new URL(mercureUrl);
url.searchParams.append('topic', `https://deinedomain.de/users/${userId}/notifications`);
const es = new EventSource(url, { withCredentials: true });
JWT-Claims eng eingrenzen. Ein Subscriber-JWT sollte nur die spezifischen Topic-Muster erlauben, die dieser Nutzer sehen darf. Ein JWT, das * erlaubt, ist eine Sicherheitslücke - jeder, der es erhält, kann jedes Topic abonnieren.
Präsenz: Wer ist gerade online?
Präsenz ist eines der am häufigsten gewünschten Echtzeit-Features und eines der am meisten missbrauchten. Der naive Ansatz - einen Heartbeat von jedem verbundenen Client alle paar Sekunden zu veröffentlichen - erzeugt bei jeder nennenswerten Nutzerzahl enormen Fan-out.
Ein besseres Muster verwendet den Mercure-Subscription-Discovery-Endpunkt. Der Hub stellt /.well-known/mercure/subscriptions bereit, das aktive Abonnements auflistet. Du kannst diesen Endpunkt aus deinem Symfony-Backend in einem sinnvollen Rhythmus abrufen (alle 10-30 Sekunden) und das Ergebnis cachen, anstatt Präsenz als Stream zu behandeln.
Für leichtgewichtige Präsenz in einem SaaS-Kontext - "zeige, wer dieses Dokument gerade betrachtet" - Präsenz-Updates vom Client über dein Symfony-Backend veröffentlichen:
#[Route('/documents/{id}/presence', methods: ['POST'])]
public function trackPresence(string $id, Request $request): JsonResponse
{
$user = $this->getUser();
$this->hub->publish(new Update(
topics: "https://deinedomain.de/documents/{$id}/presence",
data: json_encode([
'user_id' => $user->getId(),
'name' => $user->getDisplayName(),
'avatar' => $user->getAvatarUrl(),
'action' => 'aktiv', // oder 'verlassen'
]),
private: true,
));
return new JsonResponse(['ok' => true]);
}
Diesen Endpunkt via beacon beim Tab-Schließen aufrufen, um ein verlassen-Event zu senden, auch wenn der Browser entlädt:
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon(`/documents/${docId}/presence`, JSON.stringify({ action: 'verlassen' }));
}
});
Den Mercure Hub skalieren
Der Standard-Mercure-Hub verwendet In-Memory-Speicher für Abonnements, was eine einzelne Instanz bedeutet. Für Produktion mit jeder nennenswerten Last gibt es zwei Optionen.
Option 1: Einzelner Hub mit Redis. Der offizielle Hub unterstützt einen Redis-Transport für Abonnement-Speicherung und Pub/Sub. Das ermöglicht, mehrere Hub-Instanzen hinter einem Load-Balancer zu betreiben, die alle den Zustand über Redis teilen:
# Mercure Hub-Konfiguration (nicht Symfony-Konfiguration)
transport_url: redis://redis:6379
Das behandelt Zehntausende gleichzeitiger SSE-Verbindungen ohne Koordinationsprobleme.
Option 2: Verwaltetes Mercure. Mercure.rocks bietet einen verwalteten gehosteten Hub, der Skalierung, TLS und Verbindungslimits übernimmt. Für die meisten SaaS-Teams unter 50.000 gleichzeitigen Nutzern ist das die richtige Wahl - es eliminiert den Hub als operatives Anliegen vollständig, und die Kosten sind im Verhältnis zur Engineering-Zeit vernachlässigbar. Wolf-Tech empfiehlt das typischerweise Kunden, die neue SaaS-Features launchen: zuerst die Integration richtig hinbekommen, Infrastruktur später optimieren.
Für SSE-Verbindungen selbst: Moderne Linux-Kernel behandeln Hunderttausende gleichzeitiger Long-Polling-Verbindungen effizient. Der Engpass ist fast immer der Pub/Sub-Durchsatz des Hubs, nicht die Verbindungsanzahl. Profilieren vor dem Überengineering.
Backpressure und langsame Clients
SSE-Verbindungen bleiben offen. Ein Client mit einer langsamen Mobilverbindung oder einem überlasteten Netzwerk verbraucht Hub-Ressourcen unbegrenzt, wenn du kein Backpressure-Handling hast.
Der integrierte Ansatz des Hubs ist, Nachrichten pro Abonnement bis zu einem konfigurierbaren Limit zu puffern und die ältesten zu verwerfen, wenn der Puffer voll ist. Das über die max_events_to_persist-Einstellung des Hubs konfigurieren.
Auf der Symfony-Seite, für hochfrequente Topics (Events, die viele Male pro Sekunde ausgelöst werden), Updates bündeln anstatt jede einzelne Zustandsänderung zu veröffentlichen. Ein Live-Dashboard mit aggregierten Metriken muss nicht 100 Mal pro Sekunde aktualisiert werden - stattdessen alle 500ms einen zusammengefassten Snapshot veröffentlichen:
// In einem Symfony Messenger-Handler, der hochfrequente Events verarbeitet:
class MetricsAggregatorHandler implements MessageHandlerInterface
{
private array $pending = [];
public function __invoke(MetricEvent $event): void
{
$this->pending[] = $event;
// Alle 500ms ueber einen separaten Scheduler leeren,
// oder einen einfachen zaehlergesteuerten Flush fuer Vorhersagbarkeit verwenden
if (count($this->pending) >= 50) {
$this->flush();
}
}
private function flush(): void
{
$summary = $this->aggregate($this->pending);
$this->hub->publish(new Update(
topics: 'https://deinedomain.de/metrics/live',
data: json_encode($summary),
));
$this->pending = [];
}
}
Wann WebSockets noch die richtige Wahl sind
Mercure deckt den großen Teil der Echtzeit-Anforderungen in B2B-SaaS ab. Aber es gibt legitime Fälle, in denen du genuinen bidirektionalen Streaming-Bedarf hast, der nicht über HTTP geroutet werden kann:
Hochfrequente bidirektionale Daten. Multiplayer-Spiele, kollaboratives Whiteboarding mit Cursor-Sync bei 60fps, Echtzeit-Audio- oder Videostreams. Der Overhead, jede Client-Aktion über einen HTTP-POST zu routen, ist zu hoch.
Sub-50ms-Latenzanforderungen. Die Latenz von Mercure beträgt typischerweise 50-200ms durch den Hub. Für Handelsplattformen, Live-Auktionen mit engen Timing-Fenstern oder Echtzeit-Sensor-Monitoring kann das unzureichend sein.
Vollduplex-Protokoll-Tunneling. Wenn du ein binäres Protokoll oder eine bestehende Socket-basierte Integration tunnelst, kann SSE die Nutzlast nicht transportieren.
Wenn du dich in einer dieser Kategorien befindest, ist die Architektur ein dedizierter WebSocket-Service neben deiner Symfony-Anwendung - nicht WebSockets, die Symfony ersetzen. Dein PHP-Backend bleibt die Quelle der Wahrheit; die WebSocket-Schicht ist ein transportspezifischer Adapter. Der Fehler, den Teams machen, ist, den WebSocket-Server zu einem zweiten Backend mit eigener Geschäftslogik werden zu lassen.
Das in Produktion bringen
Das praktische Setup für eine Symfony-SaaS-Anwendung mit Mercure bei benutzerdefinierten Softwareprojekten umfasst:
symfony/mercure-bundlehinzufügen und Hub-Anmeldedaten über Umgebungsvariablen konfigurieren.- Den Mercure Hub als Sidecar-Container in deinem lokalen Docker-Compose-Stack und als separaten Service (oder Mercure.rocks) in Produktion betreiben.
- Updates aus asynchronen Symfony Messenger-Handlern veröffentlichen, um HTTP-Antwortzeiten sauber zu halten.
- Nutzerspezifische Subscriber-JWTs aus deinen Controllern ausgeben; sie auf die Topics eingrenzen, die der Nutzer empfangen darf.
- Redis als Hub-Transport verwenden, wenn du mehr als eine Hub-Instanz betreibst.
Die vollständige Integration - von der Ersteinrichtung bis zu einem funktionierenden Live-Dashboard mit privaten Topics und Präsenz - dauert für einen mit Symfony vertrauten Entwickler ein bis zwei Tage. Das ist der Vergleichspunkt für WebSocket-Komplexität: Du sparst keine Zeit mit WebSockets, du gibst zwei- bis viermal mehr davon aus.
Wenn dein Team Echtzeit-Features in einer Symfony-Anwendung aufbaut und auf Probleme stößt, ist die Frage meistens nicht, welches Protokoll zu verwenden ist - sondern ob die Integration von Anfang an korrekt strukturiert ist. Melde dich gerne unter hello@wolf-tech.io oder besuche wolf-tech.io, um deine Architektur zu besprechen, bevor du baust.
