Skip to content

Laravel Mercure Broadcasting: SSE vs WebSockets, and the Reverb I Retired

Laravel Mercure Broadcasting: SSE vs WebSockets, and the Reverb I Retired

Short version for the impatient: Laravel 13.32 shipped a Mercure broadcast driver, Mercure runs over Server-Sent Events instead of WebSockets, and if you’re already on FrankenPHP you can delete the Reverb process from your supervisor config and keep your broadcast() calls exactly as they are. If you want to know why I care, read on.

I’ve had a Reverb process on a client’s dashboard server for about a year. It does one job: push order status changes to a browser tab that a warehouse team keeps open all day. The traffic is entirely one direction. The server talks, the browser listens. Nobody types anything into that page that needs a socket. And yet I’ve restarted that process four times this year because it wedged after a deploy, and twice more because a certificate rotation confused it. Each time I thought the same thing: I’m running a full duplex WebSocket server to deliver what is, functionally, a notification feed.

When Laravel 13.32 landed on September 16 with a Mercure driver contributed by Kévin Dunglas, I pulled the branch the same afternoon. This post is what I found, including the one place where it bit me.

SSE vs WebSockets, without the tribalism

Server-Sent Events are a plain HTTP response that never finishes. The browser opens a GET request, the server keeps the connection open and writes data: lines whenever it has something to say. The EventSource class has been in every browser for over a decade, it reconnects on its own, and it sends a Last-Event-ID header when it does, so the server can replay what was missed. That last part is the bit people forget. Reconnection isn’t something you bolt on. It’s in the protocol.

WebSockets give you a bidirectional channel over a single connection, upgraded from HTTP. That’s the right tool when the client needs to send a lot of small messages fast: collaborative editors, multiplayer anything, live cursors, chat with typing indicators. For those, SSE is the wrong shape.

The honest comparison is about direction and volume, and I’d guess most Laravel broadcasting in the wild is one way. Notifications. Job progress. “Your export is ready.” Order status. A dashboard counter. Those are all server to client, and the client’s only contribution is “I’m here”. For that workload, SSE has a few concrete advantages I could measure:

It goes through every proxy, load balancer, CDN and corporate firewall I’ve ever configured without special handling, because it’s a long HTTP response, not an upgrade. Cloudflare’s docs on this are short because there’s nothing to configure. WebSocket support on the same edge products has a history of timeouts and per-plan limits. It uses HTTP/2 multiplexing, so ten EventSource connections from one tab don’t cost ten TCP connections. And it’s text over HTTP, so curl -N is a complete debugging tool.

The downsides are real too. SSE is text only (you’ll base64 anything binary, which you were probably doing over JSON anyway). Browsers cap SSE connections per origin on HTTP/1.1 at six, which matters if you’re stuck without HTTP/2 for some reason. And the client can’t push through the same pipe, so anything bidirectional needs a normal HTTP request going the other way. Mercure handles that case with a separate publish endpoint, which I’ll get to.

What Mercure adds on top of raw SSE

You could hand-roll SSE in a Laravel controller today with a StreamedResponse and a while (true) loop. I’ve done it. It works until you have two app servers, because now a message published on server A never reaches a browser subscribed to server B. You need a hub.

Mercure is that hub, plus a small protocol on top of SSE: topics are URLs, subscribers ask for topics via query string, publishers POST to the hub with a JWT, private topics require the subscriber to carry a JWT too, and the hub keeps an event history so Last-Event-ID replay actually works. Dunglas published the 1.0 alpha in August, and the headline changes are that authorization is now aligned to OAuth 2.0 and topic matching uses the WHATWG URL Pattern standard instead of URI Templates. The Laravel driver targets 1.0.

The part that made me actually try it: FrankenPHP ships a Mercure hub inside the binary. It’s a Caddy module, enabled with a block in the Caddyfile, and it exposes a mercure_publish() PHP function that publishes in-process. No HTTP round trip from your app to your hub, no separate daemon, no supervisor entry. If you read my post on FrankenPHP worker mode and the static that outlived my request, you know I’m already running FrankenPHP for that client, so this was a config change rather than a migration.

The driver, and how little of my code changed

Here’s the config that went into config/broadcasting.php. This is verbatim from the release:

'mercure' => [
    'driver' => 'mercure',
    'url' => env('MERCURE_URL'),
    'public_url' => env('MERCURE_PUBLIC_URL'),
    'secret' => env('MERCURE_JWT_SECRET'),
    'encryption_key' => env('MERCURE_ENCRYPTION_KEY'),
],

With the FrankenPHP built-in hub, url and secret are left empty and the driver uses mercure_publish() directly. The PR description says this plainly, and I confirmed it by reading the driver: CreatesMercureDrivers.php checks function_exists('mercure_publish') and only builds the HTTP publisher when that comes back false.

My event class didn’t change at all. This is the same ShouldBroadcast event that’s been firing at Reverb for a year:

class OrderStatusChanged implements ShouldBroadcast
{
    public function __construct(public Order $order) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel("warehouse.{$this->order->warehouse_id}")];
    }
}

On the JavaScript side the companion Echo patch adds a mercure broadcaster. Before, with Reverb:

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: 443,
    forceTLS: true,
});

After:

window.Echo = new Echo({
    broadcaster: 'mercure',
    hubUrl: '/.well-known/mercure',
});

The Echo.private('warehouse.3').listen('OrderStatusChanged', ...) call underneath stayed identical. That’s the whole point of the broadcaster abstraction, and it’s the first time it has paid off for me in a way that removed a process instead of adding one.

The auth model is different, and better

With Pusher-style drivers, every private channel subscription triggers a POST to /broadcasting/auth. Ten channels, ten requests, each one hitting your session and your channel authorization callbacks. I’d never thought hard about it because it’s cheap enough, but on the warehouse dashboard we subscribe to one channel per active picker, and on a busy morning that’s a burst of forty auth requests per page load.

The Mercure driver replaces that with a batch auth endpoint. The client sends the list of channels it wants once, Laravel runs the authorization callbacks, and the response sets a single httpOnly cookie carrying a JWT that grants subscribe access to every approved topic. The response also includes an expires_in so Echo can refresh the cookie before it lapses, without dropping the SSE connection. Forty requests became one.

Presence channels ride on Mercure’s subscription API, which the hub already exposes for “who is subscribed to this topic”. Member payloads are packed into per-channel grants in that same JWT. I haven’t stress-tested presence yet, so I’ll hold my opinion there.

Two more things from the PR that I didn’t expect. private-encrypted-* channels get a per-channel AES-256-GCM key derived with HKDF from your MERCURE_ENCRYPTION_KEY, delivered to authorized clients as a JWK in the auth response. The hub relays ciphertext and never holds the key, which matters if your hub is a managed service and not the in-process one. And whispers (client to client messages) go straight to the hub on a dedicated whisper topic without a Laravel round trip, while the publish grant deliberately excludes the channel topics themselves, so a member can whisper but can’t forge a server event. That’s a sharper security boundary than I had with Reverb, where I’d honestly never audited what a client-side whisper() could reach.

Where it bit me

Everything above worked in about an hour. Then I deployed to staging and no events arrived.

The problem was topic_prefix. The driver namespaces every topic under a configurable prefix, defaulting to https://laravel.alt/echo/. That’s an RFC 9476 .alt URL, which is intentionally non-resolvable, and it exists so multiple apps can share a hub without their topics colliding. Fine. But my staging Caddyfile had a leftover mercure block from an earlier experiment with a subscriptions directive and a hard-coded topic allow-list from the 0.x days. The 1.0 hub with those old settings accepted the subscription and silently matched nothing.

I lost ninety minutes on that, and the fix was deleting six lines of Caddyfile I’d forgotten existed. I’m putting it here because the error surface is quiet: the EventSource connects, curl -N shows a healthy stream, Laravel logs a successful publish, and nothing arrives. If that’s you, check the hub config before the driver.

The second, smaller surprise: the driver pulls in symfony/mercure as a dependency and delegates the core publishing and JWT work to it. That’s the same component Symfony has shipped for years, which I’m comfortable with, but if you have a strict dependency policy it’s a new transitive package to review.

Should you switch?

Here’s my read, and I’ll admit it’s shaped by running one particular kind of app.

If your broadcasting is server to client and you’re on FrankenPHP, or willing to be, switch. You lose a daemon and your auth traffic drops. Reconnection and replay come with the protocol instead of with your code. I’ve done this kind of infrastructure simplification for agency clients where the win was less about speed and more about having one fewer thing that pages someone at 2am.

If you have real bidirectional traffic, like a shared editor or a game lobby, stay on Reverb or Pusher. Mercure’s client publish path exists but it’s a POST per message, and that’s the wrong cost model for sixty cursor updates a second.

If you’re on Octane with Swoole or RoadRunner rather than FrankenPHP, you can still use the driver against an external Mercure hub, but now you’re back to running a separate process, and the case gets thinner. You’d be swapping one daemon for another with a different failure mode. Worth it if you specifically want SSE for proxy reasons, otherwise probably not.

Something I’m still not sure about: the docs for the Echo side are the PR itself right now. I expect that to settle in the next couple of minors, but if you go in this week, budget time for reading source.

Try it this week

Run php artisan install:broadcasting on a Laravel 13.32 app and pick Mercure when prompted; the installer support went in alongside the driver. If you’re on FrankenPHP, uncomment the Mercure block that’s already in the sample Caddyfile in /etc/frankenphp/Caddyfile, set BROADCAST_CONNECTION=mercure, and leave MERCURE_URL empty. Fire one of your existing ShouldBroadcast events and watch it arrive with curl -N 'http://localhost/.well-known/mercure?topic=*' before you touch any JavaScript. If it works there, it’ll work in Echo. If it doesn’t, read your Caddyfile before you read the driver, and save yourself my ninety minutes.