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:
- Backend options include a
mercureobject (hubUrl,topics, optionalprotocolVersion, optionalwithCredentials, optionaldebounceMs). - The Stimulus controller opens an
EventSourceto 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). - On each message, the controller emits a
datatables:mercure:messageevent and triggerstable.ajax.reload(null, false). - 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 carryingauthorization_details. - A cross-origin hub has to allow credentials.
withCredentials: truemakes the browser requireAccess-Control-Allow-Credentials: trueand a concreteAccess-Control-Allow-Originon 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
topicsentry is an object ({ "match": ..., "match_type"?: ... }), bare strings are rejected, andmatch_typedefaults toexact. A grant ported from 0.x and holdinghttps://example.com/api/books/{id}is an exact match on that literal string: it does not match the publishedhttps://example.com/api/books/42, and the hub answers403 insufficient_scope. Only aurlpatterngrant 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.topicsare 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 examplehttps://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:
withCredentialsenables cookie/auth forwarding in the SSE request.debounceMsis applied client-side before reload (500msby default).topicsaccepts 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();
}
| Parameter | Default | Purpose |
|---|---|---|
durationMs | 1200 | How 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
- Enabling the option serializes a
highlightobject into the frontend payload and adds aDT_RowIdkey to every row, which also feeds the DataTablesrowIdoption. API Platform rows never go through the PHP row mapper, so there the key is added client-side fromidField. - On an incoming Mercure message the controller records the values currently displayed, then reloads the table.
- Once that reload has redrawn the table, rows are matched by
DT_RowIdand compared field by field. Changed cells receive thedt-cell-updatedclass, and adatatables:highlightevent 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 staticdatawill not auto-refresh from SSE. - No Mercure config means no SSE subscription (dynamic import is skipped).
- If the table receives many events, tune
debounceMsto protect backend load. private: truefrom API Platform metadata maps towithCredentials— see Private Topics.highlightUpdates()needs rows to expose an identifier; a table whoseidFieldcannot be read leaves those rows unhighlighted.