Published on
By Nic Wortel
Learn how to use Composer to build production-ready Docker images for PHP applications, including best practices for caching dependencies and optimizing image size.
In this guide
Docker containers are a reliable way to deploy your application. As the image contains not only your PHP code but also all the dependencies your application needs to run, you can be sure that it will run the same way in production as it does on your local machine.
Docker should be able to build your application image from your source code, without relying on any manual steps.
This means that instead of copying a pre-installed vendor directory into the image,
your Dockerfile should run composer install as part of the build process.
This ensures an image which can be built in any environment (both locally and in CI/CD pipelines).
Running Composer during your Docker build comes with some challenges, and there are different approaches which come with pros and cons. This guide demonstrates some best practices when it comes to building production-ready Docker images.
Composer is a build-time dependency for your application image. This means that you need to have the Composer binary available in the build environment, but it does not need to be present in the final image. In fact, it is a best practice to ensure that the Composer binary is not present in the final image.
Your first guess might be to install the Composer binary using the installation script from the Composer website, similar to how you would install it on your local machine. But while the installation script is a convenient way for manual installation, it is not suitable for automated installation as it verifies the downloaded file against a hash which changes with every release.
The recommended way of using Composer in Docker is by using the official Composer image. However, you should not base your production image on the Composer image: it is not meant to be used as a production image and your application might require a different PHP version or extensions than the ones provided by the Composer image. Instead, the official Composer image's documentation recommends copying the Composer binary from the Composer image into your own image:
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
While this provides a more reliable way to get the Composer binary into your build environment than the installer script, it still has the drawback that the Composer binary becomes part of your final image. Even if you remove it in a later step, it will still be present in the image history and increase the size of your image.
A multi-stage build is a Docker feature
that lets you split your Dockerfile into multiple stages and copy artifacts from intermediate stages into the final image.
This allows you to install Composer in an intermediate stage, run composer install, and then copy only the vendor
directory into the final image:
FROM composer:2 AS build-stage
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev
FROM php:8.5-fpm
WORKDIR /app
COPY --from=build-stage /app/vendor/ vendor/
COPY . .
The above example has one drawback: if your application depends on a specific PHP version or extensions (listed in
composer.json), the composer install command will fail because the Composer image's runtime does not match the
platform requirements.
While this issue can be suppressed by passing the --ignore-platform-reqs flag to Composer, doing so would silently
ignore any missing platform requirements, which can lead to runtime errors in production.
A more reliable approach is to base the build stage on the same PHP image as your final image, and (again) copy the Composer binary into it from the official Composer image:
FROM php:8.5-fpm AS base
RUN docker-php-ext-install pdo_mysql
WORKDIR /app
FROM base AS build-stage
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev
FROM base
COPY --from=build-stage /app/vendor/ vendor/
COPY . .
An alternative approach to the multi-stage build is a single-stage build where you bind-mount the Composer binary.
Modern versions of Docker can bind-mount files from another image (or the build context) into a RUN command.
This lets you mount the Composer binary straight from the official image for the duration of the install,
without copying it into your own image.
RUN --mount=type=bind,from=composer:2,source=/usr/bin/composer,target=/usr/bin/composer \
composer install --no-dev --no-interaction
The mount only exists for the duration of the RUN command, so the Composer binary will not be present in the final image.
This is the best approach if you want to avoid complicated multi-stage builds,
but it requires a recent version of Docker with BuildKit enabled.
Composer has a few runtime requirements of its own, such as openssl to download packages over HTTPS and zip to
extract them.
The official Composer image includes these dependencies, but if you are copying or mounting the Composer binary into
your own image you need to ensure that your image (or build stage) has them as well.
When you base your image on the official php image, most of these dependencies are already present, but the zip
extension and the unzip binary are not, which means that you will need to install them yourself:
FROM base AS build-stage
RUN apt-get update && apt-get install -y --no-install-recommends libzip-dev unzip \
&& docker-php-ext-install zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
This shows the main benefit of using a multi-stage build over bind-mounting the executable: you can install Composer's dependencies in the build stage without adding them to your final image.
In some cases Composer has a dependency on git to download packages from Git repositories.
Composer 2.10
disabled the automatic fallback to source checkouts when the dist/zip install fails,
but some applications load (private) dependencies straight from a Git repository.
If that is the case, add git to the apt-get install line in your build stage as well.
Now that the Composer binary is available in the build environment, we can add a RUN composer install instruction to
run Composer.
The default behavior of Composer is optimized for development environments, and to build a production-ready image we should provide some extra options:
--no-dev, to skip the installation of development dependencies (such as unit testing and static analysis libraries)--no-interaction, to prevent Composer from asking interactive questions which would hang the build--no-progress, to prevent Composer from rendering progress bars--no-scripts, to prevent Composer from executing scripts, see Preventing unnecessary Composer reinstallsCombining those, a typical composer install instruction looks like this:
RUN composer install --no-dev --no-interaction --no-progress --no-scripts
vendor directory with .dockerignoreEvery COPY . . instruction copies your entire project into the image, including the local vendor directory.
That overwrites the dependencies Composer installed during the build, which may target different platform requirements
or include dev dependencies.
A .dockerignore file tells Docker which paths to leave out of the build context, much like .gitignore does for Git.
Excluding vendor keeps your built dependencies intact, and keeps the build context small as a bonus:
# Include anything else that shouldn't end up in the image, such as .git/, .env.local, or node_modules/
vendor/
Downloading and extracting dependencies can take a long time, especially for large applications with many dependencies.
This can add up quickly when you are rebuilding your image frequently during development or in a CI/CD pipeline.
While the first build will always take the longest, you can optimize subsequent builds by caching the result of
composer install and reusing it until something changes.
Docker caches the result of each instruction and reuses the cached layer until something it depends on changes.
This makes subsequent builds more efficient, but only when you are careful about the order of instructions in your
Dockerfile.
A COPY instruction's cache is invalidated when any copied file changes, which makes the following Dockerfile quite
inefficient:
COPY . .
RUN composer install --no-dev --no-interaction --no-progress --no-scripts
Editing a single PHP file invalidates the COPY layer, which invalidates the composer install layer below it, forcing
a full reinstall of all dependencies.
To avoid this, copy only the files Composer needs first, run composer install, then copy the rest of the files:
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-progress --no-scripts
COPY . .
Now the composer install layer is only invalidated when composer.json or composer.lock changes, which is exactly
what you want.
The --no-scripts flag is important here: many post-install scripts (Symfony's auto-scripts, for example) expect the
full application to be present, but at this point only composer.json and composer.lock have been copied and running
them would result in an error.
Instead, run composer run-script post-install-cmd after the source files have been copied, or run the relevant scripts
as individual RUN instructions.
The optimized layer ordering prevents unnecessary composer install runs, but doesn't help when we're actually updating
one of the dependencies.
A change in composer.json or composer.lock still requires a full download of all dependencies,
because the Composer cache is part of the (now invalid) layer and is not persisted between builds.
Cache mounts are a BuildKit feature that allows you to persist the Composer cache between builds without including it in the image:
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache \
composer install --no-dev --no-interaction --no-progress --no-scripts
COMPOSER_CACHE_DIR is an environment variable which points
Composer to the mounted cache directory, and the cache mount keeps that directory between builds.
After the first build, changing a dependency forces Composer to perform a fresh install, but it will only have to
download the new dependency.
Composer generates an autoloader which can load classes and interfaces when you use them in your code.
This enables you to use those classes and interfaces without littering your codebase with require_once expressions.
By default, the Composer autoloader checks the filesystem before resolving a classname.
This is convenient for development environments because it will discover new classes without having to regenerate the
autoloader, but adds unnecessary overhead in a production environment where autoloaded files shouldn't change anyway.
As part of the Docker image build, we can optimize the Composer autoloader by generating a class map which maps all
known class names to file paths, reducing the number of filesystem checks.
This can be achieved by passing the --optimize-autoloader flag to the composer install command or the --optimize
flag to the separate dump-autoload command:
RUN composer dump-autoload --no-dev --optimize
The dump-autoload command is especially useful when
the source files are not yet copied into the image
when composer install is executed.
Authoritative class maps are an enhancement of the standard class maps, by disabling all filesystem checks and only relying on the class map. This makes autoloading faster, but can break autoloading for classes which are not included in the class map (because they were generated at runtime, for example).
RUN composer dump-autoload --no-dev --classmap-authoritative
Test this carefully before enabling it in production, and revert to --optimize if something breaks.
Many applications depend on (internal or third-party) private packages, hosted in a private Git or Packagist repository. These packages are not publicly accessible and require some form of authentication to download. While it is tempting to simply copy the credentials into the image or set them as an environment variable, this is a security risk: anyone who can pull the image can see the credentials in the image history, even if you delete them in a later layer.
Even when using a multi-stage build and copying only the vendor directory into the final image, the credentials are
still present in the intermediate build stage and exported build cache.
Instead, a build secret mount should be used to securely pass the credentials during the build:
RUN --mount=type=secret,id=composer_auth,env=COMPOSER_AUTH \
composer install --no-dev --no-interaction --no-progress --no-scripts
The env=COMPOSER_AUTH option exposes the secret as an environment variable for the duration of the composer install
command, which is the environment variable Composer reads credentials from.
To make this work, the secret needs to be passed as an argument of the docker build command:
COMPOSER_AUTH='{"http-basic":{"repo.packagist.com":{"username":"token","password":"..."}}}' \
docker build --secret id=composer_auth,env=COMPOSER_AUTH .
This reads the value of the COMPOSER_AUTH environment variable and passes it as a secret with ID composer_auth to
the build.
Alternatively, it's possible to source the secret value from a file:
docker build --secret id=composer_auth,src=auth.json .
Because the mount declares env=COMPOSER_AUTH, the file's contents are exposed through that environment variable,
so auth.json must contain a valid COMPOSER_AUTH value (the same JSON structure Composer's own auth.json uses).
Just make sure to add the file to .gitignore so you don't accidentally commit it.
A complete single-stage Dockerfile combining the binary mount, manifests-first ordering, the cache mount, and the recommended flags:
FROM php:8.5-fpm
RUN docker-php-ext-install pdo_mysql
RUN apt-get update && apt-get install -y --no-install-recommends libzip-dev unzip \
&& docker-php-ext-install zip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=bind,from=composer:2,source=/usr/bin/composer,target=/usr/bin/composer \
--mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache \
composer install --no-dev --no-interaction --no-progress --no-scripts
COPY . .
RUN --mount=type=bind,from=composer:2,source=/usr/bin/composer,target=/usr/bin/composer \
composer dump-autoload --no-dev --classmap-authoritative
This example installs zip and unzip directly in the final image (see
Installing Composer's own runtime requirements).
Bind-mounting the binary can't isolate those runtime dependencies the way a build stage can,
so they remain in the final image.
The multi-stage equivalent moves the install into a build stage and copies the result forward, so the runtime image never contains Composer or the download cache:
FROM php:8.5-fpm AS base
RUN docker-php-ext-install pdo_mysql
WORKDIR /app
FROM base AS build-stage
RUN apt-get update && apt-get install -y --no-install-recommends libzip-dev unzip \
&& docker-php-ext-install zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache \
composer install --no-dev --no-interaction --no-progress --no-scripts
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative
FROM base
COPY --from=build-stage /app/vendor/ vendor/
COPY . .
The final COPY . . in each example relies on a .dockerignore that excludes the local vendor/
(see Excluding the local vendor directory with .dockerignore):
without it, that copy would overwrite the vendor/ built in the image with your local one.
Both examples skip Composer scripts with --no-scripts.
If your application defines post-install scripts (such as Symfony's auto-scripts), run them with a separate
RUN composer run-script post-install-cmd after COPY . ., once the full source is present.
A short checklist for installing dependencies in a Docker build:
composer.json and composer.lock before the source
so dependency installs are cached separately from code changes..dockerignore that excludes vendor/ so COPY . . can't overwrite the dependencies built in the image.ENV or a committed auth.json.--no-dev, --no-interaction, --no-progress, and --no-scripts,
and optimize the autoloader after the source is in place.
Are you looking to implement PHP, Composer, or Docker in your project or team? As a PHP consultant I can save you time and money by guiding you through the process and helping you avoid costly mistakes.