Type to search columns, filters, options, and extensions.

↑↓ to navigate · Enter to open · Esc to close

Documentation

Mercure Integration

Real-time DataTables refresh with Mercure SSE

Use Mercure when table data can change outside the current browser session and you want automatic refresh.

MercureConfig can be configured manually through DataTable::mercure(...), declared on #[AsDataTable(...)], or auto-resolved from #[AsDataTable(..., mercure: true)].

What The Integration Does

When mercure is configured on a table:

  1. Backend options include a mercure object (hubUrl, topics, optional protocolVersion, optional withCredentials, optional debounceMs).
  2. The Stimulus controller opens an EventSource to the hub, selecting each topic with the query parameter the hub’s protocol version expects (topic= for 0.x, match=/match_urlpattern= for 1.0).
  3. On each message, the controller emits a datatables:mercure:message event and triggers table.ajax.reload(null, false).
  4. The SSE connection is closed automatically on controller disconnect().

Mercure Protocol Version

Mercure 1.0 renamed the subscription parameters. A 0.x hub takes topic= with URI Template selectors; a 1.0 hub takes match= for an exact topic and match_urlpattern= for a URL Pattern (:id instead of {id}). Neither hub understands the other’s spelling, so the bundle asks the configured hub which protocol it speaks (HubInterface::getProtocolVersion(), available with symfony/mercure 0.8+) and builds the subscription accordingly.

Nothing to configure in the bundle. The dialect follows your hub:

# config/packages/mercure.yaml — symfony/mercure-bundle 0.5+
mercure:
    hubs:
        default:
            url: '%env(MERCURE_URL)%'
            public_url: '%env(MERCURE_PUBLIC_URL)%'
            protocol_version: '1.0'
            jwt:
                secret: '%env(MERCURE_JWT_SECRET)%'
                claims:                       # required by RFC 9068 on a 1.0 hub
                    iss: 'https://example.com'
                    sub: 'mercure-hub'

Topics keep their existing syntax. On a 1.0 hub a topic containing a {name} placeholder is sent as the equivalent URL Pattern (/api/books/{id} becomes match_urlpattern=/api/books/:p0; the group name is generated because RFC 6570 variable names are not all valid URL Pattern group names); a topic without a placeholder is sent as match=<topic>. A URL Pattern group also matches the literal placeholder, so both publishers that publish the template (/api/books/{id} — API Platform’s behavior, and this bundle’s own publish path) and publishers that publish concrete topics (/api/books/42) are received.

A topic using a URI Template operator ({?page}, {+path}, …) has no URL Pattern equivalent, and is sent as an exact match= on its literal value rather than as an invalid pattern the hub would reject with a 400.

Where the protocol version does not reach the bundle — an older symfony/mercure without HubInterface::getProtocolVersion() — the legacy topic= parameters are used, which is what those installations have always sent.

Migrating a hub to 1.0 is a breaking change for every client, and the hub will not enable compatibility by itself: see the Mercure upgrade guide. Running the hub with protocol_version_compatibility 8 keeps 0.x clients (including this bundle before it resolved the protocol version) working while you roll clients out. On a 1.0 hub the subscriber cookie is renamed __Secure-mercure_access_token, so private topics need withCredentials: true and an HTTPS hub URL.

Private Topics

API Platform marks an update private from resource metadata:

#[ApiResource(mercure: ['private' => true])]
final class Book
{
}

PublishMercureUpdatesListener::buildUpdate() passes $options['private'] ?? false straight to Symfony\Component\Mercure\Update, and a hub only delivers a private update to a subscriber whose token grants subscribe on one of the update’s topics — authorization is evaluated against the update, never against the subscription’s matchers, which only select what the client listens to. An anonymous EventSource therefore never receives it: the connection opens and the table silently stops refreshing.

The bundle reads the flag from the resource metadata and from every operation, so auto-resolution (#[AsDataTable(Book::class, mercure: true)]) serializes withCredentials: true as soon as one of them declares private. A resource that does not set private, or sets private: false, serializes exactly the payload it did before, with no withCredentials key.

An explicit withCredentials always wins, because both explicit paths are resolved first. On the attribute, an array without a topics key keeps auto topic resolution and only overrides the subscription options, so the topics stay the ones API Platform publishes:

#[AsDataTable(Book::class, mercure: ['withCredentials' => false])]
final class BookDataTable extends AbstractDataTable
{
}

->mercure(withCredentials: false) on the table is a manual configuration and never consults the auto-resolver, so on its own it subscribes to the bundle’s internal /datatables/.../{id} fallback topic, which API Platform never publishes to. Pass the topics explicitly with that call.

What The Application Still Has To Do

The bundle sets no cookie and mints no token — both belong to symfony/mercure-bundle. The subscriber cookie comes from its Authorization service or from the mercure() Twig function.

  • On a Mercure 1.0 hub the cookie is __Secure-mercure_access_token, so the hub URL must be HTTPS. The token is an RFC 9068 access token carrying authorization_details.
  • A cross-origin hub has to allow credentials. withCredentials: true makes the browser require Access-Control-Allow-Credentials: true and a concrete Access-Control-Allow-Origin on the hub response — the wildcard * is rejected for a credentialed request, so the hub’s allowed origins must name the application origin.
  • The grant has to cover the concrete published topic. Under 1.0 each topics entry is an object ({ "match": ..., "match_type"?: ... }), bare strings are rejected, and match_type defaults to exact. A grant ported from 0.x and holding https://example.com/api/books/{id} is an exact match on that literal string: it does not match the published https://example.com/api/books/42, and the hub answers 403 insufficient_scope. Only a urlpattern grant covers it:
use Symfony\Component\Mercure\Jwt\Grant;
use Symfony\Component\Mercure\Jwt\LcobucciFactory;
use Symfony\Component\Mercure\ProtocolVersion;

$factory = new LcobucciFactory($secret, protocolVersion: ProtocolVersion::V1);

$token = $factory->create(
    grants: [new Grant(
        actions: [Grant::ACTION_SUBSCRIBE],
        topics: ['urlpattern' => ['https://example.com/api/books/:id']],
    )],
    additionalClaims: [
        'iss'       => 'https://example.com',
        'aud'       => 'https://example.com/.well-known/mercure',
        'sub'       => 'user-42',
        'client_id' => 'book-table',
    ],
);

iss, aud, sub and client_id are all mandatory: create() builds an RFC 9068 access token under ProtocolVersion::V1 and throws InvalidArgumentException (The “aud” additional claim is required by RFC 9068 access tokens.) when one of them is missing.

See the Mercure authorization concepts and the Mercure upgrade guide.

Minimal Setup

#[AsDataTable(Book::class, mercure: true)]
final class BookDataTable extends AbstractDataTable
{
}

When auto-resolution is enabled:

  • the bundle reads the default Symfony Mercure hub URL
  • explicit API Platform mercure.topics are reused verbatim when available
  • @=iri(object) is resolved to the item IRI; any other @= expression topic is dropped with a logged warning naming it, and resolution then continues to the item IRI
  • otherwise the bundle builds the item IRI template absolutely, from the same routing request context (scheme, host, port, base path) as UrlGeneratorInterface::ABS_URL — for example https://api.example.com/api/books/{id}
  • if no API Platform item metadata is available, it falls back to the bundle’s own /datatables/books/{id}, which stays relative

The absolute form is what makes the subscription match what API Platform publishes, and the reason differs per protocol. A 1.0 hub matches URL Patterns with its own URL as the base, so a relative topic became https://<hub-host>/api/books/{id} and only covered the published IRI while the hub and the API shared a host. A 0.x hub compares the topic= selector exactly and then as an anchored URI Template, with no base resolution at all, so a relative selector never matched the absolute IRI on any host. An absolute topic is matched as-is on both.

The topic is read off the operation API Platform itself generates the item IRI from — the first non-collection operation whose HTTP method is GET, HEAD or OPTIONS, in declaration order, which is what ResourceMetadataCollection::getOperation() returns. A custom HttpOperation(method: 'GET') counts; a resource with no such operation gets no item topic, because API Platform has no item IRI for it either.

Without a router the topic stays relative. With a router — which a full-stack application always has — the context is the one the application configured: the current request’s, or router.request_context in a console command or Messenger consumer, where it defaults to http://localhost. Set router.request_context.host and .scheme if anything outside an HTTP request publishes, the same requirement API Platform’s own IRIs have there.

The bundle’s own /datatables/.../{id} fallback topic deliberately stays relative: nothing but the bundle publishes to it, and it publishes through the same resolver, so a context-free topic keeps subscribe and publish matching wherever each runs. Declaring the topic explicitly in the absolute form API Platform publishes remains the escape hatch for a topic of your own.

Attribute Topics

Use explicit topics on the attribute when you do not want topic auto-resolution from API Platform metadata:

#[AsDataTable(
    dataClass: Book::class,
    mercure: [
        'topics' => [
            'https://example.com/books',
        ],
    ],
)]
final class BookDataTable extends AbstractDataTable
{
}

The same form accepts one topic or several topics:

#[AsDataTable(
    dataClass: Book::class,
    mercure: [
        'topics' => [
            'https://example.com/books',
            'https://example.com/authors',
        ],
        'withCredentials' => true,
        'debounceMs' => 300,
    ],
)]

Mercure subscriptions repeat one matcher query parameter per topic. For most tables, one topic is enough. Use several topics when a single DataTable must refresh after changes published on different resources or channels. The parameter name depends on the hub’s protocol version — see Mercure Protocol Version.

Advanced Setup

$dataTable->mercure(
    topics: ['admin/books', 'admin/authors'],
    withCredentials: true,
    debounceMs: 300,
);

Notes:

  • withCredentials enables cookie/auth forwarding in the SSE request.
  • debounceMs is applied client-side before reload (500ms by default).
  • topics accepts one or many topics.

Publishing Updates

If symfony/mercure is available, the bundle registers MercureUpdatePublisher and injects it into the Ajax edit controller.

When boolean inline edit is called with topics, the controller publishes:

{
  "type": "edit",
  "id": "...",
  "field": "..."
}

You can also publish manually with MercureUpdatePublisher::publish() or publishForDataTable().

Highlighting Updated Cells

A refresh that changes a value silently is easy to miss. highlightUpdates() briefly emphasizes the cells a Mercure refresh changed:

public function configureDataTable(DataTable $table): DataTable
{
    return $table
        ->mercure(topics: ['https://example.com/users'])
        ->highlightUpdates(ignoreColumns: ['lastLoginAt'])
        ->serverSide();
}
ParameterDefaultPurpose
durationMs1200How long a cell stays emphasized.
ignoreColumns[]Data keys never highlighted.
idField'id'Property read to identify a row.

ignoreColumns matters more than it looks. A column whose rendered value changes on every refresh - a relative date, a counter, an “updated at” stamp - would be highlighted on every single event and bury the real updates under constant noise. List those columns here.

How It Works

  1. Enabling the option serializes a highlight object into the frontend payload and adds a DT_RowId key to every row, which also feeds the DataTables rowId option. API Platform rows never go through the PHP row mapper, so there the key is added client-side from idField.
  2. On an incoming Mercure message the controller records the values currently displayed, then reloads the table.
  3. Once that reload has redrawn the table, rows are matched by DT_RowId and compared field by field. Changed cells receive the dt-cell-updated class, and a datatables:highlight event carries their nodes. A sort, a search or a paging draw landing while the refresh is in flight leaves the recorded values untouched.

Comparison runs on row data rather than on rendered markup: a redraw regenerates cell HTML even where nothing changed, so comparing markup would light up the whole table. Matching rows by id means a row that only moved to another position is not reported as updated.

Only refreshes triggered by Mercure are compared - sorting, searching, and paging never highlight.

Styling

The animation lives in the bundle stylesheet and is driven by two custom properties:

:root {
    --dt-highlight-color: rgb(16 185 129 / 0.3);
}

--dt-highlight-duration is written inline from durationMs. Under prefers-reduced-motion: reduce the animation is replaced by a static background.

To drive your own effect instead, skip the class and listen to the event:

document.addEventListener('datatables:highlight', (event) => {
    for (const cell of event.detail.cells) {
        // ...
    }
})

Operational Notes

  • Mercure refresh uses ajax.reload(...): tables configured only with static data will not auto-refresh from SSE.
  • No Mercure config means no SSE subscription (dynamic import is skipped).
  • If the table receives many events, tune debounceMs to protect backend load.
  • private: true from API Platform metadata maps to withCredentials — see Private Topics.
  • highlightUpdates() needs rows to expose an identifier; a table whose idField cannot be read leaves those rows unhighlighted.