# 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. - Published: 2026-06-25 - Updated: 2026-07-06 - Author: SharpSkill - Tags: symfony, docker, frankenphp, php, deployment, devops - Reading time: 10 min --- 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](https://frankenphp.dev/) 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](https://docs.docker.com/build/building/multi-stage/) 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`. ```php // public/index.php 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](/blog/symfony/symfony-messenger-queues-workers-async-architecture) 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. ## 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](https://symfony.com/doc/current/deployment.html) 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](https://www.php.net/manual/en/opcache.preloading.php) 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. ```php // src/Controller/HealthController.php 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](/technologies/symfony) et le [guide des nouveautés de Symfony 8 et PHP 8.4](/blog/symfony/symfony-8-new-features-php-84-lazy-objects) 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` --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/symfony/symfony-docker-development-production