Building a simple PHP dockerized environment

Unlike other ecosystems like .NET or Java in which containerizing an application for local development might seem more like a burden than a feature when it comes to PHP this is a necessity as well as a welcome bonus of mirroring the production environment very closely.

Some applications might require a PHP extension that is not present on the local system or there might be some specific php.ini configuration that is set on the production server, which is considered as part of the infrastructure rather than the application itself but can cause problems because of the inconsistency (I had this happen to me several years ago). Using Docker can solve these types of problems and has also the added benefit of being a really good way to develop applications, an argument for this being the good support for remote debugging in IDEs.

Overview

I will go through setting up the entire stack, that is PHP 8.1 with fpm, nginx and, mysql as the database and at the end, we will do basic Symfony installation to test everything together.

If you don't just want to check out the repo with the entire setup you can find it on Github

Setting up docker-compose configuration

There are two files that tell docker-compose what to spin up. The first is docker-compose.yml and the other is docker-compose.override.yml. The override file is responsible for exposing the host using the hostname dockerhost. This is useful for both debugging and for referencing other containers more easily.

version: '3.5'

services:
  devbox:
    container_name: devbox-nginx
    build:
      context: ./docker/nginx
      dockerfile: Dockerfile
    ports:
      - "9001:80"
    volumes:
      - .:/app:cached
    restart: unless-stopped
    depends_on:
      - devbox-service

  devbox-service:
    container_name: devbox-service
    build:
      context: .
    volumes:
      - .:/app:cached
      - ./docker/service/php.ini:/usr/local/etc/php/conf.d/99-app.ini
      - ./docker/service/www.conf:/usr/local/etc/php-fpm.d/www.conf
    restart: unless-stopped
    environment:
      XDEBUG_CONFIG: ${XDEBUG_CONFIG}
      APP_ENV: ${APP_ENV}
      APP_DEBUG: ${APP_DEBUG}
      APP_SECRET: ${APP_SECRET}
    env_file:
      - .env
      - .env.local
    depends_on:
      - mysql

  mysql:
    image: mysql:8.0
    container_name: devbox-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: database
    ports:
      - "3308:3306"
    volumes:
      - database-volume:/var/lib/mysql

volumes:
  database-volume:
    driver: "local"

Adding the Dockerfiles

There are two Dockerfiles used. One for php-fpm and the other for nginx. The one for the app itself is the php-fpm one and it's found in the root of the project and the other one is found in docker/nginx/Dockerfile. The nginx Dockerfile just copies the nginx configuration defined in default.conf into the container. The nginx configuration is quite apart from the fact that the name of the service defined in docker-compose was specified

Dockerfile for nginx

FROM nginx:stable

COPY default.conf /etc/nginx/conf.d/default.conf

Dockefile for php-fpm

FROM php:8.1-fpm-alpine

LABEL maintainer="alexandrunastase@github"
LABEL description="Devbox Docker image"

# User build args
ARG APP_ENV="prod"
ARG APP_DEBUG="0"
ARG APP_LOG="php://stdout"

# Environment variables
ENV APP_ENV=${APP_ENV}
ENV APP_DEBUG=${APP_DEBUG}
ENV APP_LOG=${APP_LOG}

ENV XDEBUG_CONFIG=""
ENV COMPOSER_NO_INTERACTION=1

# Add PHP user
ARG PHP_USER_ID=1000
ARG PHP_GROUP_ID=1000
RUN set -x \
    && addgroup -g $PHP_GROUP_ID -S php \
    && adduser -u $PHP_USER_ID -D -S -G php php

# Install dependencies
RUN set -ex \
    && docker-php-source extract \
    && apk add --update --no-cache \
    ${PHPIZE_DEPS} \
    curl \
    # Runtime deps
    icu-dev icu-libs \
    libzip-dev zlib-dev \
    libxml2-dev \
    oniguruma-dev \
    && pecl install xdebug \
    && docker-php-ext-install intl opcache pdo_mysql zip bcmath mbstring sockets pcntl soap sockets ctype > /dev/null \
    && docker-php-ext-enable intl opcache pdo_mysql zip bcmath mbstring sockets pcntl soap sockets ctype \
    && apk del ${PHPIZE_DEPS} \
    && docker-php-source delete

# Copy configuration files
COPY ./docker/service/www.conf /usr/local/etc/php-fpm.d/www.conf
COPY ./docker/service/php.ini $PHP_INI_DIR/conf.d/99-app.ini
COPY ./docker/service/xdebug.ini $PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini

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

COPY --chown=php . /app

WORKDIR /app

USER php

Creating the Makefile

I find a Makefile a useful addition to any docker-compose setup, as it makes it much easier to access the common commands without needing to remember how a container was named or needed to search to the shell history.

Note: When editing Makefiles make sure to always use tabs instead of spaces especially when indenting commands

.PHONY: run
run:
	@if [ ! -e ".env.local" ]; then\
		cp .env .env.local; \
	fi
	@docker-compose up -d
	@echo "Service is running on http://localhost:9001"

.PHONY: install
install:
	@docker-compose exec --user="php" -T devbox-service composer install

.PHONY: stop
stop:
	@docker-compose stop

.PHONY: enter
enter:
	@docker-compose exec --user="php" devbox-service /bin/sh

.PHONY: enter-as-root
enter-as-root:
	@docker-compose exec --user="root" devbox-service /bin/sh

.PHONY: test
test:
	@docker-compose exec --user="php" -T devbox-service /bin/sh -c 'APP_ENV="test" ./bin/phpunit --testdox'

.PHONY: destroy
destroy:
	@docker-compose down --rmi local

Adding Symfony and testing everything

To test the entire setup we can setup a Symfony application. You can find instructions to do so here: https://symfony.com/doc/current/setup.html . I went with the LTS version which is 5.4 at the time of the writing.

Note: I also updated the composer file to make sure the database is created. You can skip this if you have another way to make that happen.

Setting up xDebug

Debugging can be enabled by uncommenting the contents of the file ./docker/service/xdebug.ini

These are the steps to configure xDebug on PHPStorm:

  1. Choose PHP Remote Debugging as CLI interpreter. Make sure local interpreter is removed
  2. Choose Docker Compose as the configuration type and devbox-service as the service
  3. Lifecycle should be Connect to existing container

Working demo

In the docker-compose the port 9001 is mapped for the localhost so you can check everything is working after running:

 make run

to setup the containers

 make install

to install all the composer packages.

There is one endpoint defined called http://localhost:9001/healthz which should return a 200 status code

In order to run the tests, you can run

 make test

and for running other ad-hoc commands like requiring another composer package you can do

 make enter

Tested using

  • Ubuntu 21.10
  • docker version : 20.10.14
  • docker-compose version : 1.29.1