Le problème

Reprenons la boutique en ligne découpée en services order, stock et shipping. Le client suit sa commande sur shop.example.com, l'équipe logistique travaille sur admin.example.com. Ces deux fronts Vue appellent directement l'API de chaque service, sans BFF entre eux.

Quand le stock confirme une réservation ou qu'un colis part, la page ouverte doit se mettre à jour seule. Sans diffusion, chaque front interroge chaque API à intervalle régulier, et la charge suit le nombre d'onglets ouverts.

Un hub Mercure diffuse ces changements par Server-Sent Events (SSE). Sans BFF, trois questions se posent pourtant :

  • qui authentifie l'abonné ? EventSource n'accepte pas d'en-tête Authorization : le jeton passe par un cookie, que le navigateur doit envoyer au hub ;
  • qui peut publier quoi ? Avec une clé de signature partagée, chaque service peut publier sur les topics des autres ;
  • qui voit quoi ? Le statut d'une commande ne doit atteindre que son client.

Les options

Interroger chaque API à intervalle régulier

  • Ce qu'elle règle : aucun composant à ajouter, chaque API garde son authentification.
  • Ce qu'elle coûte : des requêtes le plus souvent inutiles, et un affichage en retard d'un intervalle.

Un flux SSE ou WebSocket par service

  • Ce qu'elle règle : chaque service pousse ses propres changements, avec sa propre authentification.
  • Ce qu'elle coûte : une connexion par service et par onglet, et une diffusion à écrire et à exploiter dans chaque service.

Un BFF par front

  • Ce qu'elle règle : le front n'a qu'un interlocuteur, qui authentifie, agrège et pousse les données.
  • Ce qu'elle coûte : un service de plus par front, à faire évoluer avec chaque API qu'il agrège.

Un hub Mercure partagé, sans BFF

  • Ce qu'elle règle : une seule connexion par onglet, quel que soit le nombre de services. Les services publient par une requête HTTP, le hub gère les connexions, les reconnexions et l'historique.
  • Ce qu'elle coûte : un composant à exploiter, et des contraintes de domaine, de cookie et de jetons.

Notre recommandation

Un hub Mercure sous le domaine des fronts, un jeton de publication par service limité à ses topics, un cookie sur le domaine parent.

Chaque topic est une IRI qui identifie la ressource : https://example.com/orders/:id. Chaque service publie sous ses propres préfixes. Les données personnelles partent en mises à jour privées, qui n'atteignent que les abonnés dont le jeton couvre l'un de leurs topics.

Un hub partagé : chaque service publie sous ses préfixes, chaque front s'abonne domaine enregistrable example.com : API, hub et fronts sur des sous-domaines order.example.com jeton limité à example.com/orders/* stock.example.com jeton limité à example.com/stock/* shipping.example.com jeton limité à example.com/shipments/* hub Mercure mercure.example.com vérifie jeton et droits shop.example.com front client (Vue) SSE, EventSource avec withCredentials admin.example.com back-office (Vue) Cookie des abonnés, posé par order.example.com et envoyé au hub par chaque front Domain=example.com; Path=/.well-known/mercure; Secure; HttpOnly; SameSite=Strict À éviter : une clé de signature partagée par tous les services Chacun peut alors signer un jeton qui publie sur les topics des autres.

Ce qui a changé avec Mercure 1.0

Le hub 1.0, publié le 16 septembre 2026, rompt avec le format des brouillons précédents :

  • les droits quittent la claim mercure (mercure.publish, mercure.subscribe) pour la claim authorization_details de la RFC 9396 ;
  • le jeton devient un jeton d'accès OAuth 2.0 (RFC 9068) : en-tête typ: at+jwt, claims iss, aud et exp obligatoires ;
  • le cookie mercureAuthorization devient __Secure-mercure_access_token ;
  • les URL Patterns (https://example.com/orders/:id) remplacent les URI Templates, et le paramètre topic des abonnés devient match ou match_urlpattern.

Le hub n'accepte l'ancien format qu'en mode de compatibilité. Côté Symfony, symfony/mercure 0.8 et symfony/mercure-bundle 0.5 parlent les deux protocoles depuis août 2026. Le bundle reste en 0.x par défaut, et la documentation de symfony.com décrit encore l'ancien format : nous déclarons donc protocol_version: '1.0'.

Le jeton de l'abonné voyage dans un cookie, posé par une API comme order.example.com et lu par mercure.example.com. Un serveur ne peut fixer l'attribut Domain que sur son domaine ou un domaine parent : ce sera Domain=example.com. Cette API et le hub partagent donc le même domaine enregistrable.

Les fronts aussi : entre sous-domaines d'un même domaine, les requêtes restent « same-site », et SameSite=Strict ne bloque pas le cookie. Un hub sur un autre domaine exclut le cookie : le front lit alors le flux avec fetch() et un en-tête Authorization.

Un jeton de publication par service, limité à ses préfixes

Le hub vérifie la signature d'un jeton, puis applique les droits qu'il contient. Un service qui détient la clé de signature peut donc s'accorder tous les droits. Chaque service reçoit plutôt un jeton déjà signé et limité à ses préfixes : stock compromis ne peut rien publier sous https://example.com/orders/.

Les compromis assumés

Le service qui pose le cookie détient la clé des abonnés. Il peut signer un jeton qui lit toutes les mises à jour privées. Nous la réservons à ce seul service, distincte de celle des publieurs.

Un seul cookie par navigateur pour le hub. Le navigateur remplace un cookie de même nom, domaine et chemin : deux fronts ouverts sous deux identités écrasent mutuellement leurs droits. Si deux identités doivent coexister, le front lit le flux avec fetch() et un en-tête Authorization.

Les jetons de publication expirent. Mercure 1.0 rend exp obligatoire : un jeton signé au déploiement impose une rotation avant son échéance. Un serveur d'autorisation qui délivre des jetons courts supprime cette rotation, au prix d'un composant de plus.

Une mise à jour peut se perdre. Si le hub ne répond pas après le commit, la donnée reste juste : seul l'affichage prend du retard. Le front relit l'API à chaque connexion, car Mercure notifie et l'API fait foi.

Chaque service gère son jeton. Un relais unique, consommateur de app.events, publierait tout avec un seul jeton, mais dépendrait des contrats de tous les services.

Quand réintroduire un BFF

Nous réintroduisons un BFF quand l'un de ces signaux apparaît :

  • agrégation : chaque écran assemble les données de plusieurs services, au prix d'appels multiples ;
  • autorisation : les droits de lecture ne s'expriment plus par des préfixes de topics (règles par ressource, délégations, champs masqués selon le rôle) ;
  • exposition : les services ne doivent pas être joignables depuis le navigateur ;
  • domaines : les fronts ne peuvent pas partager un domaine enregistrable avec le hub et les API.

Mise en œuvre

Les exemples utilisent le hub Mercure 1.0.2, Symfony 7.4 avec symfony/mercure-bundle 0.5, et Vue 3.

1. Attribuer un préfixe de topics à chaque service

  • order : https://example.com/orders/* ;
  • shipping : https://example.com/shipments/* ;
  • stock : https://example.com/stock/*, en mises à jour publiques.

Une mise à jour privée porte un second topic, dit alternatif, dans l'espace du client : https://example.com/customers/:customerId/shipments/:id pour shipping. Chaque service qui publie en privé a donc un second préfixe dans cet espace. Le jeton d'un client couvre son seul espace, et une connexion suffit pour suivre ses commandes et ses colis.

2. Configurer le hub

# Caddyfile (hub)
mercure.example.com {
  mercure {
    # Jetons de publication : signés hors des services, vérifiés par clé publique.
    issuer https://example.com {
      publisher {
        jwt {env.MERCURE_PUBLISHER_PUBLIC_KEY} ES256
      }
    }
    # Jetons d'abonnement : signés par le service order, qui pose le cookie.
    issuer https://order.example.com {
      subscriber {
        jwt {env.MERCURE_SUBSCRIBER_SECRET}
      }
    }
    resource_identifier https://mercure.example.com/.well-known/mercure
    cors_origins https://shop.example.com https://admin.example.com
  }

  respond "Not Found" 404
}

Chaque bloc issuer lie un émetteur (la claim iss) à ses clés : un jeton n'est vérifié qu'avec les clés de son émetteur. cors_origins liste des origines explicites, car le joker * désactive l'envoi des cookies.

En HTTP/1.1, un navigateur n'ouvre que six connexions par domaine, tous onglets confondus. Caddy sert le hub en HTTP/2 par défaut ; un reverse proxy placé devant doit faire de même.

3. Signer un jeton de publication par service

La clé privée des publieurs reste dans le pipeline de déploiement ou le gestionnaire de secrets. Le pipeline signe un jeton par service avec la commande mercure-token du hub :

caddy mercure-token \
  --iss https://example.com \
  --aud https://mercure.example.com/.well-known/mercure \
  --sub https://shipping.example.com \
  --key @publisher.pem --alg ES256 \
  --ttl 720h \
  --publish-urlpattern 'https://example.com/shipments/*' \
  --publish-urlpattern 'https://example.com/customers/:customer/shipments/*'

La durée de vie, ici 30 jours, fixe le rythme de rotation. La documentation de Mercure conseille des minutes ou des heures : ces 30 jours sont un compromis assumé. Le service ne reçoit que le jeton :

# config/packages/mercure.yaml (service shipping)
mercure:
  hubs:
    default:
      url: '%env(MERCURE_URL)%'
      protocol_version: '1.0'
      jwt: '%env(MERCURE_PUBLISHER_TOKEN)%'

4. Publier après le commit

// src/Shipment/DispatchShipment.php (service shipping)
namespace App\Shipment;

use App\Entity\Shipment;
use App\Entity\ShipmentStatus;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mercure\Exception\RuntimeException;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

final class DispatchShipment
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly HubInterface $hub,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function __invoke(Shipment $shipment): void
    {
        $this->entityManager->wrapInTransaction(function () use ($shipment): void {
            $shipment->setStatus(ShipmentStatus::DISPATCHED);
            // L'outbox reçoit shipping.shipment.dispatched.v1 dans cette transaction.
        });

        $id = (string) $shipment->getId();
        $update = new Update(
            [
                'https://example.com/shipments/'.$id,
                'https://example.com/customers/'.$shipment->getCustomerId().'/shipments/'.$id,
            ],
            json_encode(['id' => $id, 'status' => $shipment->getStatus()], \JSON_THROW_ON_ERROR),
            private: true,
            type: 'shipment',
        );

        try {
            $this->hub->publish($update);
        } catch (RuntimeException $exception) {
            // La donnée est enregistrée : seul l'affichage en direct prend du retard.
            $this->logger->warning('Mercure update not published.', ['exception' => $exception]);
        }
    }
}

Le premier topic est le topic canonique, le second le topic alternatif : le jeton de publication doit couvrir les deux. Le champ type devient le nom de l'événement SSE, que le front écoute. L'événement shipping.shipment.dispatched.v1, lui, part par l'outbox vers des consommateurs idempotents.

Dans un consommateur d'événement, le middleware doctrine_transaction ne valide la transaction qu'après le handler. Nous y envoyons donc un message dédié avec un DispatchAfterCurrentBusStamp : Messenger ne le traite qu'après le commit.

Son handler publie sur le hub et intercepte ses erreurs, comme ci-dessus. Sinon, Messenger rejouerait l'événement consommé, que le consommateur idempotent ignorerait : la mise à jour ne partirait jamais.

Un seul service pose le cookie : ici order, qui connaît le client. Une seconde entrée de hub porte la clé des abonnés, réservée aux cookies :

# config/packages/mercure.yaml (service order)
mercure:
  default_hub: default
  hubs:
    default:
      url: '%env(MERCURE_URL)%'
      protocol_version: '1.0'
      jwt: '%env(MERCURE_PUBLISHER_TOKEN)%'
    subscriber:
      url: '%env(MERCURE_URL)%'
      protocol_version: '1.0'
      jwt:
        secret: '%env(MERCURE_SUBSCRIBER_SECRET)%'
        claims:
          iss: 'https://order.example.com'
          sub: 'https://order.example.com'
          client_id: 'https://order.example.com'

La claim aud reprend l'URL du hub, égale à son resource_identifier. Avec deux entrées, un Update envoyé par Messenger serait publié sur chacune : order publie donc par HubInterface, qui vise l'entrée par défaut.

// src/Controller/MercureAuthorizationController.php (service order)
namespace App\Controller;

use App\Entity\Customer;
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\Jwt\Grant;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;

final class MercureAuthorizationController extends AbstractController
{
    #[Route('/mercure/authorization', methods: ['POST'])]
    public function __invoke(
        Request $request,
        #[CurrentUser] Customer $customer,
        Authorization $authorization,
    ): Response {
        $customerIri = 'https://example.com/customers/'.$customer->getId();

        // Le client ne lit que les mises à jour publiées dans son espace.
        $authorization->setCookie(
            $request,
            [new Grant([Grant::ACTION_SUBSCRIBE], ['urlpattern' => [$customerIri.'/*']])],
            additionalClaims: ['sub' => $customerIri],
            hub: 'subscriber',
        );

        return new Response(null, Response::HTTP_NO_CONTENT);
    }
}

Authorization::setCookie() calcule l'attribut Domain à partir de l'hôte de la requête et de celui du hub. Il lève une exception s'ils ne partagent aucun domaine parent. Le cookie est Secure, HttpOnly, SameSite=Strict, et limité au chemin /.well-known/mercure.

Avec la configuration par défaut de PHP, le jeton expire au bout d'une heure. Pour le back-office, un endpoint semblable accorde https://example.com/orders/* et https://example.com/shipments/* selon le rôle.

6. S'abonner depuis le front Vue

Le front appelle l'endpoint après la connexion de l'utilisateur, puis avant chaque expiration du jeton, dont la durée suit l'option default_cookie_lifetime du bundle :

// src/live/authorizeLiveUpdates.ts (front shop)
export async function authorizeLiveUpdates(): Promise<void> {
  // Sans credentials: 'include', le navigateur ignore le Set-Cookie d'une autre origine.
  await fetch('https://order.example.com/mercure/authorization', {
    method: 'POST',
    credentials: 'include',
  });
}

La configuration CORS de order autorise les credentials pour https://shop.example.com. Le composable ouvre ensuite le flux :

// src/composables/useLiveShipments.ts (front shop)
import { onScopeDispose, ref } from 'vue';
import { fetchShipments, type ShipmentView } from '@/api/shipments';

const HUB_URL = 'https://mercure.example.com/.well-known/mercure';

export function useLiveShipments(customerId: string) {
  const shipments = ref(new Map<string, ShipmentView>());

  const url = new URL(HUB_URL);
  url.searchParams.append('match_urlpattern', `https://example.com/customers/${customerId}/*`);
  // withCredentials : le navigateur joint le cookie posé sur example.com.
  const source = new EventSource(url, { withCredentials: true });

  source.addEventListener('open', async () => {
    const list = await fetchShipments();
    shipments.value = new Map(list.map((shipment) => [shipment.id, shipment]));
  });

  source.addEventListener('shipment', (event) => {
    const shipment: ShipmentView = JSON.parse(event.data);
    shipments.value.set(shipment.id, shipment);
  });

  onScopeDispose(() => source.close());

  return { shipments };
}

Une réponse lente de fetchShipments() peut écraser un événement plus récent : si l'ordre compte, nous comparons une version.

EventSource se reconnecte seul après une coupure, en renvoyant l'identifiant du dernier événement reçu dans l'en-tête Last-Event-ID. Le hub rejoue alors les mises à jour manquées, tant que son historique les conserve : par défaut, le transport Bolt garde tout, sans purge.

Si le hub refuse la reconnexion (jeton expiré), le navigateur abandonne. Le front renouvelle alors le cookie et recrée l'EventSource.

Checklist

  • Le hub, les fronts et les API sur des sous-domaines d'un même domaine enregistrable.
  • Le hub servi en HTTP/2 aux navigateurs.
  • Des topics en IRI, avec un préfixe par service.
  • Un jeton de publication par service, signé hors du service, limité à ses préfixes et renouvelé avant expiration.
  • Des clés distinctes pour les publieurs et pour les abonnés.
  • Des mises à jour privées pour toute donnée personnelle.
  • Un seul service qui pose le cookie, Secure, HttpOnly et SameSite=Strict.
  • cors_origins avec des origines explicites, anonymous désactivé sauf besoin public.
  • Une publication après le commit, jamais dans la transaction.
  • Un front qui relit l'API à chaque connexion et ferme ses EventSource.
  • protocol_version: '1.0' dans le bundle, et des jetons au format 1.0.

Sources