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 Bundle Laravel Package

symfony/mercure-bundle

Symfony bundle that integrates the Mercure Component into full-stack Symfony applications, making it easy to add real-time push updates over Server-Sent Events. Provides configuration, dependency injection setup, and seamless hub integration for publishing and subscribing to live updates.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

  1. Installation

    composer require symfony/mercure-bundle
    

    Requires symfony/mercure ^0.9 as of bundle v0.6. Register the bundle in config/bundles.php:

    return [
        // ...
        Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
    ];
    
  2. Configure the hub — config/packages/mercure.yaml:

    mercure:
        default_hub: default
        hubs:
            default:
                url: '%env(MERCURE_URL)%'
                public_url: '%env(MERCURE_PUBLIC_URL)%'
                protocol_version: '1.0'          # default since 0.6.0
                jwt:
                    secret: '%env(MERCURE_JWT_SECRET)%'
                    claims:                       # REQUIRED since 0.6.0 for jwt.secret hubs
                        iss: '%env(MERCURE_JWT_ISSUER)%'
                        sub: '%env(MERCURE_JWT_SUBJECT)%'
                        client_id: '%env(MERCURE_JWT_CLIENT_ID)%'
    

    Since 0.6.1, the hub url is added to the default aud claim of protocol 1.0 publisher tokens — the hub receives the POST at exactly that URL, so it validates out of the box even when url (internal) and public_url (browser-facing) differ. Don't set aud yourself unless you mean to replace that default.

  3. .env

    MERCURE_URL=https://your-hub/.well-known/mercure
    MERCURE_PUBLIC_URL=https://your-hub/.well-known/mercure
    MERCURE_JWT_SECRET=change-me
    MERCURE_JWT_ISSUER=https://example.com
    MERCURE_JWT_SUBJECT=mercure-publisher
    MERCURE_JWT_CLIENT_ID=my-app
    MERCURE_CORS_ALLOWED_ORIGINS=https://example.com
    
  4. First publish — inject HubInterface (the Publisher/PublisherInterface services were removed in 0.6.0):

    use Symfony\Component\Mercure\HubInterface;
    use Symfony\Component\Mercure\Update;
    
    public function publish(HubInterface $hub): void
    {
        $hub->publish(new Update(
            'https://example.com/updates',
            json_encode(['message' => 'New update!']),
            private: false,
        ));
    }
    

    Note the Update signature: (string|array $topics, string $data, bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null) — there is no date parameter.

  5. First subscribe — with protocol 1.0 the browser authenticates via the __Secure-mercure_access_token cookie (mercure_access_token in kernel.debug, because browsers drop __Secure- cookies over plain HTTP):

    const url = new URL('https://your-hub/.well-known/mercure');
    url.searchParams.append('topic', 'https://example.com/updates');
    const es = new EventSource(url, { withCredentials: true });
    es.onmessage = (e) => console.log(JSON.parse(e.data));
    

Where to look first: config/packages/mercure.yaml, then Symfony\Component\Mercure\Update and Symfony\Component\Mercure\HubInterface. Open the Symfony profiler → Mercure panel to see every update your request published (TraceableHub).


Implementation Patterns

Common Workflows

  1. Publish after a domain write (controller/service, or better a Doctrine postPersist / messenger handler):

    $hub->publish(new Update(
        sprintf('https://example.com/invoices/%s', $invoice->getId()),
        json_encode(['status' => 'paid', 'total' => $invoice->getTotal()]),
        private: true,
    ));
    
  2. Topic design — topics are absolute IRIs, not arbitrary strings. Pass an array to fan out one payload to several topics in a single request:

    new Update(
        ['https://example.com/users/42', 'https://example.com/dashboards/7'],
        json_encode($payload),
    );
    

    Keep a stable convention (/resource/{id}, /users/{id}/feed) so the front end can build the same URLs.

  3. Private topics + browser auth — mark sensitive updates private: true and give the browser a JWT. Since EventSource cannot set an Authorization header, the 1.0 protocol expects the hub cookie (__Secure-mercure_access_token); set it from your app after login, or generate the subscribe URL server-side with the Twig mercure()/mercure_hub() helpers.

  4. Async publishing — never block the request on the hub. Dispatch a Messenger message and publish in a handler, so hub latency/outages don't break the user-facing flow:

    $bus->dispatch(new PublishMercureUpdate($topic, $payload));
    
  5. Multiple hubs / split signing keys — declare additional hubs and inject the remote one by named argument (autowirable since 0.6.0):

    public function __construct(private RemoteHubInterface $notificationsHub) {}
    
    mercure:
        hubs:
            notifications:
                url: '%env(HUB_URL)%'
                public_url: '%env(HUB_PUBLIC_URL)%'
                protocol_version: '1.0'
                publisher:
                    secret: '%env(HUB_PUBLISHER_SECRET)%'
                    claims: { iss: 'app', sub: 'publisher', client_id: 'app' }
                subscriber:
                    secret: '%env(HUB_SUBSCRIBER_SECRET)%'
                    claims: { iss: 'app', sub: 'subscriber', client_id: 'app' }
                http_client: 'mercure.fast_client'
    
  6. Internal vs public URL (docker-compose / k8s / reverse proxy) — a very common shape is url: http://hub:3000/.well-known/mercure for publishing from PHP and public_url: https://example.com/.well-known/mercure for browsers. Since 0.6.1 this needs no extra config: the token's default aud is the internal url, which is where the request is actually sent. Before 0.6.1 you had to add a resource_identifier or a hand-rolled aud claim to make the hub accept the publication.

    url: 'http://mercure:80/.well-known/mercure'                 # what PHP posts to → aud
    public_url: 'https://example.com/.well-known/mercure'        # what the browser opens
    

Integration Tips

  1. Dedicated HTTP client per hub — new in 0.6.0. Point http_client at a scoped client with a short timeout so a slow hub can't stall the worker:

    framework:
        http_client:
            scoped_clients:
                mercure.client:
                    base_uri: '%env(MERCURE_URL)%'
                    timeout: 2
                    max_duration: 4
    
  2. FrankenPHP / Docker Compose — url and public_url now default to null, and an empty url reliably selects FrankenPhpHub at runtime. That makes MERCURE_URL= the idiomatic Compose value for "use the built-in hub", which is still autowirable as HubInterface.

  3. Laravel-style queues vs Messenger — the pattern is identical: dispatch a job/message, publish in the worker. Keep the update payload small (the hub relays it verbatim); ship IDs and let the client refetch large resources.

  4. Testing — mock HubInterface (or use MockHub from symfony/mercure) and assert on the Update you received; there is no MercureTestHub class in this package:

    $hub = $this->createMock(HubInterface::class);
    $hub->expects($this->once())
        ->method('publish')
        ->with($this->callback(fn (Update $u) => $u->getTopics() === ['https://example.com/test']));
    
  5. Caching — do not debounce updates by caching the "last publish"; cache the rendered payload instead and always publish, otherwise clients silently miss state changes. Use topic granularity (per-resource topics) rather than connection-level throttling.


Gotchas and Tips

Upgrade checklist (0.4.x → 0.6.1)

  1. Publisher aliases are gone (BC break). Publisher, PublisherInterface, TraceablePublisher and StaticJwtProvider (mercure.hub.*.jwt_provider) are no longer registered. Use Hub / HubInterface / TraceableHub, and StaticTokenProvider at mercure.hub.*.jwt.provider. Any service argument typed against the old publisher will fail to wire. Bonus: the symfony/mercure 0.5 deprecation notices that fired on every container boot are gone.
  2. jwt.secret hubs now need claims. Without iss, sub and client_id under jwt.claims, the container will not compile — this is the most common upgrade failure. Either add them, or switch to publisher/subscriber options with their own claims.
  3. Protocol default flipped to 1.0. Against an old 0.x hub you must set protocol_version: '0.x', and the cookie name changes with it. protocol_version also accepts a ProtocolVersion enum case (handy for DI/config builders), not just the string.
  4. Cookie name over plain HTTP. In prod the cookie is __Secure-mercure_access_token, which browsers refuse to store on http://. Local dev works because kernel.debug switches it to mercure_access_token (matching the hub playground default) — if SSE auth mysteriously works in dev but not on staging over HTTP, this is why. Terminate TLS.
  5. Profiler API change (BC break). MercureDataCollector::getPublishers() was removed; use getHubs() in any custom data collector template or test.
  6. Upgrade the bundle if you run worker mode. TraceableHub used to retain messages of previous requests in long-running processes (FrankenPHP worker mode); fixed in 0.6.0. If profiler memory grew per request, this was the cause.
  7. default_hub typos now fail loudly. Naming an undefined hub raises a configuration error instead of a TypeError — re-run cache:clear after renaming hubs.
  8. 0.6.1 is a drop-in patch — and a cleanup opportunity. The hub url is now part of the default aud for protocol 1.0 publisher tokens. If your config carries a workaround for the internal/public URL split (resource_identifier, or a manual aud entry in jwt.claims), remove it and re-test with a publish; the default now matches what the hub checks. Nothing else changes, so cache:clear and a single publish is enough to validate the upgrade.

Pitfalls

  1. JWT secret mismatch → 401 Unauthorized on publish or subscribe. The secret must byte-for-byte match the hub's MERCURE_JWT_SECRET; claims are validated too (iss will be checked if the hub is configured with an issuer, and aud is checked against the hub URL).
  2. Custom aud overrides the 0.6.1 default. Defining aud under jwt.claims (or in a custom TokenProviderInterface) replaces the automatic URL-based aud. If you must set it manually, make sure it contains the exact url value the publisher posts to — not the public_url, and not the browser-visible hostname. The same caveat applies to subscriber tokens: the 0.6.1 convenience only covers publisher tokens, so subscriber aud still needs an explicit resource_identifier when internal and public URLs diverge.
  3. Accidental FrankenPHP mode. Because an empty url now selects the built-in hub, a forgotten/unset MERCURE_URL in production silently changes behaviour rather than throwing. Add a deploy assertion on the env var.
  4. CORS for EventSource. MERCURE_CORS_ALLOWED_ORIGINS must list explicit origins; * does not work with credentials, so a wildcard yields a "connected but no events" symptom.
  5. Topics must be valid IRIs. Spaces/special characters in a topic break subscription silently. Encode path segments:
    $topic = 'https://example.com/updates/' . rawurlencode($resourceId);
    
  6. EventSource retries stampede. Native reconnect (plus your own onerror handler re-creating the source) can multiply connections. Use one source per page, close it on beforeunload, and back off exponentially on error.
  7. One connection per topic set. Every distinct subscribe URL opens a new HTTP connection to the hub; group topics per page and prefer per-user topics (https://example.com/users/42/feed) over a single firehose topic everyone subscribes to.
  8. private: true without a token = silent nothing. Private updates are filtered per-subscriber; a client with no/invalid JWT simply receives no message and no error.
  9. Twig helpers missing (historical 0.4.x regression): clear the cache, and if it persists register Symfony\Bundle\MercureBundle\Twig\MercureExtension with tags: ['twig.extension'].

Debugging

  1. Profiler → Mercure panel shows the hub used, the public URL and the updates published in the current request. Fastest way to confirm "did we even publish?".
  2. HTTP client logging. Because publishing goes through the HTTP client, enable framework.http_client.default_options / a Monolog channel on Symfony\Contracts\HttpClient to see the actual POST /.well-known/mercure request, status and body. The logged request URL is your ground truth for what aud the token needs to contain.
  3. Browser side: DevTools → Network → filter EventSource. 401 = JWT/cookie problem; 404 = wrong hub path; connected-but-idle = topic mismatch or private update without a valid token.
  4. curl the hub with a manually minted token to isolate app vs hub:
    curl -i -X POST https://your-hub/.well-known/mercure \
      -H "Authorization: Bearer $JWT" \
      -d 'topic=https://example.com/test' -d 'data=hello'
    
    Decode the token (base64 -d the middle segment) to confirm aud matches the URL you posted to — the fastest diagnosis for a 0.6.x publication that 401s only in staging.

Extension Points

  1. Custom TokenProviderInterface — implement getJwt() to mint tokens dynamically (tenant-aware claims, rotation, external IdP) and reference it from the hub config instead of jwt.secret. Remember you take over claim assembly entirely, including aud (see Pitfalls #2).
  2. Decorate HubInterface — add correlation IDs, redaction of PII or metrics around publish() without touching call sites.
  3. Per-hub http_client / publisher / subscriber — the intended seams for multi-tenant or multi-region setups: distinct keys, distinct timeouts, distinct hubs, all wired through config rather than factories in code.
  4. Custom Update subclass / value object — don't subclass Update to carry metadata; wrap it in your own DTO and map to Update at the edge ($type and $id are already available for idempotency and client-side dispatch).
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.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log