A named stage installing dev dependencies and running the suite guarantees the test environment is the runtime environment plus tools, rather than a second description that drifts.
FROM php:8.1-fpm-alpine AS base
# extensions, configuration
FROM base AS vendor-dev
RUN --mount=type=cache,target=/root/.composer/cache
composer install --no-progress
FROM vendor-dev AS test
COPY . .
RUN vendor/bin/phpunit && vendor/bin/phpstan analyse
FROM base AS runtime
COPY --from=vendor-prod /app/vendor /app/vendor
# docker build --target test .
Stages not reached by the target are never built, which is what keeps this affordable — building runtime does not run the tests. The cost is that a failing test is a failing build with the output buried in build logs, which is worse to read than a test step; most pipelines keep both, with this stage as the guarantee that they cannot diverge.