Mercure — Temps réel pour Symfony (SSE + JWT)
TL;DR — Mercure est un protocole bâti sur Server-Sent Events (SSE) et HTTP/2 qui transforme un hub central (binaire Go, embarqué dans Caddy) en distributeur de messages temps réel. Symfony publie via
HubInterface::publish(); le navigateur s'abonne viaEventSourceou directement via Turbo Streams. Les topics privés sont protégés par un JWT signé (clé partagée avec le hub) listant les topics autorisés dans le claimmercure.subscribe. Pour la majorité des cas d'usage (notifications, dashboards live, présence, édition collaborative légère), Mercure remplace avantageusement une stack WebSocket maison : moins de code serveur, scaling horizontal natif, reconnexion automatique du navigateur, fallback HTTP/1.1.
🧠 Mental model — ASCII + analogie
Mercure est une gare de triage. Symfony pose un colis (publication) sur un quai ; le hub Mercure (le chef de gare) regarde l'étiquette (le topic), consulte la liste des voyageurs abonnés à ce quai, et distribue le colis à chacun. Le voyageur (le navigateur) ne demande rien d'actif : il a une oreille collée à un haut-parleur SSE et entend dès qu'on annonce son nom.
┌──────────────┐ POST /.well-known/mercure
│ Symfony │ ───────────────────────────────┐
│ (HubClient) │ topic=https://app/users/42 │
└──────────────┘ data={"event":"new_message"} ▼
┌──────────────────┐
│ Mercure Hub │
│ (Caddy + Go) │
│ │
│ topics index │
│ ┌────────────┐ │
│ │ /users/42 │──┼──► Bob (EventSource)
│ │ /chat/lobby│──┼──► Alice (EventSource)
│ │ /chat/lobby│──┼──► Carol (Turbo Stream)
│ └────────────┘ │
└──────────────────┘
▲
JWT (subscriber)
{ mercure: { subscribe: [...] } }L'analogie clé : Mercure n'est pas un WebSocket. C'est unidirectionnel (serveur → client). Le client publie via HTTP classique à Symfony, jamais directement au hub côté front (sauf admin). Cette asymétrie est volontaire : elle simplifie l'autorisation (on raisonne par requête HTTP, pas par socket persistant).
🛠️ Code minimal (PHP 8.2+)
Installation et configuration
composer require symfony/mercure-bundle
# Dans .env
# MERCURE_URL=http://mercure/.well-known/mercure
# MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
# MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"# config/packages/mercure.yaml
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: ['*']
subscribe: []
algorithm: 'hmac.sha256'
provider: nullPublication depuis un service Symfony
<?php
declare(strict_types=1);
namespace App\Notification;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
final readonly class ChatBroadcaster
{
public function __construct(private HubInterface $hub) {}
public function broadcastMessage(string $roomId, string $author, string $body): void
{
$topic = sprintf('https://chat.example.com/rooms/%s', $roomId);
$update = new Update(
topics: $topic,
data: json_encode([
'type' => 'message.created',
'author' => $author,
'body' => $body,
'at' => (new \DateTimeImmutable())->format(DATE_ATOM),
], JSON_THROW_ON_ERROR),
private: false,
id: null,
type: 'message',
retry: 3000,
);
$this->hub->publish($update);
}
public function notifyUserPrivately(int $userId, array $payload): void
{
$topic = sprintf('https://app.example.com/users/%d/notifications', $userId);
$this->hub->publish(new Update(
topics: $topic,
data: json_encode($payload, JSON_THROW_ON_ERROR),
private: true, // exige un JWT subscriber avec ce topic dans la claim
));
}
}Souscription côté client (vanilla JS)
<?php
// src/Controller/ChatController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Mercure\Discovery;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class ChatController extends AbstractController
{
#[Route('/chat/{roomId}', name: 'chat_room')]
public function room(
string $roomId,
Request $request,
Discovery $discovery,
Authorization $authorization,
): Response {
// Expose le hub URL via Link header (auto-découverte côté client)
$discovery->addLink($request);
// Cookie JWT subscriber pour topics privés
$authorization->setCookie($request, [
sprintf('https://app.example.com/users/%d/notifications', $this->getUser()->getId()),
]);
return $this->render('chat/room.html.twig', [
'roomId' => $roomId,
'topic' => sprintf('https://chat.example.com/rooms/%s', $roomId),
]);
}
}<script>
// Le helper Twig `mercure(topic)` construit l'URL publique du hub avec ?topic=...
// déjà encodé. Ce qui suit doit donc vivre dans un template Twig.
const hubUrl = new URL("{{ mercure(topic) }}");
// withCredentials envoie le cookie `mercureAuthorization` (posé par Authorization::setCookie)
// pour les topics privés. Indispensable dès qu'au moins un topic est privé.
const es = new EventSource(hubUrl, { withCredentials: true });
es.addEventListener('message', (event) => {
// event.lastEventId == l'id de l'Update : sert au rattrapage (Last-Event-ID).
const payload = JSON.parse(event.data);
appendToDom(payload);
});
// EventSource reconnecte tout seul (délai = champ `retry` de l'Update, défaut ~3s)
// et renvoie automatiquement `Last-Event-ID` pour récupérer les messages manqués
// SI le hub a un transport avec historique (bolt/redis + size_limit).
es.onerror = () => console.warn('SSE déconnecté, reconnexion automatique...');
// Anti-pattern courant (voir Pitfall #5) : un JWT subscriber qui expire pendant
// que la connexion est ouverte → boucle de reconnexion 401. Renouvelez-le AVANT
// expiration et reconstruisez l'EventSource :
// setInterval(() => fetch('/mercure/refresh-jwt', { method: 'POST' }), 50 * 60_000);
</script>Intégration Turbo Streams
// Contrôleur : on émet un fragment HTML, pas du JSON
use Symfony\UX\Turbo\Mercure\Broadcaster;
use App\Entity\Message;
#[Route('/messages', methods: ['POST'])]
public function post(
EntityManagerInterface $em,
HubInterface $hub,
Request $request,
Environment $twig,
): Response {
$message = new Message($this->getUser(), $request->get('body'));
$em->persist($message);
$em->flush();
$html = $twig->render('chat/_message.stream.html.twig', ['message' => $message]);
$hub->publish(new Update(
topics: 'https://chat.example.com/rooms/'.$message->getRoomId(),
data: $html,
type: 'message', // côté Turbo, le content-type est text/vnd.turbo-stream.html
));
return new Response(null, Response::HTTP_NO_CONTENT);
}{# templates/chat/_message.stream.html.twig #}
<turbo-stream action="append" target="messages">
<template>
<div class="message" id="msg-{{ message.id }}">
<strong>{{ message.author }}</strong> : {{ message.body }}
</div>
</template>
</turbo-stream>{# Page #}
<div id="messages"
data-controller="turbo-stream-source"
data-turbo-stream-source-url-value="{{ mercure('https://chat.example.com/rooms/'~roomId) }}">
</div>Génération manuelle d'un JWT subscriber
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
$config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($secret));
$jwt = $config->builder()
->withClaim('mercure', [
'subscribe' => [
'https://app.example.com/users/42/notifications',
'https://chat.example.com/rooms/general',
],
])
->expiresAt((new \DateTimeImmutable())->modify('+1 hour'))
->getToken($config->signer(), $config->signingKey())
->toString();🔐 Modèle de confiance & sécurité — comment raisonne un staff
Mercure a deux JWT, à ne jamais confondre :
| JWT | Qui le porte | Claim | Rôle |
|---|---|---|---|
| publisher | Symfony (serveur) | mercure.publish | Autorise à poster sur certains topics |
| subscriber | Navigateur (cookie) | mercure.subscribe | Autorise à recevoir certains topics privés |
Le hub ne connaît rien de vos utilisateurs : il ne sait ni qui est Bob, ni quels droits métier il a. Toute la logique d'autorisation est encodée dans le claim au moment où Symfony signe le JWT. C'est un modèle capability-based : « tu peux écouter ce que ton jeton dit que tu peux écouter, point ». Conséquences que tout senior doit intégrer :
Le scoping est figé à l'émission. Si vous révoquez un droit côté métier (ex. Bob quitte un projet), son cookie JWT reste valide jusqu'à expiration. Corollaire : gardez des TTL courts (15–60 min) sur les subscriber tokens si la sensibilité l'exige, et renouvelez-les. Il n'existe pas de « logout » côté hub.
Les topics privés sont matchés par motif. Le claim accepte des templates URI (RFC 6570) :
subscribe: ["https://app/users/42/{any}"]. Ne mettez jamaissubscribe: ["*"]dans un cookie navigateur — un utilisateur lirait alors les notifications de tout le monde. Le wildcard est réservé au publisher serveur, et même là on le scope en multi-tenant.private: true≠ chiffrement. Un topic privé exige juste un JWT subscriber qui le liste ; les données transitent en clair dans le hub. Pour de la confidentialité forte (PII, santé, finance), chiffrez ledatacôté Symfony et déchiffrez côté client, ou ne poussez qu'un signal (« va refetch ») — voir le pattern e-commerce plus bas.Le publisher token est l'actif le plus sensible. S'il fuit avec
publish: ["*"], un attaquant peut pousser n'importe quoi à n'importe qui (phishing temps réel, faux ordres). Stockez la clé en secret manager, scopez le claim, et envisagez un publisher token distinct par bounded context.Algorithme & clé. Par défaut HMAC SHA-256 (clé symétrique partagée Symfony↔hub). En multi-équipe, passez à RS256/ES256 (asymétrique) : le hub n'a que la clé publique pour vérifier, et seuls les services autorisés détiennent la clé privée de signature. Réglez
algorithm: 'lcobucci.es256'côté bundle + clés correspondantes côté Caddy.
// JWT subscriber scopé dynamiquement aux droits réels de l'utilisateur — la
// bonne pratique : ne JAMAIS hardcoder la liste, la dériver du domaine.
$topics = [];
$topics[] = sprintf('https://app/users/%d/notifications', $user->getId());
foreach ($user->getActiveProjects() as $project) {
$topics[] = sprintf('https://app/projects/%d/{event}', $project->getId());
}
// → Authorization::setCookie($request, $topics) ; ou un builder Lcobucci manuel.🔁 Rattrapage de messages (Last-Event-ID) — la sémantique de livraison
Beaucoup d'ingénieurs supposent que Mercure est at-least-once. Faux par défaut. La garantie dépend du transport :
local://(in-memory) : aucun historique → un message publié pendant une déconnexion est perdu (fire-and-forget).bolt:///redis://avecsize_limit/subscriptions: le hub conserve un buffer borné. À la reconnexion, l'EventSourcerenvoie le headerLast-Event-ID(le dernieridreçu) et le hub rejoue les updates manqués jusqu'à la limite du buffer.
Donc, pour que le rattrapage fonctionne, trois conditions :
- Chaque
Updatedoit avoir unidmonotone et persistant (ne laissez pasid: nullsi vous voulez du replay — fournissez un ULID ou un offset croissant). - Le transport doit avoir de l'historique configuré (
boltavecsize, ouredisavec stream). - Le client doit réémettre
Last-Event-ID(l'EventSourcenatif le fait automatiquement ; unfetch-based SSE custom doit le faire à la main).
Mental model staff : traitez Mercure comme un bus best-effort avec rattrapage borné, pas comme une file durable. Pour la livraison garantie d'événements métier (un paiement, une commande), la source de vérité reste votre base + Messenger ; Mercure ne transporte que la notification « quelque chose a changé, viens voir ». Ce découplage (push = signal, état = HTTP authentifié) est le pattern le plus robuste et celui qu'on attend d'un architecte.
🎯 Patterns courants
1. Notifications temps réel (toast utilisateur)
Topic personnel https://app/users/{id}/notifications en privé. Cookie JWT posé par le contrôleur de la page (Authorization::setCookie). Toute notification (mention, message direct, événement métier) est poussée. Le navigateur affiche une toast et joue un son si la page n'a pas le focus (document.hasFocus()).
public function mentionUser(User $author, User $mentioned, string $context): void
{
$this->hub->publish(new Update(
topics: sprintf('https://app/users/%d/notifications', $mentioned->getId()),
data: json_encode([
'kind' => 'mention',
'from' => $author->getUsername(),
'context' => $context,
'href' => $this->urlGenerator->generate('thread_show', ['id' => $context]),
]),
private: true,
));
}2. Présence (qui est en ligne)
Mercure n'a pas de notion native de "qui est connecté" (pas de socket → pas d'événement disconnect fiable). Pattern : heartbeat applicatif. Le client POST /presence/ping toutes les 15 s, le serveur stocke en Redis avec TTL 45 s, puis publie une mise à jour https://app/presence/rooms/{roomId} quand la liste change (ou périodiquement via Scheduler).
#[Route('/presence/ping', methods: ['POST'])]
public function ping(Redis $redis, HubInterface $hub): Response
{
$uid = $this->getUser()->getId();
$roomId = $this->getUser()->getCurrentRoom();
$key = "presence:room:{$roomId}";
$redis->zAdd($key, time(), (string) $uid);
$redis->zRemRangeByScore($key, 0, time() - 45);
$users = $redis->zRange($key, 0, -1);
$hub->publish(new Update(
topics: "https://app/presence/rooms/{$roomId}",
data: json_encode(['online' => $users]),
));
return new Response(null, 204);
}3. Dashboard live (métriques)
Topic public ou semi-public https://app/metrics/dashboard. Une commande console ou un consumer Messenger publie périodiquement les métriques (CPU, files en attente, ventes du jour). Le front affiche via Chart.js qui réagit à chaque event.
4. Édition collaborative légère
Pour de la coédition de niveau "présence + curseur + verrou de champ" (pas du CRDT type Yjs), Mercure suffit. Topic privé par document https://app/docs/{id}. Quand un utilisateur édite un champ, on publie {field: "title", lockedBy: 42}. Pour la fusion de texte en mode operational transform ou CRDT, préférer un vrai backend WebSocket (yjs/sharedb) — Mercure n'est pas pensé pour des updates haute fréquence par caractère.
5. Job progress (long task feedback)
Pendant un import CSV traité par Messenger, le consumer publie sur https://app/jobs/{jobId}/progress un pourcentage. La page de l'utilisateur affiche une barre de progression. Topic privé scopé sur le jobId, JWT généré au moment où le job est créé.
6. Cache invalidation push
Un service publie sur https://app/cache-bust/{key} ; un service worker JS écoute et invalide une donnée en cache local. Pattern intéressant pour des SPA qui veulent rester synchronisées sans polling.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
| Version Symfony | Composant mercure-bundle | Notes |
|---|---|---|
| 5.4 LTS | ^0.3 | Hub legacy (PHP-based). Migration vers Caddy obligatoire à terme. |
| 6.4 LTS | ^0.3 | Stable, support full du hub Caddy 0.14+. Discovery et Authorization helpers OK. |
| 7.x | ^0.3 | Compatible PHP 8.2+. Intègre HttpClient PSR-18 (au lieu de Guzzle). Le helper Twig mercure() accepte les topics multiples en array. |
Côté hub (binaire séparé), les versions à connaître :
- Mercure Hub 0.10 : protocole stable.
- Mercure Hub 0.14+ : intégré comme module Caddy (
xcaddy build --with github.com/dunglas/mercure/caddy). - Mercure Hub 0.15+ : ajout de
subscriptions API(introspection de qui écoute quoi), utile pour la présence sans Redis maison.
Breaking changes courants entre versions du protocole :
mercure.publish: ['*']⇒ devenu obligatoire pour publier sur n'importe quel topic.- Les anciens
targets(avant 0.10) n'existent plus, remplacés parsubscribe/publishclaims.
Scaling et déploiement
Le hub Mercure est conçu pour scaler horizontalement, mais avec quelques précautions architecturales.
Single-node (jusqu'à ~50 000 connexions concurrentes)
Un seul binaire Caddy + Mercure module suffit. Le bottleneck principal est la mémoire (~6 KB par connexion SSE active). Sur une VM 4 GB / 2 vCPU, on tient confortablement 30 000 connexions persistantes avec un débit de publication modéré (quelques centaines de messages/seconde).
Configuration Caddy minimale :
# Caddyfile
{
debug
}
example.com {
route {
mercure {
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
cors_origins https://example.com
anonymous
subscriptions
}
respond /healthz 200
reverse_proxy app:8000
}
}Multi-node (cluster, > 50 000 connexions)
Plusieurs instances Caddy/Mercure derrière un load balancer sticky (basé sur cookie ou IP). Le défi : un message publié sur le node A doit atteindre les abonnés connectés au node B. Solution : transport partagé.
mercure {
transport_url "redis://redis:6379/mercure" # ou bolt:// pour single-node
# ...
}Transports supportés :
bolt://(par défaut) : storage local, non clusterable. Pour single-node seulement.redis://: pub/sub Redis, idéal en cluster. Attention : Redis pub/sub n'a pas de persistance, donc un abonné déconnecté ne rattrape pas les messages manqués (sauf si vous combinez avec un store).local://: in-memory pur, pour tests.
Configuration cluster type :
[Browser]
│ SSE persistent
▼
[ALB sticky]
│ │ │
▼ ▼ ▼
hub1 hub2 hub3 <-- 3 instances Mercure
│ │ │
└─────┴─────┘
│
▼
[Redis] <-- transport pub/subCaddy en tant que reverse proxy
Caddy peut faire le double rôle : reverse proxy de votre app PHP-FPM et hub Mercure dans le même process. Avantage opérationnel énorme : 1 seul binaire, 1 seul cert TLS, 1 seul config. C'est la stack recommandée par défaut.
Surveillance / observabilité
Le hub expose des métriques Prometheus sur /metrics (depuis Mercure 0.15+) : nombre de subscribers, débit de publication, latence. À brancher sur Grafana avec un dashboard "Mercure SLO".
Logs structurés Caddy via format json. Filtrer sur logger=http.handlers.mercure pour isoler le hub.
Coûts opérationnels comparés
| Stack | Coût mensuel typique (10 000 users actifs) |
|---|---|
| Mercure self-hosted (1 VM 4GB) | ~20-30 € |
| Pusher (paying plan) | ~50-100 € (selon messages) |
| Ably (paying plan) | ~50-150 € |
| WebSocket maison (1 VM + dev/ops) | ~30 € + temps de dev |
Mercure self-hosted est typiquement le moins cher dès qu'on dépasse les plans gratuits des SaaS.
⚠️ Pitfalls — 6-10
Confondre
MERCURE_URLetMERCURE_PUBLIC_URL.MERCURE_URLest l'URL interne (typiquementhttp://mercure/.well-known/mercuredans Docker compose) utilisée par Symfony pour publier.MERCURE_PUBLIC_URLest l'URL exposée au navigateur (HTTPS, domaine public). Si vous mettez la même en dev mais pas en prod, vous aurez un 502 silencieux côté front.JWT publisher avec
publish: ["*"]en production ⇒ catastrophique si la clé fuit. Toujours scoper :publish: ["https://app/users/{id}/*"]. En multi-tenant, générez un JWT publisher dynamique par contexte.Topics non-URL absolus :
topic: "user-42""fonctionne" mais c'est une URI selon le RFC. Convention forte : utilisez toujours des URI absolus (https://app/users/42). Cela évite des collisions et facilite le filtrage par préfixe.EventSource ne gère pas les cookies cross-origin sans
withCredentials: true, et le hub doit renvoyerAccess-Control-Allow-Credentials: true+ un Origin non-*. Très fréquent en SPA séparée (front surapp.com, hub surmercure.app.com).Reconnexion infinie qui sature le hub. Si votre JWT subscriber expire à 1h, l'
EventSourceréessaye toutes les 3s à partir de l'expiration ⇒ 1200 reconnexions/heure × N utilisateurs. Renouvelez le JWT proactivement (XHR de refresh à T-5min) ou augmentez le TTL.Buffering du proxy reverse : Nginx en frontal coupe les SSE si on oublie
proxy_buffering off;etproxy_read_timeout 3600;. Idem pour AWS ALB (timeout default 60s ⇒ déconnexions à la minute).Publier dans une transaction Doctrine non commitée. Si vous appelez
$hub->publish()avant$em->flush()et que le flush échoue, le client reçoit un événement qui ne correspond à rien en base. Toujours publier après flush, ou via Messenger pour bénéficier de l'AmqpMiddlewaretransactionnel.Charge mémoire du hub : 1 connexion SSE = 1 goroutine en Go, ~4–8 KB. 50 000 connexions tiennent sur 2 GB. Au-delà, scaler horizontalement avec Mercure cluster + Redis transport.
Turbo Streams + Mercure : ne pas oublier
data-turbo-stream-source-url-value; sinon le<turbo-stream-source>ne se connecte jamais et vous débuggez 2 heures sans erreur visible.CSP
connect-src: si vous avez une Content-Security-Policy stricte, ajoutez l'URL du hub (incluant le wildcard de port en dev). Sans ça, le navigateur bloque silencieusement la connexion SSE.
🧪 Testing
<?php
// tests/Notification/ChatBroadcasterTest.php
namespace App\Tests\Notification;
use App\Notification\ChatBroadcaster;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
final class ChatBroadcasterTest extends TestCase
{
public function testBroadcastsToCorrectTopic(): void
{
$hub = $this->createMock(HubInterface::class);
$hub->expects($this->once())
->method('publish')
->with($this->callback(function (Update $u): bool {
$this->assertSame(['https://chat.example.com/rooms/lobby'], $u->getTopics());
$payload = json_decode($u->getData(), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame('message.created', $payload['type']);
return true;
}));
$broadcaster = new ChatBroadcaster($hub);
$broadcaster->broadcastMessage('lobby', 'alice', 'hello');
}
}Pour les tests d'intégration, deux approches :
- MockHub (
Symfony\Component\Mercure\MockHub) qui intercepte chaqueUpdatevia une closurepublisherque vous contrôlez — le pattern idiomatique est de capturer les updates dans un tableau partagé par référence :
use Symfony\Component\Mercure\MockHub;
use Symfony\Component\Mercure\Jwt\StaticTokenProvider;
use Symfony\Component\Mercure\Update;
// Signature : MockHub(string $url, TokenProviderInterface $jwtProvider, callable $publisher)
// Le 3e argument reçoit chaque Update et doit retourner l'id (string).
$captured = [];
$mockHub = new MockHub(
'https://example.com/.well-known/mercure',
new StaticTokenProvider('fake-jwt'),
function (Update $update) use (&$captured): string {
$captured[] = $update;
return 'urn:uuid:fake-id';
},
);
$service = new ChatBroadcaster($mockHub);
$service->broadcastMessage('lobby', 'alice', 'hi');
$this->assertCount(1, $captured);
$this->assertSame(['https://chat.example.com/rooms/lobby'], $captured[0]->getTopics());En pratique, dans un test de contrôleur fonctionnel, déclarez le
MockHubcomme service de test (config/services_test.yaml) et décorez le hub par défaut. LeWebTestCasepeut alors exécuter une vraie requête HTTP et vous inspectez les updates capturés — sans aucun hub réel ni socket ouverte.
- Tests end-to-end avec hub réel en docker-compose +
Symfony\Component\HttpClient\HttpClientpour ouvrir une connexion SSE enstream: trueet lire la première ligne.
$response = $client->request('GET', $hubUrl.'?topic=...', ['headers' => ['Accept' => 'text/event-stream']]);
foreach ($client->stream($response, timeout: 5) as $chunk) {
if ($chunk->getContent()) {
$this->assertStringContainsString('message.created', $chunk->getContent());
break;
}
}🎬 Cas d'usage concrets
Dashboard live trading FinTech
Une FinTech française propose à ses traders professionnels un dashboard de suivi de portefeuilles multi-actifs (actions, ETF, crypto, devises). Les positions doivent être rafraîchies en temps réel quand les cours évoluent en bourse. Avant Mercure, l'équipe utilisait du polling AJAX toutes les 5 secondes ; résultat : latence perçue de plusieurs secondes, charge serveur disproportionnée (chaque trader = 720 req/heure inutiles si rien ne bouge), et un code front imbuvable. La nouvelle architecture publie sur le hub Mercure dès qu'un cours franchit un palier ou qu'un ordre est exécuté. Topic par utilisateur https://app.fintech.fr/users/42/portfolio en privé (JWT subscriber avec le claim restrictif), plus un topic public https://app.fintech.fr/market/indices pour le CAC40, DAX, etc. Le front consomme via EventSource et un Stimulus controller met à jour les cellules concernées sans redessiner toute la table. Côté backend, un consumer Messenger écoute le flux de market data (via une connexion WebSocket vers le LP), et chaque mise à jour pertinente est publiée vers Mercure après filtrage par les règles utilisateur (positions ouvertes, alertes configurées). La latence de bout en bout est passée à 80ms en P95 entre une variation cours et l'affichage trader. Le coût infrastructure a chuté de 60% (suppression du polling massif), avec un hub Mercure single-node 4 vCPU/8 Go tenant 800 traders simultanés sans souffrir.
Notifications cabinet d'avocats temps réel
Un cabinet d'avocats utilise un intranet où les collaborateurs (associés, collaborateurs juniors, secrétaires, paralegals) interagissent autour des dossiers : annotations, demandes de relecture, ajout de pièces, échéances. Avant Mercure, les avocats devaient rafraîchir leur tableau de bord pour voir si une demande urgente leur avait été assignée — résultat : retards à répétition, mécontentement client. Avec Mercure, chaque utilisateur s'abonne en privé à son topic personnel https://cabinet.fr/users/{id}/notifications (cookie JWT posé à la connexion). Toute interaction métier importante (assignation de tâche, mention dans un commentaire, échéance dans 24h, modification d'un document piloté) déclenche une publication. Le front affiche une toast (avec son si la page n'a pas le focus) et incrémente le badge du menu notifications. Un canal supplémentaire par dossier https://cabinet.fr/cases/{id}/activity permet d'afficher en temps réel le flux d'activité quand plusieurs avocats consultent un même dossier (qui consulte, qui annote, qui a téléchargé une pièce). Le système gère aussi les déconnexions élégantes grâce à un heartbeat Redis (TTL 60s), affichant un indicateur de présence "en ligne / hors ligne" dans la barre latérale. Les avocats remontent un gain de réactivité considérable, et la direction y voit un atout commercial face aux concurrents encore sur des outils legacy.
Live cart sync e-commerce multi-device
Un e-commerçant spécialisé dans le mobilier haut de gamme observe que ses clients démarrent souvent leur shopping sur mobile pendant les trajets, puis basculent sur desktop le soir pour finaliser. Pour éviter qu'ils ne perdent leur panier ou ne le voient désynchronisé entre devices, l'équipe a implémenté une synchronisation temps réel via Mercure. Quand un utilisateur connecté ajoute un produit au panier sur son mobile, le backend publie sur https://shop.com/users/{id}/cart ; toutes les tabs/devices ouverts du même utilisateur reçoivent l'événement et mettent à jour leur affichage panier via Turbo Streams. Le pattern est intéressant côté contrôle : Mercure n'est utilisé que pour la notification du changement, pas pour le transport du nouvel état (qui reste fait via HTTP avec gestion d'auth standard). Quand l'event arrive, le client fait un GET /cart/_dropdown pour récupérer le HTML à jour ; cela évite de faire transiter des données métier dans le hub. Effet de bord apprécié : le détail "favoris" se synchronise aussi (cœur cliqué sur un produit côté téléphone = visible immédiatement côté desktop). Mesures observées : taux de finalisation cross-device passé de 12% à 28%, et plus aucun ticket support du genre "j'ai perdu mon panier".
🛠️ Exemple end-to-end
Dashboard de suivi de positions trading avec push Mercure et mise à jour partielle DOM via Turbo Streams.
<?php
// src/Trading/PortfolioBroadcaster.php
declare(strict_types=1);
namespace App\Trading;
use App\Entity\Position;
use App\Entity\User;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Twig\Environment;
final readonly class PortfolioBroadcaster
{
public function __construct(
private HubInterface $hub,
private Environment $twig,
) {}
public function pushPositionUpdate(User $user, Position $position): void
{
$html = $this->twig->render('trading/_position_row.stream.html.twig', [
'position' => $position,
]);
$topic = sprintf('https://app.fintech.fr/users/%d/portfolio', $user->getId());
$this->hub->publish(new Update(
topics: $topic,
data: $html,
private: true,
type: 'position.updated',
retry: 2000,
));
}
public function pushMarketTick(string $symbol, float $price, float $change): void
{
$html = $this->twig->render('trading/_market_tick.stream.html.twig', [
'symbol' => $symbol,
'price' => $price,
'change' => $change,
]);
$this->hub->publish(new Update(
topics: sprintf('https://app.fintech.fr/market/%s', $symbol),
data: $html,
private: false,
));
}
}<?php
// src/Trading/MarketDataConsumer.php
declare(strict_types=1);
namespace App\Trading;
use App\Repository\PositionRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class MarketDataConsumer
{
public function __construct(
private PositionRepository $positions,
private PortfolioBroadcaster $broadcaster,
) {}
public function __invoke(MarketTickMessage $tick): void
{
// Broadcast à tout le monde
$this->broadcaster->pushMarketTick($tick->symbol, $tick->price, $tick->change);
// Diffuse aux porteurs de la position uniquement
foreach ($this->positions->findOpenForSymbol($tick->symbol) as $position) {
$position->refreshMarketValue($tick->price);
$this->broadcaster->pushPositionUpdate($position->getOwner(), $position);
}
}
}<?php
// src/Controller/TradingDashboardController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Mercure\Discovery;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_TRADER')]
final class TradingDashboardController extends AbstractController
{
#[Route('/trading/dashboard', name: 'trading_dashboard')]
public function index(
Request $request,
Discovery $discovery,
Authorization $authorization,
): Response {
$userId = $this->getUser()->getId();
$discovery->addLink($request);
$authorization->setCookie($request, [
sprintf('https://app.fintech.fr/users/%d/portfolio', $userId),
]);
return $this->render('trading/dashboard.html.twig', [
'topicPortfolio' => sprintf('https://app.fintech.fr/users/%d/portfolio', $userId),
'topicMarket' => 'https://app.fintech.fr/market/*',
]);
}
}{# templates/trading/dashboard.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<section class="dashboard"
data-controller="trading-dashboard">
<h1>Mon portefeuille</h1>
<turbo-stream-source src="{{ mercure(topicPortfolio) }}"></turbo-stream-source>
<turbo-stream-source src="{{ mercure(topicMarket) }}"></turbo-stream-source>
<table class="positions">
<thead>
<tr><th>Symbole</th><th>Quantité</th><th>PRU</th><th>Cours</th><th>+/- jour</th><th>Valorisation</th></tr>
</thead>
<tbody id="positions">
{% for position in positions %}
{{ include('trading/_position_row.html.twig', { position }) }}
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}{# templates/trading/_position_row.stream.html.twig #}
<turbo-stream action="replace" target="position-{{ position.id }}">
<template>
{{ include('trading/_position_row.html.twig', { position }) }}
</template>
</turbo-stream>{# templates/trading/_position_row.html.twig #}
<tr id="position-{{ position.id }}"
class="position {{ position.changePercent >= 0 ? 'position--up' : 'position--down' }}">
<td>{{ position.symbol }}</td>
<td>{{ position.quantity|number_format(0) }}</td>
<td>{{ position.averagePrice|format_currency(position.currency) }}</td>
<td class="price">{{ position.currentPrice|format_currency(position.currency) }}</td>
<td class="delta">{{ position.changePercent|number_format(2) }}%</td>
<td>{{ position.marketValue|format_currency(position.currency) }}</td>
</tr>Le flux complet : un connecteur side-car écoute le LP (liquidity provider) sur WebSocket, parse les ticks et dispatch des MarketTickMessage vers Messenger. Le consumer met à jour la base, recalcule les positions impactées, et publie via Mercure. Chaque navigateur ouvert reçoit les updates via les deux <turbo-stream-source> (un pour le portefeuille perso, un pour le market général), et Turbo applique les actions replace aux lignes du tableau ciblées par leur ID. Aucun JS custom écrit pour le rendu.
🔁 Quand utiliser / éviter
Utiliser Mercure quand :
- Vous avez besoin de push serveur → client uniquement (notifications, dashboards, progress, présence légère).
- Vous voulez éviter d'opérer un serveur WebSocket persistant (Ratchet, ReactPHP, Swoole) avec sa propre stratégie d'auth.
- Vous utilisez déjà Symfony + Turbo Streams et voulez du live HTML quasi gratuit.
- Vous souhaitez un fallback automatique (SSE marche sur HTTP/1.1, traverse les proxies d'entreprise, supporte la reconnexion native).
Éviter Mercure quand :
- Vous avez besoin de bidirectionnel à haute fréquence (jeux multijoueur, terminal SSH dans le navigateur, voix/vidéo) ⇒ WebSocket natif ou WebRTC.
- Vous faites de la coédition CRDT avec OT/Yjs ⇒ Yjs sur WebSocket, plus mature.
- Vous voulez sub-millisecond latency ⇒ Mercure est très bon mais le hub Go ajoute ~5–10 ms.
- Vous êtes sur PHP-FPM classique sans hub déployable (hosting partagé sans binaire custom) ⇒ Ably/Pusher sont plus simples.
Comparaison synthétique
| Critère | Mercure | WebSocket (Ratchet/Swoole) | SSE "pur" (PHP-FPM) | Ably/Pusher |
|---|---|---|---|---|
| Direction | Server→Client | Bidirectionnel | Server→Client | Bidirectionnel |
| Auth | JWT + cookie | À implémenter | À implémenter | API key + permissions |
| Reconnexion | Auto (EventSource) | À coder | Auto (EventSource) | Auto (SDK) |
| Scaling | Cluster + Redis transport | Sharding manuel | N/A (PHP-FPM bloque worker) | Géré (SaaS) |
| Coût opérationnel | 1 binaire Go (Caddy) | Long-running PHP | Pas viable en prod | $$$ par message |
| Coédition lourde | Limité | Adapté | Non | Adapté |
| Présence native | Non (heartbeat) | À coder | Non | Oui (channels presence) |
🏋️ Exercices
Progression du « ça marche » au « ça tient en prod » jusqu'au « casse-le puis répare-le ». Tente chacun avant de lire l'indice.
Exercice 1 — Notification privée bout en bout (implement)
Objectif : pousser une toast à un utilisateur précis, et prouver qu'un autre utilisateur ne la reçoit jamais. Construis : un endpoint POST /notify/{userId} qui publie en private: true sur https://app/users/{id}/notifications, un contrôleur de page qui pose le cookie JWT via Authorization::setCookie, et un EventSource qui affiche la toast. Connecte-toi avec deux comptes dans deux navigateurs.
Indice/Solution : le test décisif est négatif — ouvre l'onglet réseau du mauvais utilisateur et vérifie le 200 mais zéro
event:reçu. Si l'utilisateur B reçoit la notif de A, ton cookie B liste un topic trop large (*ouusers/{any}). Scope au seulusers/{B.id}/notifications.
Exercice 2 — Rattrapage à la reconnexion (production-grade)
Objectif : garantir qu'aucun message n'est perdu pendant une coupure réseau de 10 s. Passe le transport en bolt:// avec historique, attribue un id ULID croissant à chaque Update, coupe le Wi-Fi 10 s pendant que tu publies 5 messages, rétablis, et vérifie que les 5 arrivent dans l'ordre.
Indice/Solution : sans
idexplicite (id: null), pas deLast-Event-IDexploitable → pas de replay. Fournis unSymfony\Component\Uid\Uliden string. Vérifie côté hub quetransport_urla unsizenon nul. Observe le headerLast-Event-IDréémis par l'EventSourcedans la requête de reconnexion.
Exercice 3 — Cohérence transactionnelle (break-then-fix)
Objectif : reproduire puis éliminer le bug « le client voit un message fantôme ». Écris un handler qui publish() avant $em->flush(), force le flush à échouer (contrainte unique violée), et observe que le front affiche un message absent de la base. Puis corrige.
Indice/Solution : déplace
publish()aprèsflush()ne suffit pas si le commit DB et la publication doivent être atomiques. La vraie solution senior : publie via Messenger avec leDoctrineTransactionMiddleware/ dispatch après commit (pattern outbox), pour que la publication n'arrive jamais si la transaction a échoué.
Exercice 4 — Multi-node sans messages perdus (production-grade)
Objectif : faire qu'un message publié sur le node A atteigne un abonné connecté au node B. Lance 2 hubs Mercure + un LB sticky, transport redis://. Connecte un abonné (forcé sur hub2 via cookie sticky), publie depuis Symfony (qui tape hub1), vérifie la réception.
Indice/Solution : si rien n'arrive, tes deux hubs sont en
bolt:///local://(isolés). Le pub/sub Redis est le bus de fan-out inter-nodes. Piège suivant : Redis pub/sub ne persiste pas → un abonné momentanément déconnecté perd le message même avec rattrapage ; combine avec un store si tu veux le replay.
Exercice 5 — Confidentialité sans fuite par le hub (break-then-fix)
Objectif : empêcher que des données sensibles transitent en clair dans le hub. Pars d'un push qui envoie le solde bancaire de l'utilisateur dans data. Démontre qu'un opérateur du hub (logs/transport Redis) peut le lire. Refactore pour que ça n'arrive plus.
Indice/Solution :
private: truen'aide pas — le payload est en clair dans le transport. Bascule sur le pattern signal-only : pousse{"refresh": "balance"}, et le client fait unGET /api/balanceauthentifié en HTTP. Le hub ne voit plus que « va rafraîchir », jamais le montant.
Exercice 6 — Stress & dimensionnement (production-grade)
Objectif : trouver le point de rupture de ton hub single-node et le justifier. Avec un script ouvrant N connexions EventSource (ou un outil type vegeta/k6 en mode SSE), monte jusqu'à saturation mémoire. Trace conso RAM vs nombre de connexions.
Indice/Solution : tu dois retrouver l'ordre de grandeur ~6–8 KB/connexion (1 goroutine Go + buffers). Si tu satures bien avant, c'est le
ulimit -n(file descriptors) ou le proxy en amont, pas le hub. Augmentenofile, désactive le buffering proxy, et reteste avant de conclure « scaler horizontalement ».
🎤 En entretien
Q : Pourquoi Mercure plutôt qu'un WebSocket, et qu'est-ce que ça t'empêche de faire ? R : Mercure est du SSE unidirectionnel server→client sur un hub Go : reconnexion + rattrapage natifs côté navigateur, auth par JWT/cookie raisonnée par requête HTTP, scaling horizontal via transport Redis — zéro serveur PHP long-running à opérer. En contrepartie, pas de bidirectionnel haute fréquence (jeux, voix/vidéo, terminal) ni de CRDT : pour ça, WebSocket/WebRTC restent obligatoires. Le client « écrit » toujours via HTTP classique vers Symfony, jamais vers le hub.
Q : Tu pousses un événement métier critique. Comment garantis-tu qu'il n'est jamais perdu ni fantôme ? R : Je ne fais pas confiance à Mercure pour la durabilité. La source de vérité c'est la base + Messenger ; Mercure ne transporte qu'un signal « ça a changé ». Je publie après commit (pattern outbox / dispatch post-transaction) pour éviter les fantômes, et pour le rattrapage j'utilise un transport avec historique (bolt/redis) + un id monotone (ULID) pour que Last-Event-ID rejoue les messages manqués. La livraison reste best-effort bornée, donc l'état réel se re-fetch en HTTP authentifié.
Q : Comment sécurises-tu les topics privés en multi-tenant ? R : Modèle capability : le JWT subscriber liste exactement les topics autorisés, dérivés des droits réels de l'utilisateur au moment de la signature (jamais * dans un cookie navigateur). TTL court car le hub n'a pas de logout — un droit révoqué reste valide jusqu'à expiration, donc je renouvelle proactivement. Le publisher token est scopé par bounded context et stocké en secret manager ; en multi-équipe je passe en signature asymétrique (ES256) pour que le hub n'ait que la clé publique. Et private n'étant pas du chiffrement, les données sensibles passent en signal-only + refetch HTTP.
Q : Ton hub sature à 50 000 connexions. Quels leviers, dans quel ordre ? R : D'abord vérifier que c'est bien le hub et pas l'environnement : ulimit -n (file descriptors), buffering/timeout du reverse proxy (Nginx proxy_buffering off, ALB idle timeout), CSP/CORS. Ensuite RAM (~6–8 KB/conn → 50k ≈ 2–3 GB). Si c'est réellement le plafond d'un node, je scale horizontalement : plusieurs hubs derrière un LB sticky avec transport redis:// pour le fan-out inter-nodes, en gardant en tête que Redis pub/sub ne persiste pas (rattrapage à gérer séparément).
🔗 Liens
- Spec Mercure : https://mercure.rocks/spec
- Bundle officiel : https://github.com/symfony/mercure-bundle
- Hub Caddy module : https://github.com/dunglas/mercure
- Symfony UX Turbo : https://symfony.com/bundles/ux-turbo/current/index.html
- RFC 6202 — HTTP Long Polling / SSE
- Article Kévin Dunglas — "Mercure : Real-time made easy"
Récap final
Mercure est le chemin de moindre résistance pour ajouter du temps réel à une application Symfony. Trois briques mentales suffisent : publier côté Symfony via HubInterface, autoriser côté navigateur via un cookie JWT scopé par topics, écouter côté front via EventSource ou <turbo-stream-source>. La nature SSE rend le protocole pragmatique (reconnexion native, fallback HTTP/1.1, simplicité d'auth) au prix de l'unidirectionnalité — limite acceptable pour 90 % des besoins métier. Pour le 10 % restant (jeux, coédition CRDT, voix), WebSocket reste roi.