11 min read

Laravel Mercure Broadcasting: Real-Time Without a WebSocket Server (and 3 Setup Traps)

Laravel 13.32 ships a Mercure broadcast driver. I built presence and encrypted channels on FrankenPHP and hit three traps the docs skip.

Laravel Mercure Broadcasting: Real-Time Without a WebSocket Server (and 3 Setup Traps)

Laravel 13.32 shipped on September 15 with a Mercure broadcast driver, written by Kévin Dunglas (who also wrote Mercure and FrankenPHP). The pitch is simple. Real-time broadcasting over Server-Sent Events, no WebSocket server to run, and with FrankenPHP you don't even run a separate hub. Your existing ShouldBroadcast events and Echo listeners keep working.

I wanted to see how much of that holds up on a fresh app today. So I built a small ops board with one public channel, one presence channel and one end-to-end encrypted private channel, served by FrankenPHP 1.12.7 with its built-in hub, on Laravel 13.33.

It works. All three channel types, two users, real payloads. But getting there took three fixes that aren't in the docs yet, and one of them breaks every fresh install. This post is the setup that actually worked, the traps in the order you'll hit them, and when Mercure beats Reverb.

What Mercure changes about broadcasting

With Reverb (or Pusher, or Soketi), the browser opens a WebSocket to a long-running server, and that server holds every connection open. For Reverb that's a PHP process you run and supervise alongside your app. I compared those three in Reverb vs Pusher vs Soketi if you want the WebSocket side in depth.

Mercure flips the transport. The browser opens a plain HTTP request using the native EventSource API and the server streams events down it. Laravel publishes an update with one HTTP POST to the hub (or an in-process function call on FrankenPHP), and the hub fans it out. The hub is written in Go and runs inside FrankenPHP's Caddy server, so the open connections live there, not in PHP workers.

Reverb WebSocket server vs Mercure hub in FrankenPHP With Reverb, the Laravel app publishes to a long-running PHP server that holds a two-way WebSocket to every browser. With Mercure on FrankenPHP, the app publishes in-process to a Go hub inside the same server, the hub streams one-way SSE to every browser, and a queue worker outside the server publishes to the hub over HTTP with a JWT. REVERB (WEBSOCKET) MERCURE ON FRANKENPHP (SSE) FRANKENPHP (CADDY, GO) PUBLISH WEBSOCKET IN-PROCESS SSE Laravel app PHP-FPM or Octane Reverb server long-running PHP process Browser Browser Browser Laravel app PHP workers Mercure hub Go, /.well-known/mercure Queue worker HTTP + JWT Browser Browser Browser A PHP process holds every open socket The Go server holds the connections, PHP never does LEGEND HOLDS OPEN CONNECTIONS FUNCTION CALL, NO NETWORK PUBLISH FROM OUTSIDE THE SERVER (NEEDS MERCURE_URL)

Your PHP code never holds a socket.

The traffic is mostly one direction, server to browser. Echo's whispers still work over Mercure, but they're published by the client straight to the hub on their own topics and never touch Laravel. There's no general client-to-server channel like a WebSocket gives you.

Install: one command, one prompt worth reading

The installer gained a --mercure flag in 13.x (PR #61587):

php artisan install:broadcasting --mercure

It asks three questions. Which hub (FrankenPHP's built-in hub or a standalone one), a JWT secret (leave it empty and it generates a 64-character one), and whether to enable end-to-end encrypted channels. Say yes to that last one if you might ever send personal data through a private channel. It just writes a 32-byte key to your .env.

Here's what it changed in my app:

// composer.json
+ "symfony/mercure": "^0.8",
+ "web-token/jwt-library": "^4.1"

// package.json
+ "laravel-echo": "^2.5.0",
+ "pusher-js": "^8.6.0"
BROADCAST_CONNECTION=mercure
MERCURE_JWT_SECRET=652d4c76...
MERCURE_ENCRYPTION_KEY="base64:ZIT5FxQG..."

And resources/js/echo.js:

import Echo from 'laravel-echo';

window.Echo = new Echo({
    broadcaster: 'mercure',
    host: import.meta.env.VITE_MERCURE_HUB_URL,
});

With the built-in hub there's no MERCURE_URL at all. The driver checks for FrankenPHP's mercure_publish() function and publishes in-process. The Echo connector defaults the hub to /.well-known/mercure on the current origin, so the empty VITE_MERCURE_HUB_URL is fine.

The pusher-js dependency isn't needed for Mercure. I removed it and everything below still worked.

The events and channels are ordinary Laravel

Nothing in the app code knows about Mercure. That's the best part of the driver. A public event:

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;

class DeployStatusUpdated implements ShouldBroadcastNow
{
    use Dispatchable;

    public function __construct(
        public string $app,
        public string $status,
    ) {}

    public function broadcastOn(): Channel
    {
        return new Channel('deploys');
    }
}

An encrypted private one just returns an EncryptedPrivateChannel:

use Illuminate\Broadcasting\EncryptedPrivateChannel;

public function broadcastOn(): EncryptedPrivateChannel
{
    return new EncryptedPrivateChannel('incidents');
}

Channel authorization in routes/channels.php is unchanged. Presence callbacks return the member payload like always:

Broadcast::channel('incidents', function ($user) {
    return $user->email === '[email protected]';
});

Broadcast::channel('ops-room', function ($user) {
    return ['id' => $user->id, 'name' => $user->name];
});

On the client, the same Echo calls you'd write for Reverb:

Echo.join('ops-room')
    .here(users => { /* seed the list */ })
    .joining(user => { /* add */ })
    .leaving(user => { /* remove */ });

Echo.channel('deploys')
    .listen('DeployStatusUpdated', e => console.log(e.app, e.status));

Echo.encryptedPrivate('incidents')
    .listen('IncidentOpened', e => console.log(e.summary));

Notice I used ShouldBroadcastNow. Keep that in mind for trap three.

Trap 1: the Echo release doesn't have the connector yet

I built the assets, logged in, and got this in the console:

Broadcaster string mercure is not supported.

The installer pins laravel-echo to ^2.5.0, and 2.5.0 is the latest release on npm. It was published on September 8. The Mercure connector (laravel/echo#549) was merged into the 2.x branch on September 10. As I write this there's no Echo release that contains it.

So a fresh install:broadcasting --mercure today gives you a working backend and a frontend that can't connect. Dunglas's own demo app sidesteps this by building Echo from source, and that's what I did too:

git clone -b 2.x https://github.com/laravel/echo.git
cd echo && pnpm install
cd packages/laravel-echo && pnpm run build

# back in your app
npm install -D ../echo/packages/laravel-echo

Check npm view laravel-echo version before you do this. Once a release after 2.5.0 lands, a plain npm update laravel-echo is the fix and this whole section goes away.

Once it loads, one EventSource carries every channel you join. Joins and leaves are batched into a single re-auth. Authorization goes through the usual /broadcasting/auth route, which answers with an httpOnly cookie the hub reads, and there's no client library to install beyond Echo.

Trap 2: the Caddyfile in most examples no longer starts

FrankenPHP's Mercure docs show the hub configured with publisher_jwt and subscriber_jwt. On FrankenPHP 1.12.7 that config refuses to boot:

the "publisher_jwt", "subscriber_jwt", "publisher_jwks_url" and
"subscriber_jwks_url" directives work only in compatibility mode,
which relaxes access-token validation: move them into an "issuer"
block for modern mode, or set "protocol_version_compatibility 8"

The bundled hub now speaks the Mercure 1.0 protocol, which switched to standard OAuth 2.0 access tokens with a required issuer (the Mercure 1.0 upgrade guide covers the full change). Laravel's driver already signs tokens for 1.0, so don't reach for compatibility mode. Use an issuer block instead.

The issuer has to match the iss claim Laravel puts in its tokens. Unless you set MERCURE_JWT_ISSUER, the driver uses your APP_URL. Here's the Caddyfile, with the mercure block exactly as I tested it:

{
	frankenphp
}

your-app.com {
	root public/
	encode zstd br gzip

	mercure {
		issuer {$APP_URL} {
			publisher {
				jwt {$MERCURE_JWT_SECRET}
			}
			subscriber {
				jwt {$MERCURE_JWT_SECRET}
			}
		}
		anonymous
		subscriptions
	}

	php_server
}

(Locally I served it on localhost:8443, because ports 80 and 443 were already taken on my machine. That needed three extra global options, http_port 8080, auto_https disable_redirects and skip_install_trust. On a server with a real domain you don't need any of them.)

anonymous lets browsers subscribe to public channels without a token. subscriptions turns on the hub's subscription events, and presence channels are built on those.

And one thing cost me a restart. My first attempt used {env.APP_URL}, which is how Caddy writes environment placeholders in a lot of places. Every subscribe then failed with a 401, and the hub log said:

invalid JWT: untrusted issuer "https://localhost:8443"

The issuer and the token were identical. The problem is that {env.APP_URL} is a runtime placeholder, and the issuer name never gets expanded. Running frankenphp adapt showed the issuer stored as the literal string {env.APP_URL}. The parse-time form {$APP_URL} gets substituted when the Caddyfile loads. Export APP_URL and MERCURE_JWT_SECRET into the environment before starting FrankenPHP, since Caddy doesn't read your .env.

With both fixes in, this is two real browser sessions after one deploy event and one incident event:

Two logged-in sessions on the Mercure demo. Both see Alice and Bob in the presence list and the public deploy event. Only Alice, who is authorized for the incidents channel, sees the decrypted incident.

Presence, public and encrypted private channels all working over one SSE connection per tab.

What actually goes over the wire

A public event on the SSE stream looks like this (captured with curl -N against the hub):

id: urn:uuid:01a0dce5-f4aa-75db-a1dd-24a7f86f65d1
data: {"channels":["deploys"],"event":"App\\Events\\DeployStatusUpdated","payload":{"app":"billing-api","status":"deployed"}}

The encrypted incident, as Alice's browser received it:

data: {"channels":["private-encrypted-incidents"],"data":"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..avbjUXGvrdWNg-wJ.jKSQWcU_YfBk..."}

That's a compact JWE. The event name and the customer email are both inside the ciphertext. Per the driver PR, each channel gets its own AES-256-GCM key derived with HKDF from your MERCURE_ENCRYPTION_KEY, the auth endpoint hands the key to authorized browsers, and the browser decrypts with WebCrypto. The hub only ever relays ciphertext. If you run a shared or third-party hub, that's a real guarantee.

Topics are namespaced too. Every channel becomes a topic under https://laravel.alt/echo/, a deliberately non-resolvable .alt URL, so two apps on one hub don't collide. Change it with topic_prefix if you share a hub between environments.

Trap 3: queue workers can't use the built-in hub

This one catches you twice.

First, the moment BROADCAST_CONNECTION=mercure points at the built-in hub, plain php artisan commands fail to boot. Even migrate:

Failed to create broadcaster for connection "mercure" with error:
The Mercure broadcasting connection requires a "url" configuration
value, unless the application is served by FrankenPHP with its
built-in Mercure hub enabled.

Loading routes/channels.php resolves the broadcaster, and mercure_publish() only exists inside the running FrankenPHP server. Not in the PHP CLI, and not in frankenphp php-cli either (I checked).

Second, and more important in production. A plain ShouldBroadcast event is queued, and queue workers are CLI processes. So the zero-config built-in hub only covers ShouldBroadcastNow events dispatched during a web request. Everything your workers broadcast would fail.

The fix is to give the driver a URL. When MERCURE_URL is set, it stops calling mercure_publish() and publishes over HTTP with a signed JWT, which works from anywhere:

MERCURE_URL=https://your-app.com/.well-known/mercure
MERCURE_PUBLIC_URL=https://your-app.com/.well-known/mercure

I tested this from the CLI against the same FrankenPHP hub, with a curl -N subscriber open. The event arrived just like the in-process one. The trade-off is that web requests now make an HTTP call too, instead of the in-process publish. For almost every app that cost is noise next to a working queue. If your broadcasts go through queued jobs at any volume, set the URL from day one.

FAQ

Do I need FrankenPHP to use the Laravel Mercure driver?

No. Set MERCURE_URL and MERCURE_PUBLIC_URL to any Mercure hub, including the standalone dunglas/mercure binary or Docker image (it needs to speak the 1.0 protocol), and the driver publishes over HTTP. FrankenPHP just removes the separate hub and adds an in-process publish path for web requests.

Does Laravel Echo support Mercure?

The connector is merged into Echo's 2.x branch but, as of September 26, 2026, not in any npm release. The latest release, 2.5.0, rejects broadcaster: 'mercure'. Build it from the 2.x branch until a newer version ships.

Do presence channels work over Mercure?

Yes. They rely on the hub's subscription events, so the hub needs the subscriptions directive. Echo seeds the member list from the hub's subscription API, then updates it live as users join and leave.

Can I use whispers and client events with Mercure?

Yes, client_events is on by default. Whispers are published by the browser directly to the hub on dedicated per-channel topics, and the grant never covers the channel's own topic, so a member can't forge a server event.

Why do my artisan commands fail after switching to Mercure?

With the built-in FrankenPHP hub and no MERCURE_URL, the driver needs mercure_publish(), which only exists inside the FrankenPHP server. Set MERCURE_URL so the CLI and queue workers publish over HTTP.

Mercure or Reverb?

Both drivers sit behind the same Laravel API, so this is an infrastructure decision, not a code decision. Switching later means changing BROADCAST_CONNECTION and the Echo config. (If you're still building your first real-time feature, my notifications walkthrough covers the event side, and it applies unchanged to either driver.)

Pick Mercure when:

  • You already serve the app with FrankenPHP (including Octane on FrankenPHP). The hub is already in the binary. There's no extra process to supervise, and no extra port to open.
  • Your real-time traffic is mostly server to browser. Notifications, dashboards, status updates, "someone else is editing this".
  • You want end-to-end encryption for private channels that a hub operator can't read.
  • Your network or proxy is unfriendly to WebSockets. SSE is plain HTTP.

Stay with Reverb when:

  • You need heavy client-to-server messaging. Multiplayer state, collaborative cursors, chat with high message rates.
  • You run PHP-FPM behind Nginx and have no plans to move. Then Mercure means running a standalone hub anyway, and Reverb is the better-trodden path in Laravel. It has more tutorials, more production mileage and a released Echo connector.
  • You need it working today without building a JavaScript package from source.

My take? For a new app on FrankenPHP, Mercure is the better default once Echo cuts a release. One binary serves PHP, TLS and real-time, the connections never touch your PHP workers, and encrypted channels come almost for free. For an existing Reverb setup there's no reason to migrate. It's the same ShouldBroadcast code either way, so move when your infrastructure moves, not before. And if you do pick Mercure, set MERCURE_URL before your first queued broadcast, not after the first failed job.

Share: X/Twitter | LinkedIn | | RSS
Hafiz Riaz

About Hafiz

Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.

Get in touch →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime