# 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](/config/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:

| 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](#eval)) |
| `XDEBUG_PROXY_MODE` | `auto` | `proxy`, `auto` or `standalone` (see [Modes](#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](#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](/config/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.

| 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](#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](#breakpoints) 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:

| 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](/config/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](/config/database) 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](#modes). 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](#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](#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_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](/config/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`.

```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.
