Symfony Mercure: Echtzeit-Updates im Browser ohne Polling
AI generated
SF
{ }
Symfony · Mercure · SSE · Echtzeit · PHP 8.4
Symfony Mercure:
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.

17 Min. Lesezeit Mercure Hub · SSE · Publisher · JWT · Topics · Frontend Symfony 7.x · PHP 8.4 · Mercure Hub 0.16+

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.

11. FAQ: Symfony Mercure und Echtzeit-Updates

1Was ist Symfony Mercure?
Echtzeit-Kommunikation via Server-Sent Events. Go-Hub verwaltet Browser-Verbindungen, Symfony published Updates per HTTP. Kein BlockedPHP-Prozess für Verbindungen.
2Mercure vs. WebSockets?
Mercure = unidirektional (Server → Client), kein WebSocket-Server nötig. WebSockets = bidirektional, für Chat und kollaborative Apps. Mercure einfacher für Status-Updates und Benachrichtigungen.
3Was ist ein Topic?
IRI wie https://mironsoft.de/orders/123. Browser abonnieren Topics, Publisher senden Updates daran. Nur abonnierte Browser erhalten das Update.
4Subscriber authentifizieren?
JWT mit Topic-Claims als Cookie mercureAuthorization. Hub validiert automatisch — Subscriber sieht nur Topics, die im JWT-Claim erlaubt sind.
5Updates aus Symfony publizieren?
HubInterface injizieren, $hub->publish(new Update(topic, data)) aufrufen. Automatisch mit Publisher-JWT signiert und an den Hub gesendet.
6JavaScript-Bibliothek nötig?
Nein. Native EventSource-API reicht: new EventSource(url, {withCredentials: true}). Automatischer Reconnect eingebaut — keine externe Bibliothek.
7Hub in Docker einrichten?
dunglas/mercure Docker-Image, Konfiguration via Umgebungsvariablen: JWT-Keys und CORS-Origins. In compose.yaml als Service definieren.
8Private vs. öffentliche Updates?
private: true → nur Subscriber mit passendem JWT-Claim erhalten das Update. Öffentlich → alle Subscriber des Topics. Für benutzerspezifische Daten immer private: true.
9Skalierung bei vielen Verbindungen?
Go-Hub hält Tausende SSE-Verbindungen mit minimalem Ressourcenverbrauch. PHP published nur. Horizontale Skalierung mit Redis als Pub/Sub-Backend möglich.
10Ohne Symfony Framework nutzbar?
Ja. symfony/mercure ist ein eigenständiges PHP-Paket. Der Hub ist framework-agnostisch — jedes Backend das HTTP-POST senden kann, kann Updates publishen.