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:
cwdandenvdo 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:
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}" }
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 |