Multistage Docker builds for PHP: keeping composer install out of your rebuild loop

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 changedRebuilds fromTime
A PHP source fileCOPY . . in vendor~4s
A dependency in composer.lockcomposer install~10s (cache mount) / ~45s (without)
An extension in php-baseEverything~2min
The base image tagEverything~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?

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.