Here is the Dockerfile almost everyone writes first:
FROM php:8.3-fpm
RUN apt-get update && apt-get install -y git unzip libicu-dev \
&& docker-php-ext-install intl opcache pdo_mysql
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /app
COPY . .
RUN composer install --no-dev
It works. It is also the reason your build takes ninety seconds every time you fix a typo.
The problem is the second-to-last line. Docker caches each instruction as a layer and reuses that layer only if its inputs are byte-identical to last time. COPY . . takes the entire source tree as input, so changing one character in one controller invalidates it — and every layer after it, including the composer install that has nothing to do with your controller and produces exactly the same vendor/ directory it produced five minutes ago.
Two changes fix this. Order layers by how often their inputs change, and split the build into stages so the tooling needed to produce the image never ends up in the image.
The one-line version
Before anything else, this is 80% of the win:
COPY composer.json composer.lock ./
RUN composer install --no-dev
COPY . .
Now the install step's only inputs are two files that change when you add a dependency, not when you edit code. Source edits invalidate only the final COPY, which takes milliseconds. Same output, but the expensive step now runs on the handful of builds that actually need it.
The general rule behind it: put the slowest-changing inputs at the top of the Dockerfile and the fastest-changing at the bottom. Base image, then system packages, then PHP extensions, then dependency manifests, then source. Every layer you can push above the source copy is a layer you stop rebuilding.
Why stages, though
Layer ordering solves rebuild time. It does not solve what is in the image.
To run composer install you need Composer, plus git and unzip for source-dist packages, plus the -dev header packages for compiling extensions. None of that is needed to serve a request, but all of it ships to production in a single-stage build: typically 150–250MB of dead weight, and a meaningfully larger attack surface. Anyone who gets code execution in your container gets a package manager and a compiler along with it.
Multistage builds let you run those tools in a throwaway stage and copy only the artifact — the vendor/ directory — into a clean final image. Three stages is the shape that has held up best for me.
The three-stage build
# syntax=docker/dockerfile:1
########################################
# 1. php-base — the PHP runtime itself
########################################
FROM php:8.3-fpm-alpine AS php-base
COPY --from=mlocati/php-extension-installer:2 \
/usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions \
intl \
opcache \
pdo_mysql \
zip
COPY docker/php/99-app.ini /usr/local/etc/php/conf.d/
WORKDIR /app
########################################
# 2. vendor — dependency resolution
########################################
FROM php-base AS vendor
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_CACHE_DIR=/tmp/composer
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/composer \
composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction \
--no-progress
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative
########################################
# 3. runtime — what actually ships
########################################
FROM php-base AS runtime
COPY --from=vendor --chown=www-data:www-data /app /app
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
Several decisions in there are load-bearing.
php-base exists so the two ends agree
The obvious way to build the vendor stage is FROM composer:2, since that image has Composer already. Resist it. Composer resolves dependencies against the PHP version and extension set of the machine it runs on, and the Composer image's PHP will not match your runtime's. You get a vendor/ directory resolved for the wrong platform, and the failure mode is a package that installs cleanly and then explodes at runtime on a missing extension.
Deriving both vendor and runtime from a shared php-base makes that mismatch structurally impossible: the PHP that resolves your dependencies is byte-identical to the PHP that runs them. You bring Composer to your PHP rather than your dependencies to Composer's PHP.
If you do want the Composer image — it is a reasonable choice if your build host is constrained — then pin the target explicitly in composer.json so resolution ignores whatever PHP is actually present:
"config": {
"platform": {
"php": "8.3.0",
"ext-intl": "8.3.0"
}
}
That is a real fix. --ignore-platform-reqs is not; it silences the check rather than answering it.
The install is split from the autoloader
--no-scripts --no-autoloader looks like superstition until you consider what those steps need. Post-install scripts routinely invoke framework commands that read your application source — which does not exist yet at that point in the build, deliberately, because copying it in would defeat the whole layering exercise. Autoloader generation needs to scan your classes, same problem.
So the install downloads and unpacks packages only. Source arrives, then dump-autoload runs with everything present. The split matters because the two halves have completely different cost profiles: resolution and download is the thirty-second step and it stays cached, autoload generation is the two-second step and reruns on every source change. --classmap-authoritative then bakes a complete class map and tells the autoloader to stop hitting the filesystem for misses, which is what you want in an immutable image.
The cache mount saves you on lockfile changes
--mount=type=cache is the modern BuildKit feature that most PHP Dockerfiles are still missing. Layer caching is all-or-nothing: change one line in composer.lock and the whole install layer rebuilds. A cache mount persists Composer's own download cache across builds, independently of layers, so that rebuild re-downloads only the packages that actually changed. Adding one dependency to a fifty-package project goes from a full re-fetch to a few seconds.
It needs the # syntax directive on line one and BuildKit enabled, which is the default in any current Docker. The cache is a build-host resource, so it does not affect the image at all.
What this buys you
Rough numbers from a mid-sized project — fifty-odd Composer packages, four extensions:
| What you changed | Rebuilds from | Time |
|---|---|---|
| A PHP source file | COPY . . in vendor | ~4s |
| A dependency in composer.lock | composer install | ~10s (cache mount) / ~45s (without) |
| An extension in php-base | Everything | ~2min |
| The base image tag | Everything | ~2min |
The top row is the one that matters, because it is the row you hit forty times a day. And the final image contains no Composer, no git, no compiler — just PHP, your extensions, your vendor directory and your code.
The .dockerignore you must not skip
This file is not optional in a multistage PHP build, and omitting it produces a bug that is genuinely hard to diagnose:
.git
vendor
node_modules
.env
.env.*
docker-compose*.yml
tests
*.md
If vendor is not ignored, the COPY . . in the vendor stage overwrites the freshly-resolved dependencies with whatever is sitting on your laptop — including dev dependencies you just excluded, and packages built against your host's PHP. The container then runs code you never intended to ship, and it works fine locally, which is the worst possible property for a bug to have.
Excluding .git is nearly as important for a different reason: it is often the largest thing in the build context, and every byte of context gets shipped to the daemon before the build even starts.
Two things worth adding once it works
Turn off opcache timestamp validation. In an immutable image the files cannot change, so checking their mtimes on every request is pure overhead. In your 99-app.ini:
opcache.enable=1
opcache.validate_timestamps=0
opcache.max_accelerated_files=20000
opcache.memory_consumption=192
Do not set this in a development image, where you very much do want your edits to take effect.
Keep a dev stage in the same file. Add a fourth stage deriving from vendor that reinstates dev dependencies and Xdebug, and point your compose file at it with target: dev. One Dockerfile, two outputs, and the dev image shares every cached layer with the production one.
The pattern generalises well beyond PHP — the same three-part split applies to npm ci, bundle install, cargo build. What differs is only which manifest files you copy first. The question is always the same one: what is the smallest set of inputs this expensive step actually depends on, and can I get everything else below it?