Debugging and Profiling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5, checked on 2026-10-05, with Xdebug 3.5.3 (the current stable release, which supports PHP 8.0 to 8.5; Xdebug 3.6, in alpha since September 2026, adds PHP 8.6). Debugging in PHP works at three levels: printing values and logging from the code itself, reading the backtraces PHP produces when something fails, and attaching a real debugger or profiler (Xdebug, phpdbg, system tracers) to a running script.

Inspecting values

var_dump() is the debugging dump: it shows the type and value of everything, including false, null, floats and object visibility. print_r() is shorter but prints false and null as nothing. var_export() produces valid PHP code that can be pasted back into a test.

<?php

declare(strict_types=1);

final class Loan
{
    public function __construct(
        public readonly string $isbn,
        private ?DateTimeImmutable $returnedAt = null,
    ) {}
}

$loan = new Loan('978-0-13-235088-4');
$data = ['id' => 7, 'tags' => ['new'], 'late' => false, 'fine' => 1.5, 'note' => null];

var_dump($data['late'], $data['fine']);   // bool(false) float(1.5)
print_r($data);                           // [late] => and [note] => print as empty
echo var_export($data, true), "\n";       // 'late' => false, 'note' => NULL, ...
var_dump($loan);                          // ["returnedAt":"Loan":private]=> NULL

Source: var_dump, print_r and var_export

print_r($value, true) and var_export($value, true) return the text instead of printing it, which is what you want inside a log message. Never leave dumps in code that serves real users: they leak internal data. Static analysis and Pest’s architecture tests can forbid them (see Testing with PHPUnit and Pest).

debug_zval_dump() is the internals view. It adds the reference count of each value, marks strings that are interned (literals stored once for the whole process) and, since 8.4, says whether an array is packed (a list with keys 0, 1, 2 and so on, stored without a hash table). It is a learning tool for copy-on-write, not an everyday one.

<?php
$name = 'Ada';                 // a literal: interned
$title = str_repeat('Dr', 1);  // built at run time: reference counted
$copy = $title;                // copy-on-write: the same string, one more reference
$list = [1, 2, 3];

debug_zval_dump($name, $title, $list);
// string(3) "Ada" interned
// string(2) "Dr" refcount(3)
// array(3) packed refcount(3){ ... }

The counts include the references held by the call itself, so read them as relative values, not as "the number of variables". There is no debug_zval_refcount() function. To read the count, use debug_zval_dump().

Logging with error_log

error_log() writes a message to the server’s error log: the file named by the error_log directive, or the SAPI’s own log (STDERR for the CLI, the web server or PHP-FPM log otherwise) when the directive is empty. Message type 3 appends to a file of your choice. Unlike echo, the output never reaches the response, so it is safe in production. Applications usually log through a PSR-3 logger such as Monolog, which Laravel uses (see PSR Standards).

<?php
error_log('reminder job finished');                                    // to the configured error log
error_log(print_r(['loan' => 7, 'days' => 3], true));                   // structured data as text
error_log(date('c') . " fine computed\n", 3, '/tmp/fines-debug.log');  // type 3: append to a file

Source: error_log

The php.ini settings that decide where errors go (display_errors, log_errors, error_reporting, error_log) are covered in Errors and Exceptions. The rule is the same everywhere: display errors in development, log them (and never display them) in production.

Reading backtraces

A backtrace (stack trace) lists the calls that led to the current point, most recent first. Frame #0 is where the exception was thrown, and each line gives the calling file and line, then the function and its arguments. Uncaught exceptions print one automatically, Throwable::getTraceAsString() and getTrace() return it, and debug_print_backtrace() and debug_backtrace() produce one at any point without an exception.

<?php
function charge(int $userId, #[\SensitiveParameter] string $card): never
{
    throw new RuntimeException('Card declined');
}

function checkout(int $userId): void
{
    charge($userId, '4111111111111111');
}

try {
    checkout(42);
} catch (RuntimeException $e) {
    echo $e->getTraceAsString(), "\n";
    // #0 /app/trace.php(9): charge(42, Object(SensitiveParameterValue))
    // #1 /app/trace.php(13): checkout(42)
    // #2 {main}
}

function where(): void
{
    debug_print_backtrace();    // #0 /app/trace.php(30): where()  #1 /app/trace.php(33): outer()
    $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'];
    echo $caller, "\n";         // outer
}

function outer(): void
{
    where();
}

outer();

Three settings change what a trace contains:

  • #[\SensitiveParameter] (8.2) replaces an argument with a SensitiveParameterValue object in every backtrace, so passwords, card numbers and tokens never reach a log file.

  • zend.exception_ignore_args (7.4) leaves all arguments out of exception traces (charge() instead of charge(42, ...)). The shipped php.ini-production turns it on; the built-in default and php.ini-development keep arguments. Check it when a production trace has no arguments.

  • fatal_error_backtraces (8.5, on by default) adds a stack trace to fatal errors such as an exceeded max_execution_time or memory limit. Before 8.5 a fatal error reported only the file and line where it happened.

Fatal error: Maximum execution time of 1 second exceeded in /app/fatal.php on line 3
Stack trace:
#0 /app/fatal.php(2): inner(3)
#1 /app/fatal.php(4): load(3)
#2 {main}

Read a trace from the top: frame #0 is where the failure happened, and the first frame that points into your code (not vendor/) is usually where to start. With a framework, the trace often has dozens of middleware and container frames; Laravel’s error page collapses vendor frames for this reason.

Xdebug 3

Xdebug is the standard PHP debugging extension. It is a Zend extension (zend_extension=xdebug, not extension=). Install it with PIE (pie install xdebug/xdebug), the distribution package (php8.5-xdebug), or pecl install xdebug and docker-php-ext-enable xdebug in the official Docker images; see Configuration and Extensions. php -v then shows with Xdebug v3.5.3.

One setting, xdebug.mode, decides which features are active. Several modes can be combined with commas, and the XDEBUG_MODE environment variable overrides the setting for a single command.

Mode What it does

off

Nothing: the extension is loaded but has (almost) no overhead

develop (default)

Better var_dump() output, stack traces with arguments and timings on errors, xdebug_info()

debug

Step debugging: connects to an IDE over the DBGp protocol

profile

Writes a cachegrind profile of every call (time and memory) per request

trace

Writes a function trace: every call, its arguments and return values

coverage

Line, branch and path coverage for PHPUnit and Pest

gcstats

Statistics about garbage-collection runs

; conf.d/99-xdebug.ini (development only)
zend_extension=xdebug
xdebug.mode=develop,debug
xdebug.start_with_request=trigger    ; debug only when XDEBUG_TRIGGER / XDEBUG_SESSION is set
xdebug.client_host=localhost         ; where the IDE listens (host.docker.internal from a container)
xdebug.client_port=9003              ; the Xdebug 3 default (Xdebug 2 used 9000)
xdebug.output_dir=/tmp/xdebug        ; profiles and traces; the directory must exist

xdebug_info() (or php --ri xdebug) prints the active modes, every setting and, in debug mode, a log of the last connection attempt. It is the first thing to check when the debugger does not stop.

Xdebug slows every request, even in develop mode: a CPU-bound benchmark ran about three times slower with Xdebug loaded in its default mode, and at normal speed with xdebug.mode=off. Never load it on production servers. Locally, keep it at off (or unloaded) and switch modes on with XDEBUG_MODE when needed, for example XDEBUG_MODE=coverage vendor/bin/pest --coverage.

Step debugging

In debug mode PHP connects out to the IDE: the IDE listens on port 9003, and Xdebug opens a connection when a request starts. Breakpoints, stepping, watches and variable inspection then happen in the IDE (PhpStorm, or VS Code with the PHP Debug extension). With start_with_request=trigger, only requests that carry the trigger start a session: an XDEBUG_TRIGGER cookie, GET/POST parameter or environment variable, which browser extensions set with one click.

# The IDE is listening on port 9003; debug one CLI command
XDEBUG_MODE=debug XDEBUG_TRIGGER=1 php artisan app:send-reminders

# From inside Docker, point Xdebug at the host machine
XDEBUG_MODE=debug XDEBUG_CONFIG="client_host=host.docker.internal" XDEBUG_TRIGGER=1 php script.php

The connection starts with a DBGp init packet like the one below, captured from Xdebug 3.5.3 on PHP 8.5.11. If the IDE never receives it, check xdebug_info(), the client host and port, and any firewall between PHP and the IDE. xdebug_break() in the code forces a breakpoint at that line.

<init xmlns="urn:debugger_protocol_v1" fileuri="file:///app/work.php" language="PHP"
      xdebug:language_version="8.5.11" protocol_version="1.0" appid="19">
  <engine version="3.5.3"><![CDATA[Xdebug]]></engine>
</init>

Profiling and tracing

profile mode writes one cachegrind.out.<pid> file per request into xdebug.output_dir. Open it with KCachegrind/QCachegrind, PhpStorm’s profiler view or webgrind to see inclusive and exclusive time per function and the call graph. trace mode writes trace.<crc>.xt, an indented log of every call with its arguments, which answers "what actually ran?" when the code path is unclear.

mkdir -p /tmp/xdebug
XDEBUG_MODE=profile php -d xdebug.output_dir=/tmp/xdebug work.php    # writes /tmp/xdebug/cachegrind.out.8
XDEBUG_MODE=trace php -d xdebug.start_with_request=yes -d xdebug.output_dir=/tmp/xdebug work.php
head -5 /tmp/xdebug/trace.*.xt
#     0.0003     465168   -> {main}() /app/work.php:0
#     0.0003     465200     -> report() /app/work.php:4
#     0.0003     465200       -> fib($n = 20) /app/work.php:3
#     0.0003     465200         -> fib($n = 19) /app/work.php:2

Xdebug’s profiler instruments every call, so it distorts timings of small, frequently called functions; use it to find where time goes, not to measure exact durations. For production, use a sampling profiler, which records the stack at intervals at a low, constant cost: Blackfire and Tideways (commercial services), SPX (open source, with a web UI) or Excimer (Wikimedia’s sampling profiler). Measuring single code paths with hrtime() is covered in OPcache, JIT and Performance.

phpdbg

phpdbg is the debugger bundled with PHP (a separate SAPI, built with --enable-phpdbg; the official Docker images include it). It needs no IDE and no extension. Its commands are typed at a prompt, or piped in for a repeatable session:

$ printf 'break inner\nrun\nback\nev $n * 2\ncontinue\nquit\n' | phpdbg -q bp.php
[Breakpoint #0 added at inner]
[Breakpoint #0 in inner() at /app/bp.php:3, hits: 1]
>00003: function inner(int $n): int { return $n * 10; }
frame #0: inner(n=4) at /app/bp.php:3
frame #1: load(n=3) at /app/bp.php:2
frame #2: {main} at /app/bp.php:4
8
40
[Script ended normally]

break file.php:12 and break Class::method set breakpoints, step/next/finish move through the code, watch $var stops when a variable changes, ev evaluates an expression, and back prints the backtrace. Command-Line PHP has a shorter introduction.

DTrace, SystemTap and bpftrace probes

PHP built with --enable-dtrace contains USDT (user-level statically defined tracing) probes, which system tracers can attach to without changing or restarting the application. Probes cost nothing until a tracer attaches, so they are usable on live systems. The probes are request-startup/request-shutdown, compile-file-entry/-return, execute-entry/-return, function-entry/-return, exception-thrown/-caught and error. The target process needs the USE_ZEND_DTRACE=1 environment variable. Most distribution and Docker builds do not enable DTrace, so check first (php -i | grep -i dtrace reports DTrace Support => disabled in the official images).

# List PHP's probes, then count function calls by name with bpftrace (Linux, eBPF)
bpftrace -l 'usdt:/usr/bin/php:php:*'
USE_ZEND_DTRACE=1 bpftrace -e 'usdt:/usr/bin/php:php:function__entry { @calls[str(arg0)] = count(); }' \
    -c '/usr/bin/php work.php'

DTrace itself is used on macOS, the BSDs and Solaris, SystemTap on older Linux systems, and bpftrace on current Linux kernels; all three read the same probes.

Where older books and tutorials are outdated

  • Turning on display_errors to debug (Powers ch. 1–5) is right for a local machine only; production code logs errors and keeps display_errors off. Since 8.5, fatal errors also include a stack trace (fatal_error_backtraces).

  • Xdebug 2 settings (xdebug.remote_enable, xdebug.remote_autostart, xdebug.profiler_enable, port 9000) no longer exist; Xdebug 3 (2020) replaced them with xdebug.mode, xdebug.start_with_request and port 9003. Many IDE guides and Docker images still show the old names.

  • Refcount explanations (Beak ch. 1) predate interned strings and immutable arrays: a literal string shows interned rather than a count, and debug_zval_dump() reports packed arrays since 8.4.

  • Secrets in backtraces: before 8.2 the only protection was zend.exception_ignore_args; mark sensitive parameters with #[\SensitiveParameter] instead.