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? EventSource accepts no Authorization header: 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.

One shared hub: each service publishes under its prefixes, each front subscribes registrable domain example.com : APIs, hub and fronts on subdomains order.example.com token restricted to example.com/orders/* stock.example.com token restricted to example.com/stock/* shipping.example.com token restricted to example.com/shipments/* Mercure hub mercure.example.com checks token and grants shop.example.com customer front (Vue) SSE, EventSource with withCredentials admin.example.com back office (Vue) Subscriber cookie, set by order.example.com and sent to the hub by each front Domain=example.com; Path=/.well-known/mercure; Secure; HttpOnly; SameSite=Strict Avoid: one signing key shared by every service Each of them can then sign a token that publishes on the topics of the others.

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 mercure claim (mercure.publish, mercure.subscribe) to the authorization_details claim of RFC 9396;
  • the token becomes an OAuth 2.0 access token (RFC 9068): typ: at+jwt header, mandatory iss, aud and exp claims;
  • the mercureAuthorization cookie becomes __Secure-mercure_access_token;
  • URL Patterns (https://example.com/orders/:id) replace URI Templates, and the subscribers' topic parameter becomes match or match_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 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.

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, HttpOnly and SameSite=Strict.
  • cors_origins with explicit origins, anonymous disabled 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 EventSource instances.
  • protocol_version: '1.0' in the bundle, and tokens in the 1.0 format.

Sources