Skip to content

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 assistant

While 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 $order here?", 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 ​

yaml
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 stop

Or with environment variables:

VariableDefaultDescription
XDEBUG_PROXY_ENABLEDfalseTurn the proxy on (true, 1 or yes)
XDEBUG_PROXY_ADDR127.0.0.1:9003Where Xdebug connects
XDEBUG_PROXY_IDE_ADDR127.0.0.1:9004Where the IDE listens for debug connections
XDEBUG_PROXY_ALLOW_EVALfalseAllow evaluating PHP expressions (see Eval)
XDEBUG_PROXY_MODEautoproxy, auto or standalone (see Modes). An unknown value falls back to auto, with a warning in the log
XDEBUG_PROXY_PAUSE_ON_FIRST_LINEfalseA session driven by Buggregator stops at the first line. Otherwise it runs to a breakpoint or xdebug_break()
XDEBUG_PROXY_ALLOW_CONTROLtrueLet MCP clients drive a session Buggregator drives (see MCP tools); false = Xdebug tools only read (except xdebug_eval with allow_eval)
XDEBUG_PROXY_IDLE_TIMEOUT10mA 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:

ini
xdebug.mode=debug
xdebug.client_host=127.0.0.1
xdebug.client_port=9003
xdebug.start_with_request=trigger

Then 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:

yaml
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 Linux

In 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:

yaml
    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:9004

Set 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.1 or inside your Docker network.

Modes ​

mode decides what happens when a session arrives.

ModeIDE answersNo IDE answers
proxythe IDE drivesBuggregator detaches; the script runs on as if there were no debugger
auto (default)the IDE drivesBuggregator drives the session
standaloneBuggregator does not call the IDEBuggregator 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 ​

  1. Turn the proxy on. The default mode: auto is enough. No IDE needs to listen on ide_addr.
  2. Tell the script where to stop. Any of these:
    • a breakpoint on the page: type /app/src/Cart.php:42 in 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.
  3. Run the request with Xdebug, e.g. php -d xdebug.mode=debug -d xdebug.start_with_request=yes script.php.
  4. 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:

ButtonWhat happens
ContinueRuns to the next breakpoint or xdebug_break(), or to the end
Step overThe next line; runs a function call without stopping in it
Step intoThe next line; goes into a function call
Step outRuns to the end of the current function and stops in the caller
ReleaseLets the script run to the end without stopping again
StopEnds 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 fieldStops
/app/src/Services/CartService.php:43at line 43 of that file
RuntimeExceptionwhen 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 proxy mode Buggregator detaches, and the script runs on as if there were no debugger. In auto and standalone Buggregator drives the session.
  • The IDE goes away later: Buggregator detaches in every mode (closed_reason ide_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, or no 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:

ToolWhat it returns
xdebug_sessionsSessions, including ones closed in the last 10 minutes: id, entry file, state, current file and line
xdebug_locationCurrent file and line, the function, and the source lines around it
xdebug_stackThe call stack, one frame per level; 0 is the current one
xdebug_variablesVariables of a frame (locals by default). Arrays and objects show their first level
xdebug_propertyOne variable or property by its full name ($user->address, $items[0]) with its children; strings up to 64 KB
xdebug_evalThe value of a PHP expression in the current frame. Present only with allow_eval: true
xdebug_breakpointsThe 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).

ToolWhat it does
xdebug_waitWaits 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_stepOne 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_continueRuns to the next breakpoint or xdebug_break(); the same wait and answer as xdebug_step
xdebug_stopmode detach (default): the script runs to the end. stop: ends the request now, work can stay half done
xdebug_breakpoint_setSaves 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_removeRemoves 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_property with $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"?}.

MethodPathAnswer
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/settingsbody {"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/sessionslive 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}/stackframes
GET/api/xdebug/sessions/{id}/contexts?frame=00 Locals, 1 Superglobals, 2 User defined constants
GET/api/xdebug/sessions/{id}/variables?frame=0&context=0variables
GET/api/xdebug/sessions/{id}/property?name=$user&frame=0&context=0&page=0one variable with children
GET/api/xdebug/sessions/{id}/source?file=…&begin=1&end=120source lines, at most 2000
GET/api/xdebug/sessions/{id}/breakpointsan IDE session: the IDE's breakpoints. A session driven here: the saved breakpoints with state, resolved_line, hit_count
POST/api/xdebug/sessions/{id}/evalbody {"expression": "count($items)"}, Content-Type: application/json; needs allow_eval
GET/api/xdebug/sessions/{id}/agent-logthe last 200 MCP calls of the session
GET/api/xdebug/breakpointssaved breakpoints: id, type (line or exception), file (a file:// URI), line, exception, condition, enabled, source (ui or mcp), created_at
POST/api/xdebug/breakpointsbody {"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=20files seen in earlier pauses and breakpoints, for suggestions
POST/api/xdebug/sessions/{id}/runcontinue; 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.

json
{"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.