---
title: "Real-time"
description: "FrankenPHP ships with a built-in Mercure hub for pushing real-time events to browsers over HTTP, as a simpler alternative to WebSockets."
sidebar:
  label: "Real-time"
  order: 1
seo:
  title: "Real-time updates with the FrankenPHP Mercure hub"
---
FrankenPHP comes with a built-in [Mercure](https://mercure.rocks) hub!
Mercure allows you to push real-time events to all the connected devices: they will receive a JavaScript event instantly.

It's a convenient alternative to WebSockets that is simple to use and is natively supported by all modern web browsers!

![Mercure](/blume-assets/content/content/assets/mercure-hub.png)

## Enabling Mercure

Mercure support is disabled by default.
Here is a minimal example of a `Caddyfile` enabling both FrankenPHP and the Mercure hub:

```caddyfile
# The hostname to respond to
localhost

mercure {
    # The issuer of the access tokens, and the keys verifying them
    issuer https://localhost {
        publisher {
            jwt !ChangeThisMercureHubJWTSecretKey!
        }
        subscriber {
            jwt !ChangeThisMercureHubJWTSecretKey!
        }
    }
    # Allows anonymous subscribers (without access token)
    anonymous
}

root public/
php_server
```

Access tokens must carry the identifier of the `issuer` they were signed by in their `iss` claim.
`mercure_publish()` doesn't need any token: it publishes directly, in-process.

<div class="fp-callout" data-callout="tip">
<p class="fp-callout-title">Tip</p>

The [sample `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile)
provided by [the Docker images](/docs/docker) already includes a commented Mercure configuration
with convenient environment variables to configure it.

Uncomment the Mercure section in `/etc/frankenphp/Caddyfile` to enable it.

</div>

If you enable compression with the `encode` directive, exclude the hub from it:
compressed SSE streams cost memory per subscriber and enable BREACH-style attacks.

```caddyfile
@compressible not path /.well-known/mercure
encode @compressible zstd br gzip
```

### With `php-server`

`frankenphp php-server --mercure` starts the hub without a `Caddyfile`, configured by environment variables:

- `MERCURE_PUBLISHER_JWT_KEY` and `MERCURE_SUBSCRIBER_JWT_KEY` (required): the keys verifying the publisher and subscriber tokens
- `MERCURE_PUBLISHER_JWT_ALG` and `MERCURE_SUBSCRIBER_JWT_ALG` (default: `HS256`): the algorithms these tokens are signed with
- `MERCURE_TRUSTED_ISSUERS` (default: `https://localhost`): the identifiers accepted in the `iss` claim of the tokens, separated by commas or spaces (the sample `Caddyfile` reads a single identifier from this variable)
- `MERCURE_RESOURCE_IDENTIFIER` (default: the URL of the hub the client contacted): the value the `aud` claim of the tokens must contain; set it when `--domain` is empty, as the site then answers any `Host`

The tokens must follow [the format described below](#using-file_get_contents): the ones issued for the 0.x hub are rejected.

## Subscribing to updates

By default, the Mercure hub is available on the `/.well-known/mercure` path of your FrankenPHP server.
To subscribe to updates, use the native [`EventSource`](https://developer.mozilla.org/docs/Web/API/EventSource) JavaScript class:

```html
<!-- public/index.html -->
<!doctype html>
<title>Mercure Example</title>
<script>
  const eventSource = new EventSource("/.well-known/mercure?match=my-topic");
  eventSource.onmessage = function (event) {
    console.log("New message:", event.data);
  };
</script>
```

## Publishing updates

### Using `mercure_publish()`

FrankenPHP provides a convenient `mercure_publish()` function to publish updates to the built-in Mercure hub:

```php
<?php
// public/publish.php

$updateID = mercure_publish('my-topic',  json_encode(['key' => 'value']));

// Write to FrankenPHP's logs
error_log("update $updateID published", 4);
```

The full function signature is:

```php
/**
 * @param string|string[] $topics The first topic is the canonical one, the others are alternate topics
 */
function mercure_publish(string|array $topics, string $data = '', bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null): string {}
```

Subscribers matching the canonical topic or any of the alternate topics receive the update.
The returned string is the ID of the update, to be used as `Last-Event-ID` to resume a stream.

`mercure_publish()` throws a `ValueError` when the update violates the protocol, notably when:

- a topic addresses the `/.well-known/mercure` namespace, which is reserved for the hub, or is `*`, which is reserved for the wildcard matcher;
- `$id` starts with `#`, is `earliest`, or contains control characters;
- `$type` is `mercure`, which is reserved for the events generated by the hub, or contains control characters;
- `$data` isn't valid UTF-8;
- `$retry` is negative.

A `TypeError` is thrown when `$topics` is an array holding a value that isn't a string.
A `RuntimeException` is thrown when no hub is configured, or when the hub fails to dispatch the update.

### Using `file_get_contents()`

To dispatch an update to connected subscribers, send an authenticated POST request to the Mercure hub with the `topic` and `data` parameters:

```php
<?php
// public/publish.php

// An access token signed with the key of the "issuer" configured in the Caddyfile
$jwt = generate_access_token();

$updateID = file_get_contents('https://localhost/.well-known/mercure', context: stream_context_create(['http' => [
    'method'  => 'POST',
    'header'  => "Content-type: application/x-www-form-urlencoded\r\nAuthorization: Bearer " . $jwt,
    'content' => http_build_query([
        'topic' => 'my-topic',
        'data' => json_encode(['key' => 'value']),
    ]),
]]));

// Write to FrankenPHP's logs
error_log("update $updateID published", 4);
```

The token is an [OAuth 2.0 access token](https://www.rfc-editor.org/rfc/rfc9068), signed with the key of the `issuer` block of the `Caddyfile`.
Its header must contain `"typ": "at+jwt"`, and its payload must grant the `publish` action on every topic of the update:

```json
{
  "iss": "https://localhost",
  "aud": "https://localhost/.well-known/mercure",
  "exp": 4102444800,
  "authorization_details": [
    {
      "type": "https://mercure.rocks/authorization-detail",
      "actions": ["publish"],
      "topics": [{ "match": "my-topic" }]
    }
  ]
}
```

`iss` must be the identifier of the `issuer` block, and `aud` the URL of the hub.
See [the Mercure documentation](https://mercure.rocks/docs/concepts/authorization) about authorization.

Generate these tokens dynamically, using a trusted [JWT library](https://www.jwt.io/libraries?programming_language=php): the protocol requires an expiration date, so they cannot be hardcoded.

<div class="fp-callout" data-callout="warning">
<p class="fp-callout-title">Warning</p>

The tokens of the Mercure protocol 0.x, using a `mercure` claim, are rejected by default.
FrankenPHP is built with the `deprecated_topic` and `deprecated_claim` build tags of the hub, so adding `protocol_version_compatibility 8` to the `mercure` block accepts them again during a migration.
This mode relaxes the validation of the access tokens: remove it once your clients issue 1.0 tokens.

</div>

### Using Symfony Mercure

Alternatively, you can use the [Symfony Mercure Component](https://symfony.com/components/Mercure), a standalone PHP library.

This library handles the JWT generation, update publishing as well as cookie-based authorization for subscribers.
Support for the Mercure protocol 1.0 requires symfony/mercure 0.8 or later.

First, install the library using Composer:

```console
composer require symfony/mercure lcobucci/jwt
```

Then, you can use it like this:

```php
<?php
// public/publish.php

require __DIR__ . '/../vendor/autoload.php';

const JWT_SECRET = '!ChangeThisMercureHubJWTSecretKey!'; // Must be the same as the publisher jwt in Caddyfile

// Set up the JWT token provider, issuing Mercure 1.0 access tokens
$jwFactory = new \Symfony\Component\Mercure\Jwt\LcobucciFactory(
    JWT_SECRET,
    jwtLifetime: 3600,
    protocolVersion: \Symfony\Component\Mercure\ProtocolVersion::V1,
);
$provider = new \Symfony\Component\Mercure\Jwt\FactoryTokenProvider(
    $jwFactory,
    [new \Symfony\Component\Mercure\Jwt\Grant([\Symfony\Component\Mercure\Jwt\Grant::ACTION_PUBLISH], ['*'])],
    [
        'iss' => 'https://localhost',
        'aud' => 'https://localhost/.well-known/mercure',
        // Required by RFC 9068: the subject and the client the token is issued to
        'sub' => 'https://localhost',
        'client_id' => 'https://localhost',
    ],
);

$hub = new \Symfony\Component\Mercure\Hub(
    'https://localhost/.well-known/mercure',
    $provider,
    protocolVersion: \Symfony\Component\Mercure\ProtocolVersion::V1,
);
// Serialize the update, and dispatch it to the hub, that will broadcast it to the clients
$updateID = $hub->publish(new \Symfony\Component\Mercure\Update('my-topic', json_encode(['key' => 'value'])));

// Write to FrankenPHP's logs
error_log("update $updateID published", 4);
```

Mercure is also natively supported by:

- [Laravel](/docs/laravel#mercure-support)
- [Symfony](https://symfony.com/doc/current/mercure.html)
- [API Platform](https://api-platform.com/docs/core/mercure/)
