TLDR: If you don’t want to follow the steps, you can find the whole setup here.
Once in a while you need to start a fresh project and the initial setup is always tedious. Because I am lazy and I don’t want to go through that process more than once, I started working on a little project that has a minimal boilerplate for a PHP and MySQL development environment inside Dockers.
The main components are:
- Apache 2.4 web server with PHP 7.4 and Xdebug
- MySQL 8
- Mailcatcher
…of course, you can add as many components as you need.
Prerequisites
- Linux environment(I haven’t tested this configuration on Windows, but it might work).
- Docker: Installation guide
- Docker Compose: Installation guide
- Some experience working with CLI
Why Docker?
I cannot explain better than docker.com what Docker is, so:
Docker is an open platform for developing, shipping, and running applications. Docker enables you to separate your applications from your infrastructure so you can deliver software quickly. With Docker, you can manage your infrastructure in the same ways you manage your applications. By taking advantage of Docker’s methodologies for shipping, testing, and deploying code quickly, you can significantly reduce the delay between writing code and running it in production.
The above explanation is basically saying that Docker is here to make your life easier. Once you have a setup in place, it is very easy to move it to another machine, share it with somebody else, etc.
Why Docker Compose?
Because docker-compose
offer us an easy way of tying the components together.
From docker.com:
Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single command, you create and start all the services from your configuration.
Let’s get started
We will start by defining the Apache image, then continue with docker-compose.yml
definition and finish with some scripts that will help us to use the environment.
First, let’s create the following folder structure in an empty directory
.
└── dev
├── servers
│ └── web
└── vhost
Defining Apache with PHP image
Of course, there is an official PHP-Apache image already build on Dockerhub, but we will need some tools that are not part it.
First, let’s create a Dockerfile at ./dev/servers/web/Dockerfile
. A Dockerfile basically defines how a Docker image needs to be build. If you want to learn more about Dockerfiles, please go to the official documentation.
The first directive that we want to add is the base image that we want to build on top of. I chose PHP 7.4 but you can try with an older version(but not < 7).
FROM php:7.4-apache
Now that we have the base image set up, we will continue to add layers until we have all that we need.
Let’s add some PHP extensions. We will make use of docker-php-ext-install
helper script that exists inside the base image.
RUN set -eux \
&& docker-php-ext-install \
pdo \
pdo_mysql \
tokenizer
Next, we need to install some packages that will be required when using composer
.
RUN set -eux \
&& apt-get update \
&& apt-get install -y \
git \
zip \
unzip \
&& rm -rf /var/lib/apt/lists/*
Because we want to be able to debug the application, we need to install Xdebug.
RUN set -eux \
&& yes | pecl install xdebug \
&& echo "zend_extension=$(find /usr/local/lib/php/extensions/ -name xdebug.so)" > /usr/local/etc/php/conf.d/xdebug.ini
And of course, Composer.
RUN set -eux \
&& php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php composer-setup.php --install-dir=/bin --filename=composer \
&& php -r "unlink('composer-setup.php');"
Now, we will need some sort of a hack in order to keep the correct file permissions. When you will want to edit your files, you will probably do that with your own user(host machine user). Because that user will not exist inside the docker container, we need to create it and assign the same user/group id as your host machine user. Belive me, it will make sense after we finish.
RUN set -eux \
&& usermod -u ${HOST_USER_ID} www-data \
&& groupmod -g ${HOST_GROUP_ID} www-data \
&& chown -R www-data:www-data /var/www
Because user and group ids may vary(in linux, the ids will usually start from 1000), we supply them by some environment variables. Those variables need to be set up as arguments in the Dockerfile. Paste this right before the FROM
directive:
ARG HOST_USER_ID
ARG HOST_GROUP_ID
Finally, we will enable some Apache modules and add an xdebug configuration.
RUN a2enmod rewrite
RUN a2enmod ssl
RUN a2enmod headers
ADD xdebug-extra.ini /usr/local/etc/php/conf.d/xdebug-extra.ini
The Xdebug configuration at ./dev/servers/web/xdebug-extra.ini
.
; Specifies the key which you need to use to be able to start debugging
xdebug.idekey=DEV_DEBUG
; Enables xdebug.
xdebug.remote_enable=on
; Specifies the port on your machine to which xdebug will connect
xdebug.remote_port=9000
xdebug.remote_connect_back=1
xdebug.remote_autostart=1
; Does not truncate output of var_dump
xdebug.var_display_max_data = -1
xdebug.var_display_max_children = -1
xdebug.var_display_max_depth = -1
The Dockerfile setup is finished. Our webserver contains the following components:
- Apache 2.4 with rewrite, ssl and headers modules enabled
- PHP 7.4 with pdo, pdo_mysql and tokenizer modules enabled
- Xdebug
- Composer
- Git
- Zip
Here is the whole file:
FROM php:7.4-apache
ARG HOST_USER_ID
ARG HOST_GROUP_ID
# Install required PHP excensions
RUN set -eux \
&& docker-php-ext-install \
pdo \
pdo_mysql \
tokenizer
# Install required utilities
RUN set -eux \
&& apt-get update \
&& apt-get install -y \
git \
zip \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install xdebug
RUN set -eux \
&& yes | pecl install xdebug \
&& echo "zend_extension=$(find /usr/local/lib/php/extensions/ -name xdebug.so)" > /usr/local/etc/php/conf.d/xdebug.ini
# Install composer
RUN set -eux \
&& php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php composer-setup.php --install-dir=/bin --filename=composer \
&& php -r "unlink('composer-setup.php');"
# Create wwww-data with host UID and GID
RUN set -eux \
&& usermod -u ${HOST_USER_ID} www-data \
&& groupmod -g ${HOST_GROUP_ID} www-data \
&& chown -R www-data:www-data /var/www
# Enable Apache modules
RUN a2enmod rewrite
RUN a2enmod ssl
RUN a2enmod headers
ADD xdebug-extra.ini /usr/local/etc/php/conf.d/xdebug-extra.ini
Docker Compose configuration
The docker-compose.yml
will contain 3 services. The web server, the database server and the mailcatcher. We will start by creating ./docker-composer.yml
and seting up the version.
Content of ./docker-compose.yml
:
version: '3'
Then we will create our first service, the webserver
:
services:
webserver:
container_name: webserver
env_file:
- .env
build:
context: dev/servers/web
args:
HOST_USER_ID: ${HOST_USER_ID:-0}
HOST_GROUP_ID: ${HOST_GROUP_ID:-0}
ports:
- '80:80'
volumes:
- ./:/var/www/html
- ./dev/vhost:/etc/apache2/sites-enabled
networks:
app_net:
The webserver is build from dev/servers/web
and will have the HOST_USER_ID
and HOST_GROUP_ID
as arguments(remember that we need the host machine user/group id in order to build the image). As you can see, the user and group id are initialized with the help of some environment variables. In order to have them, you need to create ./.env
file with the following contents:
HOST_USER_ID=1000
HOST_GROUP_ID=1000
Please replace the values with your actual user and group id(can be found by executing id
).
Also, the webserver will expose port 80
to the outside. This will give you the ability to see your application from your host machine on http://localhost
or from another machine on your network.
Next we need to map the current directory to /var/www/html
inside the container. Mapping the entire directory will give you the flexibility of keeping the app wherever you want.
Another volume map will bring the apache configuration inside the container. Right now our vhost folder is empty, so we need to create ./dev/vhost/default.conf
. The vhost configuration is minimal, so you can extend it however you need.
Contents of ./dev/vhost/default.conf
:
<VirtualHost *:80>
DocumentRoot /var/www/html/src/public
# This is the entry point in the app
DirectoryIndex index.php
# And this is the standard lumen config
<Directory /var/www/html/src/public>
Options +FollowSymlinks +Indexes
AllowOverride All
</Directory>
ErrorLog /var/log/apache2/error.log
CustomLog /var/log/apache2/access.log combined
</VirtualHost>
As you can see, the DocumentRoot
is set up as /var/www/html/src/public
. If you are planning to place the app in another location, please change the path.
The next service in our docker-compose.yml
will be the database service. Be careful to add it as a child of services
database:
container_name: database
image: mysql:8
command: --default-authentication-plugin=mysql_native_password
ports:
- 3306:3306
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=dev
- MYSQL_USER=dev
- MYSQL_PASSWORD=dev
volumes:
- database:/var/lib/mysql
networks:
app_net:
Here we do not need to build a database image because we can make use of the official MySQL image.
The only important matter here is to create a database
volume that will keep our data. If we do not do this, we will loose our data when the container is deleted. The database credentials are also using some environment variables that the MySQL image knows how to handle.
The last service will be the Mailcatcher. This server will help us avoiding the mistake of sending emails in the wild. If we setup the mailcather as our mail server, it will catch all emails that are sent and we can view them at http://localhost:1080
.
mailserver:
container_name: mailserver
image: schickling/mailcatcher
ports:
- 1080:1080
networks:
app_net:
The only thing that we need to do is expose port 1080
.
Finally, let’s setup the database volumes used above and the network.
volumes:
database:
driver: local
networks:
app_net:
driver: bridge
The whole docker-compose-yml
content:
version: '3'
services:
webserver:
container_name: webserver
env_file:
- .env
build:
context: dev/servers/web
args:
HOST_USER_ID: ${HOST_USER_ID:-0}
HOST_GROUP_ID: ${HOST_GROUP_ID:-0}
ports:
- '80:80'
volumes:
- ./:/var/www/html
- ./dev/vhost:/etc/apache2/sites-enabled
networks:
app_net:
database:
container_name: database
image: mysql:8
command: --default-authentication-plugin=mysql_native_password
ports:
- 3306:3306
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=dev
- MYSQL_USER=dev
- MYSQL_PASSWORD=dev
volumes:
- database:/var/lib/mysql
networks:
app_net:
mailserver:
container_name: mailserver
image: schickling/mailcatcher
ports:
- 1080:1080
networks:
app_net:
volumes:
database:
driver: local
networks:
app_net:
driver: bridge
We are almost ready to use the developement environment but because we are lazy, we need some helper scripts to do that. The first one is the stat.sh
script.
Create ./dev/start.sh
file with the following contents:
#!/usr/bin/env bash
set -eu
cd "$( dirname "${BASH_SOURCE[0]}" )" && cd ..
./dev/stop.sh
mkdir -p ./src
docker-compose up -d --build
This script will start the services in detached(-d
) mode. The --build
flag is used for building the images each time we start the environment. That is how we make sure that if we make a modification to the webserver Dockerfile, a new image will be built based on it. Also this script is creating a src
directory if it does not exists. This directoy will hold our web app.
Then, ./dev/stop.sh
#!/usr/bin/env bash
set -eu
cd "$( dirname "${BASH_SOURCE[0]}" )" && cd ..
docker-compose down
This script is putting the services down.
The last script is for entering the web container. We will always want to enter the web container as the same user of our host machine because we do not want to mess up with file permissions issues. Now you can take a step back and look at our webserver Dockerfile again. That is why we have made that odd user hack.
The contents of ./dev/enter-web.sh
:
#!/usr/bin/env bash
set -eu
docker exec -it -u "$(id -u):$(id -g)" webserver bash
Now the setup is complete. If you followed along, you should have this folder structure:
.
├── dev
│ ├── enter-web.sh
│ ├── servers
│ │ └── web
│ │ ├── Dockerfile
│ │ └── xdebug-extra.ini
│ ├── start.sh
│ ├── stop.sh
│ └── vhost
│ └── default.conf
└── docker-compose.yml
Using the development environment
The development environment is ready to use. We just need to start our services and create an application. I’ll be creating a Laravel application as an example.
Let’s start the services:
$ ./dev/start.sh
It will take a while for this script to launch the services for the first time. The webserver image must be build and the database image must be pulled from Dockerhub. The next time you use this script, it should take only seconds.
After the script has finished make sure that all services are running by executing docker ps
. The output should look similar to this:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
85939bc75f1b schickling/mailcatcher "mailcatcher --no-qu…" 8 minutes ago Up 8 minutes 1025/tcp, 0.0.0.0:1080->1080/tcp mailserver
12b2491192fd php-dev-env_webserver "docker-php-entrypoi…" 8 minutes ago Up 8 minutes 0.0.0.0:80->80/tcp webserver
debb7026f52a mysql:8 "docker-entrypoint.s…" 8 minutes ago Up 8 minutes 0.0.0.0:3306->3306/tcp, 33060/tcp database
Next let’s create an actual application and see how it works.
$ ./dev/enter-web.sh
$ composer create-project laravel/laravel src
After Laravel is installed we need to take a final step and configure the database and mailserver settings. Go and edit the ./src/.env
.
DB_CONNECTION=mysql
DB_HOST=database
DB_PORT=3306
DB_DATABASE=dev
DB_USERNAME=dev
DB_PASSWORD=dev
MAIL_MAILER=smtp
MAIL_HOST=mailserver
MAIL_PORT=1025 // 1025 is the SMTP port that the Mailcather is listening to
As you can see, we have set up the database host as database
and mailserver host as mailserver
. This works because the containers are in the same network and can talk to each other using container_name
as hostnames.
This is it. Now you have a basic working PHP development environment with MySQL and a mail server.
The whole setup can be found here.
Have fun :)