# Symfony en Docker in 2026: ontwikkelomgeving en productiedeployment > Een complete Symfony Docker-workflow voor 2026: een FrankenPHP-ontwikkelomgeving, een multi-stage productie-image, worker-modus, versleutelde secrets, imageoptimalisatie en deployments zonder downtime met migraties. - Published: 2026-06-25 - Updated: 2026-07-06 - Author: SharpSkill - Tags: symfony, docker, frankenphp, php, deployment, devops - Reading time: 10 min --- Deze Symfony Docker-tutorial bouwt een complete containerworkflow op: een reproduceerbare lokale ontwikkelomgeving en een gehardende productie-image, beide gecentreerd rond FrankenPHP en Symfony 7.4 LTS. Symfony in containers draaien schrapt de klassieke kloof van environment-drift tussen laptops en servers, en maakt van elke deployment het uitleveren van één immutable artefact in plaats van het uitvoeren van een broze reeks handmatige servercommando's. > **De Symfony Docker-stack van 2026** > > De officiële Symfony Docker-template levert nu [FrankenPHP](https://frankenphp.dev/) als runtime, ter vervanging van het traditionele duo Nginx plus PHP-FPM. Eén binary serveert HTTP/2 en HTTP/3, en draait Symfony in persistente worker-modus door de kernel geboot te houden tussen requests in plaats van hem bij elke aanroep opnieuw op te bouwen. ## Docker Compose-ontwikkelomgeving voor Symfony Een goede ontwikkelopzet geeft elke bijdrager dezelfde PHP-versie, dezelfde extensies en dezelfde database zonder de hostmachine aan te raken. Docker Compose beschrijft die stack declaratief. Het onderstaande voorbeeld koppelt een FrankenPHP-container, gebouwd uit een lokale `Dockerfile`, aan een PostgreSQL 17-service, met elkaar verbonden op het standaard Compose-netwerk. ```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: ``` De bind mount op de `php`-service koppelt de projectroot in de container, zodat wijzigingen in bestanden meteen doorwerken zonder de image opnieuw te bouwen. FrankenPHP genereert bij de eerste boot een lokaal TLS-certificaat, en daarom staat poort 443 open en is `caddy_data` een named volume: het certificaat overleeft een `docker compose down`. De hint `serverVersion=17` in de DSN laat Doctrine bij elke verbinding een versiedetectiequery overslaan. Zodra de stack draait, wordt elk Symfony-commando binnen de `php`-container uitgevoerd in plaats van op de host, wat de juiste PHP-versie en extensies garandeert. Een database aanmaken, migraties draaien of een shell openen volgt telkens hetzelfde patroon via `docker compose exec`. > **Consolecommando's draaien in de container** > > Laat Symfony- en Composer-aanroepen voorafgaan door `docker compose exec php` om ze tegen de PHP-runtime van de container uit te voeren, bijvoorbeeld `docker compose exec php bin/console make:entity` of `docker compose exec php composer require symfony/uid`. Op de host hoeft nooit PHP geïnstalleerd te zijn. ## Multi-stage Dockerfile voor Symfony-productie Een [multi-stage Dockerfile](https://docs.docker.com/build/building/multi-stage/) deelt een gemeenschappelijke basis en splitst zich vervolgens in een development-target en een production-target. De productiefase installeert alleen runtime-afhankelijkheden, laat de dev-packages van Composer vallen en warmt de cache op tijdens de build, zodat de draaiende container direct opstart. ```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 ``` Het kopiëren van `composer.json` en de lockbestanden vóór de rest van de broncode is de sleuteloptimalisatie: Docker cachet de laag met de dependency-installatie en draait die alleen opnieuw wanneer de manifesten veranderen, niet bij elke codewijziging. De vlag `--classmap-authoritative` vertelt Composer dat de classmap compleet is, waardoor de autoloader tijdens runtime nooit terugvalt op filesystem-lookups. De cache tijdens de build opwarmen betekent dat de eerste productierequest een volledig gecompileerde container raakt. ## FrankenPHP worker-modus en de Symfony Runtime Het klassieke PHP-FPM boot de Symfony-kernel, verwerkt één request en gooit alles weg. Worker-modus houdt de kernel en de dependency-injectioncontainer levend over requests heen, en daar komt het grootste deel van de latentiewinst vandaan. Het package `runtime/frankenphp-symfony` maakt dit mogelijk via de Symfony Runtime-component en de standaard `public/index.php`-bootstrap. ```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']); }; ``` De regel `FRANKENPHP_CONFIG="worker ./public/index.php"` uit de Dockerfile activeert deze lus. Omdat de container hergebruikt wordt, moet elke service die requestspecifieke state vasthoudt zichzelf tussen requests resetten. Symfony regelt frameworkservices automatisch, maar eigen stateful services zouden `Symfony\Contracts\Service\ResetInterface` moeten implementeren en met `kernel.reset` getagd moeten worden, zodat opgebouwde state niet naar de volgende request lekt. Dit is dezelfde discipline die langlopende [Messenger-workers](/blog/symfony/symfony-messenger-queues-workers-async-architecture) vereisen, en de beloning is een requestdoorvoer die meerdere malen hoger ligt dan PHP-FPM op dezelfde hardware. Voor lokale ontwikkeling herstart FrankenPHP met de vlag `--watch` de worker automatisch zodra bestanden veranderen, zodat de worker-modus een edit-refresh-lus niet in de weg zit. ## FrankenPHP versus PHP-FPM in Symfony De keuze tussen FrankenPHP en de klassieke stack Nginx plus PHP-FPM bepaalt zowel de Dockerfile als het runtimegedrag. De onderstaande tabel vat de praktische verschillen voor een Symfony-productieopzet samen. | Aspect | FrankenPHP worker-modus | Nginx + PHP-FPM | |--------|------------------------|-----------------| | Containers | Eén | Twee (webserver + PHP) | | Kernel-boot | Eén keer per worker | Eén keer per request | | HTTP/3 | Ingebouwd | Vereist extra configuratie | | TLS | Automatisch (Caddy) | Handmatige certificaatopzet | | State-afhandeling | Moet resetten tussen requests | Geïsoleerd per request | | Kosten koude start | Eén keer betaald bij boot | Betaald bij elke request | Worker-modus wint op doorvoer omdat hij de dure stap van kernel- en containercompilatie over duizenden requests uitsmeert. PHP-FPM blijft relevant wanneer een applicatie leunt op globals of externe libraries die uitgaan van een verse process per request, want die breken onder een hergebruikte worker. > **State-lekken in worker-modus** > > Een service die requestdata in een private property cachet, zal de data van de ene request aan de volgende serveren tenzij hij reset. Controleer singletons, event subscribers en alles wat een security token of de huidige request vasthoudt voordat je in productie overstapt op worker-modus. ## Omgevingsvariabelen en secrets beheren in productie Symfony leest configuratie uit omgevingsvariabelen, en er zijn twee degelijke manieren om ze in productie aan te leveren. De eerste is het injecteren van kale variabelen via de orchestrator of het Compose-bestand. De tweede is Symfony's versleutelde secrets-kluis, die gevoelige waarden als ciphertext in de repository opslaat en ze tijdens runtime ontsleutelt met één enkele private sleutel. ```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)" ``` De stap `composer dump-env prod` in de Dockerfile compileert de `.env`-bestanden tot één geoptimaliseerd `.env.local.php`, waarmee de runtimekost van het parsen van dotenv-bestanden bij boot verdwijnt. Elke variabele die in de echte containeromgeving is gezet, overschrijft nog steeds de gecompileerde defaults, zodat door de orchestrator aangeleverde secrets altijd winnen. De [Symfony deployment-gids](https://symfony.com/doc/current/deployment.html) documenteert de volledige precedentievolgorde, maar de praktische regel is eenvoudig: bak nooit `APP_SECRET` of databasegegevens in de image en injecteer ze bij het starten van de container. ## De Symfony-productie-image optimaliseren Twee OPcache-instellingen zijn goed voor het grootste deel van de prestatiekloof in productie: het uitschakelen van timestampvalidatie en het inschakelen van preloading. Omdat een container-image immutable is, verandert de broncode tijdens runtime nooit, dus OPcache zou nooit bestanden mogen stat'en om op wijzigingen te controleren. Preloading gaat verder door de Symfony- en applicatieklassen bij het opstarten van de server één keer in gedeeld geheugen te laden. ```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 genereert het hierboven genoemde bestand `preload.php` tijdens `cache:warmup`, zodat het bestand na de build al in de image aanwezig is. [OPcache preloading](https://www.php.net/manual/en/opcache.preloading.php) linkt deze klassen bij het opstarten en slaat de compilatiestap over bij de eerste request die ze nodig heeft. `validate_timestamps=0` zetten is alleen veilig voor immutable deployments waarbij een nieuwe release een nieuwe image betekent; op een mutable host zou het verouderde code serveren. `max_accelerated_files` boven het werkelijke aantal bestanden instellen houdt het volledige framework gecached zonder eviction. ## De Symfony-imagegrootte terugbrengen met .dockerignore Een ontbrekend `.dockerignore`-bestand blaast de build stilletjes op. Zonder dat bestand stuurt de stap `COPY . .` de lokale `vendor`-directory, de `var/cache` uit development, node-buildoutput en de `.git`-historie rechtstreeks de image en de buildcontext in. Ze uitsluiten verkleint de image, versnelt de upload van de buildcontext naar de daemon en voorkomt dat development-artefacten de zojuist geïnstalleerde productieafhankelijkheden overschrijven. ```gitignore # .dockerignore /.git/ /vendor/ /node_modules/ /var/ /.env.local /.env.*.local /tests/ /docker/ compose*.yaml Dockerfile ``` `/vendor/` uitsluiten is het belangrijkst: de productiefase draait `composer install --no-dev`, dus het meesturen van de development-gerichte vendor-directory van de host zou die stap volledig tenietdoen. `/var/` uitsluiten houdt de development-cache en -logs uit de image, waardoor de `cache:warmup` tijdens de build een schone productiecache kan produceren. Gecombineerd met de multi-stage build en Alpine-gebaseerde extensies landt een slanke Symfony-image doorgaans ruim onder de 150 MB. ## Symfony-containers deployen en migraties draaien Het productie-Compose-bestand verwijst naar een vooraf gebouwde image uit een registry in plaats van lokaal te bouwen, en definieert een health check zodat de orchestrator alleen verkeer stuurt naar een container die antwoordt. Secrets komen binnen als omgevingsvariabelen, nooit als imagelagen. ```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 ``` De health check curl't een `/health`-route, dus de applicatie moet er een blootstellen. Een minimale controller die een 200 teruggeeft zonder de database aan te raken, houdt de check snel en vermijdt dat de container als unhealthy wordt gemarkeerd tijdens een tijdelijke databasehapering. Wanneer databasegereedheid ertoe doet, kan een tweede, diepere probe een lichtgewicht query draaien, maar het liveness-endpoint blijft triviaal. ```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']); } } ``` Databasemigraties zouden als een aparte stap moeten draaien vóórdat de nieuwe containers verkeer krijgen, niet binnen de applicatieboot. Ze in een eenmalige container draaien garandeert dat het schema precies één keer per release actueel is, zelfs wanneer meerdere applicatiereplica's parallel starten. ```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 ``` De vlag `--wait` blokkeert totdat elke service healthy meldt, waardoor het deployscript een echt successignaal krijgt in plaats van een fire-and-forget-start. Dit koppelen aan een rolling update in een orchestrator als Kubernetes of Docker Swarm levert releases zonder downtime op: oude containers blijven serveren totdat de nieuwe hun health check doorstaan. Voor een breder beeld van de frameworkversies waarop deze workflow mikt, behandelen het [Symfony platformoverzicht](/technologies/symfony) en de [feature-gids voor Symfony 8 en PHP 8.4](/blog/symfony/symfony-8-new-features-php-84-lazy-objects) wat er in de huidige releaselijn is veranderd. ## Conclusie - Gebruik een multi-stage Dockerfile zodat de development- en productie-images één basis delen terwijl het productie-target uitlevert zonder de dev-afhankelijkheden van Composer - Kopieer `composer.json` en de lockbestanden vóór de broncode om de laag met de dependency-installatie gecached te houden over codewijzigingen heen - Draai FrankenPHP in worker-modus om de geboote kernel over requests te hergebruiken, en reset stateful services met `ResetInterface` om state-lekken te voorkomen - Warm de Symfony-cache op en compileer `.env` met `dump-env prod` tijdens de build, zodat de draaiende container volledig geïnitialiseerd start - Schakel OPcache preloading in en zet `validate_timestamps=0` in productie, wat veilig is juist omdat de image immutable is - Houd `APP_SECRET`, databasegegevens en de ontsleutelsleutel van de kluis uit de imagelagen; injecteer ze als omgevingsvariabelen bij het starten van de container - Pas Doctrine-migraties toe in een eenmalige container vóórdat de nieuwe replica's verkeer krijgen, en gate de uitrol op de health check met `up -d --wait` --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/symfony/symfony-docker-development-production