feat(api): speak Mercure 1.0 with a custom FrankenPHP binary - #9
Merged
Conversation
symfony/mercure-bundle ^0.5 requires symfony/config ^6.4|^7.3|^8.0, so 7.1 is not enough; moved to 7.4, the current stable. api-platform/symfony and api-platform/doctrine-orm go from ^4.0.0-alpha.6 to ^4.3, which is what pulls phpstan/phpdoc-parser ^2.0 into the allowed range. The old lock could not be installed on PHP 8.5 at all: lcobucci/jwt 5.3.0 caps at 8.3. config.platform.php is pinned to 8.2.0 — the floor declared by require.php — so the lock resolves against the bottom of the supported range and stays installable across it. None of the files the Flex recipes wrote are kept. config/reference.php is generated, so it is ignored instead. config/routes/api_platform.yaml redeclares the api_platform loader already present in config/routes.yaml and adds prefix: /api on top. config/packages/property_info.yaml turned out to change nothing the application needs. Does not enable protocol_version 1.0: the FrankenPHP runtime still embeds Mercure 0.24.2.
The stock FrankenPHP image embeds Mercure 0.24.2, which speaks 0.x only, so the api/ stack could not be migrated at all: a 1.0 configuration is rejected by the module actually running. A builder stage rebuilds the binary against mercure/caddy@v1.0.0-alpha.3. Nothing about that build is exotic. The recipe is the one documented at frankenphp.dev/docs/docker/, which already lists mercure/caddy and vulcain/caddy; only their versions are pinned here. The builder image ships Go 1.26.7, the floor the 1.0 module declares, and FrankenPHP already pins Caddy 2.11.4, the version that module wants. With a 1.0 binary the hub configuration follows: - transport_url is replaced by a transport module block, so the Bolt connection string becomes a path. - publisher_jwt / subscriber_jwt still parse but now select compatibility mode, which relaxes token validation. They move into an issuer block. - resource_identifier is pinned rather than derived per request, and ending it in /.well-known/mercure also makes it the base URL relative topics and URL Patterns resolve against. The bundle mints RFC 9068 access tokens under protocol_version: '1.0', and refuses to compile without iss, sub and client_id. The issuer is read from MERCURE_JWT_ISSUER on both sides so the token and the hub cannot drift; aud is left to its default, the hub public URL, which is what the hub pins.
BySplashGm
marked this pull request as ready for review
September 4, 2026 13:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrates the
api/stack to Mercure 1.0, which needed the hub it runs to be replacedfirst.
Depends on #7 (
api/dependency bump) —symfony/mercure-bundle ^0.5is whatintroduces
protocol_version.The stock image cannot speak 1.0
0.24.2 is not a compatibility-mode fallback: compatibility mode is a 1.0 binary
relaxing its validation. 0.24 simply speaks 0.x, so a 1.0 configuration is rejected
by the module actually running, and no amount of config gets
api/onto the newprotocol.
So a builder stage rebuilds the binary against
mercure/caddy@v1.0.0-alpha.3. Therecipe is the one documented at https://frankenphp.dev/docs/docker/ — which already
lists
mercure/caddyandvulcain/caddy; only their versions are pinned here. Boththings that made this look expensive turned out to be free:
Go 1.26.7 clears the
go >= 1.26floor that the 1.0 module declares — the floor thatmade
caddy:2.8-builderfail in the test-server image. And thebuild-infoabovealready pins Caddy 2.11.4, the version that module wants, so there is no Caddy
conflict to resolve.
Hub configuration
transport_url bolt:///data/mercure.db→ atransport bolt { path … }moduleblock. The connection-string form still parses but is the deprecated path.
publisher_jwt/subscriber_jwtmove into anissuerblock. They still parse,but in 1.0 they select compatibility mode, which relaxes access-token validation —
not something to enable by accident.
resource_identifieris pinned instead of derived per request. Ending it in/.well-known/mercurealso makes it the base URL that relative topics and URLPatterns resolve against.
Token configuration
protocol_version: '1.0'switches the bundle to RFC 9068 access tokens, and itrefuses to compile without
iss,subandclient_id. The issuer is read fromMERCURE_JWT_ISSUERby both the bundle and the hub, so the token'sissand thetrusted issuer cannot drift apart.
audis left at its default — the hubpublic_url— which is exactly whatresource_identifierpins.Verification. Built, booted and exercised end to end.
The container starting is the Caddyfile's own test —
transport bolt { path }, theissuerblock andresource_identifierall parse and load.debug:containershowsthe
Hubservice receivingProtocolVersion::V1, and the token the bundle mints isthe RFC 9068 shape:
{"typ":"at+jwt","alg":"HS256"} { "iss": "https://localhost", "sub": "esa-api", "client_id": "esa-api", "aud": "https://localhost/.well-known/mercure", "authorization_details": [ {"type": "https://mercure.rocks/authorization-detail", "actions": ["publish"], "topics": [{"match": "*"}]} ], "iat": 1788512949.415001, "jti": "…", "exp": 1788516549.415018 }Publishing with it returns
200and an update id. Subscribing anonymously with?match_urlpattern=/authors/:idreturns200 text/event-stream.jwt.publish: ['*']is translated as expected:MercureExtension.php:148buildsnew Grant([Grant::ACTION_PUBLISH], $hub['jwt']['publish']), and the emptysubscribegrant the bundle always adds is inert under 1.0 — it does not reach thetoken.
Two things found along the way, neither blocking this PR:
api/publishes to Mercure yet.mercure.hub.defaultlands in thecontainer's
removed-ids.php— no consumer, so it is inlined away. NoApiResourcesetsmercure: trueand no service injectsHubInterface. That iswhy the 0.x configuration never bothered anyone. The migration is correct but not
yet exercised by the application itself; a resource has to opt in for updates to
flow.
iat/expas floats. RFC 7519 permits a non-integer NumericDateand the hub accepts it, so this is legal, just surprising.