---
title: 'The Debugger'
source: 'https://academia.sh/en/courses/php-production/debugger'
course: 'Quality, Performance and Deployment in PHP'
language: en
updated: '2026-10-21T05:03:36+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines the step debugger, the breakpoint, and stepping as concepts'
    - 'Measures how an argument value in the exception trace behaves in the development and production profiles'
    - 'Shows that debug_backtrace() and the exception trace do not depend on the same setting'
    - 'Measures the scope of the protection when the value is written into the exception message'
---

# The Debugger

Measure which profile shows the information seen while debugging, that is, the argument value in the exception trace, the warning text, and the call stack, and whether it reaches production.

The previous lesson showed that the same assertion can be caught in development and pass silently in production. Does the information we see while debugging split in the same direction? Which value appears in the trace of an error, which warning is printed to the screen, and how much of the call stack is opened all depend on directives in the configuration file as well. This lesson measures whether the information that debugging sees reaches production.

A step debugger is the class of tool that stops a running program line by line and shows its inside. We place a breakpoint on the line where we want the program to stop; when the program gets there, it stops, and the variables, the call stack, and the values at that moment can be read. Stepping is advancing the stopped program by one line, into a function, or out of a function. These tools work on the developer's machine by attaching to the run from the outside. This lesson names no extension, installs nothing, and opens no port; the existence of the debugger is described only as a concept, and what is measured is what PHP's own tools show.

The fifth course of the ninth curriculum (M09/K05) asked which tool a piece of information is read from. Here the question changes: is the information the tool shows visible in production as well?

## Three Pieces of Information Debugging Sees

Three of PHP's own tools produce information similar to what a debugger shows. The trace of an exception (`getTrace()`, `getTraceAsString()`) lists the functions called up to the place where the exception was thrown and the arguments given to each call. `debug_backtrace()` returns the same stack at a requested moment, without an exception. Warnings and notices are printed directly into the response when `display_errors` is on.

The measurement core is the same as in the previous lessons. `side.php` does not change at all.

```php
<?php
// side.php — does this behavior reach production?
declare(strict_types=1);

// The two configuration files PHP ships (php.ini-development, php.ini-production) differ
// in seven directives; all seven are copied here verbatim. The last two production lines
// are comments in the shipped file: code cache on for the command line too, file checks off.
const PROFILES = [
    'development' => [
        'display_errors' => '1',
        'display_startup_errors' => '1',
        'error_reporting' => '32767',
        'mysqlnd.collect_memory_statistics' => '1',
        'zend.assertions' => '1',
        'zend.exception_ignore_args' => '0',
        'zend.exception_string_param_max_len' => '15',
    ],
    'production' => [
        'display_errors' => '0',
        'display_startup_errors' => '0',
        'error_reporting' => '24575',
        'mysqlnd.collect_memory_statistics' => '0',
        'zend.assertions' => '-1',
        'zend.exception_ignore_args' => '1',
        'zend.exception_string_param_max_len' => '0',
        'opcache.enable_cli' => '1',
        'opcache.validate_timestamps' => '0',
    ],
];

/** Runs the script in a separate process with no configuration file, only the profile's directives. */
function run(string $script, array $directives): bool {
    $command = ['php', '-n'];
    foreach ($directives as $name => $value) {
        array_push($command, '-d', "$name=$value");
    }
    $command[] = $script;
    $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
    $output = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    proc_close($process);
    $lines = array_values(array_filter(array_map('trim', explode("\n", $output))));
    return end($lines) === 'yes';
}

/** Derives the side from whether the behavior was observed in the two runs. */
function side(bool $inDevelopment, bool $inProduction): string {
    return match (true) {
        $inDevelopment && $inProduction => 'both sides',
        $inDevelopment => 'development only',
        $inProduction => 'production only',
        default => 'not in the run',
    };
}

function sideReport(string $name, string $script): string {
    $inDevelopment = run($script, PROFILES['development']);
    $inProduction = run($script, PROFILES['production']);
    $finalSide = side($inDevelopment, $inProduction);
    echo "$name → $finalSide\n    development ", $inDevelopment ? 'yes' : 'no',
        ' · production ', $inProduction ? 'yes' : 'no', "\n";
    return $finalSide;
}
```

A neutral example is used: the `pay()` function takes the value `card-0042` and throws a `RuntimeException`. The value resembles a card number but is not a real number; the aim is to show that an argument visible in the trace is a value that could leak somewhere else. Three probes run in the same driver.

```php
<?php
// error.php — three pieces of information debugging sees, two profiles
declare(strict_types=1);
require 'side.php';

$probes = [
    'the exception trace carries the argument value' => <<<'PHP'
        <?php
        function pay(string $card): void {
            throw new RuntimeException('declined');
        }
        try {
            pay('card-0042');
        } catch (RuntimeException $e) {
            echo str_contains($e->getTraceAsString(), 'card-0042') ? "yes\n" : "no\n";
        }
        PHP,
    'debug_backtrace carries the argument value' => <<<'PHP'
        <?php
        function pay(string $card): array {
            return debug_backtrace();
        }
        $trace = pay('card-0042');
        echo in_array('card-0042', $trace[0]['args'] ?? [], true) ? "yes\n" : "no\n";
        PHP,
    'a warning is printed into the response' => <<<'PHP'
        <?php
        ob_start();
        echo $undefined;
        $response = ob_get_clean();
        echo str_contains($response, 'Warning') ? "yes\n" : "no\n";
        PHP,
];

foreach ($probes as $name => $code) {
    file_put_contents('probe.php', $code);
    sideReport($name, 'probe.php');
}
```

```text
the exception trace carries the argument value → development only
    development yes · production no
debug_backtrace carries the argument value → both sides
    development yes · production yes
a warning is printed into the response → development only
    development yes · production no
```

In the first probe the setting difference is directly visible. In the development profile `zend.exception_ignore_args=0`, so the exception trace carries the arguments of every call and `card-0042` can be read in the trace. In the production profile `zend.exception_ignore_args=1`, so the trace carries only function names and lines; the argument list is empty. This is a protection: a trace that lands in an error log or on an error page does not carry the value the user gave. Because it is a directive tightened in production, this side being **development only** is not a defect but the intended result.

The third probe is on the same side. In the development profile `display_errors=1` and `error_reporting=32767`, so the undefined variable warning is printed into the output. In the production profile `display_errors=0`, so the warning does not enter the response. Printing an error message into the response shows the visitor the file path, the line number, and often a value. This is called information disclosure, and it is the reason it is kept off in production.

The second probe falls outside this table. The `debug_backtrace()` call returns the argument in both profiles. The reason is that the `zend.exception_ignore_args` directive concerns only the exception trace; it does not affect a stack dump that the code itself requests. The protection is a directive, not a barrier: it hides the argument from an exception trace but not from a `debug_backtrace()` call written into the code. If this call is left in a file that goes to production, the value can be read in production as well.

## The Value Written into the Message

If the protection covers the argument list, what happens when the value is written somewhere else? A developer can write the value into the message of the exception to make the error understandable: `new RuntimeException("declined: $card")`. This is a convenience often used while debugging, because the message is visible both in the log and on the screen. The question is this: does the production profile hide this value as well?

## Why Production Does Not Stop

The breakpoint of a debugger stops the program and makes its inside readable. This has two meanings. A stopped program does not answer requests: in production a breakpoint becomes a lock that keeps that request waiting. In addition, every variable a stopped program shows is open to the person attached to the debugger; this person's authority is wider than the authority of the one who sent the request. Both make the debugger a tool that belongs to the development profile.

In production narrower tools take its place: the log, the exception trace, and the metric. They do not stop the program, but the information they show depends on settings. This is exactly what this lesson measures. In development detailed information is wanted, because the reader is the person who will solve the error. In production the same information may go to a log, an error page, or a monitoring service, and who will read it there is not known. For this reason `zend.exception_ignore_args` is `1` in the production profile, and for this reason `display_errors` is `0`.

A distinction has to be made here. These directives do not hide the error; they decide where the error is shown. In production the error still occurs, is still written to the log, and is still thrown as an exception. What changes is which fields are present in the response and in the trace. So the conclusion is not "there is no error in production"; it is only "the detail of the error is not visible in production".

## Trace Format and Notice Level

We measure the trace itself as well. `getTraceAsString()` writes each call on one line, and each line starts with a sequence number such as `#0` or `#1`. `debug_print_backtrace()` prints the same information directly to the output. We measure whether the two follow the same protection. The first probe asks whether a trace line carries the name of the called function, and the second asks whether the output of `debug_print_backtrace()` carries the argument value. The third looks at another piece of information: the deprecation notice. Even if printing to the screen is turned on with `ini_set('display_errors', '1')`, the notice is never produced if the `error_reporting` value leaves it out. In the production profile `error_reporting=24575`, so the `E_DEPRECATED` flag is off.

```php
<?php
// trace.php — trace format and notice level
declare(strict_types=1);
require 'side.php';

$probes = [
    'a trace line carries the function name' => <<<'PHP'
        <?php
        function pay(string $card): void {
            throw new RuntimeException('declined');
        }
        try {
            pay('card-0042');
        } catch (RuntimeException $e) {
            echo str_contains($e->getTraceAsString(), '#0') && str_contains($e->getTraceAsString(), 'pay(') ? "yes\n" : "no\n";
        }
        PHP,
    'debug_print_backtrace carries the argument value' => <<<'PHP'
        <?php
        function pay(string $card): void {
            debug_print_backtrace();
        }
        ob_start();
        pay('card-0042');
        echo str_contains(ob_get_clean(), 'card-0042') ? "yes\n" : "no\n";
        PHP,
    'a deprecation notice arrives even when display is turned on' => <<<'PHP'
        <?php
        ini_set('display_errors', '1');
        ob_start();
        strlen(null);
        echo str_contains(ob_get_clean(), 'Deprecated') ? "yes\n" : "no\n";
        PHP,
];

foreach ($probes as $name => $code) {
    file_put_contents('probe.php', $code);
    sideReport($name, 'probe.php');
}
```

```text
a trace line carries the function name → both sides
    development yes · production yes
debug_print_backtrace carries the argument value → development only
    development yes · production no
a deprecation notice arrives even when display is turned on → development only
    development yes · production no
```

It reads as follows. The trace format is independent of the settings: a trace line carries the sequence number and the function name in both profiles. The second line shows an unexpected distinction: `debug_print_backtrace()` prints the argument value **only in development** and not in production. This difference is not created by `zend.exception_ignore_args`; that directive concerns only the exception trace. The difference comes from the `zend.exception_string_param_max_len` directive: when `debug_print_backtrace()` turns the stack into text, it truncates string arguments to this length. Because the value is 15 in development and 0 in production, `card-0042` is printed in full in development and is not printed in production. `debug_backtrace()`, measured earlier in this lesson, returns the stack as an array without turning it into text, and it gives the argument on both sides. Two functions that read the same stack behave differently from the point of view of protection. Which one is called therefore matters.

The third probe is also **development only**: the deprecation notice is not produced in the production profile even if `display_errors` is turned on, because `error_reporting` turns that level off. This shows that a warning can be invisible for two separate reasons: the notice may be produced but not printed to the screen, or it may not be produced at all. In production the second case means that a deprecation leaves no trace, either in the response or in this measurement. If a deprecated call is not noticed in development, it is not noticed in production either.

## Simulating a Breakpoint

A breakpoint stops at a line and shows the caller, the arguments, and the local variables. How much of this can `debug_backtrace()` simulate? Three probes ask what can be read from the stopped point: the name of the calling function, the argument of the call, and a local variable of the calling function.

```php
<?php
// simulate.php — how much of a breakpoint does debug_backtrace simulate
declare(strict_types=1);
require 'side.php';

$probes = [
    'the caller\'s name can be read' => <<<'PHP'
        <?php
        function halt(): array {
            return debug_backtrace();
        }
        function pay(string $card): array {
            $balance = 'balance-77';
            return halt();
        }
        $trace = pay('card-0042');
        echo ($trace[1]['function'] ?? '') === 'pay' ? "yes\n" : "no\n";
        PHP,
    'the caller\'s argument can be read' => <<<'PHP'
        <?php
        function halt(): array {
            return debug_backtrace();
        }
        function pay(string $card): array {
            $balance = 'balance-77';
            return halt();
        }
        $trace = pay('card-0042');
        echo in_array('card-0042', $trace[1]['args'] ?? [], true) ? "yes\n" : "no\n";
        PHP,
    'the caller\'s local variable can be read' => <<<'PHP'
        <?php
        function halt(): array {
            return debug_backtrace();
        }
        function pay(string $card): array {
            $balance = 'balance-77';
            return halt();
        }
        $trace = pay('card-0042');
        echo str_contains(json_encode($trace), 'balance-77') ? "yes\n" : "no\n";
        PHP,
];

foreach ($probes as $name => $code) {
    file_put_contents('probe.php', $code);
    sideReport($name, 'probe.php');
}
```

```text
the caller's name can be read → both sides
    development yes · production yes
the caller's argument can be read → both sides
    development yes · production yes
the caller's local variable can be read → not in the run
    development no · production no
```

The result draws the limit of the simulation. The caller's name and argument can be read; the local variable cannot. A real breakpoint shows all local variables on the line where it stops, while `debug_backtrace()` gives only the call chain and the arguments. The two tools are not in the same family: one stops the program and looks inside it, and the other reads a record the program leaves by itself. This difference is the reason we do not use a debugger in production, and also the reason a forgotten `debug_backtrace()` call is dangerous: the second one goes to production and shows the arguments in both profiles.

## The Bounding Measurement

The driver runs two exceptions side by side: one takes the value as an argument, and the other writes the value into the message. For each of them, whether the value `card-0042` is found somewhere inside the exception is asked separately. For the trace, `getTraceAsString()` is read; for the message, `getMessage()`.

```php
<?php
// limit.php — is the value in the argument or in the message
declare(strict_types=1);
require 'side.php';

$probes = [
    'a value in an argument is carried by the trace' => <<<'PHP'
        <?php
        function pay(string $card): void {
            throw new RuntimeException('declined');
        }
        try {
            pay('card-0042');
        } catch (RuntimeException $e) {
            echo str_contains($e->getTraceAsString(), 'card-0042') ? "yes\n" : "no\n";
        }
        PHP,
    'a value in the message is carried by the message' => <<<'PHP'
        <?php
        function pay(string $card): void {
            throw new RuntimeException("declined: $card");
        }
        try {
            pay('card-0042');
        } catch (RuntimeException $e) {
            echo str_contains($e->getMessage(), 'card-0042') ? "yes\n" : "no\n";
        }
        PHP,
    'a value in the message is carried by the trace too' => <<<'PHP'
        <?php
        function pay(string $card): void {
            throw new RuntimeException("declined: $card");
        }
        try {
            pay('card-0042');
        } catch (RuntimeException $e) {
            echo str_contains($e->getTraceAsString(), 'card-0042') ? "yes\n" : "no\n";
        }
        PHP,
];

foreach ($probes as $name => $code) {
    file_put_contents('probe.php', $code);
    sideReport($name, 'probe.php');
}
```

```text
a value in an argument is carried by the trace → development only
    development yes · production no
a value in the message is carried by the message → both sides
    development yes · production yes
a value in the message is carried by the trace too → development only
    development yes · production no
```

The result shows the limit of the protection. If the value is in an argument, the production profile removes it from the trace. If the value is written into the message, `zend.exception_ignore_args` does not touch it: `getMessage()` carries the value `card-0042` in both profiles, and the message goes to the error log or the error page as it is. The protection covers only the argument list; the message is a string, and PHP does not interfere with its contents. The third line completes the distinction: even if the value is in the message, the trace does not carry the value in production, because the argument list is empty. The value leaks not from the trace but from the message. Which field is read decides the measurement.

This has two consequences. First, the trace carrying no value in production is not a guarantee by itself; it must be verified separately that the value is not written into the message. Second, the message that is useful while debugging, the one that carries the value, has to be reviewed before it goes to production.

## Summary

- A step debugger is the class of tool that stops at a breakpoint and shows the variables and the call stack; PHP's own counterparts are the exception trace, `debug_backtrace()`, and `display_errors`.
- The argument value in the exception trace is visible **only in development**; in production `zend.exception_ignore_args=1` hides it, and this is a protection.
- Printing a warning into the response is also seen **only in development**; in production `display_errors=0` closes the information disclosure.
- `debug_backtrace()` returns the argument in both profiles: the protection is specific to the exception trace and does not cover a call written into the code.
- If the value is written into the exception message, the production profile does not hide it; the protection covers only the argument list.

## Next Step

With this lesson all the quality tools have been scanned with the question of whether a behavior splits between the two profiles: static analysis and style checking stayed outside the run, and assertions and detailed error information appeared only in development. In the next lessons the question returns to the file itself: when a file changes, which version does PHP run, the one on disk or the one in the cache? The answer is again written in the configuration file and takes a different value in the production profile.
