Writing the Dockerfile

A multi-stage build that installs dependencies in one stage and ships a slim runtime stage with no Composer and no dev packages.

SyntaxCOPY --from=builder /app /app

A stage is one FROM section of a Dockerfile. Each stage builds its own filesystem, and a later stage can copy files out of an earlier one with COPY --from=. That is how you use Composer, git and compilers to build the application without shipping any of them.

Two stages

The builder stage has Composer. It installs dependencies, generates the optimised autoloader, writes .env.local.php with composer dump-env prod, and warms the cache. The runtime stage starts from a clean PHP image, installs only the extensions, and copies /app from the builder. No Composer binary, no dependency resolution at deploy time, no dev dependencies.

Layer caching that works

Each instruction produces a layer, and a layer is reused only when the instruction and its inputs are unchanged. So copy composer.json, composer.lock and symfony.lock first and install from them; copy the source afterwards. Change a controller and only the last few layers rebuild. Copy the source first and every build reinstalls every dependency.

Shorthand and longhand

composer install --no-dev --optimize-autoloader does the install and the class map in one step. The split form - composer install --no-dev --no-autoloader --no-scripts, then composer dump-autoload --classmap-authoritative --no-dev - does the same work in two, which lets the dependency layer cache independently of your source. Use the split form in a Dockerfile and the single flag everywhere else.

What trips people up

COPY . ./ copies whatever is in your working directory, including the vendor/ and var/ you have locally.

Example

Example · php
<?php
// public/index.php

declare(strict_types=1);

use App\Kernel;

// This is the only PHP file a web server ever executes. Everything else is
// reached through the autoloader, which is why the image must contain the
// vendor/ directory Composer produced: autoload_runtime.php is written by
// symfony/runtime during composer install and does not exist without it.
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

// The returned closure is called by symfony/runtime with the resolved
// environment variables. $context['APP_ENV'] comes from the real environment,
// or from .env.local.php if "composer dump-env prod" wrote one in the builder.
return function (array $context): Kernel {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

// A container built from the Dockerfile below serves GET / through this file:
// 200, with whatever your root route renders. If the builder stage skipped
// composer dump-autoload, the same request is a 500 raised before Symfony
// starts, because the require_once line cannot find autoload_runtime.php.

When to use it

  • Cutting a build image with Composer and compilers in it down to a slim runtime image.
  • Making a one-line controller change rebuild in seconds instead of reinstalling every dependency.
  • Producing one artefact that CI tests and production runs, with no composer install on the server.
  • Baking the warmed prod cache into the image so the first request after a deploy is not the slow one.

More examples

The multi-stage Dockerfile

The shape to copy for any Symfony image. Build the first stage alone with docker build --target builder when you want a shell that has Composer in it. The php.ini-production file ships with the official image and sets production defaults such as display_errors off; without the mv, PHP runs on its built-in defaults instead.

Example · dockerfile
# Dockerfile
# syntax=docker/dockerfile:1

FROM php:8.5-cli AS builder

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions intl opcache pdo_pgsql zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# APP_ENV must be set before Composer runs, so post-install scripts
# build the prod container and not the dev one.
ENV APP_ENV=prod COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /app

# Dependencies first: this layer is reused until a lock file changes.
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress

# Then the source, which changes on every commit.
COPY . ./
RUN composer dump-autoload --classmap-authoritative --no-dev \
 && composer dump-env prod \
 && php bin/console cache:warmup

# Runtime. php-fpm is a pool of PHP processes a web server hands requests to.
FROM php:8.5-fpm AS runtime

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions intl opcache pdo_pgsql zip \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

ENV APP_ENV=prod APP_DEBUG=0
WORKDIR /app

# Only the built application crosses over: no Composer, no git, no dev packages.
COPY --from=builder /app /app

CMD ["php-fpm"]

.dockerignore

Write this file before the first build, not after the first strange bug. It also shrinks the build context, the archive of your project sent to the daemon on every build, so a .git directory with years of history stops slowing every build down for no benefit.

Example · text
# .dockerignore
.git
.env.local
.env.*.local
vendor/
var/
node_modules/
public/build/
tests/
.phpunit.cache/
compose*.yaml
Dockerfile
.dockerignore

# Everything listed here is invisible to COPY. vendor/ and var/ matter most:
# the builder stage produces both, and copying local versions over them ships
# your machine's dependencies and a dev-environment cache to production.

What composer dump-env writes

Shows the artefact the build produces and why it exists. Use the plain form when the values are harmless, and --empty whenever a credential would otherwise end up inside a layer that anyone who can pull the image can read.

Example · php
<?php

// .env.local.php - generated in the builder stage by "composer dump-env prod".
// Symfony reads this file instead of parsing .env files at runtime, which
// removes that work from every request.
return [
    'APP_ENV' => 'prod',
    'APP_DEBUG' => '0',
    'DATABASE_URL' => 'postgresql://app:app@database:5432/app?serverVersion=17&charset=utf8',
];

// Real environment variables still win over the values in this file, so a
// DATABASE_URL passed with docker run -e overrides the baked one.
//
// To get the speed without baking any values into the image:
//   composer dump-env prod --empty
// which writes only the environment name and leaves every other value to be
// supplied when the container starts.

Discussion

  • Be the first to comment on this lesson.