In this part of the tutorial series on developing PHP on Docker we
will set up our local development environment to be used by PhpStorm and Xdebug. We will also
ensure that we can run PHPUnit tests from the command line as well as from PhpStorm and throw
the tool strace
into the mix for debugging long-running processes.
All code samples are publicly available in my
Docker PHP Tutorial repository on Github.
You find the branch for this tutorial at
part-4-2-phpstorm-docker-xdebug-3-php-8-1-in-2022
All published parts of the Docker PHP Tutorial are collected under a dedicated page at Docker PHP Tutorial. The previous part was Docker from scratch for PHP 8.1 Applications in 2022 and the following one is Run Laravel 9 on Docker in 2022.
If you want to follow along, please subscribe to the RSS feed or via email to get automatic notifications when the next part comes out :)
Table of contents
Introduction
This article is mostly an update of Setting up PhpStorm with Xdebug for local development on Docker but will also cover the "remaining cases" of debugging php-fpm and php worker processes.
We will still rely on an always-running docker setup that we connect to via an SSH Configuration instead of using the built-in docker-compose capabilities as I feel it's closer to what we do in CI / production. However, we will not use SSH keys any longer but simply authenticate via password. This reduces complexity and removes any pesky warnings regarding "SSH keys being exposed in a repository".
Install Tools
Install composer
Composer is installed by pulling
the official composer docker image and simply "copying" the
composer executable over to the base php image. In addition, composer needs the extensions
mbstring
and phar
# File: .docker/images/php/base/Dockerfile
ARG ALPINE_VERSION
ARG COMPOSER_VERSION
FROM composer:${COMPOSER_VERSION} as composer
FROM alpine:${ALPINE_VERSION} as base
# ...
RUN apk add --update --no-cache \
php-mbstring~=${TARGET_PHP_VERSION} \
php-phar~=${TARGET_PHP_VERSION} \
# ...
COPY --from=composer /usr/bin/composer /usr/local/bin/composer
Because we want our build to be deterministic, we "pin" the composer version by adding a
COMPOSER_VERSION
variable to the .docker/.env
file
COMPOSER_VERSION=2.2.5
and using it in .docker/docker-compose/docker-compose-php-base.yml
:
services:
php-base:
build:
args:
- COMPOSER_VERSION=${COMPOSER_VERSION?}
Install Xdebug
Install the extension via apk
(only for the local
target):
# File: .docker/images/php/base/Dockerfile
FROM base as local
RUN apk add --no-cache --update \
php-xdebug~=${TARGET_PHP_VERSION} \
# ensure that xdebug is not enabled by default
&& rm -f /etc/php8/conf.d/00_xdebug.ini
We also don't want to enable xdebug
immediately but only when we need it (due to the decrease
in performance when the extension is enabled), hence we remove the default config file and
disable the extension in the application .ini
file
# File: .docker/images/php/base/conf.d/zz-app-local.ini
; Note:
; Remove the comment ; to enable debugging
;zend_extension=xdebug
xdebug.client_host=host.docker.internal
xdebug.start_with_request=yes
xdebug.mode=debug
See Fix Xdebug on PhpStorm when run from a Docker container
for an explanation of the xdebug.client_host=host.docker.internal
setting (previously called
xdebug.remote_host
in xdebug < 3). This will still work out of the box for Docker Desktop, but
for Linux users we need to add the
host-gateway
magic reference
to all PHP containers (we can't add it to the php base image because this is a runtime setting):
services:
service:
extra_hosts:
- host.docker.internal:host-gateway
Finally, we need to add
the environment variable PHP_IDE_CONFIG
to all PHP containers. The variable is defined as PHP_IDE_CONFIG=serverName=dofroscra
, where
"dofroscra" is the name of the server that we will configure later for debugging. Because we
need the same value in multiple places, the variable is configured in .docker/.env
:
PHP_IDE_CONFIG=serverName=dofroscra
And then added in
.docker/docker-compose/docker-compose.local.yml
services:
php-fpm:
environment:
- PHP_IDE_CONFIG=${PHP_IDE_CONFIG?}
php-worker:
environment:
- PHP_IDE_CONFIG=${PHP_IDE_CONFIG?}
application:
environment:
- PHP_IDE_CONFIG=${PHP_IDE_CONFIG?}
Install PHPUnit
PHPUnit will be installed via composer
but will not be "baked into the image" for local
development. Thus, we must run composer require
in the container. To make this more
convenient a make target for running arbitrary composer commands is added in
.make/01-00-application-setup.mk
:
.PHONY: composer
composer: ## Run composer commands. Specify the command e.g. via ARGS="install"
$(EXECUTE_IN_APPLICATION_CONTAINER) composer $(ARGS);
This allows me to run make composer ARGS="install"
from the host system to execute composer
install
in the container. In consequence, composer
will use the PHP version and extensions of
the application
container to install the dependencies, yet I will still see the installed files
locally because the codebase is configured as a volume for the container.
Before installing phpunit, we must add the required extensions dom
and xml
to the container
# File: .docker/images/php/base/Dockerfile
# ...
RUN apk add --update --no-cache \
php-dom~=${TARGET_PHP_VERSION} \
php-xml~=${TARGET_PHP_VERSION} \
as well as rebuild and restart the docker setup via
make docker-build
make docker-down
make docker-up
Now we can add phpunit via
make composer ARGS='require "phpunit/phpunit"'
which will create a composer.json
file and setup up the vendor/
directory:
$ make composer ARGS='require "phpunit/phpunit"'
Using version ^9.5 for phpunit/phpunit
./composer.json has been created
Running composer update phpunit/phpunit
Loading composer repositories with package information
Updating dependencies
...
CAUTION: If you run into the following permission error at this step, you are likely using
Linux and
haven't set the APP_USER_ID
and APP_GROUP_ID
variables as described in the previous article
under
Solving permission issues.
make composer ARGS='req phpunit/phpunit' ENV=local TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra APP_USER_NAME=application APP_GROUP_NAME=application docker-compose -p dofroscra_local --env-file ./.docker/.env -f ./.docker/docker-compose/docker-compose.yml -f ./.docker/docker-compose/docker-compose.local.yml exec -T --user application application composer req phpunit/phpunit
./composer.json is not writable.
make: *** [.make/01-00-application-setup.mk:14: composer] Error 1
I have also added
- a minimal
phpunit.xml
config file - a test case at
tests/SomeTest.php
- and a new Makefile for "anything related to qa" at
.make/01-02-application-qa.mk
:
##@ [Application: QA]
.PHONY: test
test: ## Run the test suite
$(EXECUTE_IN_WORKER_CONTAINER) vendor/bin/phpunit -c phpunit.xml
So I can run tests simply via make test
$ make test
ENV=local TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra APP_USER_NAME=application APP_GROUP_NAME=application docker-compose -p dofroscra_local --env-file ./.docker/.env -f ./.docker/docker-compose/docker-compose.yml -f ./.docker/docker-compose/docker-compose.local.yml exec -T --user application php-worker vendor/bin/phpunit
PHPUnit 9.5.13 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.324, Memory: 4.00 MB
OK (1 test, 1 assertion)
Install SSH
We will execute commands from PhpStorm via ssh in the application
container. As mentioned, we
won't use a key file for authentication but will instead simply use a password that is
configured via the APP_SSH_PASSWORD
variable in .docker/.env
and passed to the image in
.docker/docker-compose/docker-compose.local.yml
. In addition, we map port 2222
from the
host system to port 22
of the application container and make sure that the codebase is shared
as a volume between host and container
application:
build:
args:
- APP_SSH_PASSWORD=${APP_SSH_PASSWORD?}
volumes:
- ${APP_CODE_PATH_HOST?}:${APP_CODE_PATH_CONTAINER?}
ports:
- "${APPLICATION_SSH_HOST_PORT:-2222}:22"
The container already contains openssh
and sets the password
ARG BASE_IMAGE
FROM ${BASE_IMAGE} as base
FROM base as local
RUN apk add --no-cache --update \
openssh
ARG APP_SSH_PASSWORD
RUN echo "$APP_USER_NAME:$APP_SSH_PASSWORD" | chpasswd 2>&1
# Required to start sshd, otherwise the container will error out on startup with the message
# "sshd: no hostkeys available -- exiting."
# @see https://stackoverflow.com/a/65348102/413531
RUN ssh-keygen -A
# we use SSH deployment configuration in PhpStorm for local development
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]
Setup PhpStorm
We will configure a remote PHP interpreter that uses an SSH connection to run commands in the
application
container. Before,
we have been using an SFTP Deployment configuration
, which was kinda confusing ("What is SFTP doing here?"), so we will use an
SSH Configuration
instead and configure the path mappings in the Cli Interpreter interface
SSH Configuration
At File | Settings | Tools | SSH Configurations
create a new SSH Configuration named
"Docker PHP Tutorial" with the following settings
- Host: 127.0.0.1
- Port: see
APPLICATION_SSH_HOST_PORT
in.docker/docker-compose/docker-compose.local.yml
- User name: see
APP_USER_NAME
in.make/.env
- Authentication type: Password
- Password: see
APP_SSH_PASSWORD
in.docker/.env
PHP Interpreter
At File | Settings | PHP
add a new PHP CLI interpreter that uses the new SSH Configuration
In addition, we define the path to the xdebug extension because it is disabled by default but
PhpStorm can enable it automatically if required. You can find the path in the application
container via
root:/var/www/app# php -i | grep extension_dir
extension_dir => /usr/lib/php8/modules => /usr/lib/php8/modules
root:/var/www/app# ll /usr/lib/php8/modules | grep xdebug
-rwxr-xr-x 1 root root 303936 Jan 9 00:21 xdebug.so
We still need to
Fix Xdebug on PhpStorm when run from a Docker container
by adding a custom PHP option for xdebug.client_host=host.docker.internal
. That's the same value
we use in .docker/images/php/base/conf.d/zz-app-local.ini
.
In the interpreter overview we must now configure the path mappings so that PhpStorm knows
"which local file belongs to which remote one". The remote folder is defined in .docker/.env
via
APP_CODE_PATH_CONTAINER=/var/www/app
Afterwards we can set a breakpoint e.g. in setup.php
and start debugging:
The screenshot shows that PhpStorm adds the Xdebug extension that we defined previously.
PHPUnit
phpunit
is configured via File | Settings | PHP | Test Frameworks
. First, we select the
interpreter that we just added
Then, we add the paths to the composer autoload script and the phpunit.xml
configuration file.
PhpStorm will now execute tests using the PHP interpreter in the application
container
Debugging
First of all, if you haven't already please also take a look at the official xdebug documentation. Derick is doing a great job at explaining xdebug in detail including some helpful videos like Xdebug 3: Xdebug with Docker and PhpStorm in 5 minutes
Debug code executed via PhpStorm
This should already work out of the box. Simply set a break point, right-click on a file and choose "Debug '...'"
Debug code executed via php-fpm, cli or from a worker
For code that is executed "directly" by a container without PhpStorm, we first need to enable
xdebug
in the container by removing the ;
in front of the extension in
/etc/php8/conf.d/zz-app-local.ini
; Note:
; Remove the comment ; to enable debugging
zend_extension=xdebug
To make this a little more convenient, we use dedicated make recipes for those actions in
.make/01-01-application-commands.mk
.PHONY: execute-in-container
execute-in-container: ## Execute a command in a container. E.g. via "make execute-in-container DOCKER_SERVICE_NAME=php-fpm COMMAND="echo 'hello'"
@$(if $(DOCKER_SERVICE_NAME),,$(error DOCKER_SERVICE_NAME is undefined))
@$(if $(COMMAND),,$(error COMMAND is undefined))
$(EXECUTE_IN_CONTAINER) $(COMMAND);
.PHONY: enable-xdebug
enable-xdebug: ## Enable xdebug in the given container specified by "DOCKER_SERVICE_NAME". E.g. "make enable-xdebug DOCKER_SERVICE_NAME=php-fpm"
"$(MAKE)" execute-in-container APP_USER_NAME="root" DOCKER_SERVICE_NAME=$(DOCKER_SERVICE_NAME) COMMAND="sed -i 's/.*zend_extension=xdebug/zend_extension=xdebug/' '/etc/php8/conf.d/zz-app-local.ini'"
.PHONY: disable-xdebug
disable-xdebug: ## Disable xdebug in the given container specified by "DOCKER_SERVICE_NAME". E.g. "make enable-xdebug DOCKER_SERVICE_NAME=php-fpm"
"$(MAKE)" execute-in-container APP_USER_NAME="root" DOCKER_SERVICE_NAME=$(DOCKER_SERVICE_NAME) COMMAND="sed -i 's/.*zend_extension=xdebug/;zend_extension=xdebug/' '/etc/php8/conf.d/zz-app-local.ini'"
To capture incoming requests, we need to make PhpStorm listen for PHP Debug connections via
Run | Start Listening for PHP Debug Connections
.
The corresponding ports are configured at File | Settings | PHP | Debug
. In Xdebug < 3 the
default port was 9000
and in Xdebug 3 it is 9003
Finally, we need to add a server via File | Settings | PHP | Servers
The name of the server must match the value of the serverName
key in the environment variable
PHP_IDE_CONFIG
that we configured previously as serverName=dofroscra
.
php-fpm
For php-fpm
we must
restart the php-fpm
process without restarting the container
after we have activated xdebug
via
kill -USR2 1
Since this is a pain to remember, we add a make target in .make/01-01-application-commands.mk
# @see https://stackoverflow.com/a/43076457
.PHONY: restart-php-fpm
restart-php-fpm: ## Restart the php-fpm service
"$(MAKE)" execute-in-container DOCKER_SERVICE_NAME=$(DOCKER_SERVICE_NAME_PHP_FPM) COMMAND="kill -USR2 1"
So we can now simply run
make enable-xdebug DOCKER_SERVICE_NAME=php-fpm
make restart-php-fpm
Setting a breakpoint in public/index.php
and opening http://127.0.0.1/ in
a browser or via curl http://127.0.0.1/
will halt the execution as expected.
cli
Instead of triggering a PHP script via HTTP request, we can also run CLI scripts - think of the
make setup-db
target for instance. To debug such invocations, we need to follow the same steps
as before:
- enable the
xdebug
extension in theapplication
container - "Listening for PHP Debug Connections" from PhpStorm
Running the following make targets will trigger a breakpoint in setup.php
:
make enable-xdebug DOCKER_SERVICE_NAME=application
make setup-db
php-workers
And finally the same thing for long running PHP processes (aka workers). Just as before:
- enable the
xdebug
extension in thephp-worker
container - "Listening for PHP Debug Connections" from PhpStorm
- restart the php workers
Running the following make targets will trigger a breakpoint in worker.php
:
make enable-xdebug DOCKER_SERVICE_NAME=php-worker
make restart-workers
strace
strace is a great tool for debugging long running processes that I've adopted after reading What is PHP doing?. I've added it to the php base image:
RUN apk add --update --no-cache \
strace
You can attach to any running process via sudo strace -p $processId
- BUT that doesn't work
out of the box on docker and will fail with the error message
strace: attach: ptrace(PTRACE_SEIZE, 1): Operation not permitted
This is caused by a security measure from docker and can be circumvented by adding
services:
service:
cap_add:
- "SYS_PTRACE"
security_opt:
- "seccomp=unconfined"
in .docker/docker-compose/docker-compose.local.yml
to all PHP containers. After
rebuilding and restarting the docker setup, you can now e.g. log in the php-worker
container
and run strace
on a php worker process:
application:/var/www/app# ps aux
PID USER TIME COMMAND
1 applicat 0:00 {supervisord} /usr/bin/python3 /usr/bin/supervisord
7 applicat 0:00 php /var/www/app/worker.php
8 applicat 0:00 php /var/www/app/worker.php
9 applicat 0:00 php /var/www/app/worker.php
10 applicat 0:00 php /var/www/app/worker.php
11 applicat 0:00 bash
20 applicat 0:00 ps aux
application:/var/www/app# sudo strace -p 7
strace: Process 7 attached
restart_syscall(<... resuming interrupted read ...>) = 0
poll([{fd=4, events=POLLIN|POLLPRI|POLLERR|POLLHUP}], 1, 0) = 0 (Timeout)
sendto(4, "*2\r\n$4\r\nRPOP\r\n$5\r\nqueue\r\n", 25, MSG_DONTWAIT, NULL, 0) = 25
poll([{fd=4, events=POLLIN|POLLPRI|POLLERR|POLLHUP}], 1, 0) = 1 ([{fd=4, revents=POLLIN}])
recvfrom(4, "$", 1, MSG_PEEK, NULL, NULL) = 1
Wrapping up
Congratulations, you made it! If some things are not completely clear by now, don't hesitate to leave a comment. Apart from that, you should now have a fully configured development setup that works with PhpStorm as your IDE.
In the next part of this tutorial, we will use a fresh installation of Laravel on top of our setup.
Please subscribe to the RSS feed or via email to get automatic notifications when this next part comes out :)
Wanna stay in touch?
Since you ended up on this blog, chances are pretty high that you're into Software Development (probably PHP, Laravel, Docker or Google Big Query) and I'm a big fan of feedback and networking.
So - if you'd like to stay in touch, feel free to shoot me an email with a couple of words about yourself and/or connect with me on LinkedIn or Twitter or simply subscribe to my RSS feed or go the crazy route and subscribe via mail and don't forget to leave a comment :)