Skip to content

[13.x] Add a Mercure broadcast driver - #61474

Open
dunglas wants to merge 4 commits into
laravel:13.xfrom
dunglas:feat/mercure-broadcaster
Open

[13.x] Add a Mercure broadcast driver#61474
dunglas wants to merge 4 commits into
laravel:13.xfrom
dunglas:feat/mercure-broadcaster

Conversation

@dunglas

@dunglas dunglas commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

As discussed with @taylorotwell and the team, here the PR introducing support for the upcoming 1.0 version of the Mercure real-time protocol for Broadcast. Companion PR for Echo: laravel/echo#549

Mercure runs over SSE, so there is no WebSocket server to operate. With FrankenPHP the built-in hub works with zero infrastructure: no url, no publish secret, updates published in-process through mercure_publish(). Any external hub works too.

Features:

  • Batch auth endpoint: one request mints a single httpOnly cookie covering every joined channel, with expires_in so the client can refresh it proactively.
  • Public, private, and presence channels. Presence is built on the Mercure's subscription API; member payloads travel in per-channel grants.
  • End-to-end encrypted channels (private-encrypted-*): per-channel AES-256-GCM keys derived with HKDF, delivered to authorized clients as JWKs through the auth response. The hub never sees plaintext or keys.
  • Whispers: clients publish directly to the hub on dedicated per-channel whisper topics, no Laravel round-trip. Publish grants never cover channel topics, so server events cannot be forged by channel members.
  • All hub topics are namespaced under a configurable topic_prefix (default https://laravel.alt/echo/, a non-resolvable RFC 9476 .alt URL), so several apps can share one hub and Laravel topics never collide with other publishers.
  • Under the hood, core features are delegates to the battle-tested, standalone Symfony component (also maintained by the Mercure team)

Here is a demo app using this patch as well as the Echo patch: https://github.com/dunglas/laravel-mercure

*/
protected function frankenPhpMercure(array $config)
{
if (! function_exists('mercure_publish')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This holds on every FrankenPHP build: the nomercure build defines mercure_publish() as a stub that fails with error 3, and a build without the mercure directive defines it as well, so a missing url resolves without error and the first broadcast throws a bare RuntimeException('No Mercure hub configured').

I don't know if you want to handle this case or if we consider FrankenPHP without mercure an exotic config?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended!

This tag is an exotic setup requiring a custom compilation of FrankenPHP.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright and I guess there's no easy way to not declare at all the function when nomercure is used? Just out of curiosity, I think the current solution can be good enough

}
} catch (JsonException $e) {
throw new BroadcastException(sprintf('Mercure error: %s.', $e->getMessage()), 0, $e);
} catch (MercureExceptionInterface $e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wraps MercureExceptionInterface alone, but FrankenPhpHub::publish() calls mercure_publish() with no try/catch and that function throws a plain RuntimeException (No Mercure hub configured, Publish failed), which escapes broadcast() as something other than a BroadcastException. You could catch Throwable around the publish loop, or wrap the built-in hub's failures the way Hub::publish() wraps HTTP client errors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it worth it (see previous comment).

$this->mercurePublishClaims($config),
);

return new Hub(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie name, Secure flag and domain come from public_url (falling back to url) inside Authorization::createCookie() alone, so a plain http:// hub URL or an internal hostname such as https://mercure/.well-known/mercure resolves without error and then turns every broadcasting/auth call into a 500 (__Secure- cookie name over HTTP, or different second-level domain). You could validate those two conditions in createMercureDriver() so the failure points at the configuration.


$user ??= $channelUser;

$result = $this->verifyUserCanAccessChannel($request, $normalizedChannelName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A single denied channel rejects the whole batch, and the Echo connector treats a denial with no newcomers as a failure of the entire set and loops in reconnecting with backoff, so a user whose access to one channel is revoked mid-session loses every other channel until they leave the revoked one. You may want to return a per-channel result (cookie minted for the granted subset, 403 when nothing is granted) so the connector can evict the one denied channel.


// 0 (the default) delegates the lifetime to the hub's token factory:
// "session.cookie_lifetime", or an hour when that setting is 0.
$publishExpiration = (int) (($config['publish_expiration'] ?? 0) * 60);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A publish_expiration under one minute truncates to 0 here and the < 0 guard passes, so WebTokenFactory reads it as the default lifetime (session.cookie_lifetime or one hour) while the subscribe side rejects the same truncation in createMercureDriver(). You could apply the same positive-seconds check when the value is set, and reserve 0 for the explicit default.

} elseif (str_starts_with($channelName, 'presence-')) {
// A payload is scoped to its own authorization_details
// entry, so each presence channel gets its own grant.
$presenceGrants[] = new Grant([Grant::ACTION_SUBSCRIBE], [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ships the raw callback result as the presence payload, without the user_id that PusherBroadcaster and AblyBroadcaster add in validAuthenticationResponse(), and the Echo connector dedupes here() by JSON.stringify(payload), so two members whose callbacks return the same info collapse into one and a callback returning true yields a true member. It may be worth wrapping the result with the broadcasting identifier the way the other drivers do.

return null;
}

$key = base64_decode($config['encryption_key'], true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This decodes the raw value, so a key in the framework's base64: convention (what key:generate --show and APP_KEY use) fails the strict decode with the must be a base64-encoded 32-byte key message. You could accept the base64: prefix the way EncryptionServiceProvider::parseKey() does.

* from colliding with other publishers sharing the hub, and lets two
* applications sharing one hub (and one JWT secret) stay apart. The
* default is a "laravel.alt" URL: ".alt" is reserved outside the DNS
* (RFC 9476), so the IRI is guaranteed non-resolvable and unsquattable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to add a link here, so IDEs can render and people can easily look it up:

Suggested change
* (RFC 9476), so the IRI is guaranteed non-resolvable and unsquattable.
* ([RFC 9476](https://www.rfc-editor.org/rfc/rfc9476.html)), so the
* IRI is guaranteed non-resolvable and unsquattable.

* the hub inside a JWE; the routing envelope stays plaintext so a
* multiplexing subscriber can select the decryption key.
*
* @param array $channels

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can narrow this down if you want:

Suggested change
* @param array $channels
* @param string[] $channels

/**
* Build the hub topic of the given channel.
*
* Channel names are RFC 3986 encoded into a single path segment, so a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here:

Suggested change
* Channel names are RFC 3986 encoded into a single path segment, so a
* Channel names are [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html)
* encoded into a single path segment, so a

/**
* Create an instance of the driver.
*
* @param array $config

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might type the config array:

Suggested change
* @param array $config
* @param array{topic_prefix?: string, client_events?: bool, public_url?: string, url?: string, publish_expiration?: non-negative-int, publish_algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', publish_passphrase?: string, passphrase?: string, client_options?: array{auth_basic?: string|array{0: string, 1?: string}, auth_bearer?: string, query?: string[], headers?: iterable|string[]|string[][], body?: array|string|resource|\Traversable|\Closure, json?: mixed, user_data?: mixed, max_redirects?: int, http_version?: string, base_uri?: string, buffer?: bool|resource|\Closure(array<string, list<string>> $headers): (bool|resource), on_progress: callable(int, int, array): mixed, resolve?: string[], proxy?: string, timeout?: float, max_duration?: float, max_connect_duration?: float, bindto?: string, verify_peer?: bool, verify_host?: bool, cafile?: string, capath?: string, local_cert?: string, local_pk?: string, passphrase?: string, ciphers?: string, peer_fingerprint?: string|array<string, string>, capture_peer_cert_chain?: bool, crypto_method?: 9|17|33|65, extra?: array{use_persistent_connections: bool}}, cookie_name: string, subscribe_expiration?: int, subscribe_secret?: string, secret?: string, subscribe_algorithm?: string, subscribe_passphrase?: string, encryption_key?: string, claims?: array{aud?: string, iss?: string, client_id?: string}} $config

or

Suggested change
* @param array $config
* @param array{
* topic_prefix?: string,
* client_events?: bool,
* public_url?: string,
* url?: string,
* publish_expiration?: non-negative-int,
* publish_algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA',
* algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA',
* publish_passphrase?: string,
* passphrase?: string,
* client_options?: array{
* auth_basic?: string|array{0: string, 1?: string},
* auth_bearer?: string,
* query?: string[],
* headers?: iterable|string[]|string[][],
* body?: array|string|resource|\Traversable|\Closure,
* json?: mixed,
* user_data?: mixed,
* max_redirects?: int,
* http_version?: string,
* base_uri?: string,
* buffer?: bool|resource|\Closure(array<string, list<string>> $headers): (bool|resource),
* on_progress: callable(int, int, array): mixed,
* resolve?: string[],
* proxy?: string,
* timeout?: float,
* max_duration?: float,
* max_connect_duration?: float,
* bindto?: string,
* verify_peer?: bool,
* verify_host?: bool,
* cafile?: string,
* capath?: string,
* local_cert?: string,
* local_pk?: string,
* passphrase?: string,
* ciphers?: string,
* peer_fingerprint?: string|array<string, string>,
* capture_peer_cert_chain?: bool,
* crypto_method?: 9|17|33|65,
* extra?: array{use_persistent_connections?: bool}
* },
* cookie_name: string,
* subscribe_expiration?: int,
* subscribe_secret?: string,
* secret?: string,
* subscribe_algorithm?: string,
* subscribe_passphrase?: string,
* encryption_key?: string,
* claims?: array{aud?: string, iss?: string, client_id?: string}
* } $config

This would have to go to the other methods as well

* API's importKey('jwk', ...).
*
* @param string $channel
* @return array

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, you could type as well:

Suggested change
* @return array
* @return array{kty: string, k: string, alg: string, use: string}

or

Suggested change
* @return array
* @return array{
* kty: 'EC'|'RSA'|'oct'
* k: string
* alg: 'A128CBC-HS256'|'A192CBC-HS384'|'A256CBC-HS512'|'A128GCM'|'A192GCM'|'A256GCM'
* use: 'sig'|'enc'
* }

Comment on lines +19 to +22
* As recommended by the Mercure specification, updates are encrypted as JSON
* Web Encryption (RFC 7516) compact tokens and the keys are shared with
* subscribers out of band, as JSON Web Keys (RFC 7517) returned by the
* broadcasting auth endpoint; the hub is not involved in this exchange.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More links:

Suggested change
* As recommended by the Mercure specification, updates are encrypted as JSON
* Web Encryption (RFC 7516) compact tokens and the keys are shared with
* subscribers out of band, as JSON Web Keys (RFC 7517) returned by the
* broadcasting auth endpoint; the hub is not involved in this exchange.
* Web Encryption ([RFC 7516](https://www.rfc-editor.org/rfc/rfc7516.html))
* compact tokens and the keys are shared with subscribers out of band, as
* JSON Web Keys ([RFC 7517](https://www.rfc-editor.org/rfc/rfc7517.html))
* returned by thebroadcasting auth endpoint; the hub is not involved in
* this exchange.

Comment on lines +675 to +677
* RFC 9068 access tokens require "iss", "aud", and "client_id", so each
* defaults to a sensible identifier when not set explicitly (an empty
* string, e.g. from an unset .env value, counts as not set).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

link again:

Suggested change
* RFC 9068 access tokens require "iss", "aud", and "client_id", so each
* defaults to a sensible identifier when not set explicitly (an empty
* string, e.g. from an unset .env value, counts as not set).
* [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) access tokens
* require "iss", "aud", and "client_id", so each defaults to a sensible
* identifier when not set explicitly (an empty string, e.g. from an
* unset .env value, counts as not set).

Comment on lines +598 to +599
* Built eagerly so a missing "web-token/jwt-library" package or a
* malformed key surfaces at driver resolution rather than mid-broadcast.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, here, too:

Suggested change
* Built eagerly so a missing "web-token/jwt-library" package or a
* malformed key surfaces at driver resolution rather than mid-broadcast.
* Built eagerly so a missing "[web-token/jwt-library](https://packagist.org/packages/web-token/jwt-library)"
* package or a malformed key surfaces at driver resolution rather
* than mid-broadcast.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants