How to use Xdebug in Docker & PhpStorm

Let’s break this down and improve our debugging efficiency!

Nicolas Valverde
The SensioLabs Tech Blog
8 min readJun 9, 2022

--

Photo by Timelab Pro on Unsplash

Following my previous article on the deep love between PhpStorm and Docker, some people asked me for a zoom on the Xdebug specific part. Let’s dive into it!

Xdebug is, well, a debugger. It’s not the only one out there but it is pretty famous. We mainly use debuggers for going through our program step by step, it allows to pause the execution of our applications and inspect the state of everything.

In summary, this is one tool among others we can use to help us with hunting down a bug, or understanding how that big bowl of spaghetti code is supposed to work, especially in legacy applications.

It has other features, like profiling, which is also something integrated in some way in PhpStorm. But don’t get me wrong, there are better alternatives for profiling, so I’ll not cover this feature here. If you’re mainly interested in profiling, consider taking a look at the excellent blackfire.io

Before going further, there is one pre-requisite, you should have already setup the Docker integration in your Storm, you may want to refer to my previous article here if you’re not done yet with this.

From now on, I’ll assume you’re in the same state as I was at the end of this article.

Alright, first things first, Xdebug needs to be installed in the Docker image you use. I’ll use a very simple Dockerfile to showcase, but you might have to adapt this to your actual stack.

So, let’s consider this simple Dockerfile as our base.

FROM php:8.1-apache

WORKDIR /var/www/html

It is missing quite a few things to make our app actually work, but I’m only showing the relevant parts. Now let’s add Xdebug inthere

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/bin/RUN install-php-extensions xdebug

I’m using https://github.com/mlocati/docker-php-extension-installer to install the PHP extension, feel free to install it with your preferred method, but if you don’t know this installer you should definitely take a look at it!

Let’s check it is now installed. I am using docker-compose here for the sake of simplicity, but you can adapt this to a standalone docker run, just don’t forget to rebuild the container.

$ docker-compose up -d --build
$ docker-compose exec app php -v
PHP 8.1.6 (cli) (built: May 28 2022 08:14:41) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.1.6, Copyright (c) Zend Technologies
with Xdebug v3.1.4, Copyright (c) 2002-2022, by Derick Rethans

Looks like everything is fine. Now to the configuration, it’s not going to work out of the box. The tricky part actually comes here, we have to understand how Xdebug works, and how Docker networking works. The solution is actually pretty straight forward, but that’s an interesting part to understand.

So, how does Xdebug work? Basically, what we installed in the image is a PHP extension, it is the client part of XDebug. When a user — you — initiate a request and Xdebug is ON, it will need to establish a connection somewhere, usually back to the request initiator. So your local machine, actually your IDE, will be the server part of Xdebug in this scenario.

Makes sense? Alright, so when we work outside of Docker, that’s really transparent as those two parts will usually be located on the same network address: 127.0.0.1, aka localhost.

Now inside Docker, that’s not the case anymore, the Xdebug server is still on the samelocalhost, this is our IDE, but the application server — the XDebug client — is located inside a container, and localhost will of course resolve to the container itself from inside the container.

So we need to establish a connection from inside this Docker container to our host, which is quite unusual. This is where understanding a bit of Docker networking comes useful.

When you work with containers, it’s a common need to establish connections between containers, but not to the host. To establish these containers connections, Docker internally creates networks on your machine, and also gives some superpowers to your DNS resolution. Basically containers are able to reach other containers by their name, given they’re mutually reachable on their network.

By default, Docker uses a bridge network mode, refer to the documentation if you want more details on that but the important part is the gateway of these networks. The gateway happens to be our locahost, so it’s available as the first address on every internal network, e.g. given a network 192.168.0.0/24, the gateway is at 192.168.0.1.

$ ifconfig | grep docker
docker0: flags=4099<UP,BROADCAST,MULTICAST> mtu 1500
$ ifconfig docker0
docker0: flags=4099<UP,BROADCAST,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255
inet6 fe80::42:23ff:feef:4a prefixlen 64 scopeid 0x20<link>
ether 02:42:23:ef:00:4a txqueuelen 0 (Ethernet)
RX packets 63211 bytes 3439046 (3.4 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 113101 bytes 270765165 (270.7 MB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

So we kind of know where to find our localhost from inside a container, it is at 172.17.0.1from the above output. There is one issue remaining though, we can’t really guess or assume what will be the internal network range created by Docker. It can change based on various factors, plus you may have several networks on your machine.

Here’s the trick, as Docker is able to give superpowers to your internal DNS resolution, it’s also able to give one more superpower, thanks to host.docker.internal and host-gateway internal aliases.

The former comes from Docker for Windows and also works on Docker for Mac I guess, and the latter has been added for linux.

host-gateway is an alias which resolve at container startup, that means you can’t use it directly in your container.

root@c7e4089dadc0:/var/www/html# ping host.docker.internal
ping: host.docker.internal: Name or service not known
root@c7e4089dadc0:/var/www/html# ping host-gateway
ping: host-gateway: Temporary failure in name resolution

So the trick is to add a so-called extra-host to our container, which points to host-gateway. This will ultimately end up in the /etc/hosts of our container, with a resolved address.

services:
app:
extra_hosts:
host.docker.internal: host-gateway

Now we’re able to reach our host on the address host.docker.internal

root@c7e4089dadc0:/var/www/html# ping host.docker.internal
PING host.docker.internal (172.17.0.1) 56(84) bytes of data.
64 bytes from host.docker.internal (172.17.0.1): icmp_seq=1 ttl=64 time=0.157 ms
root@c7e4089dadc0:/var/www/html# cat /etc/hosts | grep host.docker.internal172.17.0.1 host.docker.internal

We could talk about why my machine happily answers to ICMP requests, but let’s stay on topic.

Alright let’s finish this setup. I’ll use the php.ini configuration mode here, you can also use environment variables, that’s up to you and your needs, refer to Xdebug documentation for your specific needs.

NB: Xdebug had major changes in how the configuration is done between v2 and v3, refer to the documentation to get equivalences, I’ll show only v3 here.

## docker-php-ext-xdebug.ini[xdebug]
zend_extension=xdebug.so
xdebug.log =/var/www/html/symfony/var/log/xdebug.log
xdebug.mode=debug
xdebug.client_host=host.docker.internal

Copy this file in the Docker image:

COPY docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/

Finally rebuild & reboot the container: docker-compose up -d --build

Now let’s verify our Storm configuration, go to the Settings, find the PHP section and open the CLI interpreters.

It is the exact same one as in the previous article. If we then click on the refresh button in the General section, Storm should see we’re now using PHP 8.1 with Xdebug 3.1.4.

Let’s now go back to our dummy test we used last time. Instead of just running it, this time I’ll put a break-point on the line, and run with debug.

This instantly opens up the debug tool. From there, we can inspect everything, go step by step inside our program, basically just dissect what’s happening.

Now we can do the same with a request originating from a browser, or a terminal, let’s try.

For this we need to add a server in Storm config. If you don’t want to do that now, you’ll get prompted later by Storm for an unknown incoming connection to accept. So go back into PHP settings, find the server menu, and add a server here. We also need to setup path mapping for this server, using a mapping at the root level of your project is enough, so let’s do this.

If you want to use it from a browser, you will have to adapt the Host section too, it’s used by storm to differentiate connections, it has to match the address you use in your browser to reach your app. If you only want to use it from CLI, you can put whatever you want in there, only the name field has to be referenced from CLI.

We also need to tell Storm to listen for connections, remember we are the server here. There is a little button for that on the top-right corner of Storm, which looks like a phone symbol, near the “run tests” button. You can also find the action under the run menu of the toolbar. Both read “Start listening for PHP debug connections”, so toggle this on.

Now let’s try from the CLI

root@c7e4089dadc0:/var/www/html/symfony# XDEBUG_SESSION=1 PHP_IDE_CONFIG="serverName=app" bin/phpunit

XDEBUG_SESSION=1 is one way of triggering Xdebug through environment variable, it might not be required depending on your config. On the other hand PHP_IDE_CONFIG is mandatory, this is what tell Storm to use the correct server, and especially its path mappings. So serverName=app matches the name of the server I previously created. This results in the same debug tool opening as previously.

Now from a browser, here again multiple methods to trigger Xdebug, I’ll use a browser extension. So debug mode on in our extension, let’s create a dummy controller to have something to break on.

Finally hit the homepage, and yeah same result again, the debug tool opens up as expected.

That’s it! You are now able to install Xdebug in Docker by yourself and use it from Storm, a browser or a CLI. Next time you start debugging with var_dump , consider investing a few minutes in installing Xdebug, especially if you have to deal with legacy, vendors, or stuff like recursion.

Obviously I should mention the Symfony VarDumper component and its dump server which is super handy too!

--

--