Setting up PHP, Docker and PHPUnit

Leia em Português

Here I'll show you some gists on my basic set up for bootstraping php applications.

My biggest goal here, is that you'll bookmark this post so you can come back, copy and paste stuff from here and start your new applications whenever you need specific bits with low effort. 😉

The good thing about following this approach is that you can easily switch between image versions without bootstraping thousands of things at once.

So...

Before we start: Make sure you have docker and docker-compose installed.

The final result

If you follow this tutorial through, you'll be able to execute different services by using docker-compose commands.

You can find the final results in the public repository.

The main idea is that every service may or not become a command. And the pattern would be the following:

$ docker-compose run <command> [--args]

Running a test suite, for example, could look like this:

$ docker-compose run tests

To make typing easier, we can also add and alias to the docker-compose run command. I'll call it dcr here:

$ alias dcr='docker-compose run'
$ dcr lol
ERROR: Can't find a suitable
configuration file in this
directory or any parent.
Are you in the right directory?

Supported filenames:
docker-compose.yml,
docker-compose.yaml

Alias created! It will complain though because there's no compose file there yet. Let's create it then!

A basic compose file

So we're creating a brand-new project, huh? Let's do it! Start by creating the project folder and later on creating our docker-compose.yml file:

$ mkdir my-project
$ cd my-project
$ touch docker-compose.yml

I'll create then the common folders my skeleton usually has. This will include a source folder, a folder for tests and a folder for binaries.

Just run this:

$ mkdir -p src/ tests/ bin/ \
  .conf/nginx/ var/

Now we can start working with our docker-compose.yml file. It will contain all dependencies this project might have.

The initial content in our docker-compose file should be quite simple. Just type in the following:

# docker-compose.yml
version: '3'
services:

We will fill in the services right now! The most basic one we need is, of course, composer.

Adding composer to docker-compose

Probably we're going to use php from inside the container. So it doesn't make much sense to run composer from the local machine, as php version might differ.

Let's then add a composer service to our file:

# docker-compose.yml
version: '3'
services:
  composer:
    image: composer:1.9.3
    environment:
      - COMPOSER_CACHE_DIR=/app/var/cache/composer
    volumes:
      - .:/app
    restart: never

The above snippet will create a composer service, that maps the current path to /app inside the container.

Setting the environment variable COMPOSER_CACHE_DIR to /app/var/cache/composer will make sure that composer will have a local cache instead of downloading everything again all the time.

So make sure that you don't push to git your var/ local folder, huh!

Just so you don't forget, let's ignore composer related files right away. Run the following commands to avoid commiting composer files:

$ echo 'vendor/' >> .gitignore
$ echo 'var/' >> .gitignore

Great! With composer in hands we are already prepared to install our most important dependency!

Prepare PHPUnit

The most important dependency from this skeleton app is the test engine, of course!

Let's install it by request it from composer:

$ dcr composer require --dev \
  phpunit/phpunit

You don't need this backslash by the way, I'll leave it there so mobile readers can also benefit from this text 😬

Should be installing deps right now, and a composer.json and composer.lock files might have appeared in your directory. Oh, there's a vendor/.

Things seem to work...

Let's then create a simple php service for handling cli stuff. We will use the official php cli image for such. And as fancy as we can get, let's do it with php 7.4! 🔥

Prepare a PHP Cli

We're gonna use the php:7.4-cli image for this.

Let's also map volumes the same way we did with composer. Might be handy in the future.

# docker-compose.yml
version: '3'
services:
  composer:
    image: composer:1.9.3
    environment:
      - COMPOSER_CACHE_DIR=/app/.cache/composer
    volumes:
      - .:/app
    restart: never
  # NEW IN THIS SECTION!!!
  php:
    image: php:7.4-cli
    restart: never
    volumes:
      - .:/app
    working_dir: /app

Here we also set the working dir to /app. So whenever we run dcr php it will act as if it was in our local root path.

Wondering how we're gonna run unit tests, right?

Lemme show ya!

Run PHPUnit inside container

Running PHPUnit should be as simple as running a cli command. Given it is a cli command...

The following then works fine:

$ dcr php vendor/bin/phpunit

You can use <TAB> for auto-completion normally 😉

Sounds really boring to type all this stuff over and over again, though. Can we make it simpler?

Yes!

Let's add a phpunit service to our docker-compose.yml:

# docker-compose.yml
version: '3'
services:
  composer:
    image: composer:1.9.3
    environment:
      - COMPOSER_CACHE_DIR=/app/.cache/composer
    volumes:
      - .:/app
    restart: never
  php:
    image: php:7.4-cli
    restart: never
    volumes:
      - .:/app
    working_dir: /app
  # NEW IN THIS SECTION!!!
  phpunit:
    image: php:7.4-cli
    restart: never
    volumes:
      - .:/app
    working_dir: /app
    entrypoint: vendor/bin/phpunit

The entrypoint field here is the catch! Now in your terminal just run the following:

$ dcr phpunit --version
PHPUnit 9.0.1 by Sebastian
Bergmann and contributors.

Ohaa! That's beautiful!

We can, by the way, generate our phpunit.xml configuration before moving to the next step.

Let's do it:

$ dcr phpunit \
  --generate-configuration

It will ask you a couple of questions. Just press enter for everything, who cares...

Create a simple test

Just to make sure things are working, right?

Let's do it!

$ touch tests/MyTest.php

And inside tests/MyTest.php add the following:

<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

class MyTest extends TestCase
{
  public function testMyTest(): void
  {
    self::assertTrue(false);
  }
}

This works perfectly! And the test also fails... You can fix it later, no worries!

Now that we managed to run our tests we can think of building the application.

Probably you want to build a web application, right? Let's then create something with nginx and PHP-FPM!!

Web Server Set Up

For setting up php fpm, we will need actually two different services. One HTTP server and the FPM instance itself.

As they are long-running processes, we won't use the docker-compose run form with them. Instead, let's lift both using the up -d version.

Final command will look like the following:

$ docker-compose up -d fpm nginx

Let's first add PHP-FPM to the game:

# docker-compose.yml
version: '3'
services:
  composer:
    image: composer:1.9.3
    environment:
      - COMPOSER_CACHE_DIR=/app/.cache/composer
    volumes:
      - .:/app
    restart: never
  php:
    image: php:7.4-cli
    restart: never
    volumes:
      - .:/app
    working_dir: /app
  phpunit:
    image: php:7.4-cli
    restart: never
    volumes:
      - .:/app
    working_dir: /app
    entrypoint: vendor/bin/phpunit
  # NEW IN THIS SECTION!!!
  fpm:
    image: php:7.4-fpm
    restart: always
    volumes:
      - .:/app

Very simple! By running docker-compose up -d fpm it should already start running in background.

Now let's set up the NGINX part that will expose a port 8080 and handle php requests by forwarding them to fpm's port 9000.

The docker-compose.yml file should be like this:

# docker-compose.yml
version: '3'
services:
  composer:
    image: composer:1.9.3
    environment:
      - COMPOSER_CACHE_DIR=/app/.cache/composer
    volumes:
      - .:/app
    restart: never
  php:
    image: php:7.4-cli
    restart: never
    volumes:
      - .:/app
    working_dir: /app
  phpunit:
    image: php:7.4-cli
    restart: never
    volumes:
      - .:/app
    working_dir: /app
    entrypoint: vendor/bin/phpunit
  fpm:
    image: php:7.4-fpm
    restart: always
    volumes:
      - .:/app
  # NEW IN THIS SECTION!!!
  nginx:
    image: nginx:1.17.8-alpine
    ports:
      - 8080:80
    volumes:
      - .:/app
      - ./var/log/nginx:/var/log/nginx
      - .conf/nginx/site.conf:/etc/nginx/conf.d/default.conf

With this we expose the port 8080 to be the container's 80 (default http port).

We also linked our current directory to /app. Normally people do /var/www but I'd like to keep it consistent with our previous services.

The var/log/nginx local path got linked to /var/log/nginx. This way we don't get blind when in need to check access or error logs.

Last but not least, the site.conf file got introduced to the container with the name default.conf. This is just a quick way for nginx to pick it up.

We need to create our config file now. Let's do it!

$ touch .conf/nginx/site.conf

Write the following config to your local .conf/nginx/site.conf file:

# .conf/nginx/site.conf
server {
  listen 80;
  listen [::]:80;

  root /app/public;
  index index.php;

  location / {
      try_files $uri $uri/ /index.php$is_args$args;
  }

  location ~ .php$ {
      try_files $uri =404;
      fastcgi_split_path_info ^(.+.php)(/.+)$;
      fastcgi_pass fpm:9000;
      fastcgi_index index.php;
      include fastcgi_params;
      fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
      fastcgi_param PATH_INFO $fastcgi_path_info;
  }

  access_log /var/log/nginx/myapp_access.log;
  error_log /var/log/nginx/myapp_error.log;
}

Notice that root is set to /app/public. This will be the entry path to every request nginx handles.

Also look at the fastcgi_pass and see that it points to fpm:9000. This is our fpm image. If you named it differently, adjust this line as well!

To test this out, let's create a simple index.php inside our /public folder. This file will serve as our application's entry point.

Just add a simple phpinfo call to this file:

# public/index.php
<?php

phpinfo();

Now just lift the nginx server:

$ docker-compose up -d nginx

From this moment on you should be able to enter http://localhost:8080/ from your browser normally.

Don't forget the autoloader

We installed composer properly, but using our own classes is still not optimal.

Let's adjust our composer.json file so composer knows from where to autoload stuff:

# composer.json
{
  "require-dev": {
    "phpunit/phpunit": "^9.0"
  },
  "autoload": {
    "psr-4": {
      "ThePHPWebsite\\": "src/"
    }
  }
}

Now just run a composer dump:

$ dcr composer -- dump
Generated autoload files
containing 646 classes

This -- before the actual command just makes sure that docker-compose won't think dump is a service instead of a parameter to our command.

To test this, let's create a file named App.php inside src/:

# src/App.php
<?php

declare(strict_types=1);

namespace ThePHPWebsite;

class App
{
  public function sayHello(): void
  {
    echo 'Hello!';
  }
}

And now just modify the public/index.php in order to use our new App class:

<?php

require_once __DIR__
  . '/../vendor/autoload.php';

use ThePHPWebsite\App;

$app = new App();

$app->sayHello();

Refresh your browser tab and we shall see a "Hello!" message on the screen!


Yeah, that's it! An awesome guide on setting up a local development environment for PHP very quickly using docker compose and enabled to run tests with phpunit.

You might want to add some other things as well, like databases, queues or maybe a Solr server...

You're free to evolve without messing up your whole computer/server/repository.

If at some point you find that you'll need specific things like a certain php extension or something fancy about your containers, just create your custom Docker and replace in your docker-compose.yml file.

Don't forget to share with your lazy friends whenever they start crying about a skeleton set up for PHP.

And, of course, let me know if you faced any trouble during this tutorial.

Cheers!