ADR-071 — Dependency version policy and the ceilings we actually hit
Status: accepted Date: 2026-09-21 Builds on ADR-009 (the QA gates that make an upgrade verifiable) and ADR-037 (the base image a version bump can force us to rebuild).
Context
Until this ADR the repository had no written rule about dependency versions, and two
pins carried no explanation at all: rector/rector: ~2.5.9 and an exact
playwright-core: "1.63.0" override. Nobody could tell whether they guarded against a known
break or were leftovers, so nobody dared move them — the classic way a stack silently ages.
At the same time the ops side had drifted badly: Traefik was four minors behind with five
security advisories open against it, cloudflared was over a year old, and four images ran
with no tag at all (hadolint/hadolint, davidanson/markdownlint-cli2, alpine,
oskarstark/php-cs-fixer-ga), so two runs of the same commit could use different tools.
Decision
A pin without an ADR is a pin to lift
Any constraint narrower than the ecosystem default (~ where ^ would do, an exact version,
an overrides entry) must name the incompatibility it guards against, here or in a comment
next to it. If it cannot, it gets lifted at the next upgrade pass. Both undocumented pins
above were lifted; nothing broke.
Everything is pinned by digest where a digest exists
Tag-only pins are invisible to Dependabot, which is how the provisioner image went unwatched
while php/pwa were tracked. Compose files get their own docker-compose Dependabot
entry, because the docker ecosystem only reads Dockerfiles — that gap is why Traefik and
cloudflared aged unnoticed.
Ceilings are recorded, not rediscovered
Each entry below is a version we tried, measured, and backed out of. Re-attempt on the named condition, not on a hunch.
| Held at | Blocker | Lift when |
|---|---|---|
phpunit/phpunit ^12.5 (api/ only) |
api-platform/test — required by api-platform/symfony 5's ApiTestCase since the 5.0 package split — requires phpunit/phpunit ^11.5 \|\| ^12.2, although its classes run unchanged on 13 |
api-platform/core#8537 is released. provisioner/ has no API Platform dependency and stays on PHPUnit 13. |
typescript ^5.9 (pwa) |
openapi-typescript 7.13 declares typescript: ^5.x, on latest and on next |
openapi-typescript widens its peer range |
typescript ~6.0 (mobile) |
typescript-eslint refuses TS 7 outright (Error: typescript-eslint does not support TS 7.0). npm hoists one TypeScript for the workspace, so mobile's choice decides what eslint-config-next parses with in pwa. |
typescript-eslint#10940 |
eslint ^9 |
eslint-plugin-react, pulled in by eslint-config-next, calls context.getFilename(), removed in ESLint 10 (TypeError: contextOrFilename.getFilename is not a function). The peer ranges permit ESLint 10 — they are wrong. |
eslint-plugin-react ships an ESLint 10-compatible release |
react ~19.2.8 (whole workspace) |
Expo SDK 57 pins React 19.2 and jest-expo ships react-test-renderer@19.2.3; React 19.3 breaks every mobile render test. Hoisting makes this workspace-wide, not per-package. |
Expo SDK 58 (still preview at the time of writing) |
| Expo SDK 57 / React Native 0.86 | SDK 58 is preview-only | SDK 58 is released |
Symfony 8.1.* |
Nothing — 8.1 is the current stable | 8.2 is released |
Note the shape of three of these: the constraint that bites is not the one you declare.
~19.2.8 on React and ~6.0 on TypeScript exist because of how npm arranges the workspace,
not because of what any manifest asks for. Check the resolved tree (npm ls <pkg>), not the
manifests.
Shared tooling is declared at the workspace root
When two workspaces ask for different versions of the same package, npm stops hoisting it and
nests a copy in each. Anything that is hoisted then cannot resolve it at all. That is not
hypothetical: with typescript declared only in pwa (5.9) and mobile (6.0), a clean
npm ci left the root without one, and the hoisted openapi-typescript died with
Cannot find package 'typescript'. The same happened to eslint, declared only in pwa,
which the hoisted eslint-plugin-react could not find.
Worse, it is unstable rather than simply broken: whether npm hoists one of the two or neither
depends on resolution order, so the same manifests can produce a working tree and a broken one.
An incremental npm install and a clean npm ci disagreed here, which is exactly how this
reached CI green locally and red in the pipeline.
So eslint and typescript are declared in the root devDependencies — the version the
hoisted tooling should see (TypeScript 5.9, which openapi-typescript and typescript-eslint
both accept). Workspaces that need a different one, like mobile on TypeScript 6, keep their
own nested copy and are unaffected. react/react-dom were already at the root for the same
reason.
Rule of thumb: if a package is consumed by something npm hoists, declare it at the root.
Reproduce with rm -rf node_modules && npm ci, never with an incremental install.
Consequences
api/ and provisioner/ run different PHPUnit majors on purpose, and api/rector.php
carries a comment saying why. The asymmetry is the price of not regressing a project that has
no reason to wait for API Platform.
Lifting the two undocumented pins cost nothing, which is the point: an unexplained pin is usually stale, not protective.