Symfony in a Container
What belongs inside a Symfony image, what is supplied from outside it, and why the official PHP images need extensions added.
docker build -t myapp:1.4.0 . && docker run --rm -e APP_ENV=prod myapp:1.4.0An image is a read-only filesystem plus a default command. A container is one running copy of that image. Building an image for a Symfony application is mostly a question of what belongs inside the filesystem and what is handed in from outside when the container starts.
Inside and outside
Inside: the PHP binary, the extensions it needs, your src/, config/, templates/, public/, and the vendor/ directory Composer produced. All of it is frozen the moment the build finishes, so the same image starts a hundred times and behaves the same way.
Outside: anything that differs per deployment, passed as environment variables - APP_ENV, DATABASE_URL, secrets. Also the database itself, Redis, uploaded files, and logs you ship elsewhere. A container's filesystem disappears when the container is replaced, so nothing you intend to keep may live only there.
The official PHP images are deliberately bare
The php images ship a small extension set. The image documentation says which extensions are compiled in "depends on the PHP version you are using" and tells you to run php -m in the container to find out. Symfony's skeleton requires php >=8.4, ext-ctype and ext-iconv in its composer.json. Past that, intl, pdo_pgsql, zip, apcu and opcache are yours to add, with docker-php-ext-install or the community install-php-extensions script.
What trips people up
A missing extension rarely breaks the build. It breaks the first request that needs it, after the deploy looked green.
Example
<?php
// src/Command/CheckImageCommand.php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:check-image',
description: 'Fails if an extension this application needs is not in the image',
)]
final class CheckImageCommand extends Command
{
/** @var list<string> */
private const REQUIRED = ['ctype', 'iconv', 'intl', 'opcache', 'pdo_pgsql', 'zip'];
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$missing = [];
foreach (self::REQUIRED as $extension) {
// extension_loaded() answers for the running PHP binary,
// which inside a container is the image's binary.
if (!\extension_loaded($extension)) {
$missing[] = $extension;
}
}
if ([] !== $missing) {
$io->error('Missing extensions: '.implode(', ', $missing));
return Command::FAILURE;
}
$io->success('All required extensions are loaded.');
return Command::SUCCESS;
}
}
// Run it against a freshly built image:
//
// $ docker run --rm myapp:1.4.0 bin/console app:check-image
//
// [ERROR] Missing extensions: intl, pdo_pgsql
//
// Exit status 1, so a build step or CI job running this command stops here.When to use it
- Deciding which PHP extensions a Symfony image must install before the first deploy.
- Moving database credentials out of the image so one build serves staging and production.
- Catching a missing pdo_pgsql in CI instead of on the first production query.
- Giving every developer the exact PHP version and extension set production runs.
More examples
Adding extensions: the short form and the long form
Both halves install the same extensions. Reach for the long form when you want every package pinned and visible; reach for install-php-extensions when the list is long and the C dependencies keep changing between PHP versions. Recognising the short form matters because most Symfony Dockerfiles you will read use it.
# Dockerfile
FROM php:8.5-cli
# Long form: you install the C libraries yourself, then compile the extension.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libicu-dev libpq-dev libzip-dev \
&& docker-php-ext-install intl pdo_pgsql zip opcache \
&& rm -rf /var/lib/apt/lists/*
# Short form: install-php-extensions wraps the docker-php-ext-* scripts and
# works out the apt packages for you. These two blocks produce the same
# extensions; the script is not part of the official image, so it is copied in.
# Pin a tag of the installer image in a real build.
# COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
# RUN install-php-extensions intl pdo_pgsql zip opcache apcu
WORKDIR /appConfiguration stays outside the image
Shows the split in practice: one build artefact, per-environment values injected at start-up. Use this when you are tempted to bake a .env file with credentials into the image, because a rebuild per environment means the thing you tested is not the thing you shipped.
# .env.docker - values for one deployment, never committed with real secrets
APP_ENV=prod
APP_DEBUG=0
DATABASE_URL=postgresql://app:s3cret@database:5432/app?serverVersion=17&charset=utf8
REDIS_URL=redis://redis:6379
# The image is the same in every environment; only the values change.
# docker run --rm --env-file .env.docker -p 8080:80 myapp:1.4.0
#
# One image, two deployments:
# docker run --rm -e APP_ENV=prod -e DATABASE_URL="$STAGING_DSN" myapp:1.4.0Letting Composer police the extension list
Use this when a library needs an extension nobody wrote down. Declaring ext-* requirements makes composer install and composer check-platform-reqs refuse to proceed in an image that lacks them, which is cheaper than finding out from a 500 on the one page that formats currency.
{
"require": {
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-intl": "*",
"ext-pdo_pgsql": "*"
}
}
// Declaring the extensions you depend on turns a runtime surprise into a
// build failure. In the Dockerfile, after copying composer.json:
//
// RUN composer check-platform-reqs
//
// It compares every platform requirement in composer.json and composer.lock
// against the PHP binary in the image and exits non-zero on a mismatch.
Discussion