mvanduijker/laravel-mercure-broadcaster
A Laravel broadcaster that sends your app's events to a Mercure hub for real-time updates over Server-Sent Events. Skip Pusher and websockets: publish model changes and notifications to browsers with a simple, lightweight setup that plugs into Laravel's existing broadcasting system.
This package is the publish-side driver for a Mercure hub: it teaches Laravel's broadcaster how to POST updates to the hub and how to hand authenticated subscribers a JWT. It is not a hub — you still run one (Docker dunglas/mercure, or the Caddy-based binary).
Install and wire up:
composer require mvanduijker/laravel-mercure-broadcaster
php artisan vendor:publish --tag=mercure # -> config/mercure.php
Then set at minimum:
MERCURE_URL — the server-to-hub publish endpoint, e.g. http://mercure:80/.well-known/mercure (can be an internal Docker hostname).MERCURE_PUBLIC_URL — the browser-facing endpoint; must be absolute and reachable by the client.MERCURE_JWT (or the signing secret variant) — a token carrying at least mercure.publish.Register the connection and make it the default:
// config/broadcasting.php
'connections' => [
'mercure' => ['driver' => 'mercure'],
],
BROADCAST_CONNECTION=mercure
Where to look first: config/mercure.php (the shipped keys are the source of truth for env names — confirm whether your version wants a pre-signed token or a secret to sign with), then the package source for MercureBroadcaster — specifically auth()/validAuthenticationResponse(), which is where the subscriber cookie is set.
First use case — a private per-user feed. Create an event implementing ShouldBroadcastNow, publish it, and subscribe with a plain EventSource (not Echo — see Gotchas).
On 3.9.0: this is a compatibility-only release (Laravel 13.x added to the constraint). Nothing to migrate; treat it as a reason to re-run your app's test suite on the new framework minor, not as a feature to adopt.
Prefer ShouldBroadcastNow for anything user-visible. Mercure is an HTTP push; routing it through a queue adds latency for no benefit. Reserve ShouldBroadcast for high-volume, non-interactive streams.
class OrderShipped implements ShouldBroadcastNow
{
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('orders.'.$this->order->id)];
}
}
Use PrivateChannel as the default. Mercure has no presence concept, and a public Channel is readable by anyone who knows the topic IRI. Private channels force a subscribe JWT, which the package's auth() mints after your routes/channels.php callback authorizes the user.
Wire authorization explicitly. On Laravel 11+:
// bootstrap/app.php
->withBroadcasting(__DIR__.'/../routes/channels.php', [
'middleware' => ['web', 'auth'],
])
The auth response sets an HTTP-only cookie, so the browser needs withCredentials:
const url = new URL(mercurePublicUrl);
url.searchParams.append('topic', orderTopic); // must equal the published IRI
const es = new EventSource(url, { withCredentials: true });
es.onmessage = (e) => handle(JSON.parse(e.data));
Ad-hoc publishes belong in an event class, not in a controller. If you need to push something that isn't modelled as a domain event, write a tiny "notification" event with ShouldBroadcastNow rather than digging for a hub client — it keeps topic derivation in one place.
Centralise topic construction. Generate the topic IRI from the same expression on both sides (a small helper or a model method), because the browser and the broadcaster must agree byte-for-byte.
Multi-topic subscriptions are just repeated topic query params — useful for a dashboard that watches several private channels with one connection.
Testing: assert at the event layer (Event::fake() + assertDispatched) rather than letting the hub call go out. For an end-to-end check, point MERCURE_URL at a local hub container and use curl -N against the public URL.
MERCURE_URL vs MERCURE_PUBLIC_URL is the #1 failure mode. If they disagree on scheme/host/port, publishing succeeds, SSE connects, and nothing ever arrives. The hub doesn't error — it just has no matching subscriber.http vs https, localhost vs 127.0.0.1, or a non-default port will silently break delivery. Echo the topic from JS and Log::info() the one the event produces; diff them once and you'll never debug it again.mercure.publish; subscribing needs mercure.subscribe with the topic in the claim (or *). A 401 from the hub is almost always the token, not Laravel auth.SameSite/Secure set correctly plus CORS on the hub — otherwise the cookie is never sent and every private channel 401s.php artisan install:broadcasting will scaffold Pusher/Reverb config you should discard.)BROADCAST_CONNECTION is the Laravel 11+ name; older docs and blog posts say BROADCAST_DRIVER. Setting the wrong one leaves you on the log driver with no error.php artisan config:clear after touching .env — a cached mercure.php survives env changes and looks like a broken hub.Last-Event-ID. Design the UI to fetch current state over HTTP on load and treat SSE as an invalidation hint.curl -N -H 'Accept: text/event-stream' '<public_url>?topic=…' → 401 means token/cookie, 200 with no events means topic mismatch, connection refused means public URL/DNS, works in curl but not the browser means CORS or withCredentials.broadcastOn().How can I help you explore Laravel packages today?