Skip to content

feat(api): speak Mercure 1.0 with a custom FrankenPHP binary - #9

Merged
soyuka merged 2 commits into
api-platform:mainfrom
BySplashGm:feat/api-mercure-1.0
Sep 8, 2026
Merged

feat(api): speak Mercure 1.0 with a custom FrankenPHP binary#9
soyuka merged 2 commits into
api-platform:mainfrom
BySplashGm:feat/api-mercure-1.0

Conversation

@BySplashGm

Copy link
Copy Markdown
Contributor

Migrates the api/ stack to Mercure 1.0, which needed the hub it runs to be replaced
first.

Depends on #7 (api/ dependency bump) — symfony/mercure-bundle ^0.5 is what
introduces protocol_version.

The stock image cannot speak 1.0

$ docker run --rm dunglas/frankenphp:1-php8.5 frankenphp build-info
dep  github.com/dunglas/mercure        v0.24.2
dep  github.com/dunglas/mercure/caddy  v0.24.2
dep  github.com/caddyserver/caddy/v2   v2.11.4

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 new
protocol.

So a builder stage rebuilds the binary against mercure/caddy@v1.0.0-alpha.3. The
recipe is the one documented at https://frankenphp.dev/docs/docker/ — which already
lists mercure/caddy and vulcain/caddy; only their versions are pinned here. Both
things that made this look expensive turned out to be free:

$ docker run --rm --entrypoint sh dunglas/frankenphp:1-builder-php8.3 -c 'go version'
go version go1.26.7 linux/arm64

Go 1.26.7 clears the go >= 1.26 floor that the 1.0 module declares — the floor that
made caddy:2.8-builder fail in the test-server image. And the build-info above
already 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 → a transport bolt { path … } module
    block. The connection-string form still parses but is the deprecated path.
  • publisher_jwt / subscriber_jwt move into an issuer block. They still parse,
    but in 1.0 they select compatibility mode, which relaxes access-token validation —
    not something to enable by accident.
  • resource_identifier is pinned instead of derived per request. Ending it in
    /.well-known/mercure also makes it the base URL that relative topics and URL
    Patterns resolve against.

Token configuration

protocol_version: '1.0' switches the bundle to RFC 9068 access tokens, and it
refuses to compile without iss, sub and client_id. The issuer is read from
MERCURE_JWT_ISSUER by both the bundle and the hub, so the token's iss and the
trusted issuer cannot drift apart. aud is left at its default — the hub
public_url — which is exactly what resource_identifier pins.


Verification. Built, booted and exercised end to end.

$ docker compose exec php frankenphp build-info | grep -E 'mercure|caddy/v2'
dep  github.com/caddyserver/caddy/v2         v2.11.4
dep  github.com/dunglas/mercure              v1.0.0-alpha.3
dep  github.com/dunglas/mercure/caddy        v1.0.0-alpha.3

The container starting is the Caddyfile's own test — transport bolt { path }, the
issuer block and resource_identifier all parse and load. debug:container shows
the Hub service receiving ProtocolVersion::V1, and the token the bundle mints is
the 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 200 and an update id. Subscribing anonymously with
?match_urlpattern=/authors/:id returns 200 text/event-stream.

jwt.publish: ['*'] is translated as expected: MercureExtension.php:148 builds
new Grant([Grant::ACTION_PUBLISH], $hub['jwt']['publish']), and the empty
subscribe grant the bundle always adds is inert under 1.0 — it does not reach the
token.

Two things found along the way, neither blocking this PR:

  • Nothing in api/ publishes to Mercure yet. mercure.hub.default lands in the
    container's removed-ids.php — no consumer, so it is inlined away. No
    ApiResource sets mercure: true and no service injects HubInterface. That is
    why 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.
  • The bundle mints iat/exp as floats. RFC 7519 permits a non-integer NumericDate
    and the hub accepts it, so this is legal, just surprising.

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
BySplashGm marked this pull request as ready for review September 4, 2026 13:28
@soyuka
soyuka merged commit 7badfdd into api-platform:main Sep 8, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants