Dockerizing Your PHP Application

Written by alexandrunastase | Published 2022/05/22
Tech Story Tags: php | docker | symfony | tech | web-development | docker-php-composer | lamp-stack | java

TLDRPHP 8.1 with fpm, nginx and, mysql as the database. Using Docker can solve these types of problems and has the added benefit of mirroring the production environment very closely. Using docker-compose is a good way to develop applications for local development. I will go through setting up the entire stack, that is PHP 8.1 with nginx, fpm and fpm. At the end, we will do basic Symfony installation to test everything together.via the TL;DR App

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 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 at 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 a Makefile

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

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 set up a Symfony application. You can find instructions to do so here. I went with the LTS version which is 5.4 at the time of writing.

I also updated the composer file to make sure the database is created. You can skip this if there's 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 the 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 to the localhost so you can check if everything is working after running:

 make run

To set up the containers:

 make install

to install all the composer packages.

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

In order to run the tests, you can run:

 make test

To run 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


Also published here.


Written by alexandrunastase | Software Engineer & Open Source enthusiast
Published by HackerNoon on 2022/05/22