My dev environment for php projects

September 30, 2024
About 6 min

Project development requires not only programming skills but also the ability to set up an effective working environment. In this article, I'll share my experience in creating a dev environment for PHP projects using Docker. I'll explain how to organize the file structure, configure Dockerfile and docker-compose.yml, and share useful tips on optimization and security. Whether you're working on a pet project or preparing for team development, these practices will help you create a reliable and flexible development environment.

Dev environment with Docker (generated by ChatGPT)

This environment is based on my favorite and most frequently used tools:

Unfortunately, I haven't yet formalized my familiar development environment into a template that could be used with the composer create project command. But it's not very convenient anyway, as the template changes and evolves from project to project. I take the version from the previous project for each new one. However, the basic principles haven't changed for a long time, and today I'll show you primarily these.

Dockerfile and state preservation

First, in the root of each project, I have a .docker folder where I put all project-specific things:

  • Dockerfile for PHP container images and others
  • Configs that are embedded inside container images or connected as volumes. For example, the main php.ini is added inside the image, while xDebug enablement is connected as a volume only in the local development environment
  • Container state. I always save the DB state to disk (it's impossible not to do so in production), and in the dev environment - also the composer cache to make its commands run faster. Here's an example structure that I commit to the repository, from my latest project:
.docker
├──data
│  ├──composer
│     └──.gitignore
│  └──postgres
│     └──.gitignore
└──php
   ├──Dockerfile
   ├──php.ini
   └──xdebug.ini
  • The data folder is for saving state from containers between restarts. Inside each subfolder is a .gitignore file with two lines: * to ignore everything in this folder and !.gitignore to commit this file itself.
  • The php folder contains Dockerfile and configs for the PHP container
  • If I need to build any other containers - I'll create another folder next to it, and put Dockerfile and all necessary configs in it as well.

And here's the starting version of my Dockerfile:

FROM php:8.3-cli-bullseye

RUN apt update \
    && apt install -y  \
        libicu-dev \
        linux-headers-generic \
        zip \
        libpq-dev \
    && apt install -y $PHPIZE_DEPS \
    && docker-php-ext-configure intl \
    && docker-php-ext-install -j$(nproc) \
        pdo \
        pdo_pgsql \
        pgsql \
        pcntl \
        sockets \
        intl \
        opcache \
    && pecl install xdebug \
    && docker-php-ext-enable intl \
    && pecl clear-cache \
    && apt purge -y $PHPIZE_DEPS \
    && apt clean

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

ADD .docker/php/php.ini /usr/local/etc/php/conf.d/40-custom.ini

WORKDIR /var/www

ARG USER_ID=1000
ARG GROUP_ID=1000

RUN groupadd -g $GROUP_ID appuser && \
    useradd -m -N -g $GROUP_ID -u $USER_ID appuser && \
    usermod -L appuser && \
    chown appuser:appuser .

USER appuser

COPY --chown=appuser:appuser ./composer.* configuration.php ./

RUN composer install \
        --prefer-dist \
        --no-ansi \
        --no-dev \
        --no-interaction \
        --no-plugins \
        --no-progress \
        --no-scripts \
    && vendor/bin/rr get --no-config -q -n

COPY --chown=appuser:appuser ./ ./
RUN composer du --classmap-authoritative

CMD ["./rr", "serve"]

The following happens in it:

  1. Install all necessary system packages using apt, docker-php-ext, and pecl, enable PHP extensions.
    1. Note: I install xDebug, but do not enable it. I'll enable and configure it with a separate config file a bit later. I do this so that it doesn't work in production under any circumstances, including carelessness.
  2. Install composer
  3. Add the PHP config for production
  4. Create a user under which the application will run. This is important because files will be created and modified inside the container (composer packages, logs, results of changes from phpcsfixer, etc.), and we need the user from the host system (ourselves) to be able to read and edit these files. We accept the user and group id values as arguments so that we can set them differently for different systems.
  5. Separately add the composer.json, composer.lock, and configuration.php files (config for yiisoft/config) to the container, and install dependencies using composer install. And immediately download the RoadRunner binary. We do this step before copying the project files, as Docker will cache the result and won't repeat these actions until the two source files change, i.e., as long as the list of dependencies and their versions remains unchanged.
  6. Add all project files
  7. Execute composer du --classmap-authoritative. This step serves two purposes:
    1. Obvious: after adding project files, we optimize the autoloader's speedopen in new window.
    2. Non-obvious: the merge map for yiisoft/configopen in new window is generated by the dump-autoload command hook.

If you have any questions about its contents - I'm always happy to answer them in my Telegram channelopen in new window.

Docker compose config

Secondly, there's always a docker-compose.yml file in the project repository. Yes, I should rename it to compose.yaml under the new standard, but I keep forgetting 😅 Here's its approximate must-have content:

version: "3.8"  
  
services:  
  php:  
    depends_on:  
      db:  
        condition: service_healthy
    build:  
      dockerfile: .docker/php/Dockerfile  
      context: ./  
      args:  
        USER_ID: ${USER_ID:-1000}  
        GROUP_ID: ${GROUP_ID:-1000}  
    command: "./rr serve -c .rr.dev.yaml"  
    user: "${USER_ID:-1000}:${GROUP_ID:-1000}"  
    restart: unless-stopped
    ports:
      - ${WEB_PORT:-80}:80
    volumes:
      - ./.docker/php/php.ini:/usr/local/etc/php/conf.d/40-custom.ini:ro
      - ./.docker/php/xdebug.ini:/usr/local/etc/php/conf.d/99-xdebug.ini:ro
      - ./.docker/data/composer:/home/appuser/.composer
      - ./:/var/www
    environment:
      TZ: Asia/Almaty
      PHP_IDE_CONFIG: ${PHP_IDE_CONFIG:-}
      DB_NAME: ${DB_NAME}
      DB_HOST: ${DB_HOST}
      DB_PORT: ${DB_PORT}
      DB_LOGIN: ${DB_LOGIN}
      DB_PASSWORD: ${DB_PASSWORD}
      XDEBUG_MODE: ${XDEBUG_MODE:-off}
      XDEBUG_CONFIG: "client_host=host.docker.internal"
      XDEBUG_TRIGGER: "yes"
  
  db:
    image: postgres:15-alpine
    restart: unless-stopped
    volumes:
      - .docker/data/postgres/db:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD:-dev}
      POSTGRES_USER: ${DB_USER:-dev}
      POSTGRES_DB: ${DB_NAME:-dev}
    ports:
      - "${DB_PORT:-5432}:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-dev}"]
      interval: 3s
      timeout: 3s
      retries: 10

From this example, you can see that I actively use environment variables. Their values are set in the .env file, which is added to .gitignore. Instead of it, I commit .env.example to the repository, which contains all possible environment variables with empty or fake values. Thanks to this approach, the application depends on environment variables, not on the ways to add them. And here's an example of my .env.example:

##################################
# Port forwarding
##################################
WEB_PORT=80

##################################
# xDebug settings
##################################
# Uncomment next line for xDebug usage with PhpStorm
# PHP_IDE_CONFIG=serverName=docker
# XDEBUG_MODE=develop,debug

# LINUX ZONE. Next configs are for xDebug in linux environment only.
# COMPOSE_FILE=docker-compose.yml:docker-compose.linux.yml
# Uncomment HOST_IP and set host ip in the docker network when using linux, look at docker-compose.linux.yml
# Although it usually is 172.17.0.1, your real value can be easily found with command `ip address | grep docker`,
# you'll see it like "inet 172.17.0.1/16 ..."
# HOST_IP=172.17.0.1

##################################
# Database settings
##################################
DB_HOST=db
DB_PORT=5432
DB_NAME=dev
DB_LOGIN=dev
DB_PASSWORD=dev

If you don't know why certain xDebug settings are needed - I recommend reading my example of configuring xDebug in Docker.

Peculiarities of development under Linux

If you're developing on Linux and not using Docker Desktop (which is exactly what I do), then the host.docker.internal host is not available inside containers. And to be able to use xDebug (it needs to connect to the IDE from the container independently), I always add this block to docker-compose.yml in the service with PHP:

extra_hosts:
  - host.docker.internal:${HOST_IP:-172.17.0.1}

And when I plan to work on a project not alone, I extract this block into a neighboring file, which I usually call docker-compose.linux.yml, while everything else remains in the main file. And to avoid writing the list of connected configs each time in every command (for example, docker compose -f docker-compose.yml -f docker-compose.linux.yml run --rm php php -v), I also add this environment variable to .env:

COMPOSE_FILE=docker-compose.yml:docker-compose.linux.yml

Launch

First of all, of course, we add composer.json and other project files. And the actual launch of such an environment is simple to the point of banality:

docker compose up -d

During the first launch, the container will first build. This can take more than 5 minutes depending on the machine's power and the quality of the internet connection. And when it finally starts - everything is ready for work and debugging.

Tips

A small advice: to be sure that the project will start in production, add/update php dependencies also through this environment, not with locally installed composer:

docker composer run --rm --no-deps php composer require foo/bar

If some package has a dependency on the environment (for example, the PHP pcntl extension is needed, which you decided not to install in your Dockerfile), you'll immediately see what the problem is. This will also help install exactly those versions of packages that are compatible with your environment, if such are available.

Conclusion

Creating an effective dev environment is not just about setting up tools, it's an evolutionary process. My approach, which I described in this article, has formed over years of working on various projects, from pet projects to large team developments.

The key idea I would like to emphasize is the balance between standardization and flexibility. On one hand, we strive for a reproducible environment that's easy to deploy on any machine. On the other hand, we need the ability to quickly adapt the configuration to the specifics of a particular project.

The use of Docker, a carefully thought-out structure of the .docker directory, flexible settings through environment variables - all this allows us to achieve this balance. We get a stable environment that is easy to customize at the same time.

I paid special attention to performance optimization (data caching, proper layer construction in Dockerfile, autoloader optimization) and convenience. These aspects are often overlooked, especially in the early stages of a project, but they are critically important for long-term success.

I hope that my experience and approach described in this article will help you not only set up environments for your projects faster but also rethink the process of organizing development itself. Remember that each project is unique, and don't be afraid to experiment and adapt these practices to your needs.

Today, I've covered the basics without delving too deep. Many topics have been touched upon here, each of which could be explored in great depth. Let me know what you find interesting, give likes on social media, and I'll publish more content on such topics. You can reach out to me on Redditopen in new window or in Xopen in new window. Let's make the development process more efficient and enjoyable together!