Symfony and Docker: the perfect pair
Docker and Symfony make a powerful combination for development. At Keytchens, a food-tech platform for real-time order management, this stack made it possible to unify development environments across the whole team.
This guide describes a complete development environment: PHP-FPM, Nginx, MySQL, Redis and RabbitMQ, driven by Docker Compose and a Makefile. The goal is simple: a new developer clones the repository, runs one command and gets a working application, identical to the one their colleagues use.
Why containerize the development environment
Without containers, every machine accumulates its own PHP version, its own extensions, its own MySQL server and its own settings. Those differences always end up producing the famous "works on my machine". Docker solves this by describing the environment as code, versioned alongside the application:
- the PHP version and the list of extensions are pinned in a Dockerfile;
- the MySQL, Redis and RabbitMQ versions match production, not whatever happens to be installed on the laptop;
- several projects with incompatible needs (PHP 7.4 and PHP 8.3, for example) live side by side without conflict;
- an environment change goes through a pull request and gets reviewed like any other code.
Docker Compose for Symfony
The compose.yaml file (or docker-compose.yml) declares all the services:
services:
php:
build: .docker/php
volumes:
- .:/var/www/html
depends_on:
- mysql
- redis
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- .:/var/www/html
- .docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: symfony
volumes:
- mysql-data:/var/lib/mysql
redis:
image: redis:7-alpine
rabbitmq:
image: rabbitmq:3-management-alpine
ports:
- "15672:15672"
volumes:
mysql-data:
A few things worth understanding in this file:
- The bind mount
.:/var/www/htmlmounts the source code from your machine into thephpandnginxcontainers: every change is visible immediately, without rebuilding the image. - The named volume
mysql-datakeeps the database data between twodocker compose downruns. Onlydocker compose down -vremoves it. - The network is created automatically: each service is reachable by its name (
mysql,redis,rabbitmq). From Symfony, you therefore never uselocalhostto reach the database. - Port 15672 exposes the RabbitMQ management UI at
http://localhost:15672(default credentialsguest/guest).
Waiting until the database is actually ready
In its short form, depends_on only guarantees start order, not availability: MySQL may still be initializing when PHP tries to connect. A healthcheck combined with the service_healthy condition fixes that:
services:
php:
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
mysql:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot"]
interval: 5s
timeout: 3s
retries: 10
Nginx configuration
The .docker/nginx/default.conf file follows the configuration recommended by the Symfony documentation. Nginx serves static files from the public/ directory and forwards everything else to PHP-FPM, reachable as php on port 9000:
server {
listen 80;
server_name localhost;
root /var/www/html/public;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass php:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location ~ \.php$ {
return 404;
}
}
The last block returns a 404 for any other PHP file: only the index.php front controller should ever be executable.
Optimized PHP Dockerfile
FROM php:8.3-fpm
RUN apt-get update && apt-get install -y \
libicu-dev libzip-dev \
&& docker-php-ext-install intl pdo_mysql zip opcache \
&& pecl install redis xdebug \
&& docker-php-ext-enable redis xdebug
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
The official php:8.3-fpm image ships the docker-php-ext-install and docker-php-ext-enable scripts. System libraries (libicu-dev for intl, libzip-dev for zip) must be installed before the extensions are compiled. PECL extensions such as redis and xdebug are built with pecl install and then enabled. Finally, COPY --from=composer:2 pulls the Composer binary from its official image, with no installer script.
If you use Messenger's AMQP transport with RabbitMQ, you also need the amqp extension: add librabbitmq-dev to the packages and amqp to the pecl install line.
Xdebug noticeably slows down every request. Turn it off by default and enable it only when you need it, with a .docker/php/xdebug.ini file copied into the image:
xdebug.mode=off
xdebug.client_host=host.docker.internal
xdebug.start_with_request=trigger
The XDEBUG_MODE=debug environment variable, passed to the container, overrides xdebug.mode: you enable debugging without rebuilding the image. On Linux, host.docker.internal does not exist by default: add extra_hosts: ["host.docker.internal:host-gateway"] to the php service.
Connecting Symfony to the services
In .env.local (not committed), the DSNs point to the Compose service names:
DATABASE_URL="mysql://root:root@mysql:3306/symfony?serverVersion=8.0&charset=utf8mb4"
REDIS_URL="redis://redis:6379"
MESSENGER_TRANSPORT_DSN="amqp://guest:guest@rabbitmq:5672/%2f/messages"
Remember to set serverVersion: Doctrine uses it to pick the right SQL platform without querying the server at boot.
A Makefile to simplify commands
Docker commands are long and easy to forget. A Makefile at the project root gives the whole team the same vocabulary:
up:
docker compose up -d
down:
docker compose down
console:
docker compose exec php php bin/console $(cmd)
test:
docker compose exec php php bin/phpunit
migrate:
docker compose exec php php bin/console doctrine:migrations:migrate -n
Usage: make up, then make console cmd="cache:clear" or make migrate. Note that Makefile recipes must be indented with a tab, not spaces. Also add a .PHONY: up down console test migrate line so that make does not confuse these targets with files of the same name.
Common pitfalls
- File permissions: PHP-FPM runs as
www-datainside the container, while your files belong to your own user. Ifvar/cacheorvar/logare not writable, align the container user's UID with yours (aUIDbuild argument andusermod) rather than runningchmod 777. - Slowness on macOS: bind mounts are slower there than on Linux. Enable VirtioFS in Docker Desktop and avoid mounting unnecessary directories.
- Composer outside the container: run
composer installinside the container, otherwise dependencies are resolved for your laptop's PHP version, not the image's. - Plain-text passwords:
root/rootis acceptable locally only. Never reuse this Compose file as-is in production. - Production image: this image ships Xdebug and mounts the code as a volume. For production, build a separate image (multi-stage build) with the code copied in,
composer install --no-devand OPcache configured without timestamp validation.
Day-to-day benefits
- Identical environment for every developer
- Full isolation of services
- Easy onboarding for new developers
- Service versions aligned with production
- Environment changes reviewed and versioned like code
Getting-started checklist
- Write the
compose.yaml, the PHP Dockerfile and the Nginx configuration. - Add a
healthcheckon MySQL and make PHP's startup depend on it. - Turn Xdebug off by default and enable it on demand.
- Set the DSNs in
.env.localusing the service names. - Document the commands in a Makefile and in the README.
- Check that a
git clonefollowed bymake upis enough to start the project on a fresh machine.
With this foundation, the development environment becomes a first-class part of the project: reproducible, documented and easy to evolve.