- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You ship a notifications feature. Sales wants live order updates in the dashboard. PMs want a little green dot when a new comment lands. Someone says "WebSockets" in the kickoff and the room nods because that's the word everyone knows.
Three weeks later you're tuning a Soketi cluster, fighting sticky-session config in your load balancer, and wondering why a feature that pushes 200 messages per minute needs its own runtime.
Mercure is the answer for most of these cases. It's a hub that speaks Server-Sent Events over plain HTTP/2, ships as a single Go binary, and integrates with Symfony through a one-line HubInterface->publish(...) call. You don't need WebSockets for 80% of "push to browser" work. You probably knew that already. What's harder is knowing where the edges are.
SSE vs WebSockets: when one-way push is enough
A WebSocket is a bidirectional TCP-ish channel over HTTP upgrade. Both sides can send anything, any time. That's strong and it costs you: every gateway, proxy, and CDN in the path needs to understand the upgrade handshake. Cloudflare does. Your enterprise customer's corporate proxy might not. Your /health checker definitely doesn't.
Server-Sent Events are the boring sibling. Plain HTTP, Content-Type: text/event-stream, the server keeps the response open and writes lines like data: {"order_id": 42}\n\n. The browser ships EventSource as a built-in API. No upgrade dance, no special framing, no extra middleware.
The trade-off: SSE is server-to-client only. If the client needs to send messages back, you do that with a regular POST request. Chat app where users type? A POST per message is cheap. Multiplayer game where every keypress matters? WebSockets win because the per-message overhead of opening a new HTTP request is real.
Rule of thumb: if more than 10% of your traffic flows client-to-server in real time, use WebSockets. Otherwise SSE is enough, and Mercure makes it easy.
Mercure's design: a hub between publishers and subscribers
Mercure is two protocols and one server.
Publishers (your Symfony app) POST updates to the hub:
POST /.well-known/mercure HTTP/1.1
Authorization: Bearer <publisher-jwt>
Content-Type: application/x-www-form-urlencoded
topic=https://example.com/orders/42&data=%7B%22status%22%3A%22shipped%22%7D
Subscribers (the browser) open a long-lived GET to the same hub with the topics they want:
GET /.well-known/mercure?topic=https://example.com/orders/42 HTTP/1.1
Accept: text/event-stream
Authorization: Bearer <subscriber-jwt>
The hub fans the update out to every subscriber on that topic. Your PHP process publishes once, ten thousand connected browsers receive it. The PHP process isn't holding any connection open. That's the architectural win: long-lived connections live on a Go binary tuned for that one job, and your PHP-FPM pool stays free to do PHP-FPM things.
From Symfony, the symfony/mercure bundle gives you a HubInterface. The whole publish path is this:
<?php
namespace App\Order;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
final class OrderShippedNotifier
{
public function __construct(private HubInterface $hub) {}
public function notify(int $orderId, string $tracking): void
{
$update = new Update(
topics: "https://example.com/orders/{$orderId}",
data: json_encode([
'order_id' => $orderId,
'status' => 'shipped',
'tracking' => $tracking,
], JSON_THROW_ON_ERROR),
private: true, // requires subscriber JWT to include this topic
);
$this->hub->publish($update);
}
}
That's it. No queue, no socket, no daemon. The publish() call is a synchronous HTTP request to the hub. Usually sub-millisecond on a co-located hub, a few ms over the network. If you want it async, dispatch it through Messenger and let a worker fire the publish.
The private: true flag matters. Without it, anyone who knows the topic URL can subscribe. With it, the subscriber's JWT must explicitly authorize this exact topic. Most production setups want every topic private.
Authorization: JWT topic-level claims
This is where Mercure gets interesting and where most tutorials stop short.
The hub trusts JWTs signed with a shared secret. Inside the JWT, two claims matter:
-
mercure.publish: list of topic patterns this token can publish to. -
mercure.subscribe: list of topic patterns this token can subscribe to.
Topic patterns support URI templates per RFC 6570. So you can issue a JWT that lets a user subscribe to https://example.com/users/{id}/notifications where {id} is bound to their user ID, and nothing else.
In Symfony, you don't usually mint JWTs by hand. The bundle does it via Authorization cookies:
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
final class DashboardController extends AbstractController
{
#[Route('/dashboard', name: 'dashboard')]
public function index(Authorization $authorization): Response
{
$user = $this->getUser();
$userId = $user->getId();
$response = $this->render('dashboard/index.html.twig');
// sets a HttpOnly, Secure cookie called "mercureAuthorization"
// the browser's EventSource sends it automatically
$authorization->setCookie(
$response,
subscribe: [
"https://example.com/users/{$userId}/notifications",
"https://example.com/orders/{user_id}", // bound below
],
additionalClaims: [
'mercure' => [
'payload' => ['user_id' => $userId],
],
],
);
return $response;
}
}
The cookie is HttpOnly and scoped to the hub's domain. The browser will never expose the JWT to your JS. When EventSource connects, the cookie rides along automatically. No bearer tokens in URLs, no localStorage exposure to XSS.
Gotcha: the cookie's Domain must match the hub's domain, and SameSite defaults bite hard if the hub is on a subdomain. If your app is at example.com and the hub is at mercure.example.com, set MERCURE_PUBLIC_URL and the cookie domain to .example.com, not the subdomain. Otherwise the cookie won't be sent and the hub returns 401 with a message that doesn't tell you why. Two senior teams can burn a full afternoon on this before they spot it.
Hub Caddy config
Mercure's reference hub is a Caddy module. A minimal Caddyfile looks like this:
{
# https://caddyserver.com/docs/caddyfile/options
auto_https off
admin off
log {
level INFO
}
}
:80 {
encode zstd gzip
mercure {
# JWT secret: must match what your Symfony app signs with
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_JWT_ALG}
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_JWT_ALG}
# CORS: every browser origin that opens EventSource
cors_origins https://app.example.com https://admin.example.com
# topic publishers must use https URIs that match these patterns
publish_origins https://app.example.com
# tune for your traffic shape; see "scaling" below
write_timeout 600s
dispatch_timeout 5s
# the "anonymous" toggle: usually false in production
anonymous false
# bolt = single-node persistence for missed messages
# for multi-node, swap to "transport_url redis://..."
transport_url "bolt:///data/mercure.db?size=1000&cleanup_frequency=0.3"
}
route /.well-known/mercure* {
mercure
}
respond /healthz "ok" 200
}
Set MERCURE_PUBLISHER_JWT_KEY and MERCURE_SUBSCRIBER_JWT_KEY in your env. They can be the same secret if you want; the distinction lets you rotate one without the other. MERCURE_JWT_ALG is usually HS256.
The transport_url is the part that bites people. bolt:// is local disk: fine for one hub, broken the second you run two. For multi-hub setups, use redis:// or any other Mercure-supported transport. The hubs subscribe to the same channel; publishing to any hub fans the message out to every connected browser on every hub.
EventSource browser-side code
The client is unsurprising and that's the point:
<script>
const userId = 42;
const url = new URL("https://mercure.example.com/.well-known/mercure");
url.searchParams.append(
"topic",
`https://example.com/users/${userId}/notifications`
);
url.searchParams.append(
"topic",
`https://example.com/orders/${userId}`
);
// withCredentials sends the HttpOnly mercureAuthorization cookie
const es = new EventSource(url, { withCredentials: true });
es.addEventListener("message", (event) => {
const payload = JSON.parse(event.data);
// route by topic embedded in payload, or use named events
handleUpdate(payload);
});
es.addEventListener("error", (event) => {
// EventSource auto-reconnects; log if you care about gaps
console.warn("mercure connection blip", event);
});
</script>
EventSource reconnects automatically with exponential backoff. On reconnect, the browser sends a Last-Event-ID header, and Mercure replays anything you missed, if the transport is bolt or redis with retention configured. That's the killer feature people miss: a flaky mobile connection drops, reconnects, and the user sees the updates they would have missed. With raw WebSockets you write that bookkeeping yourself.
If you need named events (different handlers for different message types), publish with the type argument:
$update = new Update(
topics: "https://example.com/orders/{$orderId}",
data: json_encode($payload),
type: 'order.shipped', // arrives as event.type on the client
);
$this->hub->publish($update);
Then bind on the client:
es.addEventListener("order.shipped", (event) => { /* ... */ });
Cleaner than parsing a type field out of the payload.
Scaling: multiple hubs, sticky sessions, why this is easier than WS
Here's the part that sells Mercure to ops.
A WebSocket gateway typically needs sticky sessions because the gateway holds in-memory state for each connection. Two browsers on two gateway nodes can't see each other's messages unless you bolt on a pub/sub layer (Redis, NATS) and write the fan-out yourself. Your load balancer needs ip_hash or a cookie-based stickiness rule. Auto-scaling becomes a project.
Mercure builds that in. Multiple hubs share a transport_url. A browser connected to hub A receives a publish sent to hub B. Your load balancer can round-robin; there's no stickiness required. To add capacity you start another hub container, point it at the same Redis, done.
For ~10k concurrent subscribers per hub, one Caddy-Mercure container on a small box is fine. The Go runtime doesn't blink at long-lived idle connections; that's its strength. Past that, scale horizontally by adding hubs. The PHP side scales the way it always has, because PHP isn't holding connections.
Real gotcha: the connection limit is set by your OS file descriptors and your load balancer's per-pod max connections. The default ulimit -n of 1024 will bite you at exactly the worst moment. Set it to at least 65536 on the hub. AWS ALB has a max of about 100k concurrent connections per ALB node, which sounds like a lot until your B2B SaaS lands a customer with 50k seats.
Backpressure when subscribers are slow
A slow subscriber on a hub-and-spoke push system is a buffer that grows. Mercure's write_timeout config caps how long the hub waits to flush a message to a single subscriber. Default is 600s (10 minutes), which is too generous in most apps.
If a mobile client is on a flaky 3G connection and can't drain the buffer, the hub eventually disconnects them. They reconnect, replay via Last-Event-ID, and life continues. The trap is when you publish enormous payloads. A 500KB JSON update times ten thousand slow subscribers fills hub memory fast.
Rule: keep individual updates under 4KB. If you need to send more, send a small "something changed for X" update and let the client fetch the full payload from your regular API. That gives you HTTP cache headers, range requests, and CDN caching for the heavy bytes. Mercure carries the notification, not the data.
Using Mercure from Laravel
The hub doesn't know what framework you're publishing from. There's no official Laravel bundle, but the publish is one HTTP call. You can use symfony/mercure directly in a Laravel project; Composer doesn't care:
<?php
namespace App\Notifications;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\Jwt\StaticTokenProvider;
use Symfony\Component\Mercure\Update;
final class OrderNotifier
{
private Hub $hub;
public function __construct()
{
$this->hub = new Hub(
url: config('mercure.hub_url'),
jwtProvider: new StaticTokenProvider(config('mercure.publisher_jwt')),
);
}
public function shipped(int $orderId): void
{
$this->hub->publish(new Update(
topics: "https://example.com/orders/{$orderId}",
data: json_encode(['status' => 'shipped']),
private: true,
));
}
}
Bind it as a singleton in a service provider and you're done. For minting subscriber JWTs in Laravel, lcobucci/jwt (already in Laravel's Sanctum dependency tree) does the job: sign with the shared secret, embed mercure.subscribe claims, drop in an HttpOnly cookie scoped to the hub domain.
When to reach for WebSockets instead
Mercure isn't the right answer for everything. Pick WebSockets when:
- The client sends frequent low-latency messages back. Live cursor tracking, multiplayer game state, voice/video signaling.
- You need true peer-to-peer routing via the server. SSE is broadcast-with-filters; if you need "send this to user X specifically with sub-50ms latency on both directions," WebSockets give you a tighter loop.
- You're building on top of a protocol that already runs over WebSocket: STOMP for traditional message queues, GraphQL subscriptions, MQTT-over-WS for IoT.
Everything else (notifications, dashboard updates, comment streams, presence indicators, live log tailing, chat where typing is rare relative to reading) runs cheaper on SSE through Mercure. Less to run, less to operate, less to debug. The protocol is curl-able. That alone is worth a lot when something breaks at 2am.
What's your current real-time stack? Have you tried SSE in production, or are you still defaulting to WebSockets out of habit?
If this was useful
This post is one slice of how to keep your real-time layer thin: a hub does the connection-holding, your app stays a normal request/response unit, and the boundary between them is one publish() call. Codebases that scale past their framework's defaults reach for that kind of seam everywhere: push, jobs, payments, integrations. Decoupled PHP is the architectural layer your codebase wants once "we'll just call the SDK from the controller" stops working.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)