Xdebug Proxy — Debug PHP With or Without an IDE
Buggregator receives Xdebug sessions from PHP. Two ways to use it:
- With your IDE. Buggregator sits in the middle and passes the session to the IDE unchanged. The IDE steps and sets breakpoints. The Xdebug page and an AI assistant over MCP only look at the same paused session.
- Without an IDE. No IDE answers, so Buggregator drives the session itself. You step through the code on the Xdebug page in the browser. Example: a script on a test server stops at
xdebug_break(), and you step over it line by line, with no PhpStorm set up.
PHP / Xdebug ──▶ Buggregator :9003 ──▶ IDE (PhpStorm, VS Code…), if one answers
│
├── Xdebug page: reads; steps when no IDE drives
└── MCP tools ◀── AI assistantWhile an IDE is attached, it drives. Buggregator then only reads where the script stopped: the stack, variables and source.
The proxy is available since server v2.6.0, off by default. Driving a session without an IDE is available since server v2.7.0.
Use cases
- Ask an AI assistant about a paused request — "what is in
$orderhere?", while you stay at the breakpoint. - Show a session to a colleague — they open the Xdebug page and see the same stack and variables.
- Keep the IDE in charge — the proxy passes everything through, so the IDE works as before.
- Debug where there is no IDE — a CI runner, a colleague's machine, a container: step through the code in the browser.
Setup
1. Turn the proxy on
xdebug:
enabled: true
addr: 127.0.0.1:9003 # where Xdebug connects
ide_addr: 127.0.0.1:9004 # where your IDE listens
allow_eval: false # let MCP clients and watches evaluate PHP expressions
mode: auto # proxy, auto or standalone; see Modes
pause_on_first_line: false # without an IDE: stop at the first line
idle_timeout: 10m # without an IDE: release a session paused this long; 0 = never
allow_control: true # let MCP clients step, continue and stopOr with environment variables:
| Variable | Default | Description |
|---|---|---|
XDEBUG_PROXY_ENABLED | false | Turn the proxy on (true, 1 or yes) |
XDEBUG_PROXY_ADDR | 127.0.0.1:9003 | Where Xdebug connects |
XDEBUG_PROXY_IDE_ADDR | 127.0.0.1:9004 | Where the IDE listens for debug connections |
XDEBUG_PROXY_ALLOW_EVAL | false | Allow evaluating PHP expressions (see Eval) |
XDEBUG_PROXY_MODE | auto | proxy, auto or standalone (see Modes). An unknown value falls back to auto, with a warning in the log |
XDEBUG_PROXY_PAUSE_ON_FIRST_LINE | false | A session driven by Buggregator stops at the first line. Otherwise it runs to a breakpoint or xdebug_break() |
XDEBUG_PROXY_ALLOW_CONTROL | true | Let MCP clients drive a session Buggregator drives (see MCP tools); false = Xdebug tools only read (except xdebug_eval with allow_eval) |
XDEBUG_PROXY_IDLE_TIMEOUT | 10m | A session paused by Buggregator with no command this long is released, and the script runs to the end. A Go duration (90s, 10m); 0 = never. A bad value keeps 10m, with a warning |
For the MCP tools, also turn on MCP (mcp.enabled: true).
2. Free the port for the proxy
The proxy and the IDE cannot both listen on port 9003 on one machine. Pick one of two setups.
A. Move the IDE to another port. Xdebug stays unchanged.
- IDE: set the Xdebug debug port to
9004(PhpStorm: Settings → PHP → Debug → Xdebug → Debug port). - Buggregator: the defaults (
addr: 127.0.0.1:9003,ide_addr: 127.0.0.1:9004). - Xdebug: unchanged (
xdebug.client_port=9003).
B. Keep the IDE on 9003. Point Xdebug at the proxy instead.
- IDE: unchanged (port
9003). - Buggregator:
addr: 127.0.0.1:9010,ide_addr: 127.0.0.1:9003. - Xdebug:
xdebug.client_port=9010.
3. Point Xdebug at Buggregator
A typical php.ini for setup A:
xdebug.mode=debug
xdebug.client_host=127.0.0.1
xdebug.client_port=9003
xdebug.start_with_request=triggerThen start a debug session in the IDE as usual and stop at a breakpoint.
Docker
When Buggregator runs in a container and the IDE on your machine, the proxy must listen on all interfaces inside the container and dial the host.
PHP in a container too. PHP reaches the proxy over the Docker network, so port 9003 is not published, and the IDE can stay on port 9003:
services:
buggregator:
image: ghcr.io/buggregator/server:latest
ports:
- 127.0.0.1:8000:8000
environment:
XDEBUG_PROXY_ENABLED: "true"
XDEBUG_PROXY_ADDR: 0.0.0.0:9003
XDEBUG_PROXY_IDE_ADDR: host.docker.internal:9003
extra_hosts:
- host.docker.internal:host-gateway # needed on LinuxIn the PHP container, set xdebug.client_host to the Buggregator service name (e.g., buggregator).
PHP on your machine. Then Xdebug needs the proxy on a host port, and that port cannot also be the IDE's. Publish 9003 and move the IDE to 9004:
ports:
- 127.0.0.1:8000:8000
- 127.0.0.1:9003:9003
environment:
XDEBUG_PROXY_ENABLED: "true"
XDEBUG_PROXY_ADDR: 0.0.0.0:9003
XDEBUG_PROXY_IDE_ADDR: host.docker.internal:9004Set the IDE's Xdebug debug port to 9004, and keep xdebug.client_host=127.0.0.1, xdebug.client_port=9003.
The proxy has no login of its own. Anyone who can reach its port can start a session. Keep it on
127.0.0.1or inside your Docker network.
Modes
mode decides what happens when a session arrives.
| Mode | IDE answers | No IDE answers |
|---|---|---|
proxy | the IDE drives | Buggregator detaches; the script runs on as if there were no debugger |
auto (default) | the IDE drives | Buggregator drives the session |
standalone | Buggregator does not call the IDE | Buggregator drives the session |
Change the mode for new sessions without a restart: on the Xdebug page, click the Mode: … tag or the gear (Debugger settings), pick Proxy to IDE, Auto or Standalone, and Save. The same dialog has the Pause on the first line switch. Or use PUT /api/xdebug/settings (see HTTP API). Sessions that already run keep how they started. The change is not written to the config: after a restart, the config value applies again.
Debug without an IDE
- Turn the proxy on. The default
mode: autois enough. No IDE needs to listen onide_addr. - Tell the script where to stop. Any of these:
- a breakpoint on the page: type
/app/src/Cart.php:42in Breakpoints, or click a line number while a session is paused; xdebug_break();in the code;- Pause on the first line (
pause_on_first_line: true): every session stops before its first line runs.
- a breakpoint on the page: type
- Run the request with Xdebug, e.g.
php -d xdebug.mode=debug -d xdebug.start_with_request=yes script.php. - Open the Xdebug page. The session is Paused. Step with the buttons above the code.
With none of these, the script runs to the end and does not stop.
What the buttons do:
| Button | What happens |
|---|---|
| Continue | Runs to the next breakpoint or xdebug_break(), or to the end |
| Step over | The next line; runs a function call without stopping in it |
| Step into | The next line; goes into a function call |
| Step out | Runs to the end of the current function and stops in the caller |
| Release | Lets the script run to the end without stopping again |
| Stop | Ends the request now. The rest of the script does not run, so work can stay half done. Asks first: Stop this request?, with Stop request, Release instead and Cancel |
Step over, Step into and Step out are icons; hover one for its name. Below 1180 px wide, Continue, Release and Stop lose their labels too. While an IDE drives the session, the buttons are hidden and the page says «Driven by your IDE».
Stop or release?
- Release when you are done looking: the script finishes normally, as if no debugger had been there.
- Stop when the rest must not run: e.g. the next line sends an email. A stopped request may leave work half done: a file half written, a record without its children.
The idle guard
A paused script holds resources: a PHP-FPM worker, the session lock, open database transactions. A session that Buggregator paused and nobody touched for idle_timeout (10 minutes by default) is released: the script runs to the end. The bar above the code counts down: «auto-release in 9:12» (on narrower screens only the clock and 9:12). The session then closes with closed_reason idle_detached, and the page says «Released after 10 minutes without a command». Every step, and every read that reaches Xdebug, starts the timer again. Set idle_timeout: 0 to turn the guard off.
Who can step
Anyone who can reach the API can step, stop and release sessions driven by Buggregator. Keep port 8000 on 127.0.0.1, or turn on SSO: the control routes need the same login as the rest of /api.
Breakpoints
A breakpoint stops a session that Buggregator drives at a line, or when an exception is thrown. Breakpoints are saved: every new session gets them before the script runs. A change while a session is paused goes to it at once; while the script runs, at its next pause (the row says next pause).
Add one in the Breakpoints panel:
| Type in the field | Stops |
|---|---|
/app/src/Services/CartService.php:43 | at line 43 of that file |
RuntimeException | when a RuntimeException is thrown |
* | on every exception |
An exception breakpoint matches the thrown class and its parent classes, not interfaces: Exception stops on a RuntimeException, Throwable never stops. For every exception, use *.
The path is absolute, as PHP sees it: inside the container, not on your machine. The field suggests files from earlier pauses. While a session is paused, click a line number in the code to add or remove a breakpoint there.
Each row has a checkbox to turn the breakpoint off and on, a remove button, and, while a session is paused, how often it was hit (3×). A line breakpoint can have a condition, a PHP expression: $total > 100 stops only when it is true. Conditions run PHP, so they need allow_eval: true; with eval off, adding one is refused. A breakpoint with a condition saved while eval was on is skipped after eval is turned off (the row says skipped · eval off).
If a line has no code, Xdebug moves the breakpoint to the next line with code; the row says «Xdebug moved it: no code on line 3 → 4». A breakpoint in a loop stops on every pass.
Breakpoints live in the database. With the default in-memory database they are gone after a restart; with a database file they stay.
Sessions an IDE drives use the IDE's breakpoints, not these. The page shows the IDE's breakpoints in the gutter, read-only.
What happens to a session
- Each PHP request with Xdebug is one session. Several requests at once each get their own session.
- The IDE gets the session unchanged. It does not see what the page or the MCP tools read.
- No IDE answers when the session starts: depends on the mode. In
proxymode Buggregator detaches, and the script runs on as if there were no debugger. InautoandstandaloneBuggregator drives the session. - The IDE goes away later: Buggregator detaches in every mode (
closed_reasonide_closed), and the script runs on. - A closed session stays in the list for 10 minutes with its reason, unless you dismiss it. At most the 50 newest closed sessions are kept.
The Xdebug page
The Xdebug item appears in the sidebar only when the proxy is on.
- Sessions — state (Paused, Running, Released, Detached, Stopped…), the request (
GET /checkout?step=2, or the CLI command), file and line, and who drives it:here(Buggregator),IDE, orno IDE. - Controls — Continue, Step over, Step into, Step out, Release and Stop, for a session driven by Buggregator (see Debug without an IDE). While an IDE drives, the page says so and the buttons are hidden.
- Code — the source around the paused line, with breakpoints in the gutter.
- Breakpoints — the saved breakpoints, see Breakpoints.
- Variables — locals, superglobals and user constants. Objects and arrays load when you expand them.
- Call stack — vendor frames fold into one line. Pick a frame to see its source and variables.
- Watch — expressions evaluated once per pause. Needs
allow_eval: true; otherwise the page shows the setting to turn on. - Agent activity — the MCP calls an AI assistant made in this session, including refused ones.
Values can be read only while the script is paused. While it runs, the page shows the last known line.
MCP tools
With mcp.enabled: true, MCP clients get these tools. These read; xdebug_sessions and xdebug_breakpoints work without a pause, the others need a paused session:
| Tool | What it returns |
|---|---|
xdebug_sessions | Sessions, including ones closed in the last 10 minutes: id, entry file, state, current file and line |
xdebug_location | Current file and line, the function, and the source lines around it |
xdebug_stack | The call stack, one frame per level; 0 is the current one |
xdebug_variables | Variables of a frame (locals by default). Arrays and objects show their first level |
xdebug_property | One variable or property by its full name ($user->address, $items[0]) with its children; strings up to 64 KB |
xdebug_eval | The value of a PHP expression in the current frame. Present only with allow_eval: true |
xdebug_breakpoints | The saved breakpoints; with session_id, each one's state in that session (e.g. resolved, skipped), resolved line and hit count |
These drive a session, like the buttons on the page. xdebug_step, xdebug_continue and xdebug_stop work only on a session Buggregator drives; while an IDE drives, they are refused. xdebug_wait also returns a session paused in the IDE. xdebug_breakpoint_set and xdebug_breakpoint_remove always save, but saved breakpoints never reach a session an IDE drives. On by default; xdebug.allow_control: false (env XDEBUG_PROXY_ALLOW_CONTROL=false) removes them, and the Xdebug tools only read again (except xdebug_eval with allow_eval).
| Tool | What it does |
|---|---|
xdebug_wait | Waits until a session pauses (or the one given by session_id): timeout_seconds 1–60, default 60. Returns at once if one already is |
xdebug_step | One step: mode over (default), into or out. Waits for the next pause (timeout_seconds 1–30, default 30); returns the file, line, function and source around it, or that the script ended |
xdebug_continue | Runs to the next breakpoint or xdebug_break(); the same wait and answer as xdebug_step |
xdebug_stop | mode detach (default): the script runs to the end. stop: ends the request now, work can stay half done |
xdebug_breakpoint_set | Saves a breakpoint: file (absolute path as PHP sees it) and line, or exception (a class, e.g. RuntimeException, or * for any). condition only with allow_eval |
xdebug_breakpoint_remove | Removes a saved breakpoint by id |
session_id can be left out when exactly one session is paused.
Example conversation with an assistant, while the IDE stopped at a breakpoint:
- "Where did the script stop?" →
xdebug_location - "What is in
$user?" →xdebug_propertywith$user
Without an IDE, an agent can debug on its own: set a breakpoint with xdebug_breakpoint_set (or put xdebug_break(); in the code), send the request, then xdebug_wait, read with xdebug_variables, step with xdebug_step, and finish with xdebug_stop.
Eval
allow_eval is off by default. With it on, MCP clients (xdebug_eval) and the page's watches can evaluate PHP expressions inside the debugged request. An expression can change state: $order->pay() really runs. Turn it on only where you trust everyone who can reach the API and MCP.
With eval off, xdebug_eval is not offered, and POST /api/xdebug/sessions/{id}/eval answers 403 eval_disabled.
HTTP API
The page uses these routes. They need the same login as the rest of /api when SSO is on. Errors are {"code", "error", "state"?}.
| Method | Path | Answer |
|---|---|---|
GET | /api/xdebug | {"enabled", "addr", "ide_addr", "allow_eval", "allow_control", "mode", "pause_on_first_line", "idle_timeout_seconds"}; also answers with "enabled": false when the proxy is off |
PUT | /api/xdebug/settings | body {"mode": "proxy|auto|standalone", "pause_on_first_line": true}, Content-Type: application/json; a field left out keeps its value; applies to new sessions, not saved; answers {"mode", "pause_on_first_line", "idle_timeout_seconds"} |
GET | /api/xdebug/sessions | live and closed sessions |
GET | /api/xdebug/sessions/{id} | one session |
DELETE | /api/xdebug/sessions/{id} | dismiss a closed session: 204; a live one: 409 session_live |
GET | /api/xdebug/sessions/{id}/stack | frames |
GET | /api/xdebug/sessions/{id}/contexts?frame=0 | 0 Locals, 1 Superglobals, 2 User defined constants |
GET | /api/xdebug/sessions/{id}/variables?frame=0&context=0 | variables |
GET | /api/xdebug/sessions/{id}/property?name=$user&frame=0&context=0&page=0 | one variable with children |
GET | /api/xdebug/sessions/{id}/source?file=…&begin=1&end=120 | source lines, at most 2000 |
GET | /api/xdebug/sessions/{id}/breakpoints | an IDE session: the IDE's breakpoints. A session driven here: the saved breakpoints with state, resolved_line, hit_count |
POST | /api/xdebug/sessions/{id}/eval | body {"expression": "count($items)"}, Content-Type: application/json; needs allow_eval |
GET | /api/xdebug/sessions/{id}/agent-log | the last 200 MCP calls of the session |
GET | /api/xdebug/breakpoints | saved breakpoints: id, type (line or exception), file (a file:// URI), line, exception, condition, enabled, source (ui or mcp), created_at |
POST | /api/xdebug/breakpoints | body {"type": "line", "file": "file:///app/src/Cart.php", "line": 42} or {"type": "exception", "exception": "RuntimeException"}, optional condition, enabled; 201 and the breakpoint |
PATCH | /api/xdebug/breakpoints/{id} | the same fields; a field left out keeps its value |
DELETE | /api/xdebug/breakpoints/{id} | 204 |
GET | /api/xdebug/files?q=cart&limit=20 | files seen in earlier pauses and breakpoints, for suggestions |
POST | /api/xdebug/sessions/{id}/run | continue; also step-into, step-over, step-out, stop, detach (release). Body {}, Content-Type: application/json. Answers the session at once (state running); the next pause comes as session.status |
While the script is not paused, reads and controls answer 409 not_paused. A control on a session the IDE drives answers 409 ide_attached. Other codes: 400 bad_request (a missing or bad parameter, an unknown mode), 403 eval_disabled (a breakpoint condition with eval off), 403 cross_site (a write sent from another site), 404 not_found, 415 unsupported_media_type (eval, a control, settings or a breakpoint without JSON), 422 xdebug_error (Xdebug refused the read), 502 xdebug_failed, 504 timeout.
{"code": "not_paused", "error": "session is not paused (the script is running or has not stopped at a breakpoint yet)", "state": "stopped"}
{"code": "bad_request", "error": "mode must be proxy, auto or standalone, got \"nope\""}
{"code": "bad_request", "error": "a line breakpoint needs file and line (>= 1)"}
{"code": "eval_disabled", "error": "a breakpoint condition is PHP code: it needs xdebug.allow_eval"}A session's state is one of starting, break, running, stopping, stopped, detached. A closed session also has closed_reason: ide_unreachable, ide_closed, engine_closed, stopped, detached (released from the page or by an agent) or idle_detached (released by the idle guard).
Session fields for driving: driver (ide or here), last_continuation_by (ui, agent or null), and idle_deadline (when the idle guard releases a paused session; null otherwise).
WebSocket
Events go to the xdebug channel: session.created, session.status, session.closed, session.idle (a new idle_deadline), breakpoints.changed (the full list), breakpoint.resolved (where Xdebug set it), agent.call.started, agent.call. session.status carries pause_seq, which goes up by one on every new pause: re-read the session when it changes.