symfony/mercure-bundle
Symfony bundle that integrates the Mercure Component into full-stack Symfony applications, making it easy to add real-time push updates over Server-Sent Events. Provides configuration, dependency injection setup, and seamless hub integration for publishing and subscribing to live updates.
Installation
composer require symfony/mercure-bundle
Requires symfony/mercure ^0.9 as of bundle v0.6. Register the bundle in config/bundles.php:
return [
// ...
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
];
Configure the hub — config/packages/mercure.yaml:
mercure:
default_hub: default
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
protocol_version: '1.0' # default since 0.6.0
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
claims: # REQUIRED since 0.6.0 for jwt.secret hubs
iss: '%env(MERCURE_JWT_ISSUER)%'
sub: '%env(MERCURE_JWT_SUBJECT)%'
client_id: '%env(MERCURE_JWT_CLIENT_ID)%'
Since 0.6.1, the hub url is added to the default aud claim of protocol 1.0 publisher tokens — the hub receives the POST at exactly that URL, so it validates out of the box even when url (internal) and public_url (browser-facing) differ. Don't set aud yourself unless you mean to replace that default.
.env
MERCURE_URL=https://your-hub/.well-known/mercure
MERCURE_PUBLIC_URL=https://your-hub/.well-known/mercure
MERCURE_JWT_SECRET=change-me
MERCURE_JWT_ISSUER=https://example.com
MERCURE_JWT_SUBJECT=mercure-publisher
MERCURE_JWT_CLIENT_ID=my-app
MERCURE_CORS_ALLOWED_ORIGINS=https://example.com
First publish — inject HubInterface (the Publisher/PublisherInterface services were removed in 0.6.0):
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
public function publish(HubInterface $hub): void
{
$hub->publish(new Update(
'https://example.com/updates',
json_encode(['message' => 'New update!']),
private: false,
));
}
Note the Update signature: (string|array $topics, string $data, bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null) — there is no date parameter.
First subscribe — with protocol 1.0 the browser authenticates via the __Secure-mercure_access_token cookie (mercure_access_token in kernel.debug, because browsers drop __Secure- cookies over plain HTTP):
const url = new URL('https://your-hub/.well-known/mercure');
url.searchParams.append('topic', 'https://example.com/updates');
const es = new EventSource(url, { withCredentials: true });
es.onmessage = (e) => console.log(JSON.parse(e.data));
Where to look first: config/packages/mercure.yaml, then Symfony\Component\Mercure\Update and Symfony\Component\Mercure\HubInterface. Open the Symfony profiler → Mercure panel to see every update your request published (TraceableHub).
Publish after a domain write (controller/service, or better a Doctrine postPersist / messenger handler):
$hub->publish(new Update(
sprintf('https://example.com/invoices/%s', $invoice->getId()),
json_encode(['status' => 'paid', 'total' => $invoice->getTotal()]),
private: true,
));
Topic design — topics are absolute IRIs, not arbitrary strings. Pass an array to fan out one payload to several topics in a single request:
new Update(
['https://example.com/users/42', 'https://example.com/dashboards/7'],
json_encode($payload),
);
Keep a stable convention (/resource/{id}, /users/{id}/feed) so the front end can build the same URLs.
Private topics + browser auth — mark sensitive updates private: true and give the browser a JWT. Since EventSource cannot set an Authorization header, the 1.0 protocol expects the hub cookie (__Secure-mercure_access_token); set it from your app after login, or generate the subscribe URL server-side with the Twig mercure()/mercure_hub() helpers.
Async publishing — never block the request on the hub. Dispatch a Messenger message and publish in a handler, so hub latency/outages don't break the user-facing flow:
$bus->dispatch(new PublishMercureUpdate($topic, $payload));
Multiple hubs / split signing keys — declare additional hubs and inject the remote one by named argument (autowirable since 0.6.0):
public function __construct(private RemoteHubInterface $notificationsHub) {}
mercure:
hubs:
notifications:
url: '%env(HUB_URL)%'
public_url: '%env(HUB_PUBLIC_URL)%'
protocol_version: '1.0'
publisher:
secret: '%env(HUB_PUBLISHER_SECRET)%'
claims: { iss: 'app', sub: 'publisher', client_id: 'app' }
subscriber:
secret: '%env(HUB_SUBSCRIBER_SECRET)%'
claims: { iss: 'app', sub: 'subscriber', client_id: 'app' }
http_client: 'mercure.fast_client'
Internal vs public URL (docker-compose / k8s / reverse proxy) — a very common shape is url: http://hub:3000/.well-known/mercure for publishing from PHP and public_url: https://example.com/.well-known/mercure for browsers. Since 0.6.1 this needs no extra config: the token's default aud is the internal url, which is where the request is actually sent. Before 0.6.1 you had to add a resource_identifier or a hand-rolled aud claim to make the hub accept the publication.
url: 'http://mercure:80/.well-known/mercure' # what PHP posts to → aud
public_url: 'https://example.com/.well-known/mercure' # what the browser opens
Dedicated HTTP client per hub — new in 0.6.0. Point http_client at a scoped client with a short timeout so a slow hub can't stall the worker:
framework:
http_client:
scoped_clients:
mercure.client:
base_uri: '%env(MERCURE_URL)%'
timeout: 2
max_duration: 4
FrankenPHP / Docker Compose — url and public_url now default to null, and an empty url reliably selects FrankenPhpHub at runtime. That makes MERCURE_URL= the idiomatic Compose value for "use the built-in hub", which is still autowirable as HubInterface.
Laravel-style queues vs Messenger — the pattern is identical: dispatch a job/message, publish in the worker. Keep the update payload small (the hub relays it verbatim); ship IDs and let the client refetch large resources.
Testing — mock HubInterface (or use MockHub from symfony/mercure) and assert on the Update you received; there is no MercureTestHub class in this package:
$hub = $this->createMock(HubInterface::class);
$hub->expects($this->once())
->method('publish')
->with($this->callback(fn (Update $u) => $u->getTopics() === ['https://example.com/test']));
Caching — do not debounce updates by caching the "last publish"; cache the rendered payload instead and always publish, otherwise clients silently miss state changes. Use topic granularity (per-resource topics) rather than connection-level throttling.
Publisher aliases are gone (BC break). Publisher, PublisherInterface, TraceablePublisher and StaticJwtProvider (mercure.hub.*.jwt_provider) are no longer registered. Use Hub / HubInterface / TraceableHub, and StaticTokenProvider at mercure.hub.*.jwt.provider. Any service argument typed against the old publisher will fail to wire. Bonus: the symfony/mercure 0.5 deprecation notices that fired on every container boot are gone.jwt.secret hubs now need claims. Without iss, sub and client_id under jwt.claims, the container will not compile — this is the most common upgrade failure. Either add them, or switch to publisher/subscriber options with their own claims.1.0. Against an old 0.x hub you must set protocol_version: '0.x', and the cookie name changes with it. protocol_version also accepts a ProtocolVersion enum case (handy for DI/config builders), not just the string.prod the cookie is __Secure-mercure_access_token, which browsers refuse to store on http://. Local dev works because kernel.debug switches it to mercure_access_token (matching the hub playground default) — if SSE auth mysteriously works in dev but not on staging over HTTP, this is why. Terminate TLS.MercureDataCollector::getPublishers() was removed; use getHubs() in any custom data collector template or test.TraceableHub used to retain messages of previous requests in long-running processes (FrankenPHP worker mode); fixed in 0.6.0. If profiler memory grew per request, this was the cause.default_hub typos now fail loudly. Naming an undefined hub raises a configuration error instead of a TypeError — re-run cache:clear after renaming hubs.url is now part of the default aud for protocol 1.0 publisher tokens. If your config carries a workaround for the internal/public URL split (resource_identifier, or a manual aud entry in jwt.claims), remove it and re-test with a publish; the default now matches what the hub checks. Nothing else changes, so cache:clear and a single publish is enough to validate the upgrade.401 Unauthorized on publish or subscribe. The secret must byte-for-byte match the hub's MERCURE_JWT_SECRET; claims are validated too (iss will be checked if the hub is configured with an issuer, and aud is checked against the hub URL).aud overrides the 0.6.1 default. Defining aud under jwt.claims (or in a custom TokenProviderInterface) replaces the automatic URL-based aud. If you must set it manually, make sure it contains the exact url value the publisher posts to — not the public_url, and not the browser-visible hostname. The same caveat applies to subscriber tokens: the 0.6.1 convenience only covers publisher tokens, so subscriber aud still needs an explicit resource_identifier when internal and public URLs diverge.url now selects the built-in hub, a forgotten/unset MERCURE_URL in production silently changes behaviour rather than throwing. Add a deploy assertion on the env var.MERCURE_CORS_ALLOWED_ORIGINS must list explicit origins; * does not work with credentials, so a wildcard yields a "connected but no events" symptom.$topic = 'https://example.com/updates/' . rawurlencode($resourceId);
EventSource retries stampede. Native reconnect (plus your own onerror handler re-creating the source) can multiply connections. Use one source per page, close it on beforeunload, and back off exponentially on error.https://example.com/users/42/feed) over a single firehose topic everyone subscribes to.private: true without a token = silent nothing. Private updates are filtered per-subscriber; a client with no/invalid JWT simply receives no message and no error.Symfony\Bundle\MercureBundle\Twig\MercureExtension with tags: ['twig.extension'].framework.http_client.default_options / a Monolog channel on Symfony\Contracts\HttpClient to see the actual POST /.well-known/mercure request, status and body. The logged request URL is your ground truth for what aud the token needs to contain.EventSource. 401 = JWT/cookie problem; 404 = wrong hub path; connected-but-idle = topic mismatch or private update without a valid token.curl the hub with a manually minted token to isolate app vs hub:
curl -i -X POST https://your-hub/.well-known/mercure \
-H "Authorization: Bearer $JWT" \
-d 'topic=https://example.com/test' -d 'data=hello'
Decode the token (base64 -d the middle segment) to confirm aud matches the URL you posted to — the fastest diagnosis for a 0.6.x publication that 401s only in staging.TokenProviderInterface — implement getJwt() to mint tokens dynamically (tenant-aware claims, rotation, external IdP) and reference it from the hub config instead of jwt.secret. Remember you take over claim assembly entirely, including aud (see Pitfalls #2).HubInterface — add correlation IDs, redaction of PII or metrics around publish() without touching call sites.http_client / publisher / subscriber — the intended seams for multi-tenant or multi-region setups: distinct keys, distinct timeouts, distinct hubs, all wired through config rather than factories in code.Update subclass / value object — don't subclass Update to carry metadata; wrap it in your own DTO and map to Update at the edge ($type and $id are already available for idempotency and client-side dispatch).How can I help you explore Laravel packages today?