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é ?
EventSourcen'accepte pas d'en-têteAuthorization: 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.
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 claimauthorization_detailsde la RFC 9396 ; -
le jeton devient un jeton d'accès OAuth 2.0 (RFC 9068) :
en-tête
typ: at+jwt, claimsiss,audetexpobligatoires ; -
le cookie
mercureAuthorizationdevient__Secure-mercure_access_token; -
les URL Patterns
(
https://example.com/orders/:id) remplacent les URI Templates, et le paramètretopicdes abonnés devientmatchoumatch_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 cookie impose un domaine commun
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.
5. Poser le cookie d'abonnement
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,HttpOnlyetSameSite=Strict. -
cors_originsavec des origines explicites,anonymousdé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
- IETF, The Mercure Protocol (draft-dunglas-mercure-08) : autorisation, cookie, topics alternatifs.
- Mercure, notes de version 1.0.0 et 1.0.2 : changements de format.
-
Mercure,
Authorization
:
authorization_details, cookie, expiration. - Mercure, Topics and matchers : URL Patterns et topics alternatifs.
-
Mercure,
Configuration
:
issuer,cors_origins,anonymous. -
Mercure,
Reconnection and history
:
Last-Event-IDet historique. - Mercure, Upgrade guide : mode de compatibilité.
- Symfony, changelogs du composant Mercure (0.8) et du MercureBundle (0.5) : protocole 1.0.
- Symfony, code de la classe Authorization : domaine et attributs du cookie.
- Symfony, Mercure : publication et cookie (protocole 0.x).
-
Symfony,
Messenger, messages transactionnels
:
DispatchAfterCurrentBusStamp. -
MDN,
EventSource
:
withCredentials, limite de connexions. -
MDN,
Cookies HTTP
:
Domain,SameSite, préfixe__Secure-. -
MDN,
Request.credentials
:
Set-Cookiedes réponses cross-origin. - IETF, RFC 6265, section 5.3 : remplacement d'un cookie.
- Caddy, options globales : protocoles HTTP actifs par défaut.