CakePHP Mercure Plugin

February 25, 2026 · View on GitHub

PHPStan Level 8 Build Status codecov License: MIT PHP Version Packagist Downloads

CakePHP Mercure Plugin

Push real-time updates to clients using the Mercure protocol.

Mercure

Table of Contents

Overview

This plugin provides integration between CakePHP applications and the Mercure protocol, enabling real-time push capabilities for modern web applications.

Mercure is an open protocol built on top of Server-Sent Events (SSE) that allows you to:

  • Push updates from your server to clients in real-time
  • Create live-updating UIs without complex WebSocket infrastructure
  • Broadcast data changes to multiple connected users
  • Handle authorization for private updates
  • Automatically reconnect with missed update retrieval

Common use cases include live dashboards, collaborative editing, real-time notifications, and chat applications.

Installation

Installing the Plugin

Important

Minimum Requirements:

  • PHP 8.2 or higher
  • CakePHP 5.0.1 or higher

Install the plugin using Composer:

composer require josbeir/cakephp-mercure

Load the plugin in your Application.php:

// src/Application.php
public function bootstrap(): void
{
    parent::bootstrap();

    $this->addPlugin('Mercure');
}

Alternatively, you can add it to config/plugins.php:

// config/plugins.php
return [
    'Mercure' => [],
];

Running a Mercure Hub

Mercure requires a hub server to manage persistent SSE connections. Download the official hub from Mercure.rocks.

For development, you can run the hub using Docker:

docker run -d \
    -e SERVER_NAME=:3000 \
    -e MERCURE_PUBLISHER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \
    -e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \
    -p 3000:3000 \
    dunglas/mercure

If you're using DDEV, you can install the Mercure add-on:

ddev get Rindula/ddev-mercure

For more information, see the DDEV Mercure add-on.

The hub will be available at http://localhost:3000/.well-known/mercure.

Custom DDEV Setup with Nginx Proxy

For environments with dynamic ports (like DDEV), you can set up a custom Mercure container with an nginx proxy. This allows using relative URLs that work regardless of the port.

Create .ddev/docker-compose.mercure.yaml:

services:
  mercure:
    image: dunglas/mercure
    container_name: ddev-${DDEV_SITENAME}-mercure
    restart: "no"
    expose:
      - "80"
    environment:
      SERVER_NAME: ':80'
      MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
      MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
      MERCURE_EXTRA_DIRECTIVES: |
        anonymous
        cors_origins *
    labels:
      com.ddev.site-name: ${DDEV_SITENAME}
      com.ddev.approot: ${DDEV_APPROOT}

  web:
    environment:
      - MERCURE_URL=http://mercure/.well-known/mercure
      - MERCURE_PUBLIC_URL=/mercure-hub
      - MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey!

Create .ddev/nginx/mercure.conf:

location /mercure-hub {
    rewrite ^/mercure-hub(.*)$ /.well-known/mercure\$1 break;
    proxy_pass http://mercure:80;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_set_header Cache-Control 'no-cache';
    proxy_set_header X-Accel-Buffering 'no';
    proxy_buffering off;
    chunked_transfer_encoding off;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 24h;
    proxy_connect_timeout 1h;
}

Then configure relative URLs in config/app_custom.php:

'Mercure' => [
    'url' => env('MERCURE_URL', 'http://mercure/.well-known/mercure'),
    'public_url' => env('MERCURE_PUBLIC_URL', '/mercure-hub'),
    'jwt' => [
        'secret' => env('MERCURE_JWT_SECRET', '!ChangeThisMercureHubJWTSecretKey!'),
        'algorithm' => 'HS256',
        'publish' => ['*'],
    ],
],

Run ddev restart to apply the changes. Using a relative public_url like /mercure-hub means the browser will automatically use the current origin, making it work regardless of DDEV's dynamic port assignment.

Tip

Using FrankenPHP? You're good to go! FrankenPHP has Mercure built in—no separate hub needed. See the FrankenPHP Mercure documentation for details.

Configuration

The plugin comes with sensible defaults and multiple configuration options.

Quick Setup (Environment Variables):

For development, the fastest way to get started is using environment variables in your .env file:

MERCURE_URL=http://localhost:3000/.well-known/mercure
MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure
MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey!

Configuration Files:

The plugin loads configuration in this order:

  1. Plugin defaults - vendor/josbeir/cakephp-mercure/config/mercure.php (loaded automatically)
  2. Your overrides - config/app_mercure.php (optional, loaded after plugin defaults)

Create config/app_mercure.php in your project to customize any settings. Your values will override the plugin defaults.

Cross-Subdomain Setup:

Note

If your Mercure hub runs on a different subdomain than your CakePHP application (e.g., hub.example.com vs app.example.com), you must configure the cookie domain:

# Allow cookie sharing across subdomains
MERCURE_COOKIE_DOMAIN=.example.com

This enables the authorization cookie to be accessible by both your application and the Mercure hub. Without this setting, authorization will fail for cross-subdomain requests.

For a complete list of available environment variables, see the plugin's config/mercure.php file.

Basic Usage

The plugin provides multiple integration points depending on your use case:

  • Controllers: Use the MercureComponent to centrally manage both authorization and subscriptions as topics
  • Templates: Use the MercureHelper to generate Mercure topic URLs for EventSource subscriptions in your views and templates.
  • Services & Manual Control: Use the Publisher facade to publish updates and the Authorization facade for direct response manipulation when you need lower-level control (e.g., outside controllers/views, such as in background jobs or custom middleware).

Tip

Note: Facades (Publisher, Authorization) can be used in any context where a CakePHP component or helper does not fit, such as in queue jobs, commands, models, or other non-HTTP or background processing code. This makes them ideal for use outside of controllers and views.

Choosing Your Authorization Strategy

Pick the approach that best fits your workflow:

ScenarioRecommended ApproachMethod to Use
Authorize in controller, display URL in templateMercureComponent + MercureHelper$this->Mercure->authorize() in controller, $this->Mercure->url($topics) in template
Public topics (no authorization)MercureHelper$this->Mercure->url($topics)
Manual response controlAuthorization facadeAuthorization::setCookie($response, $subscribe)

Publishing Updates

Use the Publisher facade to send updates to the Mercure hub:

use Mercure\Publisher;
use Mercure\Update\Update;

// In a controller or service
$update = new Update(
    topics: 'https://example.com/books/1',
    data: json_encode(['status' => 'OutOfStock'])
);

Publisher::publish($update);

The topics parameter identifies the resource being updated. It should be a unique IRI (Internationalized Resource Identifier), typically the resource's URL.

You can publish to multiple topics simultaneously:

$update = new Update(
    topics: [
        'https://example.com/books/1',
        'https://example.com/notifications',
    ],
    data: json_encode(['message' => 'Book status changed'])
);

Publisher::publish($update);

Tip

Using MercureComponent in Controllers: If you're publishing from a controller, the MercureComponent provides convenient methods that eliminate the need to manually create Update objects or call the Publisher facade:

// In your controller
public function initialize(): void
{
    parent::initialize();
    $this->loadComponent('Mercure.Mercure');
}

public function update($id)
{
    $book = $this->Books->get($id);
    $book = $this->Books->patchEntity($book, $this->request->getData());
    $this->Books->save($book);

    // Publish JSON directly - no need for Publisher facade
    $this->Mercure->publishJson(
        topics: "/books/{$id}",
        data: ['status' => $book->status, 'title' => $book->title]
    );

    // Or publish a rendered element
    $this->Mercure->publishView(
        topics: "/books/{$id}",
        element: 'Books/item',
        data: ['book' => $book]
    );
}

See the MercureComponent API Reference for all available methods.

Publishing JSON Data

For convenience when publishing JSON data, use the JsonUpdate class which automatically encodes arrays and objects to JSON:

use Mercure\Publisher;
use Mercure\Update\JsonUpdate;

// Simple array - no need to call json_encode()
$update = JsonUpdate::create(
    topics: 'https://example.com/books/1',
    data: ['status' => 'OutOfStock', 'quantity' => 0]
);

Publisher::publish($update);

// Or use the fluent builder pattern
$update = (new JsonUpdate('https://example.com/books/1'))
    ->data(['status' => 'OutOfStock', 'quantity' => 0])
    ->build();

Publisher::publish($update);

You can customize JSON encoding options:

// With custom JSON encoding options
$update = JsonUpdate::create(
    topics: 'https://example.com/books/1',
    data: ['title' => 'Book & Title', 'price' => 19.99],
    jsonOptions: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);

// Or using the fluent builder
$update = (new JsonUpdate('https://example.com/books/1'))
    ->data(['title' => 'Book & Title', 'price' => 19.99])
    ->jsonOptions(JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
    ->build();

Publisher::publish($update);

For private updates and event metadata:

$update = JsonUpdate::create(
    topics: 'https://example.com/users/123/notifications',
    data: ['message' => 'New notification', 'unread' => 5],
    private: true
);

// Or chain multiple options with the fluent builder
$update = (new JsonUpdate('https://example.com/books/1'))
    ->data(['title' => 'New Book', 'price' => 29.99])
    ->private()
    ->id(Text::uuid())
    ->type('book.created')
    ->retry(5000)
    ->build();

Publisher::publish($update);

Publishing Rendered Views

Use the ViewUpdate class to automatically render CakePHP views or elements and publish the rendered HTML.

Note

This is especially handy when using JavaScript frameworks like htmx (for instance, using the htmx-sse extension), Hotwire (with Turbo Streams), or similar reactive libraries, which can consume and swap HTML fragments received over Mercure for seamless real-time UI updates.

use Mercure\Publisher;
use Mercure\Update\ViewUpdate;

// Render an element
$update = ViewUpdate::create(
    topics: 'https://example.com/books/1',
    element: 'Books/item',
    viewVars: ['book' => $book]
);

// Or use the fluent builder pattern
$update = (new ViewUpdate('https://example.com/books/1'))
    ->element('Books/item')
    ->viewVars(['book' => $book])
    ->build();

Publisher::publish($update);

You can also render full templates:

// Render a template
$update = ViewUpdate::create(
    topics: 'https://example.com/notifications',
    template: 'Notifications/item',
    viewVars: ['notification' => $notification]
);

// Or with the fluent builder - add view options too
$update = (new ViewUpdate('https://example.com/notifications'))
    ->template('Notifications/item')
    ->viewVars(['notification' => $notification])
    ->viewOptions(['key' => 'value'])
    ->build();

Publisher::publish($update);

For private updates with event metadata:

$update = ViewUpdate::create(
    topics: 'https://example.com/users/123/messages',
    element: 'Messages/item',
    viewVars: ['message' => $message],
    private: true
);

// Or chain all options with the fluent builder
$update = (new ViewUpdate('https://example.com/users/123/messages'))
    ->element('Messages/item')
    ->viewVars(['message' => $message])
    ->private()
    ->id('msg-456')
    ->type('message.new')
    ->build();

Publisher::publish($update);

Subscribing to Updates

The plugin provides a View Helper to generate Mercure URLs in your templates.

First, load the helper in AppView:

// In src/View/AppView.php
public function initialize(): void
{
    parent::initialize();
    $this->loadHelper('Mercure.Mercure');
}

Then subscribe to updates from your templates:

// In your template
<div id="book-status">Available</div>

<script>
// For public topics (no authorization needed)
const eventSource = new EventSource('<?= $this->Mercure->url(['https://example.com/books/1']) ?>');

eventSource.onmessage = (event) => {
    const data = JSON.parse(event.data);
    document.getElementById('book-status').textContent = data.status;
};
</script>

Subscribe to multiple topics:

<script>
// Subscribe to multiple topics
const url = '<?= $this->Mercure->url([
    'https://example.com/books/1',
    'https://example.com/notifications'
]) ?>';

const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
    console.log('Update received:', event.data);
};
</script>

If you need to access the Mercure URL from an external JavaScript file, store it in a data element:

<script type="application/json" id="mercure-url">
<?= json_encode(
    $this->Mercure->url(['https://example.com/books/1']),
    JSON_UNESCAPED_SLASHES | JSON_HEX_TAG
) ?>
</script>

Then retrieve it from your JavaScript:

const url = JSON.parse(document.getElementById('mercure-url').textContent);
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
    console.log('Update received:', event.data);
};

Authorization

Publishing Private Updates

Mark updates as private to restrict access to authorized subscribers:

$update = new Update(
    topics: 'https://example.com/users/123/messages',
    data: json_encode(['text' => 'Private message']),
    private: true
);

Publisher::publish($update);

Private updates are only delivered to subscribers with valid JWT tokens containing matching topic selectors.

Setting Authorization Cookies

Using the Component

For centralized authorization logic, use the MercureComponent in controllers. Topics added via the component are automatically available in your views:

class BooksController extends AppController
{
    public function initialize(): void
    {
        parent::initialize();
        $this->loadComponent('Mercure.Mercure');
    }

    public function view($id)
    {
        $book = $this->Books->get($id);
        $userId = $this->request->getAttribute('identity')->id;

        // Using builder pattern
        $this->Mercure
            ->addTopic('https://example.com/books/123') // You can also set this using MercureHelper or using the defaultTopics option.
            ->addSubscribe("https://example.com/books/{$id}")
            ->addSubscribe("https://example.com/notifications/{$id}")
            ->authorize() // This sets the actual JWT cookie.

        // Or direct authorization
        $this->Mercure->authorize(
            subscribe: ["https://example.com/books/{$id}"],
            additionalClaims: ['sub' => $userId] // Optional
        );

        $this->set('book', $book);
    }

    public function logout()
    {
        // Clear authorization on logout
        $this->Mercure->clearAuthorization(); // Removes the JWT cookie.

        return $this->redirect(['action' => 'login']);
    }
}

The component provides separation of concerns (authorization in controller, URLs in template). You can also enable automatic discovery headers:

// In AppController
$this->loadComponent('Mercure.Mercure', [
    'autoDiscover' => true,  // Automatically add discovery headers
]);

Setting Default Topics

You can configure default topics that will be automatically merged with any topics you provide to url(). This is useful when you want certain topics (like notifications or global alerts) to be included in every subscription:

// In your `AppView` using the helper
public function initialize(): void
{
    parent::initialize();

    // Load helper with default topics
    $this->loadHelper('Mercure', [
        'defaultTopics' => [
            'https://example.com/notifications',
            'https://example.com/alerts'
        ]
    ]);
}

You can also set default topics using the MercureComponent in your controller:

// In your controller using the component
public function initialize(): void
{
    parent::initialize();
    $this->loadComponent('Mercure.Mercure', [
        'defaultTopics' => [
            'https://example.com/notifications',
            'https://example.com/alerts'
        ]
    ]);
}

Now every call to MercureHelper::url() will automatically include these default topics:

// In your template
<script>
// This will subscribe to: /notifications, /alerts, AND /books/123
const url = '<?= $this->Mercure->url(['/books/123']) ?>';
const eventSource = new EventSource(url, { withCredentials: true });
</script>

// You can also add topics dynamically:
$this->Mercure->addTopic('/user/' . $userId . '/messages');
$this->Mercure->addTopics(['/books/456', '/comments/789']);

// These will be merged with configured defaults
const url = '<?= $this->Mercure->url(['/books/123']) ?>';
// Result includes: /notifications, /alerts, /user/{id}/messages, /books/456, /comments/789, AND /books/123

Using the Facade classes

For more control or when not using controllers, you can use the Authorization facade directly:

use Mercure\Authorization;

public function view($id)
{
    $book = $this->Books->get($id);

    // Allow this user to subscribe to updates for this book
    $response = Authorization::setCookie(
        $this->response,
        subscribe: ["https://example.com/books/{$id}"]
    );

    $this->set('book', $book);
    return $response;
}

The cookie must be set before establishing the EventSource connection. The Mercure hub and your CakePHP application should share the same domain (different subdomains are allowed).

Mercure Discovery

The Mercure protocol supports automatic hub discovery via HTTP Link headers. This allows clients to discover the hub URL without hardcoding it, making your application more flexible and following the Mercure specification.

Using the Component

Add the discovery header from your controller using the MercureComponent:

// In your controller action
$this->Mercure->discover();

This adds a Link header to the response:

Link: <https://mercure.example.com/.well-known/mercure>; rel="mercure"

Clients can then discover the hub URL from the response headers:

fetch('/api/resource')
    .then(response => {
        const linkHeader = response.headers.get('Link');
        // Parse the Link header to extract the Mercure hub URL
        const match = linkHeader.match(/<([^>]+)>;\s*rel="mercure"/);
        if (match) {
            const hubUrl = match[1];
            const eventSource = new EventSource(hubUrl + '?topic=/api/resource');
        }
    });

Discovery with Topics and Attributes

The discover() method supports optional parameters that align with the Mercure specification:

// Add discovery header with optional link attributes
$this->Mercure->discover(
    lastEventId: '123',
    contentType: 'application/json',
    keySet: 'https://example.com/.well-known/jwks.json'
);

Include subscription topics in discovery:

When you want the rel="self" Link header to include the topics the user is authorized for, enable discoverWithTopics:

// Option 1: Enable per-call
$this->Mercure
    ->authorize(['/books/123', '/notifications/*'])
    ->discover(includeTopics: true);

// Option 2: Enable in component config
$this->loadComponent('Mercure.Mercure', [
    'discoverWithTopics' => true
]);

// Then in your action:
$this->Mercure
    ->authorize(['/books/123'])
    ->discover(); // Automatically includes topics in rel="self"

This generates both headers:

Link: <https://hub.example.com/.well-known/mercure?topic=%2Fbooks%2F123>; rel="self"
Link: <https://hub.example.com/.well-known/mercure>; rel="mercure"

The rel="self" header provides a ready-to-use subscription URL that clients can connect to directly.

Using Middleware

This is an alternative approach to add the discovery header automatically to all responses by using middleware:

// In src/Application.php
use Mercure\Http\Middleware\MercureDiscoveryMiddleware;

public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
    $middlewareQueue
        // ... other middleware
        ->add(new MercureDiscoveryMiddleware());

    return $middlewareQueue;
}

The middleware automatically adds the Link header with rel="mercure" to all responses, making the hub URL discoverable by any client.

Tip

The discovery header uses the public_url configuration (or falls back to url if not set), ensuring clients always receive the correct publicly-accessible hub URL.

Advanced Configuration

JWT Token Strategies

The plugin supports multiple JWT generation strategies:

1. Secret-based (default):

Important

When using HMAC algorithms, ensure your secret meets minimum lengths:

  • HS256: at least 32 bytes
  • HS384: at least 48 bytes
  • HS512: at least 64 bytes
'jwt' => [
    'secret' => env('MERCURE_JWT_SECRET'),
    'algorithm' => 'HS256',
    'publish' => ['*'],
    'subscribe' => ['*'],
]

2. Static token:

'jwt' => [
    'value' => env('MERCURE_JWT_TOKEN'),
]

3. Custom provider:

'jwt' => [
    'provider' => \App\Mercure\CustomTokenProvider::class,
]

Implement Mercure\Jwt\TokenProviderInterface:

namespace App\Mercure;

use Mercure\Jwt\TokenProviderInterface;

class CustomTokenProvider implements TokenProviderInterface
{
    public function getJwt(): string
    {
        // Generate and return JWT token
        return $this->generateToken();
    }
}

4. Custom factory:

'jwt' => [
    'factory' => \App\Mercure\CustomTokenFactory::class,
    'secret' => env('MERCURE_JWT_SECRET'),
    'publish' => ['*'],
]

Implement Mercure\Jwt\TokenFactoryInterface:

namespace App\Mercure;

use Mercure\Jwt\TokenFactoryInterface;

class CustomTokenFactory implements TokenFactoryInterface
{
    public function __construct(
        private string $secret,
        private string $algorithm
    ) {}

    public function create(array $subscribe = [], array $publish = [], array $additionalClaims = []): string
    {
        // Create and return JWT token
    }
}

HTTP Client Options

Configure the HTTP client used to communicate with the Mercure hub:

'http_client' => [
    'timeout' => 30,
    'ssl_verify_peer' => false, // For local development only
]

The authorization cookie contains a JWT token that authenticates subscribers to private topics. JWT expiry is automatically calculated based on cookie lifetime settings.

'cookie' => [
    'name' => 'mercureAuthorization',

    // Lifetime in seconds (0 for session cookie)
    'lifetime' => 3600,  // 1 hour

    // Or use explicit expiry datetime
    // 'expires' => '+1 hour',

    // Omit both to use PHP's session.cookie_lifetime setting

    'domain' => '.example.com',
    'path' => '/',
    'secure' => true,      // HTTPS only (recommended)
    'httponly' => true,    // Prevents XSS token theft
    'samesite' => 'strict', // CSRF protection
]

JWT Expiry Management:

The plugin automatically sets the JWT exp claim based on cookie lifetime, following this priority:

  1. additionalClaims['exp'] - Per-request override
  2. cookie.expires - Explicit datetime ('+1 hour', etc.)
  3. cookie.lifetime - Seconds (3600 for 1 hour, 0 for session)
  4. ini_get('session.cookie_lifetime') - PHP session setting
  5. Default: +1 hour

Session cookies (lifetime: 0) automatically get a 1-hour JWT expiry for security.

Security Notes:

  • httponly: true (default) prevents JavaScript access while still allowing EventSource connections
  • samesite: 'strict' (default) provides CSRF protection
  • secure: true requires HTTPS (recommended for production)
  • JWT tokens always expire - no infinite authorization

For more details, see the CakePHP Cookie documentation.

Testing

For testing, mock the Publisher service to avoid actual HTTP calls:

use Mercure\Publisher;
use Mercure\Service\PublisherInterface;
use Mercure\TestSuite\MockPublisher;

// In your test
public function testPublishing(): void
{
    // Se the mock publisher
    Publisher::setInstance(new MockPublisher());

    // Test your code that publishes updates
    $this->MyService->doSomething();

    // Clean up
    Publisher::clear();
}

Similarly for Authorization:

use Mercure\Authorization;
use Mercure\Service\AuthorizationInterface;

public function testAuthorization(): void
{
    $mockAuth = $this->createMock(AuthorizationInterface::class);
    Authorization::setInstance($mockAuth);

    // Your tests here

    Authorization::clear();
}

API Reference

Publisher

MethodReturnsDescription
publish(Update $update)boolPublish an update to the hub
setInstance(PublisherInterface $publisher)voidSet custom instance (for testing)
clear()voidClear singleton instance

MercureComponent

Controller component for centralized authorization with separation of concerns and automatic dependency injection.

Loading the Component:

public function initialize(): void
{
    parent::initialize();
    $this->loadComponent('Mercure.Mercure', [
        'autoDiscover' => true,      // Optional: auto-add discovery headers
        'discoverWithTopics' => false, // Optional: include topics in rel="self"
        'defaultTopics' => [         // Optional: topics available in all views
            '/notifications',
            '/global/alerts'
        ]
    ]);
}

Methods:

MethodReturnsDescription
addTopic(string $topic)$thisAdd a topic for the view to subscribe to
addTopics(array $topics)$thisAdd multiple topics for the view
getTopics()arrayGet all topics added in the component
resetTopics()$thisReset all accumulated topics
addSubscribe(string $topic, array $additionalClaims = [])$thisAdd a topic to authorize with optional JWT claims
addSubscribes(array $topics, array $additionalClaims = [])$thisAdd multiple topics to authorize with optional JWT claims
getSubscribe()arrayGet accumulated subscribe topics
getAdditionalClaims()arrayGet accumulated JWT claims
resetSubscribe()$thisReset accumulated subscribe topics
resetAdditionalClaims()$thisReset accumulated JWT claims
authorize(array $subscribe = [], array $additionalClaims = [])$thisSet authorization cookie (merges with accumulated state, then resets)
clearAuthorization()$thisClear authorization cookie
discover(?bool $includeTopics, ?string $lastEventId, ?string $contentType, ?string $keySet)$thisAdd Mercure discovery Link headers with optional attributes and topics
publish(Update $update)boolPublish an update to the Mercure hub
publishJson(string|array $topics, mixed $data, ...)boolPublish JSON data (auto-encodes)
publishSimple(string|array $topics, string $data, ...)boolPublish simple string data (no encoding)
publishView(string|array $topics, ?string $template, ?string $element, array $data, ...)boolPublish rendered view/element
getCookieName()stringGet the cookie name

Topic Management:

Topics added in the controller are automatically available in MercureHelper in your views:

// In controller
public function view($id)
{
    $book = $this->Books->get($id);

    // Add topics that will be available in the view
    $this->Mercure
        ->addTopic("/books/{$id}")
        ->addTopic("/user/{$userId}/updates")
        ->authorize(["/books/{$id}"]);

    $this->set('book', $book);
}

// In template - topics are automatically included
const url = '<?= $this->Mercure->url() ?>';
// Subscribes to: /books/123 and /user/456/updates (from component)

Authorization Builder Pattern:

Build up authorization topics and claims fluently, then call authorize():

// Build up gradually with claims
$this->Mercure
    ->addSubscribe('/books/123', ['sub' => $userId])
    ->addSubscribe('/notifications/*', ['role' => 'admin'])
    ->authorize()
    ->discover();

// Add multiple at once
$this->Mercure->addSubscribes(
    ['/books/123', '/notifications/*'],
    ['sub' => $userId, 'role' => 'admin']
);

// Mix builder and direct parameters
$this->Mercure
    ->addSubscribe('/books/123')
    ->authorize(['/notifications/*'], ['sub' => $userId]);

// Chain with topic management
$this->Mercure
    ->addTopic('/books/123')                          // For EventSource
    ->addSubscribe('/books/123', ['sub' => $userId])  // For authorization
    ->authorize()
    ->discover();

Claims accumulate across multiple addSubscribe() calls. The authorize() method automatically resets accumulated state after setting the cookie.

Publishing convenience methods make it easy to publish updates directly from controllers:

// Publish JSON data
$this->Mercure->publishJson(
    topics: '/books/123',
    data: ['status' => 'updated', 'title' => $book->title]
);

// Publish rendered element
$this->Mercure->publishView(
    topics: '/books/123',
    element: 'Books/item',
    data: ['book' => $book]
);

// Publish rendered template with layout
$this->Mercure->publishView(
    topics: '/notifications',
    template: 'Notifications/item',
    layout: 'ajax',
    data: ['notification' => $notification]
);

// For advanced use cases, publish an Update object directly
$update = new Update('/books/123', json_encode(['data' => 'value']));
$this->Mercure->publish($update);

Authorization

Static facade for direct authorization management (alternative to component).

MethodReturnsDescription
setCookie(Response $response, array $subscribe, array $additionalClaims)ResponseSet authorization cookie
clearCookie(Response $response)ResponseClear authorization cookie
addDiscoveryHeader(Response $response)ResponseAdd Mercure discovery Link header
getCookieName()stringGet the cookie name

MercureHelper

MethodReturnsDescription
url(array|string|null $topics, array $subscribe, array $additionalClaims)stringGet hub URL and optionally authorize (only sets cookie when $subscribe is provided). Merges with default topics if configured.
addTopic(string $topic)$thisAdd a single topic to default topics (fluent interface)
addTopics(array $topics)$thisAdd multiple topics to default topics (fluent interface)

Configuration Options:

OptionTypeDefaultDescription
defaultTopicsarray[]Topics to automatically merge with every subscription (read-only, not mutated by addTopic()/addTopics())

Update

Base class for Mercure updates. For most use cases, consider using JsonUpdate or ViewUpdate instead.

Constructor:

new Update(
    string|array $topics,
    string $data,
    bool $private = false,
    ?string $id = null,
    ?string $type = null,
    ?int $retry = null
)

Constructor Parameters:

ParameterTypeDescription
$topicsstring|arrayTopic IRI(s) for the update
$datastringUpdate content (typically JSON)
$privateboolWhether the update requires authorization
$id?stringOptional SSE event ID
$type?stringOptional SSE event type
$retry?intOptional reconnection time in milliseconds

Methods:

MethodReturnsDescription
getTopics()arrayGet topics
getData()stringGet data
isPrivate()boolCheck if private
getId()?stringGet event ID
getType()?stringGet event type
getRetry()?intGet retry value

JsonUpdate

Specialized Update class that automatically encodes data to JSON. Supports both static factory method and fluent builder pattern.

Fluent Builder Pattern (Recommended):

use Mercure\Update\JsonUpdate;

// Basic usage
$update = (new JsonUpdate('/books/1'))
    ->data(['status' => 'OutOfStock', 'quantity' => 0])
    ->build();

// With all options
$update = (new JsonUpdate('/books/1'))
    ->data(['title' => 'Book', 'price' => 29.99])
    ->jsonOptions(JSON_UNESCAPED_UNICODE)
    ->private()
    ->id(Text::uuid())
    ->type('book.updated')
    ->retry(5000)
    ->build();

Builder Methods:

MethodParameterReturnsDescription
data(mixed $data)Data to encode$thisSet data to encode as JSON
jsonOptions(int $options)JSON options$thisSet JSON encoding options
private(bool $private = true)Private flag$thisMark as private update
id(string $id)Event ID$thisSet SSE event ID
type(string $type)Event type$thisSet SSE event type
retry(int $retry)Retry delay (ms)$thisSet retry delay
build()-UpdateBuild and return Update

Static Factory Method:

JsonUpdate::create(
    string|array $topics,
    mixed $data,
    bool $private = false,
    ?string $id = null,
    ?string $type = null,
    ?int $retry = null,
    int $jsonOptions = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
): Update

ViewUpdate

Specialized Update class that automatically renders CakePHP views or elements. Supports both static factory method and fluent builder pattern.

Fluent Builder Pattern (Recommended):

use Mercure\Update\ViewUpdate;

// Render element
$update = (new ViewUpdate('/books/1'))
    ->element('Books/item')
    ->viewVars(['book' => $book])
    ->build();

// Render template with all options
$update = (new ViewUpdate('/notifications'))
    ->template('Notifications/item')
    ->viewVars(['notification' => $notification])
    ->layout('ajax')
    ->viewOptions(['key' => 'value'])
    ->private()
    ->id('notif-123')
    ->type('notification.new')
    ->build();

Builder Methods:

MethodParameterReturnsDescription
template(string $template)Template name$thisSet template to render
element(string $element)Element name$thisSet element to render
viewVars(array $viewVars)View variables$thisSet view variables
set(string $key, mixed $value)Key, value$thisSet single view variable
layout(?string $layout)Layout name$thisSet layout (null to disable)
viewOptions(array $options)ViewBuilder options$thisSet ViewBuilder options
private(bool $private = true)Private flag$thisMark as private update
id(string $id)Event ID$thisSet SSE event ID
type(string $type)Event type$thisSet SSE event type
retry(int $retry)Retry delay (ms)$thisSet retry delay
build()-UpdateBuild and return Update

Static Factory Method:

ViewUpdate::create(
    string|array $topics,
    ?string $template = null,
    ?string $element = null,
    array $viewVars = [],
    ?string $layout = null,
    bool $private = false,
    ?string $id = null,
    ?string $type = null,
    ?int $retry = null,
    array $viewOptions = []
): Update

Parameters:

ParameterTypeDescription
$topicsstring|arrayTopic IRI(s) for the update
$template?stringTemplate to render (e.g., 'Books/view')
$element?stringElement to render (e.g., 'Books/item')
$dataarrayView variables to pass to the template/element
$layout?stringLayout to use (null for no layout)
$privateboolWhether this is a private update
$id?stringOptional SSE event ID
$type?stringOptional SSE event type
$retry?intOptional reconnection time in milliseconds

Note

Either template or element must be specified, but not both.

Example:

use Mercure\Update\ViewUpdate;

// Render an element
$update = ViewUpdate::create(
    topics: '/books/1',
    element: 'Books/item',
    data: ['book' => $book]
);

// Render a template with layout
$update = ViewUpdate::create(
    topics: '/dashboard',
    template: 'Dashboard/stats',
    layout: 'ajax',
    data: ['stats' => $stats]
);

MercureDiscoveryMiddleware

A PSR-15 middleware that automatically adds the Mercure discovery Link header to all responses.

Usage:

// In src/Application.php
use Mercure\Http\Middleware\MercureDiscoveryMiddleware;

public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
    $middlewareQueue->add(new MercureDiscoveryMiddleware());
    return $middlewareQueue;
}

The middleware adds a Link header to every response:

Link: <https://mercure.example.com/.well-known/mercure>; rel="mercure"

This allows clients to automatically discover the Mercure hub URL without hardcoding it in your application.


For more information about the Mercure protocol, visit mercure.rocks.

Cookbook: Queue Job Progress Tracking

A common use case for Mercure is providing real-time progress updates for background queue jobs. This cookbook shows how to integrate Mercure with dereuromark/cakephp-queue to display live progress bars and status updates.

Creating a Progress Trait

First, create a reusable trait for publishing progress updates from queue tasks:

<?php
declare(strict_types=1);

namespace App\Queue\Task;

use Cake\Log\Log;
use Mercure\Publisher;
use Mercure\Update\JsonUpdate;
use Throwable;

/**
 * Trait for publishing real-time updates from queue tasks via Mercure.
 */
trait MercureProgressTrait
{
    protected string $mercureTopicPrefix = '/queue/job';

    /**
     * Publish a progress update for a queue job.
     */
    protected function publishProgress(int $jobId, string $status, array $data = []): void
    {
        try {
            $topic = sprintf('%s/%d', $this->mercureTopicPrefix, $jobId);
            $payload = array_merge(['status' => $status], $data);

            Publisher::publish(JsonUpdate::create(
                topics: $topic,
                data: $payload,
            ));
        } catch (Throwable $e) {
            // Log but don't fail the job if Mercure is unavailable
            Log::write('warning', sprintf(
                'Failed to publish Mercure update for job `%d`: %s',
                $jobId,
                $e->getMessage(),
            ));
        }
    }

    /**
     * Publish a "started" status update.
     */
    protected function publishStarted(int $jobId, string $message = '', array $data = []): void
    {
        $payload = $data;
        if ($message) {
            $payload['message'] = $message;
        }
        $this->publishProgress($jobId, 'started', $payload);
    }

    /**
     * Publish a progress update with percentage.
     */
    protected function publishProgressPercent(int $jobId, int $current, int $total, array $data = []): void
    {
        $percent = $total > 0 ? round(($current / $total) * 100, 1) : 0;
        $payload = array_merge([
            'current' => $current,
            'total' => $total,
            'percent' => $percent,
        ], $data);

        $this->publishProgress($jobId, 'progress', $payload);
    }

    /**
     * Publish a "completed" status update.
     */
    protected function publishCompleted(int $jobId, string $message = '', array $data = []): void
    {
        $payload = $data;
        if ($message) {
            $payload['message'] = $message;
        }
        $this->publishProgress($jobId, 'completed', $payload);
    }

    /**
     * Publish a "failed" status update.
     */
    protected function publishFailed(int $jobId, string $error, array $data = []): void
    {
        $payload = array_merge(['error' => $error], $data);
        $this->publishProgress($jobId, 'failed', $payload);
    }
}

Using the Trait in Queue Tasks

Use the trait in your queue task classes:

<?php
declare(strict_types=1);

namespace App\Queue\Task;

use Queue\Queue\Task;

class ImportProductsTask extends Task
{
    use MercureProgressTrait;

    public function run(array $data, int $jobId): void
    {
        $this->publishStarted($jobId, 'Starting product import...');

        $products = $this->fetchProducts($data['source']);
        $total = count($products);
        $saved = 0;

        foreach ($products as $index => $product) {
            $this->saveProduct($product);
            $saved++;

            // Publish progress every 10 items to avoid flooding
            if ($index % 10 === 0) {
                $this->publishProgressPercent($jobId, $index + 1, $total, [
                    'message' => sprintf('Processing %d of %d products...', $index + 1, $total),
                    'saved' => $saved,
                ]);
            }
        }

        $this->publishCompleted($jobId, sprintf('Imported %d products', $saved), [
            'total' => $total,
            'saved' => $saved,
        ]);
    }
}

Frontend: Real-time Progress Display

Create a template that subscribes to job updates and displays progress:

<?php
// templates/Admin/Jobs/monitor.php
$this->loadHelper('Mercure.Mercure');
?>
<div class="job-monitor">
    <h2>Job Progress</h2>
    <div id="job-<?= $jobId ?>" class="job-card">
        <div class="job-header">
            <strong><?= h($jobTask) ?></strong>
            <span class="badge" id="status-badge">Pending</span>
        </div>
        <div class="progress">
            <div class="progress-bar" id="progress-bar" style="width: 0%"></div>
        </div>
        <div id="job-message" class="message">Waiting for updates...</div>
        <div id="job-stats" class="stats"></div>
    </div>
</div>

<script>
document.addEventListener('DOMContentLoaded', function() {
    const jobId = <?= $jobId ?>;
    const topic = `/queue/job/${jobId}`;

    // Use the Mercure helper to get the hub URL
    // For relative URLs, combine with current origin
    const hubUrl = '<?= $this->Mercure->url() ?>';
    const url = new URL(hubUrl, window.location.origin);
    url.searchParams.append('topic', topic);

    const eventSource = new EventSource(url.toString());

    eventSource.onmessage = function(event) {
        const data = JSON.parse(event.data);
        updateJobDisplay(data);
    };

    eventSource.onerror = function() {
        console.error('EventSource connection error');
    };

    function updateJobDisplay(data) {
        const statusBadge = document.getElementById('status-badge');
        const progressBar = document.getElementById('progress-bar');
        const message = document.getElementById('job-message');
        const stats = document.getElementById('job-stats');

        // Update status badge
        statusBadge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
        statusBadge.className = 'badge badge-' + getStatusColor(data.status);

        // Update progress bar
        if (data.percent !== undefined) {
            progressBar.style.width = data.percent + '%';
        }

        // Update message
        if (data.message) {
            message.textContent = data.message;
        }

        // Update stats
        let statsHtml = '';
        if (data.current !== undefined) statsHtml += `<span>Current: ${data.current}</span> `;
        if (data.total !== undefined) statsHtml += `<span>Total: ${data.total}</span> `;
        if (data.saved !== undefined) statsHtml += `<span>Saved: ${data.saved}</span> `;
        if (statsHtml) stats.innerHTML = statsHtml;

        // Close connection on completion or failure
        if (data.status === 'completed' || data.status === 'failed') {
            progressBar.style.width = '100%';
            eventSource.close();
        }
    }

    function getStatusColor(status) {
        switch (status) {
            case 'started': return 'info';
            case 'progress': return 'warning';
            case 'completed': return 'success';
            case 'failed': return 'danger';
            default: return 'secondary';
        }
    }
});
</script>

Controller for Triggering Jobs

<?php
declare(strict_types=1);

namespace App\Controller\Admin;

use App\Controller\AppController;
use Queue\Model\Table\QueuedJobsTable;

class JobsController extends AppController
{
    public function monitor(int $id)
    {
        $job = $this->fetchTable('Queue.QueuedJobs')->get($id);

        $this->set([
            'jobId' => $job->id,
            'jobTask' => $job->job_task,
        ]);
    }

    public function trigger()
    {
        /** @var QueuedJobsTable $queuedJobsTable */
        $queuedJobsTable = $this->fetchTable('Queue.QueuedJobs');

        $job = $queuedJobsTable->createJob(
            'ImportProducts',
            ['source' => 'api'],
        );

        // Redirect to monitor page with job ID
        return $this->redirect(['action' => 'monitor', $job->id]);
    }
}

Tip

Throttling Updates: For jobs that process thousands of items, avoid publishing on every iteration. Instead, publish every N items (e.g., every 10 or 100) or use time-based throttling to prevent flooding the Mercure hub.

Contributing

Contributions are welcome! Please follow these guidelines:

  1. Code Quality: Ensure all code passes quality checks:

    composer cs-check    # Check code style
    composer stan        # Run PHPStan analysis
    composer rector-check      # Run rectoring
    composer test        # Run tests
    
  2. Code Style: Follow CakePHP coding standards. Use composer cs-fix to automatically fix style issues.

  3. Tests: Add tests for new features and ensure all tests pass.

  4. Documentation: Update the README and inline documentation as needed.

  5. Pull Requests: Submit PRs against the main branch with a clear description of changes.

License

MIT License. See LICENSE.md for details.