Symfony Mercure: Real-Time Updates in the Browser Without Polling
AI generated
SF
{ }
Symfony · Mercure · SSE · Real-Time · PHP 8.4
Symfony Mercure:
Real-Time Updates Without Polling

Anyone wanting to show live order status, notifications, or chat messages in the browser often thinks straight away of WebSockets, along with everything that entails: a dedicated server, connection management, protocol handling. Symfony Mercure and Server-Sent Events solve 80% of these use cases more simply, with less infrastructure and native browser support without external libraries.

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

1. Why Mercure instead of polling or WebSockets

Polling is the simplest method for near-real-time updates: the browser asks for new data every five seconds. That scales poorly, burdens the server with useless requests, and still produces up to five seconds of delay. Long polling improves latency but keeps the HTTP connection open, which blocks PHP processes and leads to resource problems with many concurrent users. WebSockets solve both problems but bring their own complexity: a separate WebSocket server (e.g. Ratchet, ReactPHP or Node.js) has to be run, scaled and secured. Nginx configuration, load balancer sticky sessions and firewall rules for the WebSocket port come on top.

Symfony Mercure offers a third path: Server-Sent Events (SSE) over a specialized hub server written in Go that holds thousands of concurrent connections with minimal resource consumption. The browser opens a plain HTTP connection to the Mercure Hub and receives updates as a text stream, without a WebSocket protocol, without reconnect logic (SSE handles that natively), without external libraries. Symfony communicates with the hub via an HTTP API: when something changes, the Symfony service sends a POST request to the hub, which forwards it to all connected subscribers. This architecture cleanly separates PHP (business logic) from the hub (connection management).

2. Mercure architecture: hub, publisher and subscriber

The Symfony Mercure architecture consists of three roles. The hub is a Go server that holds all SSE connections from browsers and distributes incoming updates to the correct subscribers. Publishers are Symfony services that send updates to the hub via an HTTP POST request. Subscribers are browsers that connect to the hub and receive updates for specific topics. PHP only appears in the publisher role, it never holds a long connection and never blocks a process for individual connections.

Topics are the addressing unit in Symfony Mercure. A topic is a URL or URI, for example https://mironsoft.de/orders/12345 or /orders/12345/status. Browsers subscribe to specific topics, publishers send updates to topics. Only browsers that have subscribed to a given topic receive updates for it. That allows granular targeting: an update for one customer's order goes only to that customer's browser, not to all connected users. Topic patterns such as wildcards allow subscribing to a group of topics, for example all orders of a customer.

3. Setting up the Mercure Hub as a Docker service

The Mercure Hub is a single Go binary that runs as a Docker container. The official Docker image dunglas/mercure contains everything, no extra setup required. Configuration happens via environment variables: SERVER_NAME for the bind port, MERCURE_PUBLISHER_JWT_KEY for the JWT key that publishers use for authentication, and MERCURE_SUBSCRIBER_JWT_KEY for the key that signs subscriber JWTs. In development, MERCURE_EXTRA_DIRECTIVES can be set to anonymous 1 to disable subscriber JWT authentication, which is never recommended for production.

In a Mark Shust Docker setup or a standard compose.yaml, the Mercure Hub is defined as its own service. The Symfony container must be able to reach the hub service over the internal Docker network, the hub URL inside the Symfony container is the internal Docker service URL (e.g. http://mercure/.well-known/mercure), while the browser uses the publicly reachable hub endpoint. This dual configuration, an internal publisher URL and a public subscriber URL, is an important aspect of Mercure deployment that documentation sometimes glosses over.


<?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: configuring the publisher service

The Symfony Mercure integration happens through the symfony/mercure-bundle package. After installing it via Composer and Flex, you configure the hub in config/packages/mercure.yaml. The HubInterface service is automatically available in the container and can be used via constructor injection. For projects with several Mercure hubs, for example a dedicated hub for internal admin notifications and a public hub for customers, several hub configurations can be set up. Using #[Autowire(service: 'mercure.hub.default')], the desired hub service can be injected selectively.

The Update object encapsulates all parameters of a Mercure message: topics (a string or array of strings), data (the payload as a string), the private flag (only authenticated subscribers), an ID (for event ordering and reconnect), a retry interval and an optional event type. For JSON APIs, json_encode($data) as the data string is standard. For HTML fragment updates, for example when the backend sends a rendered Twig fragment directly to the browser, the HTML string is the data payload. That allows server-side rendering of updates without client-side templating.

5. Publishing updates from Symfony

Updates in Symfony Mercure are typically published from three contexts: from a Symfony controller after a successful HTTP action, from a Symfony Messenger message handler after processing a queue message, or from a Doctrine event listener after a database change. The most common approach is the Messenger handler: a queue job processes an order, and at the end it publishes a Mercure update to the order topic. That decouples processing from the real-time update, the browser gets the update as soon as the job finishes, not immediately at the HTTP request.

The topic URL convention matters for subscription design. IRIs (Internationalized Resource Identifiers), strings that look like URLs but do not need to be real HTTP endpoints, are the recommended form. A topic like https://mironsoft.de/orders/{id} uniquely identifies an order. Browsers subscribe to exactly the topic for the order ID they are currently displaying. That prevents a user from receiving updates for other users' orders, even if they know the topic URL, the JWT on the subscriber side validates access. Private Mercure updates require the subscriber to send along a JWT that contains the topic in its claims.


<?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 authentication for topics

Security in Symfony Mercure is based on JWTs (JSON Web Tokens). Publisher JWTs authenticate Symfony against the hub, they are generated and sent automatically by HubInterface when the JWT key is set in the Symfony configuration. Subscriber JWTs authenticate the browser against the hub for private topics. These JWTs contain, in their claims, which topics the subscriber is allowed to subscribe to. The JWT is generated by the Symfony backend and handed to the browser after authentication, for example as a cookie or in the JSON response of the login endpoint.

The topic claim in the subscriber JWT is a list of topic patterns the subscriber is allowed to subscribe to. For a logged-in user with user ID 42, the JWT could contain the topics https://mironsoft.de/users/42/*, so the browser can receive all real-time events for this user, but not the events of other users. The Mercure Hub validates the JWT on every subscriber request and denies access to topics that are not permitted. The expiry date in the JWT ensures that no further updates are received after a session has expired, without the server having to actively terminate connections.

7. Frontend: the EventSource API without libraries

The browser-side part of Symfony Mercure needs no library. The native EventSource API is available in all modern browsers and connects to the Mercure Hub. The URL contains the subscribed topic as a query parameter: new EventSource('https://mironsoft.de/.well-known/mercure?topic=...'). For private topics, the subscriber JWT is passed as a cookie, the Mercure Hub reads the cookie automatically. EventSource handles dropped connections automatically with exponential reconnect, no manual reconnect logic needed.

For multiple subscribed topics in one EventSource connection, several topic query parameters are passed: ?topic=https://...&topic=https://.... Incoming events are handled via the onmessage handler. event.data contains the JSON string that the Symfony backend published. In modern JavaScript or Alpine.js, parsing and rendering Mercure updates takes only a few lines. This is the core advantage over WebSockets: no protocol handling, no handshake, no reconnect code, the browser takes care of everything, the PHP developer only writes the 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. Concrete use cases: order status and notifications

The most common use case for Symfony Mercure in e-commerce projects is live tracking of order status updates. Without real-time updates, the customer reloads the status page every few seconds or waits for an email. With Mercure, the customer sees the status in the browser instantly whenever the fulfillment process changes it: payment confirmed, warehouse notified, shipment triggered, tracking number assigned. Each step triggers a Messenger job that, at the end, publishes a Mercure update. The browser updates the status display without reloading the page.

A second important use case is the admin dashboard with live metrics. When new orders come in, sales figures in the admin panel should update without manual reloading. With Symfony Mercure, a Doctrine event listener publishes an update to an admin topic on every new order. The admin dashboard subscribes to this topic and updates counters, charts and tables in real time. The topic for admin updates is accessible only to authenticated admin users, the subscriber JWT contains the admin topic claims, regular user JWTs do not. Without Mercure, achieving the same result would require polling on intervals or a complex WebSocket server.

9. Mercure vs. WebSockets vs. polling compared

The choice between Symfony Mercure, WebSockets and polling depends on the requirements for communication direction, complexity and scalability.

Feature Polling WebSockets Mercure (SSE)
Direction Client → Server Bidirectional Server → Client
Infrastructure None extra WebSocket server Mercure Hub (Docker)
Browser support All All modern All modern + native
Latency High (interval) Very low Low (<100ms)
PHP complexity Low High Low (publisher only)
Scaling Poor Involved Hub scales independently

Symfony Mercure is not the best choice for every real-time scenario. Chat applications, multiplayer games and collaborative editors need bidirectional communication, WebSockets or WebRTC are the right choice there. For unidirectional server-to-client updates, status updates, notifications, live metrics, feed updates, Mercure is simpler, needs less PHP infrastructure and uses native browser features instead of protocol hacks.

Mironsoft

Symfony Mercure, real-time features and event-driven architecture

Real-time features for your Symfony project?

We implement real-time updates with Symfony Mercure, from hub setup through publisher services and JWT authentication to frontend integration for your stack.

Mercure setup

Hub configuration, Docker integration and Symfony publisher service

Real-time features

Order status, live notifications, admin dashboards and feed updates

Security

JWT-based topic authentication and private updates for individual users

10. Summary

Symfony Mercure makes real-time updates accessible in PHP projects, without the complexity of a WebSocket server or the load of polling. The Mercure Hub holds thousands of browser connections in Go, while PHP acts purely as a publisher, no blocking process, no open socket in PHP. Topics as IRIs structure the addressing model clearly: every resource has a URI, browsers subscribe to the URIs they are interested in. JWT-based authentication ensures that private updates only reach the right subscribers.

The frontend code is minimal: the native EventSource API, a few lines of JavaScript or Alpine.js, no WebSocket client, no external library. The native reconnect logic of SSE makes the connection resilient to network interruptions. For the backend developer, real-time communication comes down to publishing an Update object via HubInterface, the same familiar pattern as dispatching a Messenger message. Symfony Mercure is the pragmatic choice for server-to-client real-time updates in Symfony projects of any size.

Symfony Mercure: The Essentials at a Glance

Hub architecture

A Go-based hub holds browser connections, PHP only publishes updates. No long-running PHP processes, no open sockets in the app.

Topics as IRIs

Topics are URIs, e.g. https://mironsoft.de/orders/123. Browsers subscribe to specific topics, publishers send updates to them directly.

JWT security

Subscriber JWTs with topic claims control access. Private updates reach only subscribers with a matching JWT, validated automatically by the hub.

Frontend

Native EventSource API, no WebSocket client, no library. Automatic reconnect on connection loss, cookie-based JWT transfer.

11. FAQ: Symfony Mercure and Real-Time Updates

1What is Symfony Mercure?
Real-time communication via Server-Sent Events. A Go hub manages browser connections, Symfony publishes updates over HTTP. No blocked PHP process for connections.
2Mercure vs. WebSockets?
Mercure = unidirectional (server to client), no WebSocket server needed. WebSockets = bidirectional, for chat and collaborative apps. Mercure is simpler for status updates and notifications.
3What is a topic?
An IRI like https://mironsoft.de/orders/123. Browsers subscribe to topics, publishers send updates to them. Only subscribed browsers receive the update.
4Authenticating subscribers?
A JWT with topic claims as a mercureAuthorization cookie. The hub validates it automatically, the subscriber only sees topics permitted in the JWT claim.
5Publishing updates from Symfony?
Inject HubInterface, call $hub->publish(new Update(topic, data)). Automatically signed with the publisher JWT and sent to the hub.
6Is a JavaScript library needed?
No. The native EventSource API is enough: new EventSource(url, {withCredentials: true}). Automatic reconnect is built in, no external library needed.
7Setting up the hub in Docker?
Use the dunglas/mercure Docker image, configuration via environment variables: JWT keys and CORS origins. Define it as a service in compose.yaml.
8Private vs. public updates?
private: true means only subscribers with a matching JWT claim receive the update. Public means all subscribers of the topic. Always use private: true for user-specific data.
9Scaling with many connections?
The Go hub holds thousands of SSE connections with minimal resource consumption. PHP only publishes. Horizontal scaling with Redis as a pub/sub backend is possible.
10Usable without the Symfony framework?
Yes. symfony/mercure is a standalone PHP package. The hub is framework-agnostic, any backend that can send HTTP POST requests can publish updates.