Laravel with docker-compose

Victor Bolshov
Aug 30, 2019 · 15 min read

So, I’ve been using Laravel with docker-compose for development for a while, and now I’m going to make a simple yet functional project skeleton based on a classic laravel/laravel package, and I’m going to document the whole process here.

This article is targeted at PHP devs that use or want to use Laravel, and are also using or willing to use containerized environments. I will also provide some tips & tricks for PHPStorm users, and in a follow-up post I will show how you can deploy this app to Google Kubernetes Engine in Google Cloud.

Prerequisites

I will suppose you have docker installed, as well as docker-compose. Some acquaintance with command line, Git and the wonderful Composer package manager is also a must. I will also suppose you have PHP installed locally. I am using a Linux laptop, things are going to be pretty much the same on Mac, and hopefully on Windows but I’m not sure.

Why bother?

Docker & docker-compose have been around for a while already, but are still rapidly growing in terms of adoption and features. For PHP and LAMP devs, containers allow for running different versions of PHP, MySQL, Apache/Nginx for different projects, as well as any other databases, caching servers etc. Running your project with docker-compose makes it isolated from the rest of your machine, and gives your the power to start/stop the whole project with a simple shell command. Read more about benefits of docker-compose for development in this blog post.

Get started

First thing we’ll do is create a Git repository. For that, I will use Gitlab project hosting. As soon as the project is created (I created an empty project, without even a README), I can clone the repository from the terminal. Then I am going to pull everything from laravel/laravel, to my repo:

# SHELL (TERMINAL) #
$ git clone git@gitlab.com:crocodile2u/laravel-docker.git
$ cd laravel-docker
$ git remote add upstream https://github.com/laravel/laravel.git
$ git pull upstream master
$ git push origin master

Now the contents of the project folder should be familiar to you, if you have ever used Laravel.

We’re going to need composer dependencies and perform the basic setup for a Laravel app:

# SHELL (TERMINAL) #
$ composer install --ignore-platform-reqs
$ cp .env.example .env
$ php artisan key:generate

I have PHP installed locally, and for the sake of simplicity I just use php to run the setup scripts on the host machine. We could do the same without even having PHP on the host, simply running a docker container that has PHP in it. When running composer install, I provide the option `ignore-platform-reqs`. That is because we are not going to rely on the host machine’s PHP installation too much. Instead, we’ll make sure that PHP we have in a docker container has everything our application needs.

Next, I will add a docker-compose.yml file:

# docker-compose.yml #
version
: "3.4"
services
:
app:
image: php:alpine
volumes:
- "./:/app"
working_dir
: /app
command: "php artisan serve --host=0.0.0.0 --port=8000"
ports
:
- 8000:8000

As you see, this one is a really simplistic one. It declares version and one service, for our PHP app. It uses php:alpine docker image, based on a tiny Alpine Linux distro. The source code of our app (the project directory) is mounted as a volume to the container, and inside the container can be found at path /app. I specify the command to run in the container — the built-in Laravel server which is in turn based on the PHP’s built-in development web-server. I found this to be the easiest possible way to run a Laravel project in development environment:

# SHELL (TERMINAL) #
$ php artisan serve --host=0.0.0.0 --port=8000

Next, we specify port mapping. Port 8000 on the host machine will be mapped to the same port number in the app container. Without this mapping, we’d be unable to open the app in a browser, because your browser will attempt to connect to our app container from the host. Also, we’ll need to specify host (default is localhost, that is, 127.0.0.1). Why that? Well, because containers. Our PHP will run in a container, then 127.0.0.1 in the container refers to that container, and not to the host. For a little more detail, see below.

You can get the project up & running with docker-compose:

# SHELL (TERMINAL) #
$ docker-compose up
Creating network "laravel-docker_default" with the default driver
Creating laravel-docker_app_1 ... done
Attaching to laravel-docker_app_1
app_1 | Laravel development server started: <http://0.0.0.0:8000>
app_1 | PHP 7.3.3 Development Server started at Fri Aug 23 19:55:59 2019

Docker-compose has started and launched our single service “app”. Now it will keep logging the services’ output to the terminal.

Fig. 1. Docker-compose and the host machine (no port mapping)
Fig. 1. Docker-compose and the host machine (no port mapping)
Fig. 1. Docker-compose and the host machine (no port mapping)

“Volumes” section in the docker-compose.yml is responsible for specifying mount point inside the container. In our case, the project directory is mounted as “/app”, this way the source code in container is updated as we change it on the host machine.

The picture above shows the host machine and the docker-compose stack with the PHP service (`app` in the docker-compose.yml) — without the port mapping. Docker-compose provides a level of isolation of the services from the host and the outer world. In this setup, PHP service — our application — is unaccessible from outside, even from the host machine. We want to be able to connect to the app — we set up port mapping:

Fig. 2. Docker-compose and the host machine (with port mapping 8000:8000)
Fig. 2. Docker-compose and the host machine (with port mapping 8000:8000)
Fig. 2. Docker-compose and the host machine (with port mapping 8000:8000)

In order to understand what’s happening, in more depth, refer to documentation. For myself, I depict docker-compose as a firewall inside the host, with all its services behind that firewall. A service is a container, you can see it as an isolated virtual machine, although it is not. Inside the firewall, docker-compose has it’s own network, with its very own DNS that allows services to resolve each other by name. In our case, from within docker-compose, the app service can be accessed using domain name “app”. For HTTP requests, that’s gonna be http://app/. Outside docker-compose, on the host machine, domain name “app” is not going to be resolved:

# SHELL (TERMINAL) #
$ docker-compose exec app ping app
PING app (172.23.0.2): 56 data bytes
64 bytes from 172.23.0.2: seq=0 ttl=64 time=0.028 ms
$ ping app
ping: app: Name or service not known

First command: we tell docker-compose that we want to execute command “ping app” in the service app’s container, and you can see that app is resolved to 172.23.0.2 — this is the app’s IP address in the docker-compose network.

Next, I try to ping app directly from the host, and fail, exactly as expected. I made this small demonstration in order to illustrate that, when you develop in a docker-compose environment, you have to run [almost] everything from within an appropriate container and not from the host. Remember: docker-compose exec is your friend!

Now, let’s get back to the host and port that our app service is using. OK, port is 8000, it’s the Laravel’s default port, and we have mapped it to the same port on the host. What about host IP address? Why 0.0.0.0? By default, Laravel’s artisan serve command will listen to requests from 127.0.0.1 only. Because the app is running in a container, and a container is almost a virtual machine, 127.0.0.1 inside it refers to that very container, or its loopback interface, so the server would listen to requests from the container itself — only! But we need to access app from the host, that’s why we want it to listen to all interfaces. Refer to this page on superuser.com for more details.

At this point we already have a running Laravel app, so you can navigate to http://localhost:8000/ to see the Laravel’s welcome page. However, our setup is hardly very interesting with just one service. Most applications and websites will need at least a database. As you will see, adding it to our docker-compose stack is just a snap:

version: "3.4"
services
:
app:
...
db:
image: mariadb
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 1
MYSQL_DATABASE: ${DB_DATABASE}
ports:
- 3306:3306
volumes:
- "db-data:/var/lib/mysql/data"
volumes
:
db-data: {}

Now, our application stack becomes this:

Fig. 3. PHP+MySQL stack

I’ve added “db” service based on mariadb official docker image. Because this DB is only used for development, I don’t want to bother with password, just want to connect as root without any password. Mariadb image can be controlled with environment variables:

environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 1
MYSQL_DATABASE: ${DB_DATABASE}

First one allows for connection as root without password, second one will create a fresh database immediately after the database first initialization. Database name is, in turn, provided as a variable: docker-compose supports .env files and is able to parse variables declared in them. In our case (if you followed all the steps) .env contains the following lines:

DB_HOST=db
DB_PORT
=3306
DB_DATABASE
=laravel

I changed the DB_HOST from 127.0.0.1, which is the default in Laravel, to “db” —a discoverable domain in the docker-compose network. I also added port mapping 3306:3306. This is not required for the app to function. Inside docker-compose, our app can connect to db:3306 without this mapping. However, it’s super-handy to be able to easily connect to the project database from the host machine, with your favourite utility of choice: console mysql client or your IDE.

Now, switch to the terminal. You’ve probably noticed that we’re using a volume for the db service. This volume is for Mariadb to store the data, so that it’s not lost after docker-compose or the host machine restart. In this case, I created a named volume with no configuration: I let docker itself decide where and how it wants to store the files. Named volumes also have the advantage of being listed under a readable name when using docker volume ls:

$ docker volume ls
local fbe85d...f7efbf
local feca13...7948a0
local laravel-docker_db-data

This way, it’s much more manageable. Docker-compose has added a prefix of “laravel-docker_” to the volume name, which makes it even easier to sort out which volume is which, much less so for the unnamed volumes above...

If your docker-compose is still running, press Ctrl+C to stop it. Then type docker-compose up again, your PHP+Mariadb stack should be up and running.

Let’s check if the app can actually connect and query database. An easy way to do this is to run the database migrations: Laravel by default has a couple of them. Remember, we have to run them in the app container:

$ docker-compose exec app ./artisan migrate
Illuminate\Database\QueryException : could not find driver ...

A-ha. What is that? Well, the docker image we’re using for our app service, is php:alpine, the very basic PHP image, and it probably lacks the pdo_mysql extension. Let’s check that:

$ docker-compose exec app php -m           
[PHP Modules]
...
PDO
pdo_sqlite
...

OK, that’s correct, there’s no pdo_mysql. That is because official PHP docker images don’t include a lot of extensions, just those installed with PHP by default. One good thing about Docker is that you can quite easily modify software in your stack. I will add a custom Dockerfile for our app service:

laravel-docker
+-docker
+-php
+-dev.Dockerfile

And here is the Dockerfile itself:

FROM php:alpine

RUN apk add --no-cache $PHPIZE_DEPS && \
pecl install xdebug && docker-php-ext-enable xdebug && \
docker-php-ext-install pdo_mysql

VOLUME /app

If you want to know all about how to easily customize the official PHP docker image, read the documentation. Here, I have added the missing pdo_mysql extension, and also the briliant XDebug.

Docker-compose can also provide help with building this image, so our app service becomes this:

app:
volumes:
- "./:/app"
working_dir
: /app
command: "php artisan serve --host=0.0.0.0 --port=8000"
ports
:
- 8000:8000
build:
context: docker/php
dockerfile: dev.Dockerfile

Now we can call docker-compose build… it takes a while… and then we’re good to go with docker-compose up again:

$ docker-compose exec app ./artisan migrate                                 
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
...

It worked, and now the application can connect to the database!

Testing & debugging

How about testing? How about integration with IDE? In this chapter I will guide you through these topics, and we’ll be able to launch unit tests and step-by-step debug.

If you simply cd to the project folder and run phpunit, it’ll probably succeed. However, that’s just because

  1. You has PHP installed on your host machine
  2. your host PHP installation probably has all the required extensions
  3. Our tests that come with Laravel, do not try to connect to a database.

Let’s run the tests from the container:

$ docker-compose exec app ./vendor/bin/phpunit
...
OK (2 tests, 2 assertions)

Now, we’re going to add a simple check that our application can connect to DB. For this, I create a new PHPUnit Test class under tests/Feature:

class DbConnectivityTest extends TestCase
{
public function testDbConnectivity()
{
/** @var Connection $db */
$db = $this->app->make("db");
$row = $db->selectOne("SELECT 1 AS one");
$this->assertEquals(1, $row->one);
}
}

Check…

$ docker-compose exec app ./vendor/bin/phpunit
...
OK (3 tests, 3 assertions)

Huh, that was a snap! BTW, if you now try to run PHPUnit from the host, this is what you get:

There was 1 error:1) Tests\Feature\DbConnectivityTest::testDbConnectivity
Illuminate\Database\QueryException: SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed

That’s because the host machine doesn’t know such name — “db”. It’s only resolvable within the docker-compose network. I will keep saying this: to successfully adopt containers in your development and production, you have to make a shift in your mindset, to think in terms of networks, machines, ports, host-and-container relations, and always remember where you are running a command — in a container? on the host?

You can, of course, continue to run tests from the command line using docker-compose exec. Me, I like to comfort myself with IDE for that matter. Below is the instruction for PHPStorm, my IDE of choice. Get ready to run tests in style!

  1. Go to File -> Settings -> Languages and Frameworks -> PHP.
  2. Press the “three-dots” button next to the CLI interpreter dropdown. A popup opens, with the list of PHP interpreters. If you have PHP on your computer, you’ll probably find it listed there. Find the “+” button at the top left corner, right above the interpreter list and press it. A context menu pops up with a selection. Choose the top option: “From Docker, Vagrant, VM, Remote…”
  3. Another popup, choose “Docker compose” and press “OK”:
Fig. 4. Configuring PHP interpreter from docker-compose

4. Now you’re back on the “CLI interpreters” screen. Change the selection in the “Lifecycle” section to “Connect to existing container (‘docker-compose exec’)” and press OK.

5. You’re back on the PHP settings screen, press “Apply” to apply changes but keep the settings window open.

6. Now we’re going to add PHPUnit configuration. In the Settings window, navigate to Languages and Frameworks -> PHP -> Test Frameworks and press the “+” button:

Fig. 5. Adding PHPUnit configuration

7. A small popup with a selection of interpreters will appear. Choose the “app” interpreter that we’ve just set up. After that, your Settings window should become something like this:

Fig. 6. PHPUnit configuration options

Press OK and you should be good to go.

8. Check that our PHPUnit setup works. In PHPStorm project tree, select the “tests” folder, then select Run -> Run ‘tests’ from the IDE top menu, you should see something like this:

Fig 7. PHPUnit test results in PHPStorm
Fig 7. PHPUnit test results in PHPStorm
Fig 7. PHPUnit test results in PHPStorm

9. Because we’ve added XDebug to our dev docker image, we are now able also to interactively debug while running tests — this might be super-useful sometimes! Once you have the tests from ‘tests’ folder, you should see this in the top left corner of PHPStorm window:

Fig. 8. Initializing debug session for unit tests
Fig. 8. Initializing debug session for unit tests
Fig. 8. Initializing debug session for unit tests

That’s it, now you can place a breakpoint in one of the tests, press this button and see PHPStorm open the file/line with breakpoint while the execution is paused for you to debug.

It’s also possible to debug a web-page. Open the “Edit run and debug configuration” dialog:

Fig. 9. Opening ‘Edit Run and Debug configurations’ dialog
Fig. 9. Opening ‘Edit Run and Debug configurations’ dialog
Fig. 9. Opening ‘Edit Run and Debug configurations’ dialog

In that dialog, press “+” icon in the top left toolbar and choose “PHP Web Page”:

Fig. 10. Initialize creation of a new run/debug configuration

You’re going to see something like:

Fig. 11. New run/debug configuration

You have to select a server to connect to but we don’t have any servers yet, press the “…” button next to the “Server” dropdown. A new popup appears, press “+” button in the top left corner:

Fig. 12. Creating a new server

Press OK, then press OK in the previous popup, our new configuration has been added. Open file routes/web.php and place a breakpoint inside the function that handles requests to “/”. Then trigger a web debugging session in the top right part of PHPStorm window:

Fig. 13. Triggering web debugging session from PHPStorm
Fig. 13. Triggering web debugging session from PHPStorm
Fig. 13. Starting debug session

Your browser will open at the URL “http://localhost:8000/?XDEBUG_SESSION_START=NNNNN”. Unfortunately, the debug session won’t start in PHPStorm. Why? Because PHP that runs inside container has to connect to your IDE which runs on host. These two are not the same machine and not even on the same network. Default xdebug settings disallow remote debugging. Also, normally I would just setup xdebug like this:

xdebug.remote_enable=1
xdebug.remote_connect_back=1

With this combination, PHP will try to get the debugger IP address by analysing $_SERVER[‘REMOTE_ADDR’] and connect to that IP. But remember about containers! host and container are separated by a docker-compose proxy. That is, REMOTE_ADDR will contain the IP of the proxy in the docker-compose network. Luckily, there more settings, and I will this combination:

xdebug.remote_enable=1
xdebug.remote_host=192.168.1.71

The IP address is my IP in the Local Area Network, you’ll have to replace it with that of your computer. Now, question is: where do I put these settings? I put them into docker/php/.user.ini. This file is specific for every developer, so I did not add it to Git (however, docker/php/.user.ini.example is there). In order for the container to make use of it, I modified the docker-compose.yml:

app:
volumes:
- "./:/app"
- "./docker/php/.user.ini:/usr/local/etc/php/php.ini"
working_dir
: /app
command: "php artisan serve --host=0.0.0.0 --port=8000"
ports
:
- 8000:8000
build:
context: docker/php
dockerfile: dev.Dockerfile

I mount the .user.ini file as a volume inside the app container. The reason I use this file name (.user.ini) is this PHP feature: you might want to use NGinx+PHP-FPM even for development, instead of the built-in web-server. Then, in order to be able to use xdebug, you simply put the same .user.ini into public folder.

Now, get back to your browser and refresh the page. Then return to IDE and you should see this ():

Fig. 14. Interactive debugging

This project has been published as OSS: https://gitlab.com/crocodile2u/laravel-docker. The exact state where I finished this post can be found here: https://gitlab.com/crocodile2u/laravel-docker/tree/xdebug. Composer package: https://packagist.org/packages/crocodile2u/laravel-docker, I’ll be pleased if you install it and give it a try.

Conclusions

This application skeleton is still fully compatible with the “official” laravel, and I’ll try to keep it up-to-date, especially if I get some positive feedback from you. It runs in a containerized environment, at least for the purpose of development, utilizes the “infrastructure-as-code” approach: our docker-compose.yml describes all of the infrastructure, and the whole app can be easily spawned by anyone who has docker & docker-compose installed. We learned how to integrate testing and debugging tools for more comfortable development.

Stay tuned, in a follow-up I will improve this app with Kubernetes deployment templates, and we’ll have the full project running in Google Cloud!

Troubleshooting

If you have problems setting up PHP interpreter from docker-compose such as “PHPStorm does not pick up PHP version from container” you may see a picture like this:

PHPStorm being not able to connect to app container

- Go to Settings >> Build, Execution, Deployment >> Docker

- Select “TCP socket”

- Enter ‘unix:///var/run/docker.sock’ under “Engine API URL”

After that, try to set up docker-compose as remote interpreter again. You might also need to restart PHPStorm first. This solution is not mine, my friend posted it to me after he encountered this problem, and he’s found it somewhere on the internet.

The Startup

Medium's largest active publication, followed by +585K people. Follow to join our community.

Victor Bolshov

Written by

I am a PHP programmer, I like opensource software, linux etc.

The Startup

Medium's largest active publication, followed by +585K people. Follow to join our community.

More From Medium

More from The Startup

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade