> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Inside the Mercure broadcast driver

> A source-level walkthrough of the Mercure broadcast driver added to the Laravel framework: its HTTP/SSE architecture, dual publish/subscribe JWTs, single-cookie channel authorization, and end-to-end encrypted channels.

<Warning>
  The `MercureBroadcaster` covered here was added in [laravel/framework PR #61474](https://github.com/laravel/framework/pull/61474) (merged September 10, 2026). At the time of writing it is not yet part of a tagged release and is not documented on the [official docs site](https://laravel.com/docs/broadcasting). This page is a preview based on the source code and docblocks — check the `laravel/framework` changelog before relying on it in production.
</Warning>

## Overview

Laravel's broadcasting layer has long shipped [Reverb](/en/broadcasting), Pusher, and Ably as "WebSocket-native" drivers. A new `mercure` driver value has now been added to `config/broadcasting.php`.

[Mercure](https://mercure.rocks) is a real-time protocol built on Server-Sent Events (SSE) rather than a bidirectional WebSocket connection. It behaves more like HTTP/1.1 or HTTP/2 long-polling, which gives it a few distinct properties:

* It passes through ordinary HTTP infrastructure (reverse proxies, CDNs, load balancers) transparently.
* Clients can be implemented with nothing more than the browser's `EventSource` API — no dedicated client library required.
* [FrankenPHP](/en/blog/laravel-cloud) ships a built-in Mercure hub, so no extra infrastructure is needed to try it.

```mermaid theme={null}
flowchart LR
    A["Server<br>broadcast(event)"] --> B["MercureBroadcaster"]
    B --> C["Mercure hub<br>(Hub / FrankenPhpHub)"]
    C -->|"SSE (EventSource)"| D["Browser<br>Laravel Echo"]
    D --> E["Real-time<br>UI update"]
```

## The new `driver` value

The supported-drivers comment at the top of `config/broadcasting.php` now lists `mercure`:

```php theme={null}
// Supported: "reverb", "pusher", "ably", "mercure", "redis", "log", "null"
```

A sample connection configuration is provided as well:

```php theme={null}
'mercure' => [
    'driver' => 'mercure',
    'url' => env('MERCURE_URL'),
    'public_url' => env('MERCURE_PUBLIC_URL'),
    'secret' => env('MERCURE_JWT_SECRET'),
    'encryption_key' => env('MERCURE_ENCRYPTION_KEY'),
    'claims' => [
        'iss' => env('MERCURE_JWT_ISSUER'),
        'client_id' => env('APP_NAME'),
    ],
    'cookie_name' => env('MERCURE_COOKIE_NAME'),
    'subscribe_expiration' => (int) env('MERCURE_SUBSCRIBE_EXPIRATION', 5),
],
```

If `url` is omitted, the driver falls back to FrankenPHP's built-in Mercure hub (the `mercure_publish()` function). That decision happens in `CreatesMercureDrivers::mercure()`:

```php theme={null}
public function mercure(array $config)
{
    if (empty($config['url'])) {
        return $this->frankenPhpMercure($config);
    }

    // ...
}
```

When you run an external hub, `url` should point to the management API used for publishing, while `public_url` is the URL the browser connects to. Splitting the two supports setups where publishing happens over an internal network (e.g. Docker Compose) while only the public URL is exposed to the browser.

## Separate publish and subscribe tokens

Mercure is a JWT-driven access control protocol. The `CreatesMercureDrivers` trait builds **separate token factories** for publishing (server → hub) and subscribing (browser → hub):

* `secret`, `publish_secret`, and `subscribe_secret` can each be configured independently, falling back to `secret`.
* `algorithm`, `publish_algorithm`, and `subscribe_algorithm` can likewise be set per side (default `HS256`).
* HS256 requires a secret of at least 32 bytes, HS384 at least 48, and HS512 at least 64; an `InvalidArgumentException` is thrown otherwise.

```php theme={null}
protected function mercureSecret(array $config, string $side)
{
    $secret = ($config[$side.'_secret'] ?? null) ?: ($config['secret'] ?? null);

    // ...

    $minimumLength = ['HS256' => 32, 'HS384' => 48, 'HS512' => 64][$algorithm] ?? 0;

    if (strlen($secret) < $minimumLength) {
        throw new InvalidArgumentException(/* ... */);
    }

    return $secret;
}
```

The publish token carries `Grant::ACTION_PUBLISH` for every topic (`*`) and is memoized by `CachingTokenProvider`, so a fresh JWT isn't minted on every single broadcast call.

## Channel authorization via a single cookie

The biggest architectural difference from the WebSocket drivers is that subscriber authorization is handled through **one cookie**. `MercureBroadcaster::auth()` accepts a `channel_names` array (capped at 100 per request), evaluates authorization per channel, and then issues a single authorization cookie covering all of them:

```php theme={null}
public function auth($request)
{
    $channelNames = (array) $request->input('channel_names', []);

    if ($channelNames === [] ||
        count($channelNames) > 100 ||
        $channelNames !== array_filter($channelNames, 'is_string')) {
        throw new AccessDeniedHttpException;
    }

    // Evaluate authorization per channel and accumulate grants...

    return (new JsonResponse([/* ... */]))
        ->cookie($this->makeAuthorizationCookie($request, $grants, $user));
}
```

Crucially, a single denied channel does not fail the whole response — it's flagged as `denied: true` in the response payload while the rest of the authorized channels keep working. This matters because Mercure multiplexes many topics over one EventSource connection, so channels can be added or removed mid-session without tearing down the connection.

Presence channels get a different `Grant` shape than regular `private`/`private-encrypted` channels, targeting the subscribe URL pattern used by Mercure's [Subscription API](https://mercure.rocks/docs/hub/concepts/active-subscriptions).

## End-to-end encrypted channels

Channels prefixed with `private-encrypted-` are treated as end-to-end encrypted (E2EE) — meaning even the Mercure hub itself never sees the payload. This is a capability the existing Pusher and Reverb drivers don't offer.

Setting a base64-encoded 32-byte `encryption_key` activates the `ChannelEncrypter`, which wraps the event name, payload, and socket ID into a JWE (JSON Web Encryption) before `broadcast()` sends it. The hub only ever relays encrypted bytes.

```php theme={null}
'encryption_key' => env('MERCURE_ENCRYPTION_KEY'),
```

```shell theme={null}
php -r "echo base64_encode(random_bytes(32));"
```

Subscribers decrypt in the browser using the `jwk` (JSON Web Key) field returned by the `auth()` response — an out-of-band key exchange recommended by the Mercure specification, so the hub never learns the key.

<Info>
  Presence channels cannot be encrypted, since their member list flows through the hub's Subscription API by design — that's incompatible with E2EE.
</Info>

## Whispers (direct client-to-client messages)

When `client_events` is enabled (the default), every guarded channel is assigned a dedicated "whisper topic" that subscribers may publish to:

```php theme={null}
if ($this->clientEvents && $whisperTopics !== []) {
    $grants[] = new Grant([Grant::ACTION_SUBSCRIBE, Grant::ACTION_PUBLISH], $whisperTopics);
}
```

The channel's own topic stays server-only, so a client can never forge a server-originated event. This mirrors Pusher's "client events" feature, but keeps authorization cleanly separated by using distinct topics.

## Topic naming

Because Mercure hubs are often shared across multiple applications, topics are namespaced under a `topic_prefix` to avoid collisions. The default is:

```php theme={null}
protected string $topicPrefix = 'https://laravel.alt/echo/',
```

`.alt` is a reserved, unresolvable DNS suffix ([RFC 9476](https://www.rfc-editor.org/rfc/rfc9476.html)), so it can never collide with a real domain. Channel names are URL-encoded per [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html) into a single path segment:

```php theme={null}
protected function channelTopic($channelName)
{
    return $this->topicPrefix.'channel/'.rawurlencode($channelName);
}
```

## Cookie domain and error messages

Since a Mercure hub often runs on a different subdomain than the application (e.g. `mercure.example.com` vs. `app.example.com`), a failure to resolve a shared cookie domain throws a descriptive exception:

```php theme={null}
throw new BroadcastException(sprintf(
    'Mercure error: %s. Adjust the Mercure "public_url" configuration value so the hub [%s] shares a registrable domain with the application host [%s].',
    rtrim($e->getMessage(), '.'), $this->hub->getPublicUrl(), $request->getHost()
), 0, $e);
```

If you use a `__Secure-` or `__Host-` prefixed cookie name, the driver also verifies at boot time that `public_url` is HTTPS:

```php theme={null}
if (str_starts_with($hub->getCookieName(), '__') &&
    parse_url($hub->getPublicUrl(), PHP_URL_SCHEME) === 'http') {
    throw new InvalidArgumentException(/* ... */);
}
```

## Choosing between Reverb, Pusher/Ably, and Mercure

| Driver        | Transport  | Infrastructure                            | E2E encryption             |
| ------------- | ---------- | ----------------------------------------- | -------------------------- |
| Reverb        | WebSocket  | Self-hosted server required               | No                         |
| Pusher / Ably | WebSocket  | SaaS                                      | No                         |
| Mercure       | SSE (HTTP) | Self-hosted hub, or built into FrankenPHP | Yes (`private-encrypted-`) |

Mercure is a good fit when you don't need a persistent bidirectional connection — notifications, progress updates, or chat-like use cases that are primarily server-to-client. If you're already running FrankenPHP, it works with no additional infrastructure at all.

## Related pages

* [Broadcasting basics](/en/broadcasting)
* [Laravel Cloud](/en/blog/laravel-cloud) (FrankenPHP-based runtime)


## Related topics

- [Broadcasting](/en/broadcasting.md)
- [Illuminate\Support\Manager — anatomy of the driver system](/en/advanced/manager.md)
- [Concurrency](/en/concurrency.md)
- [Laravel Socialite (Social Authentication)](/en/socialite.md)
- [Deferred Service Providers](/en/advanced/deferred-provider.md)
