Extreme debugging in PHP
It was one of those akward, lonely and quiet nights. I could not tell what Spotify was playing, nor what time it was. My eyes were fixed on my terminal’s cursor. It was blinking, constantly blinking, like it was taunting me.
Some of my tests were crashing, unexpectedly, while PHPUnit was trying to generate coverage. There was no exception, no error, no nothing. Coverage was never created and the process was exiting with code 143.
What the actual 🤬?
So, first things first. How can you debug your tests, or even PHPUnit in such a case? The answer is easy. You don’t because you can’t. Such behavior alludes that something quite sinister lurks in your code. Something that crashes deep inside PHP’s code. A statement, whose existence, creates a fatal error.
But we aren’t here to talk about what you can’t do. We are here to talk about what you can, actually, do.
You can debug PHP!
I’ve lost count how many times I stumbled across a similar issue. Normally, I would totally ignore the issue. I would try my tests one by one in order to determine which one is the problematic. I would surrender, had I not been able to pinpoint the problematic piece of code. But I am not that person and I suspect that you aren’t that person as well. So… Let’s debug it.
For starters, you can’t debug PHP with pre-compiled PHP binaries. You have to properly configure and compile your own binaries! And since we’ll need a whole toolchain for this, let’s Dockerize it.
I’m using Debian for this Docker image, but feel free to use the linux flavor you prefer.
👶 Baby Steps
FROM debian:bullseye-slim
WORKDIR /php
We, obviously, need to install the minimum set of dependencies to build PHP, so let’s have a look at the official repo:
RUN apt-get update && apt-get install -y pkg-config build-essential \
autoconf bison re2c libxml2-dev libsqlite3-dev
We need to fetch the source for the PHP version we want to build. Luckily, we will not have to git clone
PHP’s repo, since PHP offers source code downloads in their page. We’ll use PHP 7.4.33 and we need a way to download it into our image. Unfortunately wget
is not installed in bullseye-slim
so we’ll have to apt-get
it as well.
So, we’ll go to the previous step where we are fetching PHP’s dependencies and we’ll add wget
as well.
After doing so, we are ready to download PHP:
RUN wget -nv https://www.php.net/distributions/php-7.4.33.tar.gz && \
tar xzf php-7.4.33.tar.gz
WORKDIR /php/php-7.4.33
We have downloaded, extracted and cd’d into PHP’s source! Now what? We’ll have to generate the configuration and configure the build prior to compiling:
RUN ./buildconf -f
At this point, we are ready to configure and compile PHP 7.4.33. However, this isn’t a simple, copy snippet / paste snippet, case.
- You have to know how you want to configure your binaries.
- You have to know which PHP extensions your code requires.
- You have to know whether to enable or disable something.
And this is a process of thinking, reading, trying and failing, until you succeed.
So, build your image, create a container and open a shell into it. Your working directory will be the one where PHP’s sources lie. Type ./configure --help | more
and read through the extensive list of configuration options. A minimum debug build requires the flag --enable-debug
.
However, in my case, and most probably in yours as well, you’ll need some other stuff too. Such as cURL, sockets or intl. And in order to set up these, you’ll have some fun.
🛠️ Configuring and Building PHP
Make a list of the extensions you need included and features you need enabled (or disabled). DO NOT ADD THE CONFIGURE OPTIONS TO THE DOCKERFILE, YET.
I repeat, DO NOT ADD THEM TO THE DOCKERFILE, YET!
You remember me telling you, to open a shell into the container you built? The reason you need it, is because you will have to fetch the extensions’ dependencies.
Didn’t I tell you that this is going to be fun?
./configure --enable-fpm \
--enable-debug \
--with-openssl \
--with-zlib \
--with-curl \
--enable-gd \
--with-gettext \
--enable-intl \
--with-ldap \
--with-mysqli \
--enable-pcntl \
--enable-sockets \
--enable-sysvmsg \
--with-xsl \
--with-zip \
--enable-mbstring \
--with-pdo-mysql \
--disable-short-tags
The configure script is going to perform some checks and crash at some point or another, complaining about not being able to find some development dependencies. If it can’t find ldap
you’ll probably have to apt-get install libldap-dev
. If it can’t find Oniguruma, you’ll (not obviously, but probably) have to install libonig-dev
. Rinse and repeat. Use common sense, apt-cache search
and Google, in order to determine what you actually need.
When you have gathered every package you have to install, you’ll have to modify your Dockerfile so that they get fetched, before you even attempt to configure PHP.
Before venturing forth, run a make clean && make -j${nproc} && make install
. Just in case. If everything went well, php -v
should give you output, stating it’s version and that it is a NTS DEBUG
build.
#EverythingIsAwesome.
At this point, we’ll also need xdebug. You know the drill. Grab its source in tar.gz
format from xdebug’s downloads page, bring it into the container and extract it is its own directory. phpize
, configure
, make
.
When everything is tested in the container you are working, you should move everything into your Dockerfile. It should look something like this:
FROM debian:bullseye-slim
RUN apt-get update && \
apt-get upgrade && \
apt-get install pkg-config build-essential autoconf bison re2c wget gdb \
libxml2-dev libsqlite3-dev libonig-dev libz-dev \
libssl-dev libcurl4-openssl-dev libzip-dev libpng-dev \
libldap-dev libxslt-dev
WORKDIR /php
# Download PHP & xdebug
RUN wget -nv https://www.php.net/distributions/php-7.4.33.tar.gz && \
tar xzf php-7.4.33.tar.gz && \
wget -nv https://xdebug.org/files/xdebug-3.1.6.tgz && \
tar xzf xdebug-3.1.6.tgz
# Build PHP
WORKDIR /php/php-7.4.33
RUN mkdir -p /usr/local/etc/php/conf.d/
RUN ./buildconf -f && \
./configure --enable-fpm \
--enable-debug \
--with-openssl \
--with-zlib \
--with-curl \
--enable-gd \
--with-gettext \
--enable-intl \
--with-ldap \
--with-mysqli \
--enable-pcntl \
--enable-sockets \
--enable-sysvmsg \
--with-xsl \
--with-zip \
--enable-mbstring \
--with-pdo-mysql \
--disable-short-tags \
--with-config-file-scan-dir=/usr/local/etc/php/conf.d/ && \
make clean && \
make -j${nproc} && \
make install
# Build xdebug
WORKDIR /php/xdebug-3.1.6
RUN phpize && \
./configure --enable-xdebug && \
make -j${nproc} && \
make install
RUN mkdir -p /var/www/html/
WORKDIR /var/www/html
It’s party time! 🥳
If you are observant enough, you’ll already have noticed that I added gdb
in the packages I am fetching into our image. We’ll need the GNU debugger in order to make PHP crash, then investigate why it crashed.
But before even attempting this, we’ll have to properly configure PHP. php.ini
and the likes.
You’ll need, at least, to increase the memory_limit
to enable xdebug
and to set xdebug.mode
to coverage
. If you are still reading me, you’ll probably have guessed (correctly) that I’ll tell you to Read The Fine Manual, on how to do such things. Yes, I know that you can spam -d
flags to PHP, however I highly suggest to craft a php.ini
and an xdebug.ini
(and each and every other .ini
for each and every other extension you need configured).
And (not only that, but mostly) that is the reason, you will definately need a docker-compose.yaml
. You’ll have to mount your project at /var/www/html
and you’ll have to mount the directory containing your PHP configuration at /usr/local/etc/php/conf.d/
Is your container ready yet?
Open a shell into it…
I cannot say that I am a debugging expert, nor that I navigate through gdb’s commands with ease. I usually work on a need-to-know basis; Google is my best friend. So, unfortunately, this cannot be a gdb tutorial. But I can, at this point, give you some insight into what’s going on.
Assuming that everything went fine, your cursor should be blinking and waiting to do thy bidding. Type gdb php
in order to load PHP into the debugger and watch the cursor blink again. We’ll actually need to r
un something. In my case I have to r ./vendor/bin/phpunit --coverage-clover=clover.xml
And it crashed… 🤬!
Now, we’ll have to figure out why it crashes. Let’s type bt
in order to examine the stack… Get a cheat sheet and happy hunting!
It took me hours to understand what the problem was. PHP was actually trying to perform an invalid operation. I not only had to examine the stack and functions’ parameters. I had PHP’s source code, side by side with my debugger, trying to understand what each line was doing.
Eventually, after hours and hours of examing the values of structures and pointers, reading through C code and trying to follow the execution path, I pinpointed the source of all evil. It was a relatively safe shell_exec,
hidden in a destructor. It seems that, somehow; don’t ask, I never understood; PHP tried to execute this destructor and then all hell broke loose.