◀ Back to blog
PHP / Symfony

Symfony development with Docker

Published on 22 Oct 2024· 7 min read
#Symfony#Docker#Développement

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/html mounts the source code from your machine into the php and nginx containers: every change is visible immediately, without rebuilding the image.
  • The named volume mysql-data keeps the database data between two docker compose down runs. Only docker compose down -v removes it.
  • The network is created automatically: each service is reachable by its name (mysql, redis, rabbitmq). From Symfony, you therefore never use localhost to reach the database.
  • Port 15672 exposes the RabbitMQ management UI at http://localhost:15672 (default credentials guest / 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-data inside the container, while your files belong to your own user. If var/cache or var/log are not writable, align the container user's UID with yours (a UID build argument and usermod) rather than running chmod 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 install inside the container, otherwise dependencies are resolved for your laptop's PHP version, not the image's.
  • Plain-text passwords: root / root is 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-dev and 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

  1. Write the compose.yaml, the PHP Dockerfile and the Nginx configuration.
  2. Add a healthcheck on MySQL and make PHP's startup depend on it.
  3. Turn Xdebug off by default and enable it on demand.
  4. Set the DSNs in .env.local using the service names.
  5. Document the commands in a Makefile and in the README.
  6. Check that a git clone followed by make up is 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.