My favorite Laravel development environment, with Docker, Nginx, PHP-FPM Xdebug in VSCode

snakepy

Fabio

Posted on July 8, 2022

My favorite Laravel development environment, with Docker, Nginx, PHP-FPM Xdebug in VSCode

In my last post 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.

Table Of Content

Why should I use the set up

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

The only requirement is that you have a newer version of docker installed. (In older docker versions you might have to manually install docker-compose as well).

Set up Guide

Break Down

  • Create the Dockerfiles - I am gonna use an image that I have created for this guide (GitHub or from hub.docker)
  • 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

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

First the main Dockerfile for the actual app container:



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


Enter fullscreen mode Exit fullscreen mode

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 ✌️). Next we copy configs for debugging on the php artisan serve container. They can be found at (.docker/xdebug.ini):



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


Enter fullscreen mode Exit fullscreen mode

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.



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


Enter fullscreen mode Exit fullscreen mode

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):



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


Enter fullscreen mode Exit fullscreen mode
ENV Vars and docker-compose

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 following vars:



# 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


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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:



        extra_hosts:
            - "host.docker.internal:host-gateway"


Enter fullscreen mode Exit fullscreen mode

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

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 describes the issue very well. In the command 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

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

Example:



envsubst '$$DOCKER_NGINX_LOCAL_IP $$DOCKER_APP_CONTAINER_NAME' 


Enter fullscreen mode Exit fullscreen mode

Here is the Nginx config template you need to copy into your project, I have them at (.docker/nginx/default.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";
    }
}


Enter fullscreen mode Exit fullscreen mode

Set Permissions

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



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


Enter fullscreen mode Exit fullscreen mode

Redis Lib (optional)

I am going to connect to redis using the libary predis. Simply add the following to your compose.json



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


Enter fullscreen mode Exit fullscreen mode

and inside config/databse.php:122 I switched to use predis.

First Run 🏁

If you have followed the previous steps now is the moment of truth: πŸ’¦ πŸ’¦



docker compose run


Enter fullscreen mode Exit fullscreen mode

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 πŸš€πŸš€πŸš€:
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 or leave me a comment and I can try to assist you. Also have a look the troubleshooting section.

Set Up VS Code

Now the set up for VS Code to actually use the debugger you need first to install the following extension.

Then copy over the launch.json file I have prepared. You will find multiple configurations in there. I will go over one so you understand whats going on.



{
  "name": "Listen for Xdebug inside docker",
  "type": "php",
  "request": "launch",
  "hostname": "0.0.0.0",
  "pathMappings": { "/app": "${workspaceRoot}/" },
  "port": 9003,
  "log": true
},


Enter fullscreen mode Exit fullscreen mode
  • 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

  • "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:



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


Enter fullscreen mode Exit fullscreen mode

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

Troubleshooting

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.



xdebug.log=/tmp/xdebug.log
xdebug.remote_log=/tmp/xdebug.log


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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.



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 


Enter fullscreen mode Exit fullscreen mode

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

I hope I could help you with this set up let me know what you guys might think! πŸ˜„

πŸ’– πŸ’ͺ πŸ™… 🚩
snakepy
Fabio

Posted on July 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related