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 POST au 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.
🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.