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.

Syntaxdocker build -t myapp:1.4.0 . && docker run --rm -e APP_ENV=prod myapp:1.4.0

An 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

Example · php
<?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.

Example · dockerfile
# 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 /app

Configuration 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.

Example · bash
# .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.0

Letting 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.

Example · json
{
    "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

  • Be the first to comment on this lesson.