Tutoriel · Mercure · Symfony
Mercure avec Symfony : pousser des mises à jour au navigateur
Un hub qui garde les connexions ouvertes à la place de PHP : installer Mercure, publier un Update depuis Symfony, s’abonner avec EventSource et découvrir le hub par en-tête Link.
Le principe : un hub entre PHP et les navigateurs
L’article sur les Server-Sent Events s’est arrêté sur une limite : un flux SSE servi par PHP immobilise un worker par abonné. Mercure, protocole ouvert conçu par Kévin Dunglas, résout ce problème en confiant les connexions à un hub : un serveur écrit en Go, capable de tenir des milliers de connexions SSE ouvertes.
- L’application Symfony publie par une simple requête
POSTau hub, puis termine sa requête normalement. - Les navigateurs s’abonnent au hub avec
EventSource, à un ou plusieurs topics. - Le hub distribue chaque mise à jour aux abonnés du topic, gère la reconnexion et, selon sa configuration, l’historique pour
Last-Event-ID.
Tout reste du HTTP standard : pas de WebSocket, pas de client propriétaire, et un hub interchangeable puisque le protocole est spécifié publiquement.
Lancer un hub
Le hub de référence est un module de Caddy, distribué en image Docker. En développement :
# compose.yaml (extrait) — hub Mercure en développement
services:
mercure:
image: dunglas/mercure
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins https://espace.acme-assurances.example
ports:
- '8090:80' Une application servie par FrankenPHP, le serveur d’applications PHP du même auteur bâti sur Caddy, embarque le hub : une directive dans le Caddyfile suffit, sans service supplémentaire.
# Caddyfile d'une application servie par FrankenPHP : le hub tourne dans le même processus.
espace.acme-assurances.example {
root * /app/public
mercure {
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY}
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
anonymous
}
php_server
} L’URL du hub est toujours /.well-known/mercure. Le secret des clés JWT signe les autorisations de publication et d’abonnement, détaillées dans l’article sur les mises à jour privées.
Installer MercureBundle
composer require symfony/mercure-bundle installe le bundle et sa recette Flex, qui ajoute les variables d’environnement :
# .env — URL interne (publication), URL publique (abonnement), secret partagé avec le hub
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://espace.acme-assurances.example/.well-known/mercure
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" Deux URL distinctes, car Symfony publie par le réseau interne (le nom du service Docker) tandis que le navigateur s’abonne par l’URL publique. Le bundle signe lui-même le JWT de publication à partir de MERCURE_JWT_SECRET.
Publier une mise à jour
On injecte HubInterface et on publie un Update : un ou plusieurs topics, des données, et en option un type d’événement, un identifiant et un délai de reconnexion.
<?php
namespace App\Sinistre;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
final class SinistreNotifier
{
public function __construct(private readonly HubInterface $hub)
{
}
public function statutChange(Sinistre $sinistre): void
{
$this->hub->publish(new Update(
// Le topic est un IRI : de préférence l'URL de la ressource concernée.
topics: 'https://espace.acme-assurances.example/sinistres/' . $sinistre->reference,
data: json_encode([
'reference' => $sinistre->reference,
'statut' => $sinistre->statut,
], JSON_THROW_ON_ERROR),
type: 'sinistre.statut',
));
}
} La publication est un appel HTTP au hub : s’il est lent ou indisponible, la requête de l’utilisateur en pâtit. Avec Messenger, il suffit de router Update vers un transport asynchrone et de le dispatcher : le bundle fournit le handler.
# config/packages/messenger.yaml — publication hors de la requête HTTP
framework:
messenger:
routing:
'Symfony\Component\Mercure\Update': async // Dans le service, à la place de $hub->publish() : le bundle fournit le handler qui publie sur le hub.
$this->bus->dispatch(new Update($topic, $data)); Publier après la validation de la transaction en base, jamais avant : sinon le navigateur, prévenu trop tôt, relit un état qui n’existe pas encore.
S’abonner depuis le navigateur
La fonction Twig mercure() construit l’URL d’abonnement à partir de l’URL publique du hub et du topic :
{# templates/sinistre/show.html.twig #}
<p>Statut : <strong id="statut">{{ sinistre.statut }}</strong></p>
<script>
const source = new EventSource("{{ mercure('https://espace.acme-assurances.example/sinistres/' ~ sinistre.reference)|escape('js') }}");
source.addEventListener('sinistre.statut', (e) => {
document.getElementById('statut').textContent = JSON.parse(e.data).statut;
});
</script> Un même EventSource peut écouter plusieurs topics, et un topic d’abonnement peut être un gabarit d’URI : le tableau de bord d’un gestionnaire reçoit ainsi tous les sinistres avec une seule connexion.
// Un abonnement, plusieurs topics : ici tous les sinistres, grâce à un gabarit d'URI (RFC 6570).
const url = new URL('https://espace.acme-assurances.example/.well-known/mercure');
url.searchParams.append('topic', 'https://espace.acme-assurances.example/sinistres/{reference}');
url.searchParams.append('topic', 'https://espace.acme-assurances.example/alertes');
const source = new EventSource(url);Découvrir le hub
Pour une API consommée par une application mobile ou un front séparé, coder l’URL du hub en dur crée un couplage. Le protocole prévoit une découverte : la ressource annonce son hub dans un en-tête Link, et le client s’abonne en prenant l’URL de la ressource comme topic.
use Symfony\Component\Mercure\Discovery;
#[Route('/api/sinistres/{reference}', methods: ['GET'])]
public function show(string $reference, Request $request, Discovery $discovery): JsonResponse
{
// Ajoute « Link: <https://…/.well-known/mercure>; rel="mercure" » à la réponse.
$discovery->addLink($request);
return $this->json($this->sinistres->get($reference));
} API Platform ajoute cet en-tête et publie automatiquement les modifications d’une ressource marquée mercure: true.
Un contrôleur Stimulus réutilisable
Plutôt qu’un script par page, un contrôleur Stimulus ouvre la connexion quand l’élément apparaît et la ferme quand il disparaît, y compris lors d’une navigation Turbo :
// assets/controllers/live_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = { url: String, type: { type: String, default: 'message' } };
static targets = ['field'];
connect() {
this.source = new EventSource(this.urlValue);
this.source.addEventListener(this.typeValue, (e) => this.apply(JSON.parse(e.data)));
}
disconnect() {
// Turbo ou une navigation retire l'élément : on rend la connexion au hub.
this.source?.close();
}
apply(data) {
this.fieldTargets.forEach((el) => {
if (el.dataset.field in data) el.textContent = data[el.dataset.field];
});
}
} <div data-controller="live"
data-live-url-value="{{ mercure(topic)|escape('html_attr') }}"
data-live-type-value="sinistre.statut">
Statut : <strong data-live-target="field" data-field="statut">{{ sinistre.statut }}</strong>
</div> Pour remplacer des fragments HTML entiers plutôt que des valeurs, Symfony UX Turbo sait consommer des Turbo Streams publiés sur Mercure : le serveur rend le HTML, le navigateur l’insère.
Bonnes pratiques
- Des topics qui sont des URL de ressources : lisibles, uniques, et compatibles avec la découverte.
- Des données minimales : l’identifiant et ce qui a changé. Le navigateur peut relire la ressource complète par l’API si besoin.
- Un état initial rendu par le serveur : la page affiche le statut courant, Mercure n’apporte que les changements suivants.
- Un historique côté hub si les coupures ne doivent rien faire perdre, sinon relire l’état à la reconnexion.
- Par défaut, tout est public : dès qu’une donnée concerne un utilisateur, passer en mise à jour privée.