Skip to content

chore: allow phpunit ^13.0 - #8537

Merged
soyuka merged 1 commit into
api-platform:5.0from
mislavjakopovic:chore/allow-phpunit-13
Sep 22, 2026
Merged

soyuka merged 1 commit into
api-platform:5.0from
mislavjakopovic:chore/allow-phpunit-13

Conversation

@mislavjakopovic

@mislavjakopovic mislavjakopovic commented Sep 16, 2026

Copy link
Copy Markdown
Q A
Branch? 5.0
Tickets none
PRs Depends on #8557, #8558 and #8559 (merge first)
License MIT
Doc PR n/a

Since #7887 api-platform/test split requires phpunit/phpunit ^11.5 || ^12.2, it cannot be installed next to PHPUnit 13, although the classes run unchanged on it.

Before the split those classes shipped inside api-platform/symfony, which lists PHPUnit only in require-dev, a section Composer never enforces for dependencies, so PHPUnit 13 worked - the extraction moved that constraint into require, and it now blocks "bleeding edge" migration to API Platform 5.

This PR widens the constraint to ^11.5 || ^12.2 || ^13.0 in the root composer.json and in every component manifest that pins it, same shape as c0886c1 (^11.5) and #7623 (^12.2).

The serializer component's sebastian/exporter pin (#7663) gains ^8.2 the same way, since PHPUnit 13 requires exporter 8.

After initial commit on this pull request and failed pipelines, the rabbit hole went moderately deep.

In the end several preparation PRs were needed for the suite to stay green under 11, 12 and 13:

PHPUnit 13 requires PHP 8.4.1, so the 8.2 and 8.3 jobs will keep resolving 11.5 and 12, while the 8.4 and 8.5 jobs move to PHPUnit 13 without a workflow change.

The separate pull requests run against the currently supported PHPUnit versions, showing that these changes should remain backwards compatible.

For that very reason I've cherry-picked their commits into this pull request as well, so CI on here runs the same changes together on PHPUnit 13.3.x.

I've chosen this separation into several PRs for cleaner readability and easier reviews from maintainers, hope that's okay.

I would suggest keeping this pull request as a draft until all related preparation PRs are merged; then I will adjust description of it and rebase branch to the dependencies bump commit alone.

One last note which might be of concern - the root conflict entry <9.5, phpunit.baseline.xml and docs/composer.json (^10, only reachable via require-dev since #8527) are left untouched.

@mislavjakopovic
mislavjakopovic marked this pull request as draft September 16, 2026 13:45
vincentchalamon added a commit to vincentchalamon/bike-trip-planner that referenced this pull request Sep 21, 2026
… surfaced

Three behaviours change on the wire, all accepted rather than worked around
since nothing is deployed yet:

- the JSON-LD content type loses its "; charset=utf-8" suffix (27 assertions);
- an operation declaring an output DTO now reports the resource short name in
  @type ("Stage", not "StageResponse"); @context still points at the DTO. No
  client keys on the value — it is an untyped string in core/schema.d.ts;
- responses carry the LDP Allow / Accept-Post headers.

ApiTestCase::$alwaysBootKernel flipped from null (boot on every createClient) to
false (reuse a booted kernel). Reusing it keeps the Doctrine connection open
across tests, so Foundry's per-test migrate reset has to terminate that live
connection to drop the database — which kills the migration run itself and
leaves every table missing. There was no shared base class to fix this in, so
this adds App\Tests\ApiTestCase; the 45 test files move onto it in the same pass
as the api-platform/test namespace split.

PHPUnit stays at ^12.5 in api/ only: api-platform/test caps it at ^12.2 even
though its classes run unchanged on 13 (api-platform/core#8537). provisioner/ is
an independent Composer project with no API Platform dependency and keeps 13.
See ADR-070.

Flipping ignoreIndirectDeprecations to false is what made the rest visible —
vendor deprecations were simply not being counted before:

- doctrine/orm wants SortDirection instead of "ASC"/"DESC", in one #[ORM\OrderBy]
  and three QueryBuilder::orderBy() calls;
- zenstruck/foundry 2.9 deprecated the Factories/ResetDatabase traits in favour
  of the PHPUnit extension (already registered) plus #[ResetDatabase]: 62 files;
- framework.profiler.collect_serializer_data and
  doctrine.orm.controller_resolver.auto_mapping are removed in the next majors
  and already equalled the only accepted value.

Dropping collect_serializer_data also dropped the only framework.profiler
declaration in dev, which silently disabled the profiler and every service it
brings (PHPStan caught it via a missing mailer.message_logger_listener). The key
is restored as `profiler: null` with a comment saying it is load-bearing.

Rector 2.6 removes the version-numbered PHPUnit sets; withComposerBased(phpunit)
was already configured and derives them from composer.lock.

The two undocumented pins — rector/rector ~2.5.9 and an exact playwright-core
override — are lifted; nothing broke, which is the usual fate of a pin nobody
can explain.
vincentchalamon added a commit to vincentchalamon/bike-trip-planner that referenced this pull request Sep 21, 2026
…cure 1.0) (#1298)

* ci: pin every floating image and close the Dependabot gaps

Four images ran with no tag at all (hadolint, markdownlint-cli2, alpine,
oskarstark/php-cs-fixer-ga), so two runs of the same commit could use different
tool versions — and the Makefile drifted from CI, which pins hadolint via the
action. All are now pinned.

The third-party `esoledad/php:8.5` backing all ten PHP jobs is digest-pinned
rather than replaced: swapping the job container is a CI change no local run can
validate, so it stays a follow-up. Freezing the digest is the part that is safe
to do now.

docker/bake-action moves to v7.4.0 for its workflow-command injection fix.

Dependabot was watching neither the compose files (the `docker` ecosystem only
reads Dockerfiles, which is how traefik and cloudflared aged unnoticed), nor
`provisioner/`, `core/`, `mobile/`, the root npm manifest holding the security
overrides, nor the vendor-bin namespaces where php-cs-fixer lives.

* build(deps): bump the ops images

Traefik v3.3 -> v3.7.13 closes five security advisories; cloudflared was over a
year behind at 2025.7.0.

The provisioner Dockerfile was the only one pinned by tag alone, so Dependabot
could not see it while php and pwa were tracked: php and composer are now
digest-pinned, and install-php-extensions goes 2.11.12 -> 2.11.27 with its
checksum recomputed.

Side stacks: glitchtip 6.1.6 -> 6.2.6, its redis:7-alpine aligned onto the
redis:8-alpine the app stack already runs, and uptime-kuma 1.23.16 -> 2.5.5.

Note that uptime-kuma 1.x -> 2.x is a major with its own data migration; it is
deployed out of band (.docker/uptime-kuma/README.md), so the bump lands here but
the migration is a manual step.

* feat(mercure)!: speak the Mercure 1.0 protocol end to end

The hub is embedded in the FrankenPHP binary (ADR-037) and upstream pins
dunglas/mercure/caddy 0.24.2, which only speaks 0.x. A frankenphp_builder stage
now recompiles it at v1.0.0. Two facts make that cheap: mercure/caddy v1.0.0
wants Caddy v2.11.4, exactly what the upstream image embeds, and frankenphp is
built from the sources vendored in the builder image so it links against its own
libphp. The only obstacle was the toolchain — the module declares go 1.27 while
the builder ships Go 1.26 under GOTOOLCHAIN=local — which GOTOOLCHAIN=auto
resolves. Runtime and builder images must now be bumped together.

Protocol 1.0 is a breaking change on three axes, each of which fails silently
(the stream simply never delivers):

- subscriptions move from ?topic= to ?match=, verified against a live hub:
  ?match= returns 200 text/event-stream, ?topic= returns 400.
- tokens become RFC 9068 access tokens (typ: at+jwt, authorization_details).
  MercureTokenIssuer is rewritten on top of symfony/mercure 0.8 instead of
  hand-rolling the claim with lcobucci, which also removes the risk of it
  drifting from the tokens the bundle mints when publishing.
- the subscription API gains a match-type segment: /subscriptions/exact/<topic>
  answers 401 (route present, token required) while the 0.x path is now 404.

The cookie is renamed to __Secure-mercure_access_token. Its Secure flag is
load-bearing, not defensive: browsers reject the prefix without it.

aud is pinned on both sides rather than derived, because the backend publishes
through the internal MERCURE_URL while browsers subscribe on the public one, and
an unpinned hub expects a different audience per origin.

compose.dev.yaml passed the "demo" directive, renamed "playground" in 1.0 — and
an unknown directive is now fatal rather than a silent no-op, so this alone
stopped the dev hub from booting.

The mocked E2E suite fakes SSE with a CustomEvent and never touches the hub, so
it stays green whatever the protocol does. The integration smoke test now asserts
?match= and the cookie name explicitly.

* feat(api)!: upgrade to API Platform 5.0 and purge the deprecations it surfaced

Three behaviours change on the wire, all accepted rather than worked around
since nothing is deployed yet:

- the JSON-LD content type loses its "; charset=utf-8" suffix (27 assertions);
- an operation declaring an output DTO now reports the resource short name in
  @type ("Stage", not "StageResponse"); @context still points at the DTO. No
  client keys on the value — it is an untyped string in core/schema.d.ts;
- responses carry the LDP Allow / Accept-Post headers.

ApiTestCase::$alwaysBootKernel flipped from null (boot on every createClient) to
false (reuse a booted kernel). Reusing it keeps the Doctrine connection open
across tests, so Foundry's per-test migrate reset has to terminate that live
connection to drop the database — which kills the migration run itself and
leaves every table missing. There was no shared base class to fix this in, so
this adds App\Tests\ApiTestCase; the 45 test files move onto it in the same pass
as the api-platform/test namespace split.

PHPUnit stays at ^12.5 in api/ only: api-platform/test caps it at ^12.2 even
though its classes run unchanged on 13 (api-platform/core#8537). provisioner/ is
an independent Composer project with no API Platform dependency and keeps 13.
See ADR-070.

Flipping ignoreIndirectDeprecations to false is what made the rest visible —
vendor deprecations were simply not being counted before:

- doctrine/orm wants SortDirection instead of "ASC"/"DESC", in one #[ORM\OrderBy]
  and three QueryBuilder::orderBy() calls;
- zenstruck/foundry 2.9 deprecated the Factories/ResetDatabase traits in favour
  of the PHPUnit extension (already registered) plus #[ResetDatabase]: 62 files;
- framework.profiler.collect_serializer_data and
  doctrine.orm.controller_resolver.auto_mapping are removed in the next majors
  and already equalled the only accepted value.

Dropping collect_serializer_data also dropped the only framework.profiler
declaration in dev, which silently disabled the profiler and every service it
brings (PHPStan caught it via a missing mailer.message_logger_listener). The key
is restored as `profiler: null` with a comment saying it is load-bearing.

Rector 2.6 removes the version-numbered PHPUnit sets; withComposerBased(phpunit)
was already configured and derives them from composer.lock.

The two undocumented pins — rector/rector ~2.5.9 and an exact playwright-core
override — are lifted; nothing broke, which is the usual fate of a pin nobody
can explain.

* chore(deps): bump the web and mobile toolchains

Web: Next 16.3.5, Sentry 10.75, maplibre-gl 6.10, next-intl 4.14.5, vite 8.3,
vitest 5.0.1, zod 4.6.5 (pwa and core), prettier 3.9.8 — whose reformatting of
nine pre-existing files is the bulk of the diff.

Mobile stays on Expo SDK 57 / RN 0.86 (SDK 58 is preview-only), with every
expo-* patch, RN 0.86.3 and maplibre-react-native 11.4.

Three upgrades were tried and backed out; all three are recorded in ADR-070
rather than left as folklore:

- ESLint 10: eslint-plugin-react, pulled in by eslint-config-next, still calls
  context.getFilename(), removed in v10. The peer ranges allow ESLint 10 — they
  are simply wrong.
- TypeScript 7 on mobile: typescript-eslint refuses it outright, and npm hoists a
  single TypeScript for the whole workspace, so mobile's choice decided what
  eslint-config-next parsed with in pwa.
- React 19.3: Expo 57 pins React 19.2 and jest-expo ships react-test-renderer
  19.2.3, so 19.3 broke every mobile render test. The constraint is tightened to
  ~19.2.8 — ^19.2.8 admitted 19.3, and only the lockfile was holding it back.

Jest 30 needed two fixes its types surfaced: `global` is no longer re-exported
(use globalThis) and setImmediate is typed as taking no argument.

lucide-react-native 1.x moved the per-icon files to .mjs and gated dist/ behind
its exports map, so the deep imports the tree-shaking relies on (#1176) had to
become the public icons/* subpath. jest-expo's resolver picks the react-native
condition there, i.e. ESM, so the module mapper stays — retargeted at the CJS
build through a resolved filesystem path, which exports cannot refuse. `trash-2`
is gone from the package (though lucide-react still ships it as an alias of
`trash`); the two have identical path data, so Trash2 now maps to trash.

* docs: record the dependency version policy and the ceilings we hit

There was no ADR on versioning at all, which is how two pins ended up with no
explanation behind them and how four images ended up untagged. ADR-070 states
the rule (a pin without an ADR is a pin to lift) and tabulates each ceiling with
the condition that lifts it, so the next pass re-attempts on a fact rather than
on a hunch.

Three of those ceilings share a shape worth naming: the constraint that bites is
not the one you declare. npm hoists one React and one TypeScript for the whole
workspace, so a bump in mobile silently changes what pwa builds against.

ADR-037 gets an addendum: the PHP image is no longer the upstream FrankenPHP
image but a recompilation of it, because the Mercure hub lives inside that
binary.

* feat(mercure): add the MERCURE_ISSUER dev default

Mercure 1.0 access tokens are RFC 9068, so the backend has to mint an iss that
matches the Caddyfile issuer block. compose.yaml derives it from DOMAIN for both
php and worker, but anything running outside Compose — bin/console, PHPStan's
cache warmup, a throwaway PHPUnit container — reads api/.env instead and would
die on a missing environment variable.

Verified by running the Mercure functional test with no MERCURE_ISSUER in the
environment: it still mints a valid at+jwt, so the dotenv value is what the
configured hub picks up.

* chore(core): regenerate schema.d.ts after the Mercure docblock change

An #[ApiResource] class docblock becomes the resource description in the exported
OpenAPI, so renaming the cookie in MercureToken's PHPDoc drifts the generated
types and fails the OpenAPI -> TS drift job.

Worth recording that this two-line docblock is the *only* drift: API Platform 5
changed nothing in the exported schema, even though it now emits OpenAPI 3.2.

* fix(ci): declare eslint and typescript at the root, and repair three pins

Four red jobs, three causes.

**ESLint and typegen** shared one: when two workspaces ask for different versions
of a package, npm stops hoisting it and nests a copy in each — and anything that
*is* hoisted can then no longer resolve it. `typescript` lived only in pwa (5.9)
and mobile (6.0), so a clean `npm ci` left the root without one and the hoisted
`openapi-typescript` died with `Cannot find package 'typescript'`. Same story for
`eslint`, declared only in pwa, which the hoisted `eslint-plugin-react` could not
find.

This is unstable rather than plainly broken: whether npm hoists one of the two or
neither depends on resolution order, which is why an incremental `npm install`
locally and a clean `npm ci` in CI disagreed. Both are now declared in the root
devDependencies at the version the hoisted tooling should see; mobile keeps its
nested TypeScript 6. Verified by reproducing the CI layout (`npm ci` from a
removed node_modules), not by an incremental install.

**PHP-CS-Fixer** was my own regression: pinning `oskarstark/php-cs-fixer-ga` to
its newest *tag* (3.26.0) downgraded the bundled PHP below what the config file
needs, so it died on a parse error at the `new Config()->...` chain. `latest`
carries PHP 8.5.10; it is now pinned by digest, which keeps the reproducibility
the tag was meant to buy without the downgrade.

**hadolint** flagged the new builder stage: xcaddy is pinned to v0.4.7 (DL3062),
and the build-info verification writes to a temp file instead of piping into grep
(DL4006). Rebuilt to confirm the binary still reports mercure/caddy v1.0.0.

ADR-070 gains the hoisting rule: if a package is consumed by something npm
hoists, declare it at the root, and reproduce with `npm ci` rather than an
incremental install.

* fix(tests): give each test class its own $alwaysBootKernel slot

A static property a subclass does not redeclare is the ancestor's storage. The
23 Functional classes that want kernel reuse assigned
`self::$alwaysBootKernel = false` in setUpBeforeClass(), so every one of those
writes landed in App\Tests\ApiTestCase's single slot — and since PHPUnit runs the
suite in one process with no reset, whichever class ran first flipped the flag
off for every other Functional test for the rest of the run.

That is order-dependent rather than reliably broken, which is the worst shape:
it silently re-introduces, suite-wide, the kernel-reuse/Foundry-migrate failure
the base class exists to prevent. Redeclaring the property in each class gives it
real per-class storage.

Also from review:

- docker/bake-action goes back to a full commit SHA. Replacing the previous SHA
  pin with a floating tag contradicted the very commit that introduced it ("pin
  every floating image"), and a tag can be re-pointed by the action owner.
- the Caddyfile keeps its `log` directive. Removing the redaction filter was
  right — the `authorization` query parameter no longer exists in Mercure 1.0 —
  but removing the whole block turned access logging off, taking the
  X-Request-Id correlation trail with it.

* build(deps): require symfony/polyfill-php86 explicitly

Four files name \SortDirection directly — PHP's native global enum, which ships
in 8.6, so on this project's 8.5 it comes from symfony/polyfill-php86. That
polyfill was only present transitively, via doctrine/orm and doctrine/collections.
Relying on a transitive dependency for a class we reference by name is a latent
break: the day Doctrine stops needing it, four call sites fatal with nothing in
composer.json to explain why.

Declaring it also settles a review finding that has now been raised twice.
\SortDirection resolves under the production autoloader — verified in the built
prod image (classmap-authoritative, --no-dev, PHP 8.5.10), where it loads from
vendor/symfony/polyfill-php86/Resources/stubs/SortDirection.php, and where
doctrine:mapping:info reports [OK] App\ApiResource\TripRequest, i.e. Doctrine
reads the #[ORM\OrderBy] attribute the finding predicted would be fatal.

The suggested alternative, Doctrine\Common\Collections\Order, belongs to the
Collections criteria API; Doctrine's own deprecation message asks for
SortDirection by name, and doctrine/orm imports that same global enum in
ClassMetadata.

A comment at the TripRequest call site now records all of this, so the next
reader does not re-litigate it.

* docs: renumber the version-policy ADR to 071

main landed its own ADR-070 (freshness is dispatch completeness) while this
branch was open, so the dependency-version ADR takes the next free number.

* docs: drop a sentence duplicated into the compose-layout paragraph

Inserting the Mercure 1.0 note ended on the same sentence the original text
resumed with, so "in prod that edge serves plain HTTP behind Traefik +
Cloudflare Tunnel" appeared twice back to back. CLAUDE.md is loaded as project
instructions for every session, so the redundancy is not free.
@mislavjakopovic
mislavjakopovic changed the base branch from main to 5.0 September 21, 2026 23:04
@mislavjakopovic

mislavjakopovic commented Sep 22, 2026

Copy link
Copy Markdown
Author

Hey guys,

As CONTRIBUTING.md says, pinging you here now that this should be ready for review (desc).

Please keep in mind that this is my first contribution to API Platform - I've tried my best and hopefully there will not be many problems.

As such I would like to thank all the maintainers (and @soyuka @dunglas) for amazing work they have done over the years and let you know that if there are any issues, I will be monitoring these PRs closely until merged.

If something doesn't makes sense, if for example Mercure failed pipelines get fixed upstream - I would be happy to rebase all of them (so we're 100% nothing is breaking), the same about discussing / fixing if anything else is found or recommended.

I appreciate any suggestions, critique etc - I currently have several large production systems running 4.3 "bleeding edge" with PHPUnit 13 and am more than looking forward to 5.0 soon, so any feedback is more than welcome!

Thank you,
Mislav

@vincentchalamon
vincentchalamon marked this pull request as ready for review September 22, 2026 07:28
@vincentchalamon vincentchalamon added [RFR] Ready for Review dependencies Pull requests that update a dependency file labels Sep 22, 2026
@soyuka
soyuka changed the base branch from 5.0 to 4.4 September 22, 2026 09:02
@soyuka
soyuka force-pushed the chore/allow-phpunit-13 branch from 74b812d to 1af3b2b Compare September 22, 2026 09:28
@soyuka
soyuka changed the base branch from 4.4 to 5.0 September 22, 2026 13:01
@soyuka
soyuka force-pushed the chore/allow-phpunit-13 branch from 1af3b2b to 37f1dc8 Compare September 22, 2026 13:53
@soyuka
soyuka force-pushed the chore/allow-phpunit-13 branch from 37f1dc8 to b5e7af2 Compare September 22, 2026 14:17
@soyuka
soyuka merged commit e3fabf1 into api-platform:5.0 Sep 22, 2026
2 checks passed
@soyuka

soyuka commented Sep 22, 2026

Copy link
Copy Markdown
Member

Thanks @mislavjakopovic for the patches!

vincentchalamon added a commit to api-platform/demo that referenced this pull request Sep 22, 2026
api-platform/core#8537 widened api-platform/test to
"^11.5 || ^12.2 || ^13.0", but no release carries it yet: Packagist still
serves v5.0.0 with "^11.5 || ^12.2", which caps the demo at PHPUnit 12.

Widening the constraint here changes nothing today, the lock stays on 12.5.35.
It only means the next `composer update` picks PHPUnit 13 on its own once
api-platform/test ships the change, instead of needing another manual bump.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file [RFR] Ready for Review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants