Symfony et Docker en 2026 : environnement de développement et déploiement en production

Un workflow Symfony Docker complet pour 2026 : environnement de développement FrankenPHP, image de production multi-étapes, mode worker, secrets chiffrés, optimisation de l'image et déploiements sans interruption avec migrations.

Tutoriel Symfony Docker 2026 : environnement de développement et déploiement en production

Ce tutoriel Symfony Docker construit un workflow de conteneurs complet : un environnement de développement local reproductible et une image de production durcie, tous deux centrés sur FrankenPHP et Symfony 7.4 LTS. Exécuter Symfony dans des conteneurs supprime le classique décalage d'environnement entre les postes de développement et les serveurs, et transforme chaque déploiement en la livraison d'un unique artefact immuable, au lieu d'enchaîner une fragile série de commandes serveur manuelles.

La stack Symfony Docker de 2026

Le template officiel Symfony Docker embarque désormais FrankenPHP comme runtime, en remplacement du duo traditionnel Nginx et PHP-FPM. Un seul binaire sert HTTP/2 et HTTP/3 et exécute Symfony en mode worker persistant, en gardant le kernel démarré entre les requêtes plutôt que de le reconstruire à chaque appel.

Environnement de développement Docker Compose pour Symfony

Un bon environnement de développement donne à chaque contributeur la même version de PHP, les mêmes extensions et la même base de données, sans jamais toucher à la machine hôte. Docker Compose décrit cette stack de manière déclarative. L'exemple ci-dessous associe un conteneur FrankenPHP construit à partir d'un Dockerfile local à un service PostgreSQL 17, reliés entre eux sur le réseau Compose par défaut.

yaml
# compose.yaml
services:
  php:
    build:
      context: .
      target: frankenphp_dev
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./:/app                    # bind mount: edits on the host appear instantly
      - caddy_data:/data           # persist TLS certificates across restarts
    environment:
      DATABASE_URL: "postgresql://app:app@database:5432/app?serverVersion=17"
    depends_on:
      - database

  database:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  caddy_data:
  db_data:

Le bind mount du service php fait correspondre la racine du projet à l'intérieur du conteneur : les changements de fichiers prennent effet sans reconstruire l'image. FrankenPHP génère un certificat TLS local au premier démarrage, d'où l'exposition du port 443 et le fait que caddy_data soit un volume nommé : le certificat survit à un docker compose down. L'indication serverVersion=17 dans la DSN permet à Doctrine d'éviter une requête de détection de version à chaque connexion.

Une fois la stack lancée, chaque commande Symfony s'exécute dans le conteneur php plutôt que sur l'hôte, ce qui garantit la bonne version de PHP et les bonnes extensions. Créer une base de données, lancer des migrations ou ouvrir un shell suivent tous le même schéma via docker compose exec.

Lancer les commandes console dans le conteneur

Préfixez les appels Symfony et Composer par docker compose exec php pour les exécuter contre le runtime PHP du conteneur, par exemple docker compose exec php bin/console make:entity ou docker compose exec php composer require symfony/uid. L'hôte n'a jamais besoin d'avoir PHP installé.

Dockerfile multi-étapes pour la production Symfony

Un Dockerfile multi-étapes partage une base commune, puis se scinde en une cible de développement et une cible de production. L'étape de production n'installe que les dépendances d'exécution, écarte les paquets dev de Composer et préchauffe le cache au moment du build, si bien que le conteneur en fonctionnement démarre instantanément.

dockerfile
# Dockerfile
FROM dunglas/frankenphp:1-php8.4 AS base

WORKDIR /app

# Install the PHP extensions Symfony relies on
RUN install-php-extensions \
    intl \
    opcache \
    pdo_pgsql \
    zip

COPY --from=composer/composer:2-bin /composer /usr/bin/composer

# --- Development target ---
FROM base AS frankenphp_dev
ENV APP_ENV=dev
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"

# --- Production target ---
FROM base AS frankenphp_prod
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
ENV APP_RUNTIME="Runtime\FrankenPhpSymfony\Runtime"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

# Layer caching: copy manifests first, install, then copy the source
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative \
    && composer dump-env prod \
    && bin/console cache:warmup

Copier composer.json et les fichiers de lock avant le reste du code source est l'optimisation clé : Docker met en cache la couche d'installation des dépendances et ne la rejoue que lorsque les manifestes changent, pas à chaque modification de code. L'option --classmap-authoritative indique à Composer que la classmap est complète, si bien que l'autoloader ne se rabat jamais sur des recherches système de fichiers à l'exécution. Préchauffer le cache pendant le build signifie que la première requête de production frappe un conteneur entièrement compilé.

Mode worker FrankenPHP et le composant Runtime de Symfony

Le PHP-FPM traditionnel démarre le kernel Symfony, traite une requête, puis jette tout. Le mode worker garde le kernel et le conteneur d'injection de dépendances en vie d'une requête à l'autre, et c'est de là que provient l'essentiel du gain de latence. Le paquet runtime/frankenphp-symfony rend cela possible grâce au composant Symfony Runtime et au bootstrap standard public/index.php.

public/index.phpphp
use App\Kernel;

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

La ligne FRANKENPHP_CONFIG="worker ./public/index.php" définie dans le Dockerfile active cette boucle. Comme le conteneur est réutilisé, tout service qui conserve un état spécifique à une requête doit se réinitialiser entre les requêtes. Symfony gère automatiquement les services du framework, mais les services stateful personnalisés devraient implémenter Symfony\Contracts\Service\ResetInterface et être marqués avec le tag kernel.reset, afin que l'état accumulé ne fuite pas vers la requête suivante. C'est la même discipline qu'exigent les workers Messenger de longue durée, et la récompense est un débit de requêtes plusieurs fois supérieur à celui de PHP-FPM sur le même matériel.

En développement local, lancer FrankenPHP avec le flag --watch redémarre automatiquement le worker à chaque changement de fichier, de sorte que le mode worker ne gêne pas la boucle édition-rafraîchissement.

FrankenPHP face à PHP-FPM avec Symfony

Le choix entre FrankenPHP et la stack classique Nginx plus PHP-FPM façonne à la fois le Dockerfile et le comportement à l'exécution. Le tableau ci-dessous résume les différences concrètes pour un environnement de production Symfony.

| Aspect | Mode worker FrankenPHP | Nginx + PHP-FPM | |--------|------------------------|-----------------| | Conteneurs | Un seul | Deux (serveur web + PHP) | | Démarrage du kernel | Une fois par worker | Une fois par requête | | HTTP/3 | Intégré | Nécessite une configuration supplémentaire | | TLS | Automatique (Caddy) | Configuration manuelle du certificat | | Gestion de l'état | Doit se réinitialiser entre les requêtes | Isolé à chaque requête | | Coût du démarrage à froid | Payé une fois au boot | Payé à chaque requête |

Le mode worker l'emporte sur le débit parce qu'il amortit l'étape coûteuse de démarrage du kernel et de compilation du conteneur sur des milliers de requêtes. PHP-FPM reste pertinent quand une application s'appuie sur des variables globales ou des bibliothèques tierces qui supposent un processus neuf par requête, car celles-ci cassent sous un worker réutilisé.

Fuites d'état en mode worker

Un service qui met en cache les données d'une requête dans une propriété privée servira les données d'une requête à la suivante s'il ne se réinitialise pas. Auditez les singletons, les event subscribers et tout ce qui détient un token de sécurité ou la requête courante avant de passer au mode worker en production.

Prêt à réussir tes entretiens Symfony ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Gérer les variables d'environnement et les secrets en production

Symfony lit sa configuration depuis les variables d'environnement, et il existe deux façons saines de les fournir en production. La première consiste à injecter des variables en clair via l'orchestrateur ou le fichier Compose. La seconde est le coffre-fort de secrets chiffrés de Symfony, qui stocke les valeurs sensibles dans le dépôt sous forme de texte chiffré et les déchiffre à l'exécution avec une unique clé privée.

bash
# Generate the keypair; commit the public key, keep the private key out of the image
php bin/console secrets:generate-keys

# Encrypt a value into config/secrets/prod/
php bin/console secrets:set DATABASE_URL

# In production, expose only the decryption key to the container
export SYMFONY_DECRYPTION_SECRET="$(cat config/secrets/prod/prod.decrypt.private.php)"

L'étape composer dump-env prod du Dockerfile compile les fichiers .env en un unique .env.local.php optimisé, ce qui supprime le coût d'exécution lié au parsing des fichiers dotenv au démarrage. Toute variable définie dans l'environnement réel du conteneur écrase malgré tout les valeurs par défaut compilées : les secrets fournis par l'orchestrateur l'emportent toujours. Le guide de déploiement Symfony documente l'ordre de précédence complet, mais la règle pratique est simple : ne jamais figer APP_SECRET ni les identifiants de base de données dans l'image, et les injecter au démarrage du conteneur.

Optimiser l'image de production Symfony

Deux réglages OPcache expliquent l'essentiel de l'écart de performance en production : la désactivation de la validation des timestamps et l'activation du preloading. Comme une image de conteneur est immuable, le code source ne change jamais à l'exécution, donc OPcache ne devrait jamais faire de stat sur les fichiers pour vérifier d'éventuelles modifications. Le preloading va plus loin en chargeant les classes de Symfony et de l'application en mémoire partagée, une seule fois au démarrage du serveur.

ini
; docker/php/prod.ini
opcache.enable=1
opcache.preload=/app/var/cache/prod/App_KernelProdContainer.preload.php
opcache.preload_user=root
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0     ; immutable image: never re-check the filesystem
realpath_cache_size=4096K
realpath_cache_ttl=600

Symfony génère le fichier preload.php listé ci-dessus lors du cache:warmup, si bien que le fichier existe déjà dans l'image après le build. Le preloading OPcache lie ces classes au démarrage et évite l'étape de compilation lors de la première requête qui les nécessite. Régler validate_timestamps=0 n'est sûr que pour des déploiements immuables, où une nouvelle version signifie une nouvelle image ; sur un hôte mutable, cela servirait du code périmé. Dimensionner max_accelerated_files au-dessus du nombre réel de fichiers garde tout le framework en cache sans éviction.

Réduire la taille de l'image Symfony avec .dockerignore

Un fichier .dockerignore absent gonfle silencieusement le build. Sans lui, l'étape COPY . . embarque le répertoire vendor local, le var/cache de développement, la sortie de build node et l'historique .git directement dans l'image et le contexte de build. Les exclure réduit l'image, accélère l'envoi du contexte de build vers le daemon et empêche les artefacts de développement d'écraser les dépendances de production fraîchement installées.

gitignore
# .dockerignore
/.git/
/vendor/
/node_modules/
/var/
/.env.local
/.env.*.local
/tests/
/docker/
compose*.yaml
Dockerfile

Ignorer /vendor/ compte le plus : l'étape de production exécute composer install --no-dev, donc embarquer le répertoire vendor à saveur développement de l'hôte annulerait complètement cette étape. Exclure /var/ garde le cache et les logs de développement hors de l'image, ce qui laisse le cache:warmup du build produire un cache de production propre. Combinée au build multi-étapes et aux extensions basées sur Alpine, une image Symfony épurée passe généralement bien sous les 150 Mo.

Déployer les conteneurs Symfony et lancer les migrations

Le fichier Compose de production référence une image pré-construite issue d'un registre plutôt que de la construire localement, et il définit un health check pour que l'orchestrateur ne route le trafic que vers un conteneur qui répond. Les secrets arrivent comme variables d'environnement, jamais comme couches d'image.

yaml
# compose.prod.yaml
services:
  php:
    image: registry.example.com/app:${TAG}
    environment:
      APP_ENV: prod
      APP_SECRET: ${APP_SECRET}
      DATABASE_URL: ${DATABASE_URL}
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost/health"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped

Le health check interroge une route /health via curl, donc l'application doit en exposer une. Un contrôleur minimal qui renvoie un 200 sans toucher à la base de données garde la vérification rapide et évite de marquer le conteneur comme non sain lors d'un incident transitoire de base de données. Quand la disponibilité de la base compte vraiment, une seconde sonde plus profonde peut exécuter une requête légère, mais l'endpoint de liveness reste trivial.

src/Controller/HealthController.phpphp
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class HealthController extends AbstractController
{
    #[Route('/health', name: 'health', methods: ['GET'])]
    public function __invoke(): JsonResponse
    {
        return new JsonResponse(['status' => 'ok']);
    }
}

Les migrations de base de données devraient s'exécuter comme une étape distincte avant que les nouveaux conteneurs ne prennent le trafic, et non à l'intérieur du boot de l'application. Les lancer dans un conteneur éphémère garantit que le schéma est à jour exactement une fois par release, même quand plusieurs répliques de l'application démarrent en parallèle.

bash
# deploy.sh
set -euo pipefail

docker compose -f compose.prod.yaml pull

# Apply migrations once, in an isolated container
docker compose -f compose.prod.yaml run --rm php \
  bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration

# Start replicas and wait for the health check to pass
docker compose -f compose.prod.yaml up -d --wait

Le flag --wait bloque jusqu'à ce que chaque service se déclare sain, ce qui donne au script de déploiement un vrai signal de succès plutôt qu'un démarrage lancé à l'aveugle. Combiner cela à une mise à jour progressive dans un orchestrateur comme Kubernetes ou Docker Swarm produit des releases sans interruption : les anciens conteneurs continuent de servir jusqu'à ce que les nouveaux passent leur health check. Pour une vue d'ensemble des versions du framework que vise ce workflow, la présentation de la plateforme Symfony et le guide des nouveautés de Symfony 8 et PHP 8.4 détaillent ce qui a changé dans la ligne de version actuelle.

Conclusion

  • Utilisez un Dockerfile multi-étapes pour que les images de développement et de production partagent une base unique, pendant que la cible de production se livre sans les dépendances dev de Composer
  • Copiez composer.json et les fichiers de lock avant le code source pour garder la couche d'installation des dépendances en cache d'une modification de code à l'autre
  • Exécutez FrankenPHP en mode worker pour réutiliser le kernel démarré d'une requête à l'autre, et réinitialisez les services stateful avec ResetInterface afin de prévenir les fuites d'état
  • Préchauffez le cache Symfony et compilez .env avec dump-env prod au moment du build, pour que le conteneur en fonctionnement démarre entièrement initialisé
  • Activez le preloading OPcache et réglez validate_timestamps=0 en production, ce qui est sûr précisément parce que l'image est immuable
  • Gardez APP_SECRET, les identifiants de base de données et la clé de déchiffrement du coffre hors des couches d'image ; injectez-les comme variables d'environnement au démarrage du conteneur
  • Appliquez les migrations Doctrine dans un conteneur éphémère avant que les nouvelles répliques ne prennent le trafic, et conditionnez le déploiement au health check avec up -d --wait

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#symfony
#docker
#frankenphp
#php
#deployment
#devops

Partager

Articles similaires