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.

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 officiële Symfony Docker-template levert nu FrankenPHP 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.
# 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.
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 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
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 /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:warmupHet 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.
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 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.
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.
Klaar om je Symfony gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
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.
# 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 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.
; 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=600Symfony genereert het hierboven genoemde bestand preload.php tijdens cache:warmup, zodat het bestand na de build al in de image aanwezig is. OPcache preloading 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.
# .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.
# 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-stoppedDe 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.
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.
# 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 --waitDe 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 en de feature-gids voor Symfony 8 en PHP 8.4 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.jsonen 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
ResetInterfaceom state-lekken te voorkomen - Warm de Symfony-cache op en compileer
.envmetdump-env prodtijdens de build, zodat de draaiende container volledig geïnitialiseerd start - Schakel OPcache preloading in en zet
validate_timestamps=0in 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
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Tags
Delen
Gerelateerde artikelen

Doctrine ORM: Relaties beheersen in Symfony
Volledige gids voor Doctrine ORM-relaties in Symfony. OneToMany, ManyToMany, laadstrategieën en performance-optimalisatie met praktische voorbeelden.

Symfony-sollicitatievragen: Top 25 in 2026
De 25 meest gestelde Symfony-sollicitatievragen. Architectuur, Doctrine ORM, services, beveiliging, formulieren en tests met gedetailleerde antwoorden en codevoorbeelden.

Symfony 7: API Platform en Best Practices
Volledige gids voor het bouwen van professionele REST API's met Symfony 7 en API Platform 4. State Providers, Processors, validatie en serialisatie uitgelegd met praktische voorbeelden.