The problem
Back to the online shop split into the order,
stock and shipping services.
Customers follow their order on
shop.example.com, the logistics team works on
admin.example.com. Both Vue fronts call each
service's API directly, with no BFF in between.
When stock confirms a reservation or a parcel leaves, the open page must update by itself. Without a broadcast mechanism, each front polls each API at a fixed interval, and the load grows with the number of open tabs.
A Mercure hub broadcasts these changes over Server-Sent Events (SSE). Without a BFF, three questions remain:
-
who authenticates the subscriber?
EventSourceaccepts noAuthorizationheader: the token travels in a cookie, which the browser must send to the hub; - who may publish what? With a shared signing key, every service can publish on the topics of the others;
- who sees what? An order's status must reach its customer only.
The options
Polling each API at a fixed interval
- What it solves: no component to add, each API keeps its own authentication.
- What it costs: requests that are useless most of the time, and a display that lags by one interval.
One SSE or WebSocket stream per service
- What it solves: each service pushes its own changes, with its own authentication.
- What it costs: one connection per service and per tab, and a broadcast to write and run in every service.
One BFF per front
- What it solves: the front has a single counterpart, which authenticates, aggregates and pushes the data.
- What it costs: one more service per front, to evolve with every API it aggregates.
A shared Mercure hub, without a BFF
- What it solves: a single connection per tab, whatever the number of services. Services publish with an HTTP request, the hub handles connections, reconnections and history.
- What it costs: one component to run, and constraints on domains, cookies and tokens.
Our recommendation
A Mercure hub under the fronts' domain, one publisher token per service restricted to its topics, a cookie on the parent domain.
Each topic is an IRI that identifies the resource:
https://example.com/orders/:id. Each service
publishes under its own prefixes. Personal data goes out as
private updates, which only reach subscribers whose token
covers one of their topics.
What changed with Mercure 1.0
The 1.0 hub, released on 16 September 2026, breaks with the format of the earlier drafts:
-
grants move from the
mercureclaim (mercure.publish,mercure.subscribe) to theauthorization_detailsclaim of RFC 9396; -
the token becomes an OAuth 2.0 access token (RFC 9068):
typ: at+jwtheader, mandatoryiss,audandexpclaims; -
the
mercureAuthorizationcookie becomes__Secure-mercure_access_token; -
URL Patterns (
https://example.com/orders/:id) replace URI Templates, and the subscribers'topicparameter becomesmatchormatch_urlpattern.
The hub only accepts the old format in compatibility mode.
On the Symfony side, symfony/mercure 0.8 and
symfony/mercure-bundle 0.5 have spoken both
protocols since August 2026. The bundle still defaults to
0.x, and the symfony.com documentation still
describes the old format: we therefore declare
protocol_version: '1.0'.
The cookie requires a shared domain
The subscriber's token travels in a cookie, set by an API
such as order.example.com and read by
mercure.example.com. A server can only set the
Domain attribute to its own domain or a parent
domain: here, Domain=example.com. This API and
the hub therefore share the same registrable domain.
So do the fronts: between subdomains of the same domain,
requests stay "same-site", and
SameSite=Strict does not block the cookie. A
hub on another domain rules the cookie out: the front then
reads the stream with fetch() and an
Authorization header.
One publisher token per service, restricted to its prefixes
The hub checks a token's signature, then applies the grants
it carries. A service holding the signing key can therefore
grant itself every right. Each service gets an already
signed token instead, restricted to its prefixes: a
compromised stock cannot publish anything under
https://example.com/orders/.
The trade-offs we accept
The service that sets the cookie holds the subscriber key. It can sign a token that reads every private update. We keep it in that single service, separate from the publishers' key.
One cookie per browser for the hub. The
browser replaces a cookie with the same name, domain and
path: two fronts opened under two identities overwrite each
other's grants. If two identities must coexist, the front
reads the stream with fetch() and an
Authorization header.
Publisher tokens expire. Mercure 1.0 makes
exp mandatory: a token signed at deployment
needs a rotation before it expires. An authorization server
issuing short-lived tokens removes this rotation, at the
cost of one more component.
An update can be lost. If the hub does not answer after the commit, the data stays correct: only the display lags. The front fetches the state from the API again on every connection, because Mercure notifies and the API is the reference.
Each service manages its own token. A
single relay consuming app.events would publish
everything with one token, but would depend on every
service's contracts.
When to bring a BFF back
We bring a BFF back when one of these signals shows up:
- aggregation: each screen assembles data from several services, at the cost of many calls;
- authorization: read rights can no longer be expressed as topic prefixes (per-resource rules, delegations, fields hidden by role);
- exposure: the services must not be reachable from the browser;
- domains: the fronts cannot share a registrable domain with the hub and the APIs.
Implementation
The examples use the Mercure hub 1.0.2, Symfony 7.4 with
symfony/mercure-bundle 0.5, and Vue 3.
1. Give each service a topic prefix
-
order:https://example.com/orders/*; -
shipping:https://example.com/shipments/*; -
stock:https://example.com/stock/*, as public updates.
A private update carries a second topic, called an alternate
topic, in the customer's space:
https://example.com/customers/:customerId/shipments/:id
for shipping. Each service publishing private
updates therefore has a second prefix in that space. A
customer's token covers only that space, and one connection
is enough to follow their orders and parcels.
2. Configure the hub
# Caddyfile (hub)
mercure.example.com {
mercure {
# Publisher tokens: signed outside the services, verified with a public key.
issuer https://example.com {
publisher {
jwt {env.MERCURE_PUBLISHER_PUBLIC_KEY} ES256
}
}
# Subscriber tokens: signed by the order service, which sets the 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
}
Each issuer block binds an issuer (the
iss claim) to its keys: a token is only
verified with its issuer's keys.
cors_origins lists explicit origins, because
the * wildcard disables cookies.
Over HTTP/1.1, a browser opens at most six connections per domain, across all tabs. Caddy serves the hub over HTTP/2 by default; a reverse proxy in front of it must do the same.
3. Sign one publisher token per service
The publishers' private key stays in the deployment pipeline
or the secret manager. The pipeline signs one token per
service with the hub's mercure-token command:
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/*'
The lifetime, 30 days here, sets the rotation pace. The Mercure documentation advises minutes or hours: these 30 days are a deliberate trade-off. The service only receives the token:
# config/packages/mercure.yaml (shipping service)
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
protocol_version: '1.0'
jwt: '%env(MERCURE_PUBLISHER_TOKEN)%'
4. Publish after the commit
// src/Shipment/DispatchShipment.php (shipping service)
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);
// The outbox receives shipping.shipment.dispatched.v1 in this 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) {
// The data is stored: only the live display lags behind.
$this->logger->warning('Mercure update not published.', ['exception' => $exception]);
}
}
}
The first topic is the canonical topic, the second the
alternate topic: the publisher token must cover both. The
type field becomes the name of the SSE event
the front listens to. The
shipping.shipment.dispatched.v1 event itself
leaves through
the outbox, towards
idempotent consumers.
In an event consumer, the
doctrine_transaction middleware only commits
the transaction after the handler. There, we therefore send
a dedicated message with a
DispatchAfterCurrentBusStamp: Messenger only
handles it after the commit.
Its handler publishes to the hub and catches the hub's errors, as above. Otherwise, Messenger would retry the consumed event, which the idempotent consumer would skip: the update would never go out.
5. Set the subscriber cookie
A single service sets the cookie: here order,
which knows the customer. A second hub entry holds the
subscriber key, kept for the cookies:
# config/packages/mercure.yaml (order service)
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'
The aud claim takes the hub's URL, equal to its
resource_identifier. With two entries, an
Update sent through Messenger would be
published on both: order therefore publishes
through HubInterface, which targets the default
entry.
// src/Controller/MercureAuthorizationController.php (order service)
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();
// The customer only reads the updates published in their own space.
$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() derives the
Domain attribute from the request's host and
the hub's host. It throws an exception if they share no
parent domain. The cookie is Secure,
HttpOnly, SameSite=Strict, and
limited to the /.well-known/mercure path.
With PHP's default configuration, the token expires after
one hour. For the back office, a similar endpoint grants
https://example.com/orders/* and
https://example.com/shipments/* according to
the role.
6. Subscribe from the Vue front
The front calls the endpoint after the user logs in, then
before each token expiry, whose lifetime follows the
bundle's default_cookie_lifetime option:
// src/live/authorizeLiveUpdates.ts (shop front)
export async function authorizeLiveUpdates(): Promise<void> {
// Without credentials: 'include', the browser ignores a Set-Cookie from another origin.
await fetch('https://order.example.com/mercure/authorization', {
method: 'POST',
credentials: 'include',
});
}
The CORS configuration of order allows
credentials for https://shop.example.com. The
composable then opens the stream:
// src/composables/useLiveShipments.ts (shop front)
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: the browser attaches the cookie set on 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 };
}
A slow fetchShipments() response can overwrite
a more recent event: when order matters, we compare a
version.
EventSource reconnects by itself after a drop,
sending the ID of the last event received in the
Last-Event-ID header. The hub then replays the
missed updates, as long as its history still holds them: by
default, the Bolt transport keeps everything, with no purge.
If the hub refuses the reconnection (expired token), the
browser gives up. The front then renews the cookie and
creates a new EventSource.
Checklist
- The hub, the fronts and the APIs on subdomains of the same registrable domain.
- The hub served over HTTP/2 to browsers.
- Topics as IRIs, with one prefix per service.
- One publisher token per service, signed outside the service, restricted to its prefixes and renewed before it expires.
- Separate keys for publishers and for subscribers.
- Private updates for any personal data.
-
A single service that sets the cookie,
Secure,HttpOnlyandSameSite=Strict. -
cors_originswith explicit origins,anonymousdisabled unless public data requires it. - Publication after the commit, never inside the transaction.
-
A front that fetches the state from the API again on every
connection and closes its
EventSourceinstances. -
protocol_version: '1.0'in the bundle, and tokens in the 1.0 format.
Sources
- IETF, The Mercure Protocol (draft-dunglas-mercure-08): authorization, cookie, alternate topics.
- Mercure, release notes 1.0.0 and 1.0.2: format changes.
-
Mercure,
Authorization:
authorization_details, cookie, expiry. - Mercure, Topics and matchers: URL Patterns and alternate topics.
-
Mercure,
Configuration:
issuer,cors_origins,anonymous. -
Mercure,
Reconnection and history:
Last-Event-IDand history. - Mercure, Upgrade guide: compatibility mode.
- Symfony, changelogs of the Mercure component (0.8) and of MercureBundle (0.5): protocol 1.0.
- Symfony, source of the Authorization class: cookie domain and attributes.
- Symfony, Mercure: publishing and cookie (protocol 0.x).
-
Symfony,
Messenger, transactional messages:
DispatchAfterCurrentBusStamp. -
MDN,
EventSource:
withCredentials, connection limit. -
MDN,
HTTP cookies:
Domain,SameSite,__Secure-prefix. -
MDN,
Request.credentials:
Set-Cookieon cross-origin responses. - IETF, RFC 6265, section 5.3: replacing a cookie.
- Caddy, global options: HTTP protocols enabled by default.