Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Mercure Laravel Package

symfony/mercure

Symfony Mercure is the PHP publisher component for the Mercure protocol, letting you push real-time updates from your app to browsers and HTTP clients. It handles JWT authentication and dispatching updates to a Mercure hub.

View on GitHub
Deep Wiki
Context7

Getting Started

symfony/mercure is framework-agnostic: it implements the publisher half of the Mercure protocol and nothing else. There is no Laravel service provider, no facade, no auto-discovery. You wire it yourself — which takes about 15 minutes.

0. Know which protocol you speak. As of 0.9.0 the component defaults to Mercure protocol 1.0 (ProtocolVersion::V1) — in Hub, FrankenPhpHub, MockHub and Jwt\LcobucciFactory. That is a wire-level change, not a cosmetic one: cookie name, claims shape and the subscription query parameter all move. Two decisions:

  • Talking to a hub 1.0 → take the new defaults, do nothing.
  • Talking to a 0.x hub → pass ProtocolVersion::Legacy everywhere (the hub binding, LcobucciFactory, MockHub in tests, FrankenPhpHub). Mixing versions is the new #1 first-day bug (see Gotchas).

1. Install

composer require symfony/mercure lcobucci/jwt

lcobucci/jwt is only needed if you want the component to mint JWTs for you (Jwt\LcobucciFactory). If your platform hands you a pre-signed token, skip it and use Jwt\StaticTokenProvider.

2. Run a hub 1.0 (the component is useless without one)

docker run --rm -p 80:80 \
  -e MERCURE_PUBLISHER_JWT_ALG=HS256 \
  -e MERCURE_PUBLISHER_JWT_KEY='!ChangeMePublisher!' \
  -e MERCURE_SUBSCRIBER_JWT_ALG=HS256 \
  -e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeMeSubscriber!' \
  -e MERCURE_EXTRA_DIRECTIVES='cors_origins *' \
  dunglas/mercure

The publish endpoint is https://hub.example.com/.well-known/mercure; browsers subscribe to the same URL with ?match=.... Keep the publisher and subscriber algorithms identical to what you pass LcobucciFactory.

3. Configure and bind — create config/mercure.php and register the hub in AppServiceProvider. Keep two URLs: an internal one for publishing (Docker/service DNS) and a public one for the browser.

// config/mercure.php
return [
    'internal_url'   => env('MERCURE_INTERNAL_URL', 'http://mercure/.well-known/mercure'),
    'public_url'     => env('MERCURE_PUBLIC_URL', 'https://hub.example.com/.well-known/mercure'),
    'publisher_jwt'  => env('MERCURE_PUBLISHER_JWT_KEY'),
    'subscriber_jwt' => env('MERCURE_SUBSCRIBER_JWT_KEY'),
    'cookie_domain'  => env('MERCURE_COOKIE_DOMAIN', '.example.com'),
    // protocol 1.0 requires these on every token: iss / aud / sub / client_id
    'issuer'         => env('MERCURE_ISSUER', 'https://app.example.com'),
    'audience'       => env('MERCURE_AUDIENCE', 'https://hub.example.com'),
    'client_id'      => env('MERCURE_CLIENT_ID', 'laravel-app'),
];
// AppServiceProvider::register()
use Symfony\Component\Mercure\{Hub, HubInterface, ProtocolVersion};
use Symfony\Component\Mercure\Jwt\{FactoryTokenProvider, LcobucciFactory};
use Symfony\Component\HttpClient\HttpClient;

$this->app->singleton(HubInterface::class, fn () => new Hub(
    config('mercure.internal_url'),
    new FactoryTokenProvider(
        new LcobucciFactory(
            config('mercure.publisher_jwt'),
            'HS256',
            protocolVersion: ProtocolVersion::V1, // default in 0.9; Legacy for 0.x hubs
            // V1 also needs iss/aud/sub/client_id — check the
            // LcobucciFactory::__construct() signature, it changed in 0.9
        ),
        publish: ['/orders/{id}'], // tighten per-topic in production
    ),
    HttpClient::create(['timeout' => 2, 'max_duration' => 3]),
));

The provider still nests the claims for you — under V1 they land under authorization_details instead of mercure, so the publish: list is unchanged at the call site.

4. First use case — publish from a controller after a write, then subscribe from the browser:

public function store(StoreOrderRequest $request, HubInterface $hub)
{
    $order = Order::create($request->validated());

    $hub->publish(new Update(
        "/orders/{$order->id}",
        json_encode($order->toArray()),
    ));

    return response()->json($order, 201);
}
const url = new URL('https://hub.example.com/.well-known/mercure');
url.searchParams.append('match', '/orders/42'); // `topic` on a Legacy/0.x hub
new EventSource(url).onmessage = e => console.log(JSON.parse(e.data));

Where to look first: ProtocolVersion, Hub::publish(), Update, the Jwt\* namespace (TokenProviderInterface / TokenFactoryInterface are the extension seams), and Authorization for browser cookies. Upgrading from 0.6/0.7? The three breaking items are: default protocol 1.0, cookie name __Secure-mercure_access_token, and authorization_details claims requiring iss/aud/sub/client_id. Pinning ProtocolVersion::Legacy restores the entire old behaviour in one argument.

Implementation Patterns

Publish from a queued listener, never inline in the write path. A hub hiccup should not 500 a user's checkout. Bind HubInterface into a job:

class PublishUpdate implements ShouldQueue
{
    use Queueable;
    public int $tries = 3;
    public array $backoff = [1, 5, 15];

    public function __construct(public string $topic, public string $data, public bool $private = false) {}

    public function handle(HubInterface $hub): void
    {
        $hub->publish(new Update($this->topic, $this->data, $this->private));
    }
}

Dispatch from an Eloquent observer or a domain event listener. This gives you retries, backoff and observability for free — the analogue of the component's Symfony-Messenger Messenger\UpdateHandler.

Respect the transaction boundary. Publishing inside DB::transaction() races the client: it receives the update, refetches, and sees the pre-commit row. Use DB::afterCommit() or dispatch the job with ->afterCommit().

Topics are your routing contract. Treat them as resource IRIs, not ad-hoc strings: /orders/{id}, /users/{id}/notifications, /tenants/{id}/dashboard. Scope topics in the publisher claim so a single tab subscribes to a handful of topics, not dozens. Under V1 the subscriber side of that contract is expressed as URI templates inside authorization_details, and the browser's subscription parameter is match (was topic) — so a helper that builds subscription URLs must emit match on protocol 1.0 and topic on Legacy.

Private updates for per-user data. Mark Update($topic, $data, private: true) and ensure the subscriber token grants that topic (or a matching URI template) in its authorization_details. Public updates need no grant at all. Templates like /users/{id}/notifications are still the right shape — one grant covers all users if the hub expands them at subscribe time, which is why you must never template over untrusted input.

Issue the subscriber cookie on login. EventSource cannot send an Authorization header, so cross-origin subscriptions need a cookie. Authorization::createCookie() returns a Symfony\Component\HttpFoundation\Cookie, which Laravel's cookie jar already uses. Under V1 the cookie is named __Secure-mercure_access_token:

public function realtimeToken(Request $request)
{
    $auth = new Authorization(
        tokenFactory: new LcobucciFactory(config('mercure.subscriber_jwt'), 'HS256'),
        cookieDomain: config('mercure.cookie_domain'),
        sameSite: 'none', // required for cross-site SSE; pair with Secure
    );

    return response()
        ->json(['hub' => config('mercure.public_url')])
        ->withCookie($auth->createCookie(
            uri: config('mercure.public_url'),
            subscribe: ["/users/{$request->user()->id}/notifications"],
        ));
}

Prefer createCookie() (+ withCookie()) over Authorization::setCookie(), which calls PHP's raw setcookie() and sidesteps Laravel's cookie queueing.

Wrap publishing behind your own thin service. A RealtimePublisher class with typed methods (orderUpdated(Order $o)) lets you swap in a null/fake implementation in tests, centralise json_encode, enforce topic naming, and add logging — without every call site knowing about Update. It also gives you one place to flip ProtocolVersion if you need to support a legacy hub alongside a new one.

Multiple hubs (e.g. one per region or tenant): bind HubRegistry and resolve with $registry->getHub('eu'); it also implements HubInterface and proxies to the default hub, so existing type-hints keep working. Construct each Hub with its own protocol version.

Server-rendered pages: the component's mercure() Twig helper has no Blade equivalent — and in 0.9 it emits match instead of topic. Mirror it with a small helper that returns the public hub URL plus the list of topics for the current page, emitting the right parameter for your protocol version, and render that into a data- attribute for your JS bootstrap.

Graceful degradation: EventSource reconnects automatically and honours the retry value from Update (new Update($topic, $data, retry: 3000)). Combine that with a client-side poll fallback when the hub is unreachable — a real risk you own, since the hub is a separate service.

Gotchas and Tips

  • Protocol version mismatch beats every other bug. A 0.9 client pointed at a 0.x hub gets 401 on publish (claims are under authorization_details, which the old hub ignores) and silence on subscribe (match param unrecognised, connection stays open). A 0.x client against a hub 1.0 fails the same way in reverse. Decide once, put it in config, and pass it to Hub, LcobucciFactory, FrankenPhpHub and MockHub together. ProtocolVersion::Legacy is the supported escape hatch.
  • Publisher key ≠ subscriber key. The hub validates publishes with MERCURE_PUBLISHER_JWT_KEY and subscriptions with MERCURE_SUBSCRIBER_JWT_KEY. Signing a publish token with the subscriber secret yields a bare 401 with no body, and it's still the most common first-day bug. Never ship the publisher secret to the frontend.
  • Claims moved to authorization_details under V1. The V1 token is RFC-9396-shaped and requires iss, aud, sub and client_id in addition to the key/algorithm — a token with only a valid signature and a topic list will be rejected. FactoryTokenProvider($factory, publish: [...]) still nests them for you; if you hand-roll tokens with lcobucci/jwt, you own the shape. ['*'] remains a dev-only escape hatch.
  • The subscriber cookie is now __Secure-mercure_access_token. The __Secure- prefix means browsers refuse to store it unless it arrives with Secure over HTTPS (localhost is usually treated as a secure context; any other plain-http:// hostname is not). Anything reading the old cookie name in JS, in nginx, or in the hub's cookie-name setting must be updated in lockstep, and SameSite=None (which itself requires Secure) is effectively mandatory for cross-site SSE.
  • data must be a string. No array serialisation: json_encode() (or any \Stringable) explicitly. Passing an array throws a TypeError deep in the request body builder.
  • publish() throws on non-2xx. ExceptionInterface/RuntimeException on hub errors, timeouts, or malformed JWT. Always try/catch or queue it. Also set timeout on the injected HttpClient — the default has none, so a hung hub blocks a PHP-FPM worker indefinitely.
  • A missing subscribe grant fails silently. The hub returns 200 to the browser, the connection stays open, and nothing ever arrives. Debug by decoding the subscriber JWT, confirming the grant landed in authorization_details, verifying the topic string and the match parameter match exactly (including trailing slashes and template expansion), and checking hub logs.
  • Cookie scope for subdomain hubs. If the hub is hub.example.com and the app is app.example.com, set cookieDomain to .example.com. The hub's CORS must also allow credentials (cors_origins + Access-Control-Allow-Credentials). SameSite=Lax silently drops the cookie on the subscription request.
  • HTTP/1.1 connection limits. Browsers cap ~6 connections per origin; several open tabs can starve your SSE streams. Terminate the hub behind HTTP/2 (Caddy/FrankenPHP do this by default) — non-negotiable for production.
  • Last-Event-ID replay is bounded. Reconnection replays from the hub's history store (MERCURE_HISTORY_SIZE). It is a convenience, not a delivery guarantee; anything that must not be lost belongs in your database with a client-side resync.
  • Testing: swap HubInterface for Symfony\Component\Mercure\MockHub in the container and assert against $mockHub->getUpdates(). MockHub now defaults to protocol 1.0 too — pass ProtocolVersion::Legacy if your production hub still is. It returns a synthetic ID, so don't assert on the returned string; and if a test asserts on a generated JWT or a Set-Cookie header, expect authorization_details and __Secure-mercure_access_token.
  • Debugging in dev: decorate your binding with Symfony\Component\Mercure\Debug\TraceableHub to record calls, or just log inside your RealtimePublisher — no Laravel Debug Bar integration exists for this component.
  • FrankenPHP / Octane: FrankenPhpHub defaults to V1 like everything else, and as of 0.9 publish() throws an explanatory RuntimeException when mercure_publish() isn't defined (e.g. under the CLI) instead of a fatal "call to undefined function". That's a clearer error, not a fix — still keep the HTTP Hub bound for CLI and queue workers, and don't assume FrankenPHP's native path is active; verify it.
  • Asymmetric keys: Jwt\WebTokenFactory gained a fully-specified Ed25519 algorithm (needs web-token/jwt-library ≥ 4.3). Prefer it over the polymorphic EdDSA, which that version deprecates. lcobucci/jwt 4 and 5 remain supported for LcobucciFactory.
  • Version pinning: 0.9.x defaults to protocol 1.0 and requires current PHP/Symfony. Stay on 0.7/0.8 for an older LTS stack or a 0.x hub, and lock the minor version — the 0.x line has made breaking changes repeatedly (StaticJwtProvider → StaticTokenProvider, Publisher → Hub, and now the protocol/cookie/claims trio).
  • Don't confuse it with Laravel broadcasting. This is SSE, unidirectional, and replaces Echo/Pusher only for server→client pushes. Presence channels, client-to-client messaging, and whisper-style features still need WebSockets.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo