My dev environment for php projects
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.
This environment is based on my favorite and most frequently used tools:
- Docker for containerization
- Docker compose for container management
- Latest version of PHP
- Composer for PHP dependency management
- xDebug for application debugging
- RoadRunner for running the application in Long-Running mode
- PHP framework Yii3
- PostgreSQL database, as it supports keep-alive connections, unlike some others.
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 containsDockerfile
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 /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 ./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 ./ ./
RUN composer du --classmap-authoritative
CMD ["./rr", "serve"]
The following happens in it:
- Install all necessary system packages using
apt
,docker-php-ext
, andpecl
, enable PHP extensions.- 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.
- Install
composer
- Add the PHP config for production
- 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.
- Separately add the
composer.json
,composer.lock
, andconfiguration.php
files (config foryiisoft/config
) to the container, and install dependencies usingcomposer 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. - Add all project files
- Execute
composer du --classmap-authoritative
. This step serves two purposes:- Obvious: after adding project files, we optimize the autoloader's speed.
- Non-obvious: the merge map for yiisoft/config 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 channel.
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 Reddit or in X. Let's make the development process more efficient and enjoyable together!