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_reporting level 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 matching catch block. If it finds one, execution resumes there; the stack unwinds, running any finally blocks 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?) and log_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) and display_errors in development so you see every issue immediately, not just the ones that happen to crash the script.
  • In production, disable display_errors but keep log_errors on, 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; use print_r() when you just need a readable structural overview.
  • Throw specific exception types (InvalidArgumentException, ValueError, custom exceptions) rather than generic Exception, so callers can catch precisely what they expect.
  • Always use strict comparison (===, !==) when checking for null, false, or 0, 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_dump statements.
  • Register a global exception handler with set_exception_handler() as a safety net, even if you also use targeted try/catch blocks.
  • Remove or gate debug output (var_dump, test echo statements) before deploying — leftover debug output in production is both a bug and a potential information leak.

Practice Exercises

  • Write a function parseAge(string $input): int that converts a string to an integer age. Make it throw a ValueError if the string isn’t numeric or the resulting number is negative. Test it with valid input, invalid input, and a negative number, using try/catch to print a friendly message for each failure.
  • Take a script that uses echo to inspect an array of mixed values (some strings, some numbers, some booleans). Rewrite it to use var_dump() instead, and write down, for each value, what type and value var_dump() reports that echo alone 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 with try/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.