Xdebug: Breakpoints, Stepping, Coverage, and Profiling

Understand how to install and configure Xdebug, use breakpoints/stepping/watches/call stacks for interactive debugging in an IDE, and use Xdebug's coverage and profiling modes to analyze test coverage and performance.

Xdebug is the standard PHP extension for interactive debugging and code analysis — it is what lets you set a breakpoint in an IDE, pause execution mid-request, inspect variables, and step through code line by line, instead of debugging exclusively with var_dump() and echo. Beyond step debugging, the same extension provides code coverage analysis (used by PHPUnit to report which lines your tests exercise) and a profiler (used to find performance bottlenecks). This lesson is necessarily descriptive rather than runnable, since Xdebug is a server-side extension and IDE integration, not PHP code you execute directly.

Installing and configuring Xdebug

Xdebug is installed as a PHP extension (commonly via PECL: pecl install xdebug, or your OS package manager, e.g. apt install php8.2-xdebug), then enabled and configured in php.ini or a dedicated xdebug.ini. The key directive is xdebug.mode, a comma-separated list of which Xdebug features are active — common values are debug (step debugging), coverage (for PHPUnit code coverage), profile (the profiler), trace (function call tracing), and develop (enables var_dump()-enhancing features and stack traces on errors). Running with every mode enabled all the time is not recommended, since debug/coverage/profile modes carry measurable performance overhead — a common pattern is xdebug.mode=develop,debug locally, and Xdebug entirely disabled (or absent) in production.

For step debugging specifically, xdebug.client_host and xdebug.client_port (default port 9003 as of Xdebug 3) tell the extension where to connect to signal a debugging session to your IDE, and xdebug.start_with_request=yes (or =trigger, requiring a cookie/header/GET parameter to opt in per-request) controls when a debug session actually begins. Xdebug 3 replaced the older, more fragmented Xdebug 2 configuration (remote_enable, remote_host, etc.) with this simplified mode-based system.

Breakpoints, stepping, watches, call stacks

A breakpoint is a line you mark in your IDE (VS Code with the PHP Debug extension, PhpStorm, or any DBGp-protocol-compatible client) where execution should pause. When the debugged request reaches that line, Xdebug halts the script and opens a DBGp connection back to the IDE, which then shows you the exact call stack, all local variables in scope, and lets you interact before resuming.

From a paused breakpoint you can: step over (execute the current line and stop at the next one in the same scope, without descending into function calls it makes), step into (descend into the next function call to debug inside it), and step out (run until the current function returns, then pause in the caller) — the three fundamental stepping operations every debugger provides. A watch lets you register an arbitrary expression (not just a bare variable, e.g. $user->getBalance() > 0) that the IDE re-evaluates and displays every time execution pauses, useful for tracking a derived condition across many steps without manually re-checking it. The call stack panel shows the full chain of function calls that led to the current line, each frame inspectable independently — essential for understanding how you got somewhere in deeply nested code.

• Breakpoint -- a line marked to pause execution when reached • Step over -- run the current line, stop at the next line in the same function • Step into -- descend into a function call the current line makes • Step out -- finish the current function, pause back in its caller • Watch -- an expression re-evaluated and shown at every pause • Call stack -- the full chain of active function calls, each frame independently inspectable

Code coverage

With xdebug.mode=coverage, Xdebug can record exactly which lines of code executed during a run — this is the engine underneath PHPUnit's --coverage-html and --coverage-text reports. PHPUnit uses Xdebug's (or the alternative, lighter-weight PCOV extension's) coverage-driver API internally; you don't call Xdebug's coverage functions directly in ordinary use, but understanding that it's what's happening explains why coverage runs are noticeably slower than a normal test run: every executed line is being recorded.

Coverage reports are typically read as a percentage per file/class/method, plus a line-by-line highlighted view showing which lines were never hit by any test — a useful, though incomplete, signal (100% line coverage does not mean every logical branch or edge case was tested, only that every line executed at least once).

Profiling

With xdebug.mode=profile, Xdebug records a Cachegrind-format profile file (by default under xdebug.output_dir, named cachegrind.out.<pid> or similar) capturing how much time and how many calls were spent in every function during the request — this is how you find real performance bottlenecks instead of guessing. The file is read with a Cachegrind-compatible viewer: KCachegrind/QCachegrind on Linux, or Webgrind (a small PHP+web-based viewer) and PhpStorm's built-in profiler viewer are common choices, all of which render the data as a call graph or table sortable by self-time and inclusive-time per function.

Because profiling has real overhead and generates a file per request, xdebug.mode=profile is normally enabled only on demand (via xdebug.start_with_request=trigger and a request-specific flag) against a single slow endpoint you're investigating, never left on for a whole production environment.

IDE integration

PhpStorm has built-in, zero-extra-config Xdebug support: enabling 'Start Listening for PHP Debug Connections' and setting a breakpoint is usually enough once Xdebug itself is installed and pointed at the right port. VS Code needs the 'PHP Debug' extension (by Xdebug's own maintainer) plus a launch.json configuration specifying the port (9003) and, for a server running in Docker or on a remote host, path mappings so the IDE can translate the server's file paths to your local checked-out copy. Both integrate the same DBGp protocol Xdebug speaks, so the underlying breakpoint/step/watch/call-stack experience is functionally the same regardless of editor.

Typical Xdebug configuration and CLI usage

-d flags on the php CLI override ini settings for just that invocation, which is the standard way to turn on coverage/profiling only when you need it without editing php.ini. xdebug.idekey (or the browser extension / X-DEBUG-SESSION cookie for start_with_request=trigger) is how Xdebug's debug session is matched to a specific listening IDE when several people share a development server.

; xdebug.ini (development machine)
zend_extension=xdebug
xdebug.mode=develop,debug
xdebug.client_host=127.0.0.1
xdebug.client_port=9003
xdebug.start_with_request=yes
xdebug.idekey=VSCODE

; Enabling coverage or profiling on demand for a single CLI run, without
; touching the ini file permanently:
php -dxdebug.mode=coverage -dxdebug.start_with_request=yes ./vendor/bin/phpunit --coverage-html build/coverage
php -dxdebug.mode=profile -dxdebug.output_dir=/tmp/profiles slow-script.php

Code Examples

With a breakpoint set on the $sum += ... line and xdebug.mode=debug active, the IDE pauses on the first loop iteration; a watch on $sum lets you confirm it accumulates 19.98 then 23.48 across the two iterations without manually adding echo/var_dump statements or re-running the script for each check.

Setting XDEBUG_MODE=coverage as an environment variable (equivalent to -dxdebug.mode=coverage) for just this command enables Xdebug's coverage recording only for the test run, avoiding the overhead during normal development requests. PHPUnit then reports per-class, per-method, and per-line coverage percentages and writes a browsable HTML report to build/coverage. Expected result: Lines: 88.47% (782/884)

Enabling profile mode only via -d flags for a single invocation targets exactly the slow request under investigation. The resulting Cachegrind file, opened in a viewer like KCachegrind, typically reveals a small number of functions responsible for most of the self-time (e.g. an N+1 query loop or an unindexed database call) -- the kind of hotspot that's tedious to find by manually timing code with microtime().

Common mistake: Leaving xdebug.mode=profile enabled globally on a shared development or, worse, production server generates a Cachegrind file per request and adds real overhead to every single one -- always scope profiling to the one request or script you're actively investigating.

<?php
function total(array $items): float
{
    $sum = 0.0;
    foreach ($items as $item) {
        $sum += $item['price'] * $item['qty']; // <- set a breakpoint on this line
    }
    return $sum;
}

echo total([
    ['price' => 9.99, 'qty' => 2],
    ['price' => 3.50, 'qty' => 1],
]);
# Running PHPUnit with coverage, using Xdebug as the coverage driver
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html build/coverage --coverage-text

# Output (excerpt)
# Code Coverage Report:
#   Classes: 82.35% (14/17)
#   Methods: 91.20% (104/114)
#   Lines:   88.47% (782/884)
# Profiling a single slow request on demand, then inspecting it
php -dxdebug.mode=profile -dxdebug.output_dir=/tmp/xdebug-profiles public/index.php

# -> writes /tmp/xdebug-profiles/cachegrind.out.48213

# Inspect with a Cachegrind viewer, e.g.:
kcachegrind /tmp/xdebug-profiles/cachegrind.out.48213
# or upload to Webgrind / open directly in PhpStorm's profiler viewer

Important Rules

xdebug.mode controls which features are active (develop, debug, coverage, profile, trace) -- enable only what you currently need, since debug/coverage/profile all carry overhead.

Xdebug 3 uses xdebug.client_host/xdebug.client_port and xdebug.start_with_request; the old Xdebug 2 remote_* settings no longer apply.

Step over / step into / step out are the three fundamental stepping operations every DBGp-compatible IDE debugger provides on top of Xdebug.

PHPUnit's coverage reports use Xdebug (or PCOV) as the underlying driver -- 100% line coverage still doesn't guarantee every branch or edge case was tested.

Never leave xdebug.mode=debug/coverage/profile enabled on a production server -- install and enable Xdebug only in development/CI, or scope it per-request with start_with_request=trigger.

Where this shows up

Teams standardize Xdebug configuration inside their Docker development images (a Dockerfile installing php8.2-xdebug with a checked-in xdebug.ini) so every developer's IDE connects identically regardless of host OS, and CI pipelines commonly install Xdebug or PCOV solely for the coverage run, disabling it again for the actual test execution step to keep CI fast, since coverage mode alone can double or triple test suite runtime.; production php.ini zend_extension=xdebug xdebug.mode=develop,debug,profile; production php.ini; Xdebug not installed/enabled at all in production; local xdebug.ini zend_extension=xdebug xdebug.mode=develop,debug

Xdebug adds substantial overhead to every request when active, and exposing a debug/profile-capable extension on a production server is also a security surface (an accidentally reachable debug port). Xdebug should be installed only on development machines and CI runners, never enabled -- ideally not even installed -- on production.

[multiple-choice] What does 'step into' do when paused at a breakpoint on a line that calls a function? A. Runs the current line and stops at the next line in the same scope B. Descends into the called function so you can debug inside it C. Runs until the current function returns, pausing in the caller D. Skips the breakpoint entirely and resumes normal execution

B. Descends into the called function so you can debug inside it — 'Step into' descends into whatever function the current line calls, letting you continue debugging inside it. 'Step over' would instead execute that call as a single step and stop at the next line in the current scope; 'step out' finishes the current function and pauses back in its caller.

[true-false] It's safe and recommended to leave xdebug.mode=debug,profile,coverage enabled on a production server for convenience. A. True B. False

B. False — False. Xdebug's debug, coverage, and profile modes all add meaningful runtime overhead and expand the server's attack surface. Xdebug should be installed and enabled only in development and CI environments, not production.

Write the xdebug.ini configuration lines needed to enable step debugging for a local VS Code setup listening on the default port, with the IDE key set to 'VSCODE', plus a separate CLI command that runs a single PHP script with profiling enabled just for that one run, writing output to /tmp/profiles.; xdebug.ini zend_extension=xdebug xdebug.mode=develop,debug xdebug.client_port=9003 xdebug.start_with_request=yes xdebug.idekey=VSCODE # CLI profiling run php -dxdebug.mode=profile -dxdebug.output_dir=/tmp/profiles script.php xdebug.mode needs 'debug' for the ini file; use -dxdebug.mode=profile -dxdebug.output_dir=... for the one-off CLI profiling run.