PHP Debugging
Debugging is the process of figuring out why your PHP code isn’t doing what you expect, then fixing it. Every PHP developer — beginner or expert — spends real time reading error messages, inspecting variable values, and tracing how execution flows through a script. Learning PHP’s built-in debugging tools well turns a vague “something is broken” into a precise, fixable diagnosis in minutes instead of hours. This lesson covers everything from basic error display settings to exceptions, backtraces, logging, and the tools professional PHP developers rely on daily.
Overview: How PHP Debugging Works
When PHP runs a script, the Zend Engine (PHP’s core runtime) executes it opcode by opcode. Whenever something unexpected happens — a missing variable, a type mismatch, a call to an undefined function — the engine raises an internal signal. Depending on its severity, that signal becomes one of several things: a notice (a minor issue, like reading an undefined array key), a warning (a more serious issue that doesn’t stop execution, like passing the wrong argument type to a built-in function), a fatal error (execution halts immediately, like calling an undefined function), or — since PHP 7 — a throwable object (an Error or Exception, both implementing the Throwable interface) that can be caught with try/catch.
Two settings control what you actually see when something goes wrong: error_reporting, which decides which severities are reported at all, and display_errors, which decides whether reported errors are printed to the output. In production, you almost always want display_errors off (so users never see raw error text or stack traces that could leak sensitive paths or data) and log_errors on instead, writing the same information to a log file you can inspect safely. In development, you want both error_reporting(E_ALL) and display_errors on, so nothing is hidden from you while you write code.
Beyond error visibility, PHP debugging is really about answering one question repeatedly: what is the actual state of my program right now? Functions like var_dump(), print_r(), and var_export() let you inspect that state directly. debug_backtrace() and exception stack traces let you see how execution got to a particular line. And tools like Xdebug take this further, letting you pause execution entirely and step through code line by line in your editor.
Syntax
There’s no single “debugging syntax” — instead, there’s a standard toolkit you combine. A typical setup looks like this:
<?php
// General debugging setup pattern
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/php/app-errors.log');
try {
// risky code here
} catch (Throwable $e) {
error_log($e->getMessage());
}
| Tool / Function | Purpose |
|---|---|
error_reporting() |
Sets which error severities (E_ALL, E_WARNING, E_NOTICE, …) are reported |
ini_set('display_errors', ...) |
Controls whether errors are printed to output |
var_dump() |
Prints a variable’s type and value, recursively, with full precision |
print_r() |
Prints a human-readable representation of a variable (arrays/objects) |
var_export() |
Prints a variable as valid, re-parsable PHP code |
error_log() |
Sends a message to the configured error log instead of the output |
debug_backtrace() |
Returns an array describing the current call stack |
set_error_handler() |
Registers a custom function to run on PHP errors/warnings |
set_exception_handler() |
Registers a custom function to run on uncaught exceptions |
try / catch / finally |
Catches Throwable objects and lets you handle them gracefully |
Examples
Example 1: Inspecting variables with print_r and var_dump
<?php
declare(strict_types=1);
error_reporting(E_ALL);
ini_set('display_errors', '1');
$user = [
'name' => 'Ada Lovelace',
'age' => 36,
'skills' => ['math', 'logic', 'programming'],
];
echo "Using print_r:\n";
print_r($user);
echo "\nUsing var_dump:\n";
var_dump($user);
Output:
Using print_r:
Array
(
[name] => Ada Lovelace
[age] => 36
[skills] => Array
(
[0] => math
[1] => logic
[2] => programming
)
)
Using var_dump:
array(3) {
["name"]=>
string(12) "Ada Lovelace"
["age"]=>
int(36)
["skills"]=>
array(3) {
[0]=>
string(4) "math"
[1]=>
string(5) "logic"
[2]=>
string(11) "programming"
}
}
print_r() gives you a readable structural view, which is great for a quick look. var_dump() is more precise: it shows the exact type of every value (string, int, array) and the byte length of every string, which is invaluable when you’re chasing bugs caused by type coercion — for example, telling the difference between the string "36" and the integer 36, which print_r() would display identically.
Example 2: Catching exceptions and logging failures
<?php
declare(strict_types=1);
function safeDivide(int $numerator, int $denominator): float
{
if ($denominator === 0) {
throw new InvalidArgumentException('Denominator cannot be zero.');
}
return $numerator / $denominator;
}
set_exception_handler(function (Throwable $e): void {
error_log('Uncaught exception: ' . $e->getMessage());
echo 'Something went wrong. Please try again later.' . PHP_EOL;
});
$pairs = [[10, 2], [9, 0], [20, 4]];
foreach ($pairs as [$numerator, $denominator]) {
try {
$result = safeDivide($numerator, $denominator);
echo "{$numerator} / {$denominator} = {$result}" . PHP_EOL;
} catch (InvalidArgumentException $e) {
echo "Skipped {$numerator} / {$denominator}: {$e->getMessage()}" . PHP_EOL;
}
}
Output:
10 / 2 = 5
Skipped 9 / 0: Denominator cannot be zero.
20 / 4 = 5
Here, safeDivide() throws a specific, meaningful exception instead of letting PHP produce a fatal DivisionByZeroError or, worse, silently returning something wrong like INF. The try/catch block handles the expected failure case locally, while set_exception_handler() acts as a safety net for anything unexpected that slips through uncaught anywhere in the script — logging it instead of showing a raw stack trace to the user.
Example 3: Tracing the call stack with debug_backtrace
<?php
declare(strict_types=1);
function calculateDiscount(float $price, float $percentage): float
{
if ($percentage < 0 || $percentage > 100) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
echo 'Called from: ' . $trace[0]['function'] . PHP_EOL;
throw new ValueError("Percentage must be between 0 and 100, got {$percentage}.");
}
return $price - ($price * $percentage / 100);
}
function applyDiscount(float $price, float $percentage): float
{
return calculateDiscount($price, $percentage);
}
try {
echo applyDiscount(100.0, 20.0) . PHP_EOL;
echo applyDiscount(50.0, 150.0) . PHP_EOL;
} catch (ValueError $e) {
echo 'Caught error: ' . $e->getMessage() . PHP_EOL;
}
Output:
80
Called from: applyDiscount
Caught error: Percentage must be between 0 and 100, got 150.
debug_backtrace() returns an array of stack frames describing exactly how execution reached the current line — which function called it, from where, and with what arguments (unless you pass DEBUG_BACKTRACE_IGNORE_ARGS to skip arguments for performance). This is extremely useful in deeply nested code where a plain error message alone doesn’t tell you which caller is responsible for bad input.
How It Works Step by Step (Under the Hood)
When PHP encounters a problem during execution, several things happen in sequence:
- The Zend Engine detects the condition (undefined variable, type error, division by zero, etc.) while executing an opcode.
- PHP checks the current
error_reportinglevel to decide whether this severity should be reported at all. If it’s masked out, nothing happens. - If a custom handler was registered with
set_error_handler(), PHP calls it instead of using the default handler — your handler can log the error, convert it to an exception, or suppress it. - For throwables (
Error/Exception), PHP instead searches upward through the call stack for a matchingcatchblock. If it finds one, execution resumes there; the stack unwinds, running anyfinallyblocks along the way. - If nothing catches the throwable, PHP calls the function registered with
set_exception_handler()if one exists, or falls back to its default behavior: printing (or logging) an uncaught error and terminating the script with a fatal error. - Everything that was reported and not suppressed is subject to
display_errors(should it print to output?) andlog_errors(should it be written to the error log?) independently — you can have one, both, or neither on.
Understanding this pipeline explains a common point of confusion: turning off display_errors does not stop errors from happening, it only stops them from being printed. The error still occurs, is still logged (if log_errors is on), and a fatal error still halts the script — it just does so silently from the visitor’s perspective, which is exactly the behavior you want in production.
Common Mistakes
Mistake 1: Suppressing errors with @ instead of handling them
The @ operator silences errors on an expression, but it doesn’t fix the underlying problem — it just hides it, making the resulting bug harder to find later.
<?php
$config = @file_get_contents('/nonexistent/path/config.json');
$data = json_decode($config, true);
echo $data['app_name'];
If the file doesn’t exist, $config becomes false, json_decode(false, true) returns null, and accessing $data['app_name'] silently emits a warning and evaluates to null — the script keeps running with no clear signal that anything went wrong. The fix is to check explicitly and fail loudly and early:
<?php
$path = '/nonexistent/path/config.json';
if (!is_readable($path)) {
throw new RuntimeException("Config file not found or not readable: {$path}");
}
$config = file_get_contents($path);
$data = json_decode($config, true, flags: JSON_THROW_ON_ERROR);
echo $data['app_name'] ?? 'Unknown';
This version throws a specific, descriptive exception the moment something is wrong, instead of letting a vague null silently propagate through the rest of the script.
Mistake 2: Using echo to inspect values that might be false, null, or an empty string
echo converts everything to a string, and false, null, and "" all print as nothing — so you can’t tell them apart.
<?php
function findUser(array $users, int $id): ?string
{
foreach ($users as $user) {
if ($user['id'] === $id) {
return $user['name'];
}
}
return null;
}
$users = [['id' => 1, 'name' => 'Grace']];
$result = findUser($users, 99);
echo $result;
Running this prints nothing at all, leaving you guessing whether the function returned null, false, or an empty string. Use var_dump() instead, which always shows the exact type:
<?php
function findUser(array $users, int $id): ?string
{
foreach ($users as $user) {
if ($user['id'] === $id) {
return $user['name'];
}
}
return null;
}
$users = [['id' => 1, 'name' => 'Grace']];
$result = findUser($users, 99);
var_dump($result);
if ($result === null) {
echo "No user found with that ID." . PHP_EOL;
}
Now the output unambiguously shows NULL, and the code that follows can check for it explicitly with strict comparison (===).
Best Practices
- Enable
error_reporting(E_ALL)anddisplay_errorsin development so you see every issue immediately, not just the ones that happen to crash the script. - In production, disable
display_errorsbut keeplog_errorson, writing to a file you actively monitor — never show raw stack traces to end users. - Use
var_dump()when you need to know a value’s exact type; useprint_r()when you just need a readable structural overview. - Throw specific exception types (
InvalidArgumentException,ValueError, custom exceptions) rather than genericException, so callers can catch precisely what they expect. - Always use strict comparison (
===,!==) when checking fornull,false, or0, since loose comparison treats many different values as equal. - Avoid the
@error-suppression operator; handle the failure case explicitly instead. - Install and use Xdebug locally for real step-through debugging in your editor (breakpoints, variable inspection, call stacks) rather than relying solely on
echo/var_dumpstatements. - Register a global exception handler with
set_exception_handler()as a safety net, even if you also use targetedtry/catchblocks. - Remove or gate debug output (
var_dump, testechostatements) before deploying — leftover debug output in production is both a bug and a potential information leak.
Practice Exercises
- Write a function
parseAge(string $input): intthat converts a string to an integer age. Make it throw aValueErrorif the string isn’t numeric or the resulting number is negative. Test it with valid input, invalid input, and a negative number, usingtry/catchto print a friendly message for each failure. - Take a script that uses
echoto inspect an array of mixed values (some strings, some numbers, some booleans). Rewrite it to usevar_dump()instead, and write down, for each value, what type and valuevar_dump()reports thatechoalone would have hidden. - Write a function that calls itself recursively three levels deep, and on the innermost call, use
debug_backtrace()to print the chain of function names that led to that point. Expected output: a list showing each calling function in order, innermost first.
Summary
- PHP debugging combines error visibility settings (
error_reporting,display_errors,log_errors) with inspection tools (var_dump,print_r,var_export) and flow-control tools (try/catch,set_error_handler,set_exception_handler). - Notices, warnings, and fatal errors are separate from throwables (
Error/Exception); only throwables can be caught withtry/catch. - In development, show everything; in production, log everything but show nothing to the end user.
var_dump()reveals exact types and lengths;print_r()is for quick, readable structure checks.debug_backtrace()and exception stack traces answer “how did execution get here?” — essential for bugs in deeply nested code.- Never suppress errors with
@as a substitute for real error handling — always fail loudly, explicitly, and as early as possible.
