Skip to content

marko/broadcasting-mercure

Mercure broadcasting driver --- publish realtime updates through a Mercure hub. The hub holds the browsers’ Server-Sent Events connections; PHP makes one short, authenticated HTTP request per event, so viewers never occupy PHP workers. Installing this package binds BroadcasterInterface from marko/broadcasting to MercureBroadcaster and provides MercureSubscriberToken for authorizing private channels in the browser.

Terminal window
composer require marko/broadcasting-mercure

This installs marko/broadcasting and marko/http. You also need an HTTP client driver such as marko/http-guzzle.

FrankenPHP ships a Mercure hub inside its Caddy server, so there is nothing else to run. Enable it in your Caddyfile:

Caddyfile
{
frankenphp
}
example.com {
mercure {
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY}
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
}
php_server
}

The hub is then served at https://example.com/.well-known/mercure. Any standalone Mercure hub works the same way.

.env
MERCURE_URL=https://example.com/.well-known/mercure
MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
MERCURE_PUBLISHER_JWT_KEY=change-me-publisher-key
MERCURE_SUBSCRIBER_JWT_KEY=change-me-subscriber-key
MERCURE_TOPIC_PREFIX=https://example.com/
config/broadcasting-mercure.php
use Marko\Config\Env;
return [
'hub_url' => Env::string('MERCURE_URL', 'http://localhost/.well-known/mercure'),
'public_url' => Env::string('MERCURE_PUBLIC_URL', 'http://localhost/.well-known/mercure'),
'publisher_jwt_key' => Env::string('MERCURE_PUBLISHER_JWT_KEY', ''),
'publisher_jwt' => Env::string('MERCURE_PUBLISHER_JWT', ''),
'subscriber_jwt_key' => Env::string('MERCURE_SUBSCRIBER_JWT_KEY', ''),
'subscriber_jwt_ttl' => Env::int('MERCURE_SUBSCRIBER_JWT_TTL', 3600, min: 0),
'topic_prefix' => Env::string('MERCURE_TOPIC_PREFIX', ''),
'cookie_domain' => Env::string('MERCURE_COOKIE_DOMAIN', ''),
'cookie_secure' => Env::bool('MERCURE_COOKIE_SECURE', true),
'timeout' => 5,
];
KeyPurpose
hub_urlURL PHP publishes to (may be an internal address)
public_urlURL browsers connect to with EventSource
publisher_jwt_keyHS256 key the hub uses to verify publisher tokens; the driver signs a token with a mercure.publish: ["*"] claim
publisher_jwtA pre-generated publisher token, used instead of signing one when set
subscriber_jwt_keyHS256 key the hub uses to verify subscriber tokens
subscriber_jwt_ttlLifetime of subscriber tokens and the cookie, in seconds
topic_prefixPrepended to each channel name to form the Mercure topic (Mercure recommends IRIs, e.g. https://example.com/)
cookie_domainDomain for the mercureAuthorization cookie; empty means the current host only
cookie_secureWhether the cookie is HTTPS-only
timeoutPublish request timeout, in seconds

Use BroadcasterInterface as described in marko/broadcasting. Each broadcast is a POST to the hub with these form fields:

FieldValue
topictopic_prefix + channel name
dataThe JSON-encoded $data
typeThe event name (the SSE event type)
idThe $id argument, when given (used by the hub for Last-Event-ID replay)
privateon for a PrivateChannel

A publish that does not reach the hub, or that the hub rejects, throws BroadcastException with the message Failed to broadcast to channel '{channel}' via Mercure.

  • Rejected (non-2xx response): thrown by BroadcastException::rejected(). The context holds the status and the hub’s own error text, capped at 500 bytes: The Mercure server responded with HTTP 401: Unauthorized. The suggestion depends on the status. For 401/403 it points at the publisher JWT settings in config/broadcasting-mercure.php. For 413 it points at the payload size limit configured on the hub. For any other 4xx it points at the event name, channel name and payload. For 5xx it reports a failure on the hub’s side.
  • Unreachable (connection or transport error): thrown by BroadcastException::publishFailed(). The context holds the HTTP client’s error message, and the client’s exception is attached as the previous exception.

The publisher JWT travels in the Authorization header, so it never appears in either exception.

Mercure delivers updates as Server-Sent Events. Listen by event name, because type is set to the event name:

const source = new EventSource(hubUrl, { withCredentials: true });
source.addEventListener('seat.sold', (event) => {
const data = JSON.parse(event.data);
markSeatSold(data.seat);
});

Build hubUrl on the server with MercureSubscriberToken::subscribeUrl(), which adds one topic parameter per channel:

use Marko\Broadcasting\Mercure\Subscriber\MercureSubscriberToken;
use Marko\Broadcasting\PrivateChannel;
$hubUrl = $this->mercureSubscriberToken->subscribeUrl([
'shows.42',
new PrivateChannel('customers.7'),
]);
// https://example.com/.well-known/mercure?topic=https%3A%2F%2Fexample.com%2Fshows.42&topic=...

Private updates are delivered only to subscribers holding a JWT whose mercure.subscribe claim lists the topic. MercureSubscriberToken builds that token, including only the private channels the ChannelRegistry authorizes for the user. Public channels need no token and are left out of the claim. A private channel with no registered authorizer throws ChannelAuthorizationException. Mercure reads each claim entry as a topic selector, which can be a URI template. So a topic (topic_prefix + channel name) containing { } * ,, whitespace or control characters throws MercureException::unsafeTopic() and is never signed. subscribeUrl() refuses such topics the same way.

The usual way to hand the token to the browser is the mercureAuthorization cookie, which the hub reads automatically:

use Marko\Authentication\Contracts\GuardInterface;
use Marko\Broadcasting\Mercure\Subscriber\MercureSubscriberToken;
use Marko\Broadcasting\PrivateChannel;
use Marko\Routing\Attributes\Get;
use Marko\Routing\Http\Response;
use Marko\View\ViewInterface;
class ShowController
{
public function __construct(
private ViewInterface $view,
private GuardInterface $guard,
private MercureSubscriberToken $mercureSubscriberToken,
) {}
#[Get('/shows/{showId}')]
public function show(int $showId): Response
{
$user = $this->guard->user();
$channels = ["shows.$showId", new PrivateChannel("customers.{$user?->getAuthIdentifier()}")];
$response = $this->view->render('shop::show', [
'hubUrl' => $this->mercureSubscriberToken->subscribeUrl($channels),
]);
return $this->mercureSubscriberToken->withAuthorizationCookie($response, $channels, $user);
}
}

The cookie is HttpOnly, SameSite=Strict, Secure (unless cookie_secure is false), scoped to the hub’s path, and expires with the token. The hub must be on the same site as your app --- set cookie_domain when it lives on a subdomain. To send the token another way (for example an Authorization header from a native client), call for($channels, $user) to get the raw JWT.

Implements BroadcasterInterface.

use Marko\Broadcasting\Mercure\Driver\MercureBroadcaster;
public function broadcast(string|Channel $channel, string $event, array $data, ?string $id = null): void;
public function dispatch(BroadcastableInterface $broadcastable): void;
use Marko\Broadcasting\Mercure\Subscriber\MercureSubscriberToken;
public const string COOKIE_NAME = 'mercureAuthorization';
/** @param list<string|Channel> $channels */
public function for(array $channels, ?AuthenticatableInterface $user): string;
/** @param list<string|Channel> $channels */
public function withAuthorizationCookie(Response $response, array $channels, ?AuthenticatableInterface $user): Response;
/** @param list<string|Channel> $channels */
public function subscribeUrl(array $channels): string;

The JWT exp claim and the cookie expiry are subscriber_jwt_ttl seconds after the injected PSR-20 ClockInterface. In tests, construct MercureSubscriberToken with a FakeClock to assert an exact expiry.

A minimal HS256 encoder used for publisher and subscriber tokens --- no JWT library dependency.

use Marko\Broadcasting\Mercure\Jwt\MercureJwt;
/** @param array<string, mixed> $claims */
public function encode(array $claims, string $key): string;

Readonly value object built from config/broadcasting-mercure.php by the module binding. Replace the MercureConfig binding in your own module to build it differently.

ExceptionThrown when
MercureException::emptySigningKey()A JWT is signed with an empty key
MercureException::missingPublisherCredentials()Neither publisher_jwt nor publisher_jwt_key is set
MercureException::missingSubscriberKey()A subscriber token is requested without subscriber_jwt_key
MercureException::unsafeTopic()A subscriber token or subscribe URL is built for a topic containing { } * ,, whitespace or control characters
BroadcastException::rejected()The hub answers with a non-2xx status; the context holds the status and the capped response body
BroadcastException::publishFailed()The hub cannot be reached (connection or transport failure)
BroadcastException::presenceChannelsUnsupported()A PresenceChannel is broadcast to or included in a subscriber token or URL