symfony/mercure-notifier
Symfony Mercure Notifier bridge integrates Mercure with the Symfony Notifier component, enabling real-time push notifications through a Mercure hub. Configure a Mercure transport to deliver notifier messages to browsers and clients.
This package is thin glue, not a notifier in its own right: it adds a mercure:// transport to symfony/notifier that publishes notifications to a Mercure hub as SSE updates. In practice you spend 10% of your time here and 90% in Mercure configuration and frontend subscription code.
Minimal setup
composer require symfony/mercure-notifier symfony/mercure-bundle
# config/packages/mercure.yaml
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%' # server-side publish endpoint
public_url: '%env(MERCURE_PUBLIC_URL)%' # browser-facing hub
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
# config/packages/notifier.yaml
framework:
notifier:
chatter_transports:
mercure: 'mercure://default' # "default" = hub name above
First real use case — push a live in-app notification to one user's browser:
use Symfony\Component\Notifier\Bridge\Mercure\MercureMessage;
use Symfony\Component\Notifier\ChatterInterface;
final class LiveNotifier
{
public function __construct(private ChatterInterface $chatter) {}
public function notify(int $userId, string $title, string $body): void
{
$this->chatter->send(
(new MercureMessage())
->subject($title)
->content(json_encode(['title' => $title, 'body' => $body]))
->topic(sprintf('https://example.com/users/%d/notifications', $userId))
);
}
}
Browser side:
const es = new EventSource(
`${MERCURE_PUBLIC_URL}?topic=${encodeURIComponent(`https://example.com/users/${userId}/notifications`)}`,
{ withCredentials: true }
);
es.onmessage = (e) => toast(JSON.parse(e.data));
Where to look first
vendor/symfony/mercure-notifier/MercureTransport.php — it's ~40 lines; read it, then MercureTransportFactory.php. Everything the package does is there.config/packages/mercure.yaml and .env — most failures are MERCURE_URL / JWT config, not code.LogicException thrown in doSend() tells you exactly what type the transport accepts.Direct chatter vs. NotifierInterface. The clean path is ChatterInterface + a MercureMessage, because the message carries the topic and update options. Going through NotifierInterface::send(new Notification(...), ['mercure']) builds a generic ChatMessage, which the Mercure transport may reject or publish without your intended topic. If you want the high-level Notification API, write a tiny custom channel that converts to MercureMessage.
Per-user private topics (the default for anything user-specific):
$message
->topic('https://example.com/users/'.$user->getId().'/notifications')
->options((new MercureOptions())->private(true));
Then mint a subscriber JWT containing mercure.subscribe for that topic and set it as a cookie (mercureAuthorization) — otherwise the browser connects successfully but receives nothing, which is the single most common "it doesn't work" report. The bridge exposes MercureOptions for the Mercure update flags (private, id, type, retry); verify the setter names against the version you installed, as they track symfony/mercure.
Public broadcast for announcements/global toasts: omit private, use a stable IRI topic like https://example.com/announcements, and let any client subscribe unauthenticated.
JSON envelope convention. Mercure carries a string. Pick one shape early and standardize on it across all publishers:
{ "type": "order.shipped", "title": "Shipped", "body": "...", "url": "/orders/42" }
Frontend switches on type. This avoids a rewrite when you add a second notification source.
Async publishing. HubInterface::publish() is a synchronous HTTP call to the hub. In request paths, dispatch a NotificationMessage through Messenger and publish in the handler. Keeps response times flat and gives you retries for hub blips.
Domain-event fan-out. Rather than sprinkling ChatterInterface calls, listen to domain events (OrderShipped, CommentMentioned) and translate them to Mercure messages in one listener. You get one place to change topic conventions and payload shapes.
Reconnection. Use Last-Event-ID and the id update option for anything where missing a message matters; EventSource reconnects automatically and replays from that ID only if the hub kept history (it does not by default — see gotchas).
Testing. Mock Symfony\Component\Mercure\HubInterface and assert publish() received an Update with the expected topic and data. The bridge ships a transport-factory test case; extending it is the fastest way to keep DSN parsing covered. In functional tests, either swap the hub for a fake or assert on the fake's captured updates.
MERCURE_URL vs MERCURE_PUBLIC_URL. The notifier publishes to url (internal, e.g. http://hub:3000/.well-known/mercure); browsers connect to public_url. Swapping them produces either connection timeouts server-side or mixed-content/CORS failures client-side.mercure.publish (usually a secret on the mercure bundle config); subscribing to private topics needs a JWT with mercure.subscribe for the exact topic IRIs, delivered as a cookie. Getting the first right and the second wrong yields a silent empty stream.https://example.com/users/1/notifications and .../users/1/Notifications and .../users/01/... are different topics. Build them with one helper, not inline sprintf in five places.EventSource instances starve each other and the page. Use HTTP/2, or open one connection and subscribe to multiple topics via repeated topic= query params.type vs onmessage. If you set a custom type on the update, it fires as a named event (es.addEventListener('order.shipped', ...)), and onmessage never fires. Choose one convention.mercure://default resolves a hub from mercure.hub.default via the Mercure bundle's registry. In a non-full-stack app (no bundle), you must register MercureTransportFactory yourself with the right HubInterface; the DSN alone does nothing.getMessageId() is null. Mercure doesn't return a publish ID, so SentMessage::getMessageId() is empty. Don't build idempotency or logging around it.TransportException, hiding the HTTP detail. Catch it and inspect the previous exception, or log the hub response at the Mercure client level.symfony/mercure-notifier, symfony/notifier, and symfony/mercure are released together; version skew between them is the usual source of "method does not exist" surprises after composer update.bin/console debug:container mercure.hub.default to confirm the hub wiring; bin/console debug:container --tag=notifier.transport_factory to confirm the transport is registered; then curl -X POST to the hub with a hand-made JWT to isolate whether the problem is Symfony or Mercure. There is no debug:notifier command.CORS_ALLOWED_ORIGINS on the Mercure container). Server-side publishing works fine while the browser silently fails with a CORS error only visible in devtools.How can I help you explore Laravel packages today?