Skip to content

Debug PHP with Bugsaur

Adapter: php — Bugsaur's own native DBGp adapter.

PHP is the one language Bugsaur does not delegate. It ships php-dbgp-adapter, built from the same workspace, which replaces the usual DAP↔DBGp bridge. At runtime only bugsaur, php-dbgp-adapter and Xdebug are involved — no Node.js, no npm, no VS Code, no vscode-php-debug.

PHP works differently from the other languages

This is the one thing to understand before anything else:

The adapter launches nothing — it listens

For Rust, Go and Python, Bugsaur starts the program. For PHP it does not. php-dbgp-adapter opens a DBGp port and waits; the PHP process is started by somebody else — PHP-FPM, Docker, or a CLI invocation.

Two consequences follow, and they explain most of the surprises:

  • cwd and env do not apply. There is no process for Bugsaur to give a working directory or environment to. Set them where PHP actually starts: docker-compose.yml, the FPM pool config, the container's own .env.
  • Only three keys are read from the DAP request body: port, pathMappings, sessionMode.

Install

No external adapter to install. make build produces php-dbgp-adapter next to bugsaur; put the build directory on PATH so the catalog finds it:

make build
export PATH="$PWD/target/debug:$PATH"

Configure Xdebug

In the PHP environment itself — the container, or your local php.ini:

xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003

xdebug.start_with_request=yes means Xdebug tries to connect for every new PHP request. Use localhost instead of host.docker.internal when PHP runs on the same machine rather than in a container.

A request that finished before you started debugging cannot be debugged afterwards

Xdebug connects at the start of a request. If the listener was not running then, that request simply ran without debugging — repeat it after starting the session.

Session modes

sessionMode in launch_arguments decides the lifecycle:

Mode When to use it Order of operations
server PHP-FPM, the built-in server or a Docker web container is already running docker compose up -d web → start the session → send a new request
cli a one-shot CLI script that starts and exits start the session → docker compose run --rm php

server is the recommended mode for ordinary web development: start the container once, use it normally without debugging, and start a session only when you want to inspect a response.

cli exists for the case where the container itself runs a PHP command and exits. There the adapter must be listening before the container starts, or the one-shot process can finish before Xdebug ever connects.

A local project

version = 1
default = "shop"

[profiles.shop]
adapter = "php"
program = "."

[profiles.shop.launch_arguments]
sessionMode = "server"
port = 9003

PHP in Docker

What matters here is path mappings, not the working directory:

[profiles.docker]
adapter = "php"
program = "."

[profiles.docker.launch_arguments]
sessionMode = "server"
port = 9003
pathMappings = { "/app" = "${root}" }   # path in the container -> path on the host

pathMappings translates between the paths Xdebug reports — which exist inside the container — and the paths on your machine. Without it, the debugger stops at a file it cannot show, and breakpoints on host paths never match container paths.

Full walk-through, including the compose setup: Guides → Debug PHP in Docker.

PHPUnit under the cursor

To debug the test under your cursor, add the command that runs PHPUnit inside the container:

[profiles.docker.launch_arguments]
sessionMode = "server"
port = 9003
pathMappings = { "/app" = "${root}" }
testCommand = ["docker", "compose", "exec", "-T", "-e", "XDEBUG_TRIGGER=1", "php", "php", "vendor/bin/phpunit"]

:DebugTest recognises test* methods and methods carrying the #[Test] attribute in .php files. For a method it passes --filter Class::method; with the cursor outside any method it runs the whole file.

testCommand runs after the adapter has started waiting for Xdebug, so the listener is already up when the connection comes back. XDEBUG_TRIGGER=1 is also passed to the external process.

Two PHP projects at once

The adapter owns the DBGp port, and there is one port per machine — by default 9003. A second project starting at the same time is refused. Give each project its own port, and set the same port in its container:

# a/.bugsaur/config.toml
[profiles.docker.launch_arguments]
port = 9003
pathMappings = { "/app" = "${root}" }

# b/.bugsaur/config.toml
[profiles.docker.launch_arguments]
port = 9004
pathMappings = { "/app" = "${root}" }
; project B's xdebug.ini
xdebug.client_port=9004

The windows separate themselves: they are named after the project directory.

Diagnostics

Symptom Likely cause What to do
Breakpoints show a dimmed ring Xdebug has not connected yet normal before the first request — send one
Session starts, nothing ever stops the request ran before the listener was up, or start_with_request is off repeat the request; check xdebug.mode=debug
Port already in use another project holds 9003 give this project its own port, above
Stops, but no source is shown pathMappings missing or wrong Source not found
Xdebug cannot reach the host client_host wrong for the setup host.docker.internal from a container, localhost locally
cwd / env in the profile do nothing by design — the adapter launches nothing set them where PHP starts