◀ Back to blog
Docker

A PHP development environment with Docker

Published on 12 Mar 2024· 7 min read
#Docker#PHP#Développement

An identical dev environment for the whole team

No more "it works on my machine". Docker guarantees that every developer works in an identical environment, eliminating compatibility issues.

Without containers, every workstation ends up with its own PHP version, its own extensions, a MySQL version different from production and php.ini settings inherited from old projects. The bugs that result are the most expensive to diagnose, because they cannot be reproduced anywhere else. With Docker, the environment is described in files versioned alongside the code: upgrading PHP becomes a change reviewed in code review, and every team member picks it up with a simple git pull.

This article builds a complete PHP development stack: PHP with Xdebug, MySQL and a test mail server, then covers debugging, volume performance on macOS and Windows, and the classic pitfalls.

Complete development stack

services:
  php:
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - .:/var/www/html
      - composer-cache:/root/.composer
    environment:
      - APP_ENV=dev
      - XDEBUG_MODE=debug

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: app
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

volumes:
  composer-cache:
  mysql-data:

Let's go through the choices in this file:

  • The code is mounted (.:/var/www/html): every change made in the IDE is immediately visible in the container, without rebuilding the image.
  • The Composer cache lives in a named volume: downloaded packages are kept even when the container is recreated.
  • MySQL publishes port 3306 so that a GUI client on the workstation can connect to it. Data survives restarts thanks to the mysql-data volume. Use the same major version as in production.
  • MailHog intercepts every email sent by the application and displays it at http://localhost:8025: no risk of writing to real customers from a development machine. In a Symfony project, point MAILER_DSN to smtp://mailhog:1025. Since MailHog is no longer maintained, Mailpit (image axllent/mailpit, same ports) is today an active and compatible alternative.

Plain-text passwords are acceptable here because this environment never leaves the developer's machine.

The development Dockerfile

The Dockerfile.dev file starts from the official PHP image and adds the usual extensions, Composer and Xdebug. It is deliberately kept separate from the production Dockerfile, which must never contain Xdebug:

FROM php:8.3-fpm

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip libicu-dev libzip-dev \
    && docker-php-ext-install intl pdo_mysql zip opcache \
    && pecl install xdebug \
    && docker-php-ext-enable xdebug \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/zz-xdebug.ini

WORKDIR /var/www/html

The COPY --from=composer:2 line pulls the Composer binary from the official image, with no installation script. The Xdebug configuration file is prefixed with zz- so that it is loaded after the ones generated by docker-php-ext-enable.

Xdebug configuration

Debugging is essential in development. Here is the optimal Xdebug configuration:

[xdebug]
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003

Every line matters. xdebug.mode=debug enables step debugging; the XDEBUG_MODE environment variable in the Compose file takes precedence over this value, which lets you switch to XDEBUG_MODE=off without rebuilding the image when you do not need it. start_with_request=yes starts a session on every request; if that slows your application down too much, use trigger instead, with a browser extension that adds the trigger on demand. Port 9003 has been the default since Xdebug 3 (the old 9000 clashed with PHP-FPM).

The host.docker.internal name refers to the host machine, where the IDE runs. Docker Desktop provides it automatically on macOS and Windows; on Linux you have to declare it. For PhpStorm, also add a server name, which is used to find the path mapping, including for CLI commands:

services:
  php:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - PHP_IDE_CONFIG=serverName=docker

In PhpStorm, then create a server named docker and map the project root to /var/www/html. Without this mapping, the IDE does receive the connection but never stops on any breakpoint.

Hot reload and performance

On macOS and Windows, mounted volumes can be slow. Use synchronization strategies:

  • Mutagen for fast file synchronization
  • Exclude vendor/ and node_modules/ from the mount
  • Use named volumes for dependencies

The slowness comes from Docker running inside a virtual machine on these systems: every access to a mounted file crosses the boundary between the host and the VM. Recent versions of Docker Desktop use VirtioFS, which is much faster than before, but a Symfony project reads thousands of files in vendor/ on every request. Putting these directories in named volumes keeps them inside the VM:

services:
  php:
    volumes:
      - .:/var/www/html
      - vendor:/var/www/html/vendor
      - composer-cache:/root/.composer

volumes:
  vendor:
  composer-cache:

The trade-off: the vendor/ directory is no longer visible from the host, and the IDE loses autocompletion for dependencies. Many teams therefore also run a local composer install for the IDE, or use PhpStorm's remote interpreter. Symfony's cache (var/) can be handled the same way. On Linux, mounts are native and fast: these optimizations are not needed.

Everyday commands

All PHP commands run inside the container, never with the host's PHP:

docker compose up -d --build
docker compose exec php composer install
docker compose exec php bin/console doctrine:migrations:migrate --no-interaction
docker compose exec php bin/console cache:clear

# On Linux, so that created files belong to your user
docker compose exec -u "$(id -u):$(id -g)" php composer require symfony/uid

Group these commands in a Makefile or a script documented in the README: a new developer should only have to remember one or two commands.

Common pitfalls

  • Files owned by root on Linux: commands run as root in the container create files that the host user can no longer modify. Run them with your UID, as shown above.
  • Using localhost to reach the database: inside a container, localhost means the container itself. The database host is the service name, here mysql.
  • Leaving Xdebug on all the time: it noticeably slows down every request and every Composer command. Turn it off when you are not debugging.
  • A dev environment too far from production: same PHP version, same extensions, same database engine, otherwise the benefit of Docker disappears.

This approach gives any new developer joining the team a working development environment in under 5 minutes.