My favorite Laravel development environment, with Docker, Nginx, PHP-FPM Xdebug in VSCode
Tags: php, webdev, laravel, docker
Published: 2022-07-08 19:20:00

**In my [last post](https://dev.to/snakepy/how-to-debug-laravel-apps-with-laravel-apps-with-xdebuger-in-vs-code-8cp) I showed how you can set up XDebug for a simple dev environment, which used `php artisan serve`. However, what I am showing today is actually more perfomant. Once you understand the set up, it will probably soon be your favorite Laravel dev environment as well.**

The setup can be found in this [repository](https://github.com/snake-py/laravel-docker-nginx).

## Table Of Content <a name="toc"></a>

- [Why should I use the set up :sunglasses:](#resoning)
- [Requirements](#requirements)
- [Set Up Guide](#extensive-guide)
  - [Break Down](#breakDown)
  - [Dockerfiles and Xdebug Configs](#dockerfiles)
  - [ENV Vars and docker-compose](#dockercompose)
  - [NGINX Configs](#nginxconfigs)
  - [Set Permissions](#permissions)
  - [Redis Lib (optional)](#redislib)
  - [First Run](#runit)
- [VS Code Set Up](#vscode)
- [Troubleshooting :boom:](#troubleshooting) 

## Why should I use the set up <a name="resoning"></a>

The set up is great, because you will be able to: 

- Use Nginx and php-fpm to process requests much faster
- Use the debugger with Nginx calls
- Use the debugger for the queue 
- Have a redis cache set up
- Have a database set up
- Have a `php artisan serve` server as fallback  

Nginx with php-fpm enables parallel processing of requests, while with just the `php artisan serve` you will only be able to process requests in sequence. 

## Requirements <a name="requirements"></a>

The **only requirement** is that you have a newer version of [docker installed](https://docs.docker.com/get-docker/). (In older docker versions you might have to manually install docker-compose as well).

## Set up Guide <a name="extensive-guide"></a>

### Break Down <a name="breakDown"></a>

- Create the Dockerfiles - I am gonna use an image that I have created for this guide ([GitHub](https://github.com/snake-py/laravel-dev-image) or from [hub.docker](https://hub.docker.com/r/snakepy/laravel-dev-image))
- Create the XDebug configs
- Set the .env vars
- Create the docker-compose
- Create the Nginx config
- Set the correct permissions for `storage` and `bootstrap`
- (optional) Add a library for Redis


#### Dockerfiles and Xdebug Configs <a name="dockerfiles"></a>

We are going to need 3 files, 2 `Dockerfiles` and one `docker-compose.yml` file. 

First the main [Dockerfile](https://github.com/snake-py/laravel-docker-nginx/blob/main/Dockerfile) for the actual app container:

```Dockerfile
FROM snakepy/laravel-dev-image:php7.4-a696761a6b37e2480ba83edc4edee9a7632f3332

WORKDIR /app

COPY .docker/xdebug.ini /etc/php/7.4/cli/conf.d/99-xdebug.ini

RUN npm install
```
You can see we are pulling the image which comes with all the plugins you will need. (I plan to release a PHP 8 image as well, let me know if you guys also are interested in a production image :v:). Next we copy configs for debugging on the `php artisan serve` container. They can be found at ([.docker/xdebug.ini](https://github.com/snake-py/laravel-docker-nginx/blob/main/.docker/xdebug.ini)): 

```conf
zend_extension = xdebug

xdebug.remote_enable=on
xdebug.remote_autostart = 1
xdebug.mode=develop,gcstats,coverage,profile,debug
xdebug.idekey=docker
xdebug.start_with_request=yes
;xdebug.log=/tmp/xdebug.log
xdebug.client_port=9003
xdebug.client_host='host.docker.internal' 
xdebug.discover_client_host=0
``` 

Now we need to define a second docker file for serving the app with PHP-FPM. We need to do that because Nginx does not come with a PHP plugin. If we'd use Apache we could directly serve from Apache. 

```Dockerfile
FROM php:7.4-fpm

RUN pecl install xdebug-2.9.2 \
    && docker-php-ext-enable xdebug  

RUN docker-php-ext-install mysqli pdo pdo_mysql

COPY .docker/xdebug-nginx.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
``` 
**Note** here we are not pulling the image I have prepared, it would be overkill, hence we are going with the default image from PHP. The Xdebug configs are being copied as well, but they are slightly different. See at ([.docker/xdebug-nginx.ini](https://github.com/snake-py/laravel-docker-nginx/blob/main/.docker/xdebug-nginx.ini)):

```conf
zend_extension = xdebug

xdebug.remote_enable=on
xdebug.remote_autostart = 1
xdebug.default_enable=1

xdebug.mode=develop,gcstats,coverage,profile,debug
xdebug.idekey=docker
xdebug.start_with_request=yes
;xdebug.log=/tmp/xdebug.log
;xdebug.remote_log=/tmp/xdebug.log
xdebug.client_host='host.docker.internal' 
xdebug.discover_client_host=0
xdebug.remote_handler=dbgp
```

##### ENV Vars and docker-compose <a name="dockercompose"></a>

This is by far the hardest part to get right. The docker-compose file I am going to present requires some .env vars, hence I am going to show these first.

Please set in [.env](https://github.com/snake-py/laravel-docker-nginx/blob/main/.env.example) following vars: 

```.env
# Docker Ports
DOCKER_EXPOSED_DB_PORT=3307
DOCKER_EXPOSED_NGINX_PORT=10000
DOCKER_EXPOSED_ARTISAN_SERVE_PORT=8000

## can be retrieved with hostename -I
DOCKER_NGINX_LOCAL_IP=

## DO NOT CHANGE!If you change this, you also need to change DB_HOST and REDIS_HOST
DOCKER_APP_CONTAINER_NAME="laravel-dev-to"

DB_CONNECTION=mysql
DB_HOST=laravel-dev-to-db
DB_PORT=3306
DB_DATABASE=test
DB_USERNAME=test
DB_PASSWORD=Password123!

REDIS_HOST=laravel-dev-to-cache
REDIS_PASSWORD=REDIS_PASSWORD
REDIS_PORT=6379
``` 
Note that `DOCKER_APP_CONTAINER_NAME` is used as a prefix in the docker-compose hence the naming convention to `DB_HOST` and `REDIS_HOST` must be maintained. 

Let's look at the [docker-compose.yml](https://github.com/snake-py/laravel-docker-nginx/blob/main/docker-compose.yml): 

```yml
version: '3.8'
services:
    app:
        container_name: ${DOCKER_APP_CONTAINER_NAME}
        extra_hosts:
            - "host.docker.internal:host-gateway"
        build: 
            context: .
            dockerfile: Dockerfile
        command: php artisan serve --host=0.0.0.0
        volumes:
            - .:/app
        ports:
            - ${DOCKER_EXPOSED_ARTISAN_SERVE_PORT}:8000
        depends_on: 
            - db
            - composer
        networks:
            - proxynet
        env_file:
            - .env
    queue:
        container_name: ${DOCKER_APP_CONTAINER_NAME}-queue
        extra_hosts:
            - "host.docker.internal:host-gateway"
        volumes:
            - .:/app
        build: 
            context: .
            dockerfile: Dockerfile
        command: 'php artisan queue:work'
        environment:
            - REDIS_HOST=${REDIS_HOST}
            - REDIS_PASSWORD=${REDIS_PASSWORD}
            - REDIS_PORT=${REDIS_PORT}
        depends_on: 
            - app
            - cache
            - db   
            - composer
        networks:
            - proxynet     
    db:
        container_name: ${DOCKER_APP_CONTAINER_NAME}-db
        platform: linux/x86_64
        image: mysql:8.0
        restart: "no"
        environment: 
            MYSQL_ROOT:  root
            MYSQL_ROOT_PASSWORD: root
            MYSQL_DATABASE: ${DB_DATABASE}
            MYSQL_USER:  ${DB_USERNAME}
            MYSQL_PASSWORD:  ${DB_PASSWORD}
        ports:
            - ${DOCKER_EXPOSED_DB_PORT}:3306
        volumes:
            - db_data:/var/lib/mysql
        networks:
            - proxynet
        env_file:
            - .env
    cache:
        container_name: ${DOCKER_APP_CONTAINER_NAME}-cache
        command: redis-server --requirepass ${REDIS_PASSWORD}
        image: redis:5.0
        ports:
            - :6379
        volumes:
            - cache_data:/data
        networks:
            - proxynet
    nginx:
        image: nginx:stable-alpine
        container_name: ${DOCKER_APP_CONTAINER_NAME}-nginx
        ports:
            - ${DOCKER_EXPOSED_NGINX_PORT}:80
        volumes:
            - ./:/var/www/html
            - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.temp
        depends_on:
            - app
            - composer
            - db
            - cache
        command: /bin/sh -c "envsubst '$$DOCKER_NGINX_LOCAL_IP $$DOCKER_APP_CONTAINER_NAME' < /etc/nginx/conf.d/default.temp > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"
        env_file:
            - .env
        networks:
            - proxynet
    php-fpm:
        extra_hosts:
            - "host.docker.internal:host-gateway"
        build: 
            dockerfile: Dockerfile.PHP-FPM
        container_name: ${DOCKER_APP_CONTAINER_NAME}-php-fpm
        volumes:
            - ./.docker/xdebug-nginx.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
            - ./:/var/www/html
        ports:
            - ":9000"
        depends_on:
            - app
            - composer
            - db
            - cache
        networks:
            - proxynet
    composer:
        build: 
            context: .
            dockerfile: Dockerfile
        container_name: ${DOCKER_APP_CONTAINER_NAME}-composer
        volumes:
            - .:/app
        command: composer install
        networks:
            - proxynet
        env_file:
            - .env

volumes:
  db_data:
    driver: "local"
  cache_data:
    driver: "local"


networks:
  proxynet:
    name: portal
```
Okay I am going to break down the docker-compose file so actually understand what going on there. It will build the following containers: 

- app the basic Laravel dev app
- db a MySQL database
- cache Redis cache
- queue basic Laravel dev queue worker
- Nginx the web server for serving to php-fpm and serving static content
- php-fpm server for executing PHP code for Nginx
- composer runs an install on container boot and will the exit

All these containers are being wrapped into a proxynet in case you want to use the same network in another compose file like a Vue frontend. 
 
The variables from the .env file are being used to determine which ports are going to be exposed. In my example we are exposing `php artisan serve` on port 8000 and `nginx` on port 10000. The `mysql db` will be exposed on port 3307.


The last thing I want to point out about the compose file is the use of:
 
```yml
        extra_hosts:
            - "host.docker.internal:host-gateway"
```

Which basically tells docker to resolve docker-container-names to their network IP address. 

In the next section we are going to take a closer look at the Nginx compose section and configs.  

#### NGINX Configs <a name="nginxconfigs"></a>

Let us talk about the Nginx volumes. Nginx mounts two volumes. `./:/var/www/html` will be used for static file serving, like images. `./.docker/nginx/default.conf:/etc/nginx/conf.d/default.temp` this volume are the actual nginx configs. If you know Nginx then you will notice that we actually put a "wrong" file name into the image `default.temp`. We do not mount the Nginx to the end/correct location because we require `envsubst` to run over it and replace two variables in the nginx.conf file.`ènvsubst` has the ability to run over a config file and replace only the variables with the names which were provided to it. I think this [answer](https://unix.stackexchange.com/a/294400/524330) describes the issue very well. In the [command](https://github.com/snake-py/laravel-docker-nginx/blob/main/docker-compose.yml#L85) `envsubst` we define the correct output path for nginx and run the Nginx deamon.    

These Varibles need to be replaced and the other should not be touched: 

1. $DOCKER_APP_CONTAINER_NAME needs to replaced so nginx can proxy all php requests to php-fpm
2. $DOCKER_NGINX_LOCAL_IP is required for XDebug

:exclamation: I had a lot of difficulty getting it right, but note that you need to escape the variables for docker with two dollar signs `$$`. :exclamation:

**Example:**
```bash
envsubst '$$DOCKER_NGINX_LOCAL_IP $$DOCKER_APP_CONTAINER_NAME' 
```

Here is the Nginx config template you need to copy into your project, I have them at ([.docker/nginx/default.conf](https://github.com/snake-py/laravel-docker-nginx/blob/main/.docker/nginx/default.conf)):

```conf
server {
    listen  80;

    # this path MUST be exactly as docker-compose.fpm.volumes,
    # even if it doesn't exist in this dock.
    root /var/www/html/public;

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

    location ~ ^/.+\.php(/|$) {
        fastcgi_pass $DOCKER_APP_CONTAINER_NAME-php-fpm:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_param PHP_VALUE "xdebug.remote_autostart=1
        xdebug.remote_enable=1
        xdebug.remote_host=$DOCKER_NGINX_LOCAL_IP";
    }
}
```

#### Set Permissions <a name="permissions"></a>

Simply give the ./storage and ./bootstrap folder these permissions:

```bash
chmod -R 777 ./storage
chmod -R 777 ./bootstrap
```

#### Redis Lib (optional) <a name="redislib"></a>
  
I am going to connect to redis using the libary [predis](https://github.com/predis/predis). Simply add the following to your compose.json

```json
"require": {
  ...
  "predis/predis": "^1.1",
  ...
 }
```

and inside [config/databse.php:122](https://github.com/snake-py/laravel-docker-nginx/blob/main/config/database.php#L122) I switched to use predis. 

#### First Run :checkered_flag: <a name="runit"></a>

If you have followed the previous steps now is the moment of truth: :sweat_drops: :sweat_drops:

```bash
docker compose run
```
If this is the first boot and you had no vendor folder prior to this - then wait till the compose container exits and shut the container down and rerun `docker compose up`.

now you should be able to hit :rocket::rocket::rocket:: 
http://localhost:8000
and
http://localhost:10000

If everything is opening up as expected then you can continue with the IDE set up for XDebug. If not then have a look a the [github repo](https://github.com/snake-py/laravel-docker-nginx) or leave me a comment and I can try to assist you. Also have a look the [troubleshooting](#troubleshooting) section. 

## Set Up VS Code <a name="vscode"></a>

Now the set up for VS Code to actually use the debugger you need first to install the following [extension](https://github.com/xdebug/vscode-php-debug).

Then copy over the [launch.json](https://github.com/snake-py/laravel-docker-nginx/blob/main/.vscode/launch.json) file I have prepared. You will find multiple configurations in there. I will go over one so you understand whats going on. 

```json
{
  "name": "Listen for Xdebug inside docker",
  "type": "php",
  "request": "launch",
  "hostname": "0.0.0.0",
  "pathMappings": { "/app": "${workspaceRoot}/" },
  "port": 9003,
  "log": true
},
``` 
- `name` shows up in the debugger drop down
- `hostname` needs to be set to 0.0.0.0 so the server can find the XDebug report from localhost
- `pathMappings` is basically a map where the folders are placed on the server => `{"path_to_app_inside_docker": "${workspaceRoot}/"}` the `workspaceRoot` is automatically set by VS Code
- `port` the port on which XDebug will answer

If you want to debug now, you will need to run `docker compose up` once the containers are running you can choose from the drop down, on which requests you want to listen to. I usually just launch two debugger instances:

![VSCode Debugger tab](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f89t4pjvv7pxbg34ua9a.png)

- "Listen for Xdebug inside docker"
and
- "Listen for Xdebug inside docker nginx"

After the debugger is launched you should be able to hit break points, if requests are going to http://loclahost:8000 or http://localhost:10000 
  
To test this insert this into `api.php`:

```php
Route::get('/test', function () {
    return response()->json(['message' => 'Visit my portfolio site at snake-py.com'], 200);
});
```

The set a a break point on the return statement line and try to hit the route http://localhost:8000/api/test or http://localhost:10000/api/test

![Break point is being hittet](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o2vpjxvs739gimcb5nka.png)
 
## Troubleshooting <a name="troubleshooting"></a>

There is actually a lot of stuff which might go wrong during your first set up. I just want to mention here a few ways which helped me debug. (I also try to keep this a little updated). Feel free to open a GitHub issue or comment here for support. 

**Debugger does not connect?**
Enable the following config, which will output a log file wiht an error you can google. 

```conf 
xdebug.log=/tmp/xdebug.log
xdebug.remote_log=/tmp/xdebug.log
```
The log file will be in the app container or the php-fpm container depending on which port your hitting. To get into the php-fpm container is no bash installed yo you need execute:

```bash
docker exec -it <php-fpm-container-name> bin/sh
```

**Database or cache does not connect properly?**
If you are sure that the containers can reach each other and that the names you have provided in the .env file are correct you can go inside the app container and run a config clear. 

```bash
docker ps # to get the name of the app container
docker exec -it <container_name> bash
php artisan config:clear
php artisan cache:clear
php artisan route:clear
php artisan migrate 
```
If one of these commands fails then you usually did not provide the correct name or port in the .env file.

**Nginx is failing on boot?**
Did you set the correct local ip address? Is the php-fpm container name correctly prefixed? Check with `docker ps`

It should look like this:
![Example of the running containers](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zxigsjx3y9kcap0t1zc6.png)




I hope I could help you with this set up let me know what you guys might think! :smile: