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();
Source: Exception::getTraceAsString, debug_backtrace and SensitiveParameter
Three settings change what a trace contains:
-
#[\SensitiveParameter](8.2) replaces an argument with aSensitiveParameterValueobject 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 ofcharge(42, ...)). The shippedphp.ini-productionturns it on; the built-in default andphp.ini-developmentkeep 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 exceededmax_execution_timeor 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 |
|---|---|
|
Nothing: the extension is loaded but has (almost) no overhead |
|
Better |
|
Step debugging: connects to an IDE over the DBGp protocol |
|
Writes a cachegrind profile of every call (time and memory) per request |
|
Writes a function trace: every call, its arguments and return values |
|
Line, branch and path coverage for PHPUnit and Pest |
|
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
Source: Xdebug — All Settings and Xdebug — Installation
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>
Source: Xdebug — Step Debugging and Xdebug — DBGp protocol
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
Source: Xdebug — Profiling and Xdebug — Function Trace
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]
Source: PHP Manual — phpdbg and PHP Manual — Debugging in PHP
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_errorsto debug (Powers ch. 1–5) is right for a local machine only; production code logs errors and keepsdisplay_errorsoff. 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 withxdebug.mode,xdebug.start_with_requestand 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
internedrather than a count, anddebug_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.