Echtzeit-Features mit Symfony Mercure: Eine Produktionsalternative zu WebSockets

#Symfony Mercure
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Die meisten Teams fuegen 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 Bruecke zwischen ihrer Symfony-Anwendung und ihrer neuen Echtzeit-Schicht zu bauen. Dann verbringen sie die naechsten sechs Monate damit, sie zu warten.

Symfony Mercure existiert, weil das fuer 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 Anwendungsfaelle behandelt, die Teams in Richtung WebSocket-Komplexitaet treiben: Live-Dashboards, Benachrichtigungen, Praesenz-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 Angriffslaeche.

Dieser Beitrag fuehrt durch eine realistische Produktionsarchitektur: Wie Mercure funktioniert, wofuer 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 koennen beide jederzeit Nachrichten ueber eine persistente Verbindung senden. Diese Flexibilitaet ist genuinen Nutzen fuer 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 benoetigt Sticky Sessions oder eine geteilte Pub/Sub-Schicht, und jede Standard-HTTP-Middleware (Authentifizierung, Rate Limiting, Caching) muss entweder umgangen oder fuer 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 zurueck ueber normale HTTP-Anfragen. Das ist keine Einschraenkung - 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 uebernimmt Reconnection, Lueckenfuellung 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 veroeffentlicht Updates an den Hub (ein HTTP-POST). Der Hub sendet diese Updates an alle abonnierten Clients ueber ihre SSE-Verbindungen. Deine PHP-Anwendung ist zustandslos - sie haelt keine offenen Verbindungen - also skaliert sie horizontal ohne jegliche Koordination. Der Hub uebernimmt 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 Binaerdatei ausgeliefert. Es gibt eine verwaltete gehostete Version, ein Dockerfile und eine Symfony-Binaerdatei, die den Hub fuer 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 benotigst.

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 vollstaendige serverseitige Setup. Das Bundle uebernimmt JWT-Generierung fuer deine Publish-Anfragen und stellt den HubInterface-Service zum Veroeffent­lichen von Updates bereit.

Updates aus deiner Anwendung veroeffentlichen

Das Symfony Mercure-Bundle stellt ein HubInterface bereit, das du ueberall injectest, wo du veroeffentlichen musst. Topics sind URI-Templates - sie muessen nichts aufloesen, 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 Veroeffentlichen an mehrere Topics ermoeglicht einer Sendung, Abonnenten verschiedener Topic-Muster zu erreichen - nuetzlich, 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),
));

Fuer Hochdurchsatz-Anwendungen veroeffentliche aus einem asynchronen Symfony Messenger-Handler heraus, anstatt direkt im Request-Zyklus. Das haelt deine HTTP-Antwortzeiten schnell und gibt dir Retry-Logik, wenn der Hub voruebergehend nicht verfuegbar ist.

Clientseitig abonnieren

Die integrierte EventSource-API des Browsers uebernimmt die SSE-Verbindung. Fuer oeffentliche 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 Lueckenfuellung ist im Protokoll eingebaut - du bekommst sie kostenlos.

Fuer 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

Oeffentliche Topics funktionieren ohne Authentifizierung. Fuer private Daten - nutzerspezifische Benachrichtigungen, Admin-Dashboards, interner Bestellstatus - benotigst 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 uebergibst du den Token als Cookie (der empfohlene Ansatz - der Hub validiert ihn automatisch) oder als Authorization-Header. Der Cookie-Ansatz ist fuer 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 Sicherheitsluecke - jeder, der es erhaelt, kann jedes Topic abonnieren.

Praesenz: Wer ist gerade online?

Praesenz ist eines der am haeufigsten gewuenschten Echtzeit-Features und eines der am meisten missbrauchten. Der naive Ansatz - einen Heartbeat von jedem verbundenen Client alle paar Sekunden zu veroeffentlichen - 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 Praesenz als Stream zu behandeln.

Fuer leichtgewichtige Praesenz in einem SaaS-Kontext - "zeige, wer dieses Dokument gerade betrachtet" - Praesenz-Updates vom Client ueber dein Symfony-Backend veroeffentlichen:

#[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-Schliessen aufrufen, um ein verlassen-Event zu senden, auch wenn der Browser entlaedt:

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 fuer Abonnements, was eine einzelne Instanz bedeutet. Fuer Produktion mit jeder nennenswerten Last gibt es zwei Optionen.

Option 1: Einzelner Hub mit Redis. Der offizielle Hub unterstuetzt einen Redis-Transport fuer Abonnement-Speicherung und Pub/Sub. Das ermoeglicht, mehrere Hub-Instanzen hinter einem Load-Balancer zu betreiben, die alle den Zustand ueber 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 uebernimmt. Fuer die meisten SaaS-Teams unter 50.000 gleichzeitigen Nutzern ist das die richtige Wahl - es eliminiert den Hub als operatives Anliegen vollstaendig, und die Kosten sind im Verhaeltnis zur Engineering-Zeit vernachlaessigbar. Wolf-Tech empfiehlt das typischerweise Kunden, die neue SaaS-Features launchen: zuerst die Integration richtig hinbekommen, Infrastruktur spaeter optimieren.

Fuer 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 Ueberengineering.

Backpressure und langsame Clients

SSE-Verbindungen bleiben offen. Ein Client mit einer langsamen Mobilverbindung oder einem ueberlasteten 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 aeltesten zu verwerfen, wenn der Puffer voll ist. Das ueber die max_events_to_persist-Einstellung des Hubs konfigurieren.

Auf der Symfony-Seite, fuer hochfrequente Topics (Events, die viele Male pro Sekunde ausgeloest werden), Updates buendeln anstatt jede einzelne Zustandsaenderung zu veroeffentlichen. Ein Live-Dashboard mit aggregierten Metriken muss nicht 100 Mal pro Sekunde aktualisiert werden - stattdessen alle 500ms einen zusammengefassten Snapshot veroeffentlichen:

// 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 grossen Teil der Echtzeit-Anforderungen in B2B-SaaS ab. Aber es gibt legitime Faelle, in denen du genuinen bidirektionalen Streaming-Bedarf hast, der nicht ueber 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 ueber einen HTTP-POST zu routen, ist zu hoch.

Sub-50ms-Latenzanforderungen. Die Latenz von Mercure betraegt typischerweise 50-200ms durch den Hub. Fuer Handelsplattformen, Live-Auktionen mit engen Timing-Fenstern oder Echtzeit-Sensor-Monitoring kann das unzureichend sein.

Vollduplex-Protokoll-Tunneling. Wenn du ein binaeres 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 Geschaeftslogik werden zu lassen.

Das in Produktion bringen

Das praktische Setup fuer eine Symfony-SaaS-Anwendung mit Mercure bei benutzerdefinierten Softwareprojekten umfasst:

  1. symfony/mercure-bundle hinzufuegen und Hub-Anmeldedaten ueber Umgebungsvariablen konfigurieren.
  2. Den Mercure Hub als Sidecar-Container in deinem lokalen Docker-Compose-Stack und als separaten Service (oder Mercure.rocks) in Produktion betreiben.
  3. Updates aus asynchronen Symfony Messenger-Handlern veroeffentlichen, um HTTP-Antwortzeiten sauber zu halten.
  4. Nutzerspezifische Subscriber-JWTs aus deinen Controllern ausgeben; sie auf die Topics eingrenzen, die der Nutzer empfangen darf.
  5. Redis als Hub-Transport verwenden, wenn du mehr als eine Hub-Instanz betreibst.

Die vollstaendige Integration - von der Ersteinrichtung bis zu einem funktionierenden Live-Dashboard mit privaten Topics und Praesenz - dauert fuer einen mit Symfony vertrauten Entwickler ein bis zwei Tage. Das ist der Vergleichspunkt fuer WebSocket-Komplexitaet: 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 stoesst, 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.