Debugging PHP in Docker with an AI Agent and Xdebug
DEV Community

Debugging PHP in Docker with an AI Agent and Xdebug

Debugging PHP in Docker with an AI Agent and Xdebug

For a while now I have used LLMs to automate parts of my work. One of those parts is debugging hard problems with Xdebug. Before, I set the breakpoints by hand and built complicated requests to see what happens inside. It usually took a few requests, and I had to move the breakpoints again and again. Then I found that PhpStorm has Xdebug in its MCP server. For simple GET requests, that is enough. But it fails on endpoints that need authorization, and on any method other than GET. So I did not wait for JetBrains. I wrote my own small MCP server, and it makes this work much faster. I share this tool with you, and I hope it helps you too - especially if you work with Docker.

TL;DR

Tired of reading, and you have not started yet? Fair enough. Straight to setting up the MCP server in PhpStorm, or straight to installing xdbg.

AI agents are good at debugging from code alone - until the bug lives in runtime state. Then you need Xdebug, and every existing Xdebug tool is built for humans clicking through a GUI. PhpStorm ships an MCP server since 2025.2, but it is GET-only, has no headers, no path for CLI in Docker, and no control over Xdebug inside the container. So I built xdbg: an MCP server that gives an agent the full loop - enable Xdebug, set a breakpoint, fire a real request, step, inspect, detach.

  • Licence: MIT
  • Install: one-line
  • Compatible with: Claude Code, opencode, Cursor and any MCP-capable client
  • Source: github.com/crazy-goat/xdbg

Written against xdbg v0.1.1 (Go 1.26), Docker 29.7.2 / Compose 5.5.0, and any PHP 8 image with Xdebug 3. PhpStorm checked on 2025.2 and again on 2026.2.1.


The Mighty Duo: var_dump and die

This is the standard. You need to check something fast, so you print the value and stop the script. It is quick, it always works, and it needs no setup.

function add(int $a, int $b): int
{
    $result = $a + $b;
    var_dump($result);
    die();
}

It also has costs:

  • You are editing the code in order to debug it, and you only see the one place you thought to print.
  • Inside a loop, var_dump fills the screen a thousand times and you scroll for the single line that matters.
  • die() gives you exactly one breakpoint: it kills the process on the spot, so you cannot continue and see what came after it.

That hurts most in an event loop, where the state you actually want often shows up on the second pass - and die() never lets you get there.


Analysis by an LLM

Most bugs come straight from the code, and an LLM finds those much faster than any debugger. You paste the stack trace, you show the file, and you have an answer in seconds. But this method has a clear limit. With harder problems, with your own framework, or with anything the model has not seen before, there is a real chance the LLM will invent a problem instead of finding the one you have.

For example, this one. php-fpm kept dying with a segfault, but only on Colima. The LLM was sure the fault sat in the container runtime: first Colima, then Podman, then Rosetta. Every answer sounded reasonable, and every one of them was wrong. It changed its mind only after I asked it to write a test - and then it found that the bug was in PHP itself, and had been there for years. The story of that hunt is here. The two lines in question, in ext/opcache/shared_alloc_mmap.c:

OPcache now reserves requested_size + huge_page_size before it remaps the block with MAP_HUGETLB, so a kernel that fails halfway cannot leave a hole behind.

Nothing an agent could have guessed from my application code.

Click to open full size.

And a made-up answer looks exactly like a correct one, so you need a cheap way to tell the two apart.


Debugging with Tests

That cheap way is a test. Ask the LLM to write one that proves the problem is real: if it was right, the test fails for exactly the reason it gave you, and if it made the problem up, the test passes and you have lost two minutes. Either way you now know, and you also keep the test.

From my experience, a bug is very often a feature that somebody forgot about. A test written this way also writes that feature down, so the next person does not have to guess what the code was supposed to do. It costs a little more time than a quick print, and it gives that time back later.

But some bugs are very hard to put into a test. The state that breaks them only exists in the running system - the real database, the real queue, the real timing, a service that answers differently today than it did yesterday. A test has to pretend all of that, and it pretends what you already believe. For bugs like these you have to look at the living thing, so you need something else.


Manual Debugging in the IDE

That something else is a real debugger. For a human the most comfortable one is an IDE like PhpStorm. You set the breakpoint by hand, you fire the request, and you walk through the code line by line, looking at the state as you go. It works, and for a long time it was the only way.

But it has costs:

  • You have to know where to put the breakpoint before you know what is wrong, so if you guess badly you start again.
  • Every repeat means the same clicks: listen, fire the request, step, look.
  • One wrong choice - step into instead of step over - can cost you a lot of time, because now you are ten frames deep inside framework code you never wanted to see.

A real session: stopped at StreamConnection.php:154, three frames deep, with $this open. Every part of this I had to ask for by hand - where the breakpoint goes, when to run, and each step after that.

Click to open full size.

And here is the part that annoys me most, which is not PhpStorm's fault at all: I forget to turn the debugger off. Then I run the tests, or a linter, or just one ordinary request, and the process stops somewhere for no visible reason. The classic version goes like this:

  1. I have tested the fix, it works, so I tell myself: full test suite and then I am done for the day.
  2. I start the suite and go to make a drink, because the suite takes a while.
  3. I come back, and nothing has moved - the tests are sitting on a breakpoint.
  4. Then I complain quietly to myself for forgetting again.

I would like to blame my memory, and at my age that excuse comes more easily every year. There must be a better way.


The Simplest Path: PhpStorm MCP

And there is. The fastest path to automating Xdebug is PhpStorm - since version 2025.2 it ships with its own MCP server. Go to Settings โ†’ Tools โ†’ MCP Server, click Enable MCP Server, auto-configure the client - Claude Code, Cursor, opencode - and you are in the game. The plugin is installed by default, so there is nothing to fiddle with.

A predefined set of Xdebug tools is ready out of the box:

  • Setting breakpoints (xdebug_set_breakpoint)
  • Starting a PHP server (xdebug_start_server)
  • Making URL requests (xdebug_request)
  • Stepping through code (xdebug_step_into, xdebug_step_over, xdebug_step_out)
  • Inspecting the stack and context

You start a debugging session without leaving the chat - like opening the fridge and discovering there is a warm dinner inside.

The flow is different from manual debugging. Instead of clicking around yourself and deciding where to place a breakpoint, you write a prompt describing the problem and ask the agent to use Xdebug through PhpStorm to find the cause. The agent analyses the code, sets breakpoints on its own where it suspects the problem, then drives the session step by step: reads the stack, inspects variables, dives deeper into calls. If the runtime goes down a different path than expected, it moves the breakpoint, repeats the request, and analyses again. All by itself.

For a start this is close to ideal. But in its current form it comes with a few limits, and they are not small print - they change how you work.


Where PhpStorm MCP Stopped Being Enough

None of what follows is a complaint about PhpStorm - it is a very good IDE and its MCP server does what it set out to do. It is a description of the shape of our problem, which happens to sit outside that shape.

I first hit this on PhpStorm 2025.2, and it is still the same on 2026.2.1 - four releases later.

The biggest one is simple

PhpStorm has to be open, and it has to be open on the right project. The MCP server is the IDE, so the debugger only exists while the window is running and pointing at the code you care about. Switch project, or close the IDE to free some memory, and the agent loses its tools in the middle of a session. It also means the agent shares the debugger with you: your breakpoints, your listening state, your run configurations.

Not everyone has PhpStorm

It is currently the default IDE for many PHP developers, but let us be honest - for people starting their careers the licence can be too expensive, and some simply prefer something else. For them this path is dead from the first minute - a party they cannot enter because they are not on the list.

The rest is about what it does not reach - and almost all of it comes down to one thing: it works as if Docker were not in the picture

  • No control over Xdebug inside the container. PhpStorm MCP does not enable or disable Xdebug in the container - and by default we keep it disabled, because it slows everything down: requests, CLI, tests. Without automatic toggling it is easy to forget to turn it off, and then phpunit, phpstan or composer install stop on breakpoints.
  • Host โ†” container paths. The MCP surface works in host paths - hand it a container path like /var/www/app/src/Kernel.php and it refuses outright with File not found. That part is honest. The trap is what comes next: pass the host path and you get back "Breakpoint created.", but whether that breakpoint ever hits depends on path mappings configured on the IDE side, which the agent cannot see, verify or repair. It receives a confident success and then silence - and starts moving the breakpoint one line at a time trying to work out why nothing stops.
  • No path to a command already running in a container. Debugging bin/console app:foo inside Docker is possible, but only through a run configuration someone created by hand in the GUI, backed by a docker-compose interpreter. The MCP surface cannot create one, and xdebug_start_debugger_session takes only an existing configuration or a local filePath + line - there is no attach-to-running-process mode. So a worker or a cron job that is already executing is out of reach.
  • GET only, no headers. xdebug_request supports only GET and does not allow setting headers. In real API work - POST/PUT/PATCH with JSON, a JWT in Authorization, cookies, Content-Type - that is simply not enough.
  • The port is held for the whole session. PhpStorm MCP starts a session and keeps port 9003 occupied until you turn it off yourself. The agent often forgets, then runs tests - which hang on breakpoints because Xdebug keeps stopping the runtime.
  • Conflicts on port 9003. When the port is occupied you have to manually work out what is listening and which tool is holding the connection.
  • No good documentation. The official docs list the functions but do not describe the process - how to combine them into a working flow. The LLM knows what tools exist, but has to figure out by itself how to use them in practice.

If Not PhpStorm, Then What?

Two MCP servers for Xdebug already existed, and it is worth knowing what they do.

  • koriym/xdebug-mcp - trace-first: forward traces, profiling, coverage - and reaches containers by wrapping a command you hand it: xstep --break=โ€ฆ -- docker compose run --rm php โ€ฆ. Its code excludes php-fpm and php-cgi on purpose: CLI binaries only.
  • kpanuragh/xdebug-mcp - closer to interactive debugging: breakpoints, stepping, watches - with proper container path translation configured through a static PATH_MAPPINGS map.

Both of them miss the part I needed. Neither turns Xdebug on and off inside a running container, so you leave it enabled and then wonder why phpunit hangs. Neither fires the request either - you still bring your own trigger, and for a POST with a 1600-byte bearer token that trigger is most of the problem. So with either one, there is still no way to debug an ordinary web request going through php-fpm in Docker, which is what I do most days.

The older tools do not close it either:

  • phpdbg, PHP's built-in CLI debugger
  • debugclient, the DBGp client shipped with Xdebug
  • Editor integrations such as VS Code PHP Debug

All of them are designed more for humans than for AI. Each works interactively: typing commands, clicking through a GUI, managing the session by hand. Great for debugging by a human, terrible as an interface for an agent that needs a programmable, automated API.

So I had no choice but to vibe-code my own tool. I had a working prototype after 10 minutes and a usable tool within an hour, using only open-source models available through opencode - which significantly reduced the cost. Funnily enough, writing this article took me longer than preparing xdbg together with the repo and docs.


What the Tool Actually Needed

At first I only wanted xdbg_request, because that was what I needed most, with minimal requirements: GET/POST/PUT/โ€ฆ and passing headers. Coding went smoothly. But it quickly turned out that firing requests is only half the job. The other half is knowing whether Xdebug is even enabled. Hence xdbg_container_status, xdbg_container_enable, xdbg_container_disable. Enabled Xdebug reduces performance and can block calls such as phpunit.

On top of that, the agent would often set a breakpoint, start debugging, and execution never paused - so it would rack its brain over what happened, burning through mountains of tokens on guesswork.

Then came the authorization problem. Debugging very often ended with a 401. First thought: the token expired. I generated another one, it seemed to work, and a moment later there was another 401. It took me a while to realise that our token is around 1600 bytes long and the LLM simply slips when copying it into an MCP function. The same happened when a longer JSON

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.