---
title: 'Quality, Performance and Deployment in PHP'
source: 'https://academia.sh/en/courses/php-production'
language: en
updated: '2026-10-21T05:03:35+00:00'
license: 'CC BY-SA 4.0'
lessons: 10
outcomes:
    - 'Measures which side a behavior is on by running it in separate processes under the development and production profiles'
    - 'Finds defects a run cannot catch with a checker that reads the code without running it'
    - 'Explains why assertions, error display and exception trace arguments are turned off in production'
    - 'Shows that the code cache runs the old version of a file changed in place and across a symbolic link switch, and invalidates the cache'
    - "Measures the memory limit, call counts and the built-in server's state between requests under both profiles"
---

# Quality, Performance and Deployment in PHP

PHP ships two configuration files, one for development and one for production, and the two deliberately differ in seven directives. Does a behavior of the code you wrote ship to production? Some behaviors show up on both sides, some stay on the developer's machine, some appear only in production, and some never enter any run, so only a tool that reads the code without running it finds them. The course measures static analysis, style checking, tests, debugging, the code cache, profiling, memory management, the built-in server, ini directives and deployment with a single question — does this behavior ship to production?

## Quality Tools

Static analysis, code style checking, testing frameworks and the debugger — each as a tool that shows whether a behavior is seen inside or outside the run and whether it ships to production.

### Static Analysis

Source: https://academia.sh/en/courses/php-production/static-analysis

Learn what tools that read code without running it can find, what they cannot, and how to measure whether a behavior reaches production with a single question.

The Web Application Framework course held the code and the configuration fixed and measured whether a part ran in the request. In this course the code and the request are fixed; what changes is the place where the code runs. The same script can show one behavior on a developer's machine and another in production. Most of the time this difference is not a defect but a design: PHP ships two separate configuration files, and the two differ on purpose. Every lesson in the course asks a single question: does this behavior reach production?

The first lesson opens with tools that read code without running it. Static analysis examines a program's source for defects without executing it. For that reason it sits in a place of its own on the axis: defects that never appear inside the run can only be revealed by a tool outside the run.

#### Two Configuration Files, Seven Differences

The PHP source distribution contains two sample configuration files: `php.ini-development` and `php.ini-production`. Both carry most of the same directives with the same values. They differ on purpose in seven directives: error display (`display_errors`), display of startup errors (`display_startup_errors`), error level (`error_reporting`), assertions (`zend.assertions`), arguments in the exception trace (`zend.exception_ignore_args`), the length of an argument string (`zend.exception_string_param_max_len`), and memory statistics (`mysqlnd.collect_memory_statistics`). The production profile adds the code cache on top of these.

This difference is not a mistake. Production does not print error messages into the response, does not compile assertions, does not put arguments into the exception trace, and removes deprecation notices from the error level. The lesson does not try to close this gap; it names the side on which each behavior stands. In the DevOps and Platform Engineering curriculum every deviation between development and production was treated as a hidden defect and an attempt was made to close it. Here most of the difference is intentional and is shipped by PHP itself.

#### The Measurement Core

The course measures every behavior with the same two files. The first is `side.php`, and it stays exactly the same in every lesson of the course.

```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;
}
```

The file has four parts. The `PROFILES` constant carries the seven differences of the two configuration files verbatim. The last two production lines are comments in the shipped file; the course sets them up explicitly, because the code cache is off by default on the command line. `run()` runs a probe script in a separate process and checks whether the last line is `yes`. `side()` derives one of the four sides from two boolean values. `sideReport()` prints this as one line and one detail line.

Two rules are born in this file. The first is the `-n` flag: the child process never loads the machine's configuration file and runs only with the profile's directives. This way the measurement does not depend on which configuration file happens to be on that machine. The second is that the child process is started by the name `'php'`, through PATH. `PHP_BINARY` is not used, because the path of the machine's binary cannot be trusted: that path may point to a place that does not sit next to the libraries the binary depends on, and the process may not start at all.

The four sides do not change throughout the course: if a behavior is observed in both profiles, it is **both sides**; if it is observed only in development, **development only**; if it is observed only in production, **production only**; if it is observed in neither, **not in the run**. The side is given as a rule, not as a label. Each probe prints `yes` when it observes the behavior and `no` when it does not. `run()` runs in both profiles, and `side()` derives the name from the two results.

#### Twelve Behaviors, Four Sides

The second file, `behaviors.php`, passes twelve small probes through this core. The probe texts sit inside nowdoc strings, so variables and escapes do not interfere.

```php
<?php
// behaviors.php — twelve behaviors, four sides
declare(strict_types=1);
require 'side.php';

// Each probe prints "yes" on its last line if the behavior was observed, "no" if not.
$behaviors = [
    'a request that exceeds the memory limit stops' => <<<'PHP'
        <?php
        ini_set('memory_limit', '32M');
        register_shutdown_function(function () {
            $last = error_get_last();
            echo ($last['type'] ?? 0) === E_ERROR ? "yes\n" : "no\n";
        });
        $big = str_repeat('x', 64 * 1024 * 1024);
        PHP,
    'the destructor runs when the request ends' => <<<'PHP'
        <?php
        $connection = new class {
            public function __destruct() { echo "yes\n"; }
        };
        PHP,
    'a warning reaches the log' => <<<'PHP'
        <?php
        $log = __DIR__ . '/log.txt';
        @unlink($log);
        ini_set('log_errors', '1');
        ini_set('error_log', $log);
        ob_start();
        echo $undefined;
        ob_end_clean();
        echo str_contains((string) @file_get_contents($log), 'Warning') ? "yes\n" : "no\n";
        PHP,
    'a wrong-typed argument stops execution' => <<<'PHP'
        <?php
        declare(strict_types=1);
        function quantity(int $n): int { return $n; }
        try { quantity('3'); echo "no\n"; } catch (TypeError) { echo "yes\n"; }
        PHP,
    'a failing assertion stops the request' => <<<'PHP'
        <?php
        try { assert(1 === 2); echo "no\n"; } catch (AssertionError) { echo "yes\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,
    'a deprecated call reaches the log' => <<<'PHP'
        <?php
        $log = __DIR__ . '/log.txt';
        @unlink($log);
        ini_set('log_errors', '1');
        ini_set('error_log', $log);
        ob_start();
        strlen(null);
        ob_end_clean();
        echo str_contains((string) @file_get_contents($log), 'Deprecated') ? "yes\n" : "no\n";
        PHP,
    'the exception trace carries the argument value' => <<<'PHP'
        <?php
        function pay(string $card) { throw new RuntimeException('declined'); }
        try { pay('card-0042'); } catch (RuntimeException $e) {
            echo count($e->getTrace()[0]['args'] ?? []) > 0 ? "yes\n" : "no\n";
        }
        PHP,
    'the old version of a file changed in place runs' => <<<'PHP'
        <?php
        $version = __DIR__ . '/version.php';
        file_put_contents($version, "<?php return 1;\n");
        touch($version, time() - 60);
        $before = include $version;
        file_put_contents($version, "<?php return 2;\n");
        touch($version, time() - 30);
        $after = include $version;
        echo $after === $before ? "yes\n" : "no\n";
        PHP,
    'the old version runs after the symbolic link switch' => <<<'PHP'
        <?php
        foreach ([1, 2] as $n) {
            @mkdir("release-$n");
            file_put_contents("release-$n/hello.php", "<?php return $n;\n");
            touch("release-$n/hello.php", time() - 60);
        }
        @unlink('current');
        symlink('release-1', 'current');
        $before = include 'current/hello.php';
        @unlink('current-new');
        symlink('release-2', 'current-new');
        rename('current-new', 'current');
        $after = include 'current/hello.php';
        echo $after === $before ? "yes\n" : "no\n";
        PHP,
    'an undefined function in an uncalled branch is reported' => <<<'PHP'
        <?php
        function report(bool $summary): string { return $summary ? summarize() : 'full'; }
        report(false);
        echo error_get_last() !== null ? "yes\n" : "no\n";
        PHP,
    'unformatted code is reported' => <<<'PHP'
        <?php
        file_put_contents(__DIR__ . '/unformatted.php', "<?php\nfunction  add(\$a,\$b){return \$a+\$b;}   \n");
        include __DIR__ . '/unformatted.php';
        add(1, 2);
        echo error_get_last() !== null ? "yes\n" : "no\n";
        PHP,
];

$tally = array_fill_keys(['both sides', 'development only', 'production only', 'not in the run'], 0);
foreach ($behaviors as $name => $code) {
    file_put_contents('probe.php', $code);
    $tally[sideReport($name, 'probe.php')]++;
}
echo "\n";
foreach ($tally as $finalSide => $n) {
    echo "$finalSide: $n\n";
}
```

The full output is:

```text
a request that exceeds the memory limit stops → both sides
    development yes · production yes
the destructor runs when the request ends → both sides
    development yes · production yes
a warning reaches the log → both sides
    development yes · production yes
a wrong-typed argument stops execution → both sides
    development yes · production yes
a failing assertion stops the request → development only
    development yes · production no
a warning is printed into the response → development only
    development yes · production no
a deprecated call reaches the log → development only
    development yes · production no
the exception trace carries the argument value → development only
    development yes · production no
the old version of a file changed in place runs → production only
    development no · production yes
the old version runs after the symbolic link switch → production only
    development no · production yes
an undefined function in an uncalled branch is reported → not in the run
    development no · production no
unformatted code is reported → not in the run
    development no · production no

both sides: 4
development only: 4
production only: 2
not in the run: 2
```

The numbers are binding, and none of the ten lessons of the course changes them: of the twelve behaviors, four are on both sides, four are in development only, two are in production only, and two are not in the run. The four groups read as follows.

The four behaviors on both sides (the memory limit, the destructor running at the end of the request, the open log, and the `TypeError` under `strict_types`) are independent of the configuration file. Because printing to the screen is off in production, the main way to see an error is the log, and the log lands in the same place on both sides.

The four behaviors in development only are the places where production deliberately stays silent: a failing assertion, a warning printed into the response, a deprecation notice reaching the log, and an exception trace that carries arguments. In production, `zend.assertions=-1` does not compile the assertion at all.

The two behaviors in production only are behaviors the developer never sees: the code cache compiles a file once and never looks at the disk again, so both an overwritten file and a switched symbolic link run the old version.

The two behaviors that are not in the run are defects the run cannot catch. The rest of the lesson examines the first of them.

#### The Defect the Run Cannot Find

The eleventh of the twelve probes is the undefined function in an uncalled branch. The excerpt is as follows.

```php
<?php
// report.php — a function that calls an undefined function in one branch
declare(strict_types=1);

function report(bool $summary): string {
    return $summary ? summarize() : 'full';
}
```

`summarize()` is not defined anywhere, but when `report(false)` is called, that branch is never entered. PHP resolves a function name at call time; for the uncalled branch it produces no notice at compile time or at run time. This behavior is the same in both profiles, and `error_get_last()` stays `null`. The question of the Web Application Framework course was whether a part was called in this request. Here the part is always called; what is asked is whether the behavior of the called part is the same in the two profiles. For this defect the answer is the same in both profiles: silence.

PHP's own check, `php -l`, cannot see this defect either. The command checks only the syntax; that is, it looks at whether the file can be parsed as PHP, and it resolves no function.

```bash
# check.sh — php -l looks at syntax only
printf '<?php\nfunction report( {\n' > broken.php
php -n -l broken.php >/dev/null 2>&1
echo "broken file: $?"
printf '<?php\nfunction report() { return 1; }\n' > valid.php
php -n -l valid.php >/dev/null 2>&1
echo "valid file: $?"
php -n -l report.php >/dev/null 2>&1
echo "file with an undefined function: $?"
```

```text
broken file: 255
valid file: 0
file with an undefined function: 0
```

The broken file gives 255. The valid file and the file that calls an undefined function give the same code, 0. The lesson prints only the exit code.

The tokenizer extension goes one step further. `PhpToken::tokenize()` splits the source into tokens: keywords, names, variables, operators. The checker below compares the function names that are defined with the names that are called. A name is defined if it comes right after the `function` keyword in the source. A name is called if it is followed by `(` and is not preceded by `->`, `::`, or `new`. A call counts as undefined if it is neither defined in the source nor passes PHP's own `function_exists()` check.

```php
<?php
// check.php — a small checker that compares called names with defined names
declare(strict_types=1);

/** Splits the source into tokens; discards whitespace and comments. */
function tokens(string $source): array {
    $list = PhpToken::tokenize($source);
    return array_values(array_filter($list, fn(PhpToken $b) => !$b->isIgnorable()));
}

/** Collects the function names that are defined and called. */
function collectNames(string $source): array {
    $b = tokens($source);
    $defined = [];
    $called = [];
    foreach ($b as $i => $token) {
        if (!$token->is(T_STRING)) {
            continue;
        }
        $previous = $b[$i - 1] ?? null;
        $next = $b[$i + 1] ?? null;
        if ($previous !== null && $previous->is(T_FUNCTION)) {
            $defined[] = $token->text;
        } elseif ($next !== null && $next->text === '('
            && ($previous === null || !$previous->is([T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_NEW]))) {
            $called[] = $token->text;
        }
    }
    return ['defined' => $defined, 'called' => $called];
}

/** Returns the names of the calls whose definition cannot be found. */
function undefinedCalls(string $source): array {
    $names = collectNames($source);
    $missing = [];
    foreach (array_unique($names['called']) as $name) {
        if (!in_array($name, $names['defined'], true) && !function_exists($name)) {
            $missing[] = $name;
        }
    }
    return $missing;
}
```

The driver first measures with a probe that the defect stays silent in both profiles, then applies the checker to the same file, and finally actually runs the branch.

```php
<?php
// analysis.php — run or read
declare(strict_types=1);
require 'side.php';
require 'check.php';

file_put_contents('probe.php', <<<'PHP'
    <?php
    include 'report.php';
    report(false);
    echo error_get_last() !== null ? "yes\n" : "no\n";
    PHP);
sideReport('an undefined function in an uncalled branch is reported', 'probe.php');

echo 'checker found: ', implode(', ', undefinedCalls(file_get_contents('report.php'))), "\n";

include 'report.php';
try {
    report(true);
} catch (Error $e) {
    echo 'when the branch is entered: ', get_class($e), "\n";
}
```

```text
an undefined function in an uncalled branch is reported → not in the run
    development no · production no
checker found: summarize
when the branch is entered: Error
```

The run is silent in both profiles: the side is not in the run. The checker finds the name `summarize`. When the branch is entered, PHP throws an `Error`; that is, the defect is real, and only the run hides it until that request.

#### The Bounding Measurement

Static analysis sees what the run cannot see, but it has a limit too: the name must be readable in the source. The code below calls the same undefined function, but takes the name from a variable.

```php
<?php
// limit.php — does the checker see a call whose name is carried in a variable
declare(strict_types=1);
require 'check.php';

file_put_contents('dynamic.php', <<<'PHP'
    <?php
    $name = 'summarize';
    $name();
    PHP);

echo 'undefined calls the checker found: ', count(undefinedCalls(file_get_contents('dynamic.php'))), "\n";
try {
    include 'dynamic.php';
} catch (Error $e) {
    echo 'direct run: ', get_class($e), "\n";
}
```

```text
undefined calls the checker found: 0
direct run: Error
```

The checker finds nothing, while the direct run throws an `Error`. At the token level, the name of the call is not even the word `summarize`; it is a string constant (`T_CONSTANT_ENCAPSED_STRING`), and `$name` is a variable. At this point every tool that reads the source either needs an inference layer that tries to resolve the name or has to accept its limit. What the measurement teaches is that the sentence "the checker gave clean output" does not mean "there is no defect": it means only "this tool, with this way of reading, saw no defect".

#### Summary

- Static analysis reads code without running it; for that reason its side on the measurement axis is **not in the run**.
- PHP's development and production configuration files differ on purpose in seven directives: error display, display of startup errors, error level, assertions, arguments in the exception trace, argument string length, and memory statistics.
- The measuring question is a single one, does this behavior reach production; the four answers (both sides, development only, production only, not in the run) do not change throughout the course, and the distribution of the twelve behaviors is 4, 4, 2, 2.
- `php -n -l` checks only the syntax: the broken file gives 255, while the valid file and the file with an undefined function give 0.
- A small checker that reads tokens compares called names with defined names and finds the name `summarize`, but it cannot see a name carried as a string.

#### Next Step

In this lesson a tool that reads the code showed the defect the run could not see. The second behavior of the same class is not in the run either: unformatted code runs without trouble in both profiles. The next lesson asks whether the format of the code changes the run, and uses the same token reading for whitespace, line endings, and indentation.

### Code Style Checking

Source: https://academia.sh/en/courses/php-production/code-style-checking

Measure that unformatted code runs the same in both profiles, that only a style checker reports it, and where a formatter can change the meaning.

In the previous lesson a tool that reads code without running it found a defect the run could not see. Does the format of the code fall into the same class? Extra spaces, a tab forgotten at the end of a line, or a file indented half with tabs and half with spaces may mean nothing to PHP. This lesson measures that assumption and asks the same question on the measurement axis: does this behavior reach production?

A style checker reads the source against a coding standard and reports deviations. The concept of a standard recommendation (PSR) was introduced in the Object-Oriented PHP and Standards course; a coding standard is the set of rules that determines how code is written. What is measured here is not the content of the standard but the place in the run of the tool that applies it.

#### Unformatted Code Runs in Both Profiles

The measurement core of the lesson is the same as in the previous lesson. `side.php` does not change at all; in this lesson it only prints its own probe with `sideReport()`.

```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;
}
```

The code to be measured is a small file written to be unformatted on purpose: two spaces after the function name, trailing whitespace at the end of a line, a tab on one line, and four-space indentation on another. The driver produces the file with escape sequences, so the invisible characters stay visible in the lesson's source too.

#### A Small Style Checker

The checker looks at tokens, as in the previous lesson. When PHP splits the source into tokens, whitespace becomes a token as well: `T_WHITESPACE`. A whitespace token carries line endings and the indentation that follows them in one piece. The checker splits this piece at the line endings: the whitespace left before each line ending is trailing whitespace; whitespace that contains no line ending and is at least two characters long is consecutive spaces; the whitespace left after the last line ending is indentation, and it is counted as tab indentation if it contains a tab and as space indentation if it does not.

A formatter uses the same split, but instead of producing counts it rewrites the whitespace: it deletes trailing whitespace, reduces consecutive spaces to a single space, and replaces every tab with four spaces. Every token other than whitespace is written back with its text unchanged. The `outputOf()` function runs a file in a separate process with the profile's directives and returns its output.

```php
<?php
// style.php — a style checker and formatter that look at tokens
declare(strict_types=1);

/** Extracts four counts from whitespace tokens; ignores whitespace inside strings and comments. */
function styleCounts(string $source): array {
    $tally = ['trailingWhitespace' => 0, 'consecutive' => 0, 'tabIndent' => 0, 'spaceIndent' => 0];
    foreach (PhpToken::tokenize($source) as $token) {
        if (!$token->is(T_WHITESPACE)) {
            continue;
        }
        $parts = explode("\n", $token->text);
        $last = array_pop($parts);
        foreach ($parts as $part) {
            if ($part !== '') {
                $tally['trailingWhitespace']++;
            }
        }
        if ($parts === []) {
            if (strlen($last) >= 2) {
                $tally['consecutive']++;
            }
        } elseif ($last !== '') {
            $tally[str_contains($last, "\t") ? 'tabIndent' : 'spaceIndent']++;
        }
    }
    return $tally;
}

/** Rewrites only whitespace tokens; the other tokens stay as they are. */
function format(string $source): string {
    $output = '';
    foreach (PhpToken::tokenize($source) as $token) {
        if (!$token->is(T_WHITESPACE)) {
            $output .= $token->text;
            continue;
        }
        $parts = explode("\n", $token->text);
        $last = array_pop($parts);
        if ($parts === []) {
            $output .= ' ';
            continue;
        }
        $output .= str_repeat("\n", count($parts)) . str_replace("\t", '    ', $last);
    }
    return $output;
}

/** Runs the script in a separate process with the given directives and returns its output. */
function outputOf(string $script, array $directives): string {
    $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);
    return $output;
}
```

The driver produces the unformatted file, also writes the formatted version of the same file, and measures three things. The first is whether the unformatted code produces a notice in the two profiles; the second is the counts of the checker and the formatter; the third is the output of four runs: the unformatted and the formatted file, in the development and the production profile.

```php
<?php
// format.php — format does not change the run
declare(strict_types=1);
require 'side.php';
require 'style.php';

$unformatted = "<?php\n"
    . "function  add(\$a,\$b){return \$a+\$b;}   \n"
    . "function  multiply(\$a,\$b){\n"
    . "\t\$result = \$a * \$b;\n"
    . "    return \$result;\n"
    . "}\n"
    . "echo add(2, 3), multiply(2, 3), \"\\n\";\n";
file_put_contents('unformatted.php', $unformatted);
file_put_contents('formatted.php', format($unformatted));

file_put_contents('probe.php', <<<'PHP'
    <?php
    ob_start();
    include 'unformatted.php';
    ob_end_clean();
    echo error_get_last() !== null ? "yes\n" : "no\n";
    PHP);
sideReport('unformatted code is reported', 'probe.php');

function countLine(string $source): string {
    $s = styleCounts($source);
    return "trailing whitespace {$s['trailingWhitespace']} · consecutive spaces {$s['consecutive']}"
        . " · tab indentation {$s['tabIndent']} · space indentation {$s['spaceIndent']}";
}
echo 'unformatted: ', countLine($unformatted), "\n";
echo 'formatted: ', countLine(file_get_contents('formatted.php')), "\n";

$results = [];
foreach (['unformatted.php', 'formatted.php'] as $file) {
    foreach (PROFILES as $directives) {
        $results[] = trim(outputOf($file, $directives));
    }
}
echo 'output of the four runs: ', implode(' ', $results), "\n";
echo 'all the same: ', count(array_unique($results)) === 1 ? 'yes' : 'no', "\n";
$formatted = file_get_contents('formatted.php');
echo 'does a second pass change the file: ', format($formatted) === $formatted ? 'no' : 'yes', "\n";
```

```text
unformatted code is reported → not in the run
    development no · production no
unformatted: trailing whitespace 1 · consecutive spaces 2 · tab indentation 1 · space indentation 1
formatted: trailing whitespace 0 · consecutive spaces 0 · tab indentation 0 · space indentation 2
output of the four runs: 56 56 56 56
all the same: yes
does a second pass change the file: no
```

The side is not in the run: the unformatted file runs in both profiles without producing a notice. The checker counts the four kinds of deviation separately; in the formatted file, trailing whitespace, consecutive spaces, and tab indentation drop to zero, and space indentation rises to two. The output of the four runs is exactly the same. Because the formatter touches only whitespace tokens, the meaning of the file does not change; what proves it is the equality of the four outputs.

#### Checking and Fixing Are Separate Jobs

The last line of the output shows the difference between the two tools. The checker reads and reports; it changes no file. The formatter writes; for that reason, when it is applied to the same file a second time, it should no longer change the file, otherwise every run produces the next difference. The measurement confirms this: the second pass does not change the file.

What the two tools have in common is that both stand outside the run. Neither needs to enter the package that is sent to production; they run on the developer's machine and in the step before deployment. This is the same position as the static analyzer of the previous lesson and gives the same side on the measurement axis: not in the run. The difference is in the class of defect. Static analysis finds a mistake the run does not catch; style checking finds a deviation the run does not count as wrong. PHP counts neither unformatted code nor trailing whitespace as an error, because both conform to the rules of the language.

For that reason the justification for style checking is not correctness but readability and consistency. Trailing whitespace appears as an invisible change in a version difference; mixed indentation makes the same file align differently in different editors; consecutive spaces hide what a change actually changes. Handing the enforcement of the standard to a tool instead of to human attention keeps these three deviations from slipping past unnoticed.

#### Where Format Changes the Run

So far format has not changed the run: whitespace, indentation, and trailing whitespace did not move the output by a single character. PHP has an exception to this rule, and the exception shows why style checking should not be treated as merely an aesthetic job. A file opens with `<?php` and closes with `?>`; every character that comes after the closing tag is written to the output as plain text, not as PHP code. There is a single exception: a single newline that immediately follows the closing tag is swallowed.

The measurement produces a file of the same name with three different contents and includes each in an output buffer. The only character the file writes is the letter `a`; if the length of the buffer is more than that, the whitespace after the closing tag has leaked into the output.

```php
<?php
// closing.php — does a character after the closing tag leak into the output
declare(strict_types=1);
require 'side.php';

$probes = [
    'a single newline is swallowed' => <<<'PHP'
        <?php
        $close = '?' . '>';
        file_put_contents('part.php', "<?php echo 'a'; $close\n");
        ob_start();
        include 'part.php';
        echo strlen(ob_get_clean()) === 1 ? "yes\n" : "no\n";
        PHP,
    'a second newline leaks' => <<<'PHP'
        <?php
        $close = '?' . '>';
        file_put_contents('part.php', "<?php echo 'a'; $close\n\n");
        ob_start();
        include 'part.php';
        echo strlen(ob_get_clean()) === 2 ? "yes\n" : "no\n";
        PHP,
    'whitespace after the closing tag leaks' => <<<'PHP'
        <?php
        $close = '?' . '>';
        file_put_contents('part.php', "<?php echo 'a'; $close ");
        ob_start();
        include 'part.php';
        echo strlen(ob_get_clean()) === 2 ? "yes\n" : "no\n";
        PHP,
];

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

```text
a single newline is swallowed → both sides
    development yes · production yes
a second newline leaks → both sides
    development yes · production yes
whitespace after the closing tag leaks → both sides
    development yes · production yes
```

All three probes are on both sides: the leak does not depend on a configuration difference; it is a rule of the language. This is why it differs from the earlier three deviations. Trailing whitespace changed nothing; whitespace after the closing tag changes the output. In a real application this means that one extra line at the end of a file adds a character to the output of the code that includes that file; a test whose output is compared character by character, or a binary response, breaks for this reason. The side is the same as before: not "not in the run" but both sides. Never writing the closing tag in pure PHP files removes this class of problem entirely; style standards such as PSR-12 also make it a rule that the closing tag is not written in pure PHP files.

#### The First Statement of the File

The second point about format is the position of the `declare(strict_types=1)` statement. This statement must be the first statement of the file; if there is an `echo` before it, PHP refuses to compile the file. A style checker can keep this order as a rule, but the order is not only style; a misplaced statement makes the file impossible to run. The driver below writes two small files, runs each in a separate PHP process, and asks whether the exit code is nonzero.

```php
<?php
// order.php — does the position of the declare statement change the run
declare(strict_types=1);
require 'side.php';

$probes = [
    'a file does not run when declare is not the first statement' => <<<'PHP'
        <?php
        file_put_contents('part.php', "<?php\necho 1;\ndeclare(strict_types=1);\n");
        exec('php -n part.php 2>/dev/null', $output, $code);
        echo $code !== 0 ? "yes\n" : "no\n";
        PHP,
    'a file runs when declare is the first statement' => <<<'PHP'
        <?php
        file_put_contents('part.php', "<?php\ndeclare(strict_types=1);\necho 1;\n");
        exec('php -n part.php 2>/dev/null', $output, $code);
        echo $code === 0 ? "yes\n" : "no\n";
        PHP,
];

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

```text
a file does not run when declare is not the first statement → both sides
    development yes · production yes
a file runs when declare is the first statement → both sides
    development yes · production yes
```

The result is on both sides again. A compile error depends not on a setting but on the syntax of the language; it occurs the same way in development and in production. In the development profile the error is printed into the response while in production it goes only to the log, but a file that does not compile does not run on either side. Read together, these two measurements divide style checking into two classes: deviations that do not affect the run at all (trailing whitespace, indentation, consecutive spaces) and deviations that do (the character after the closing tag, the position of the first statement). The second class is where a style checker finding something is not a convenience but a guarantee.

#### The Bounding Measurement

Whitespace is not always whitespace. Trailing whitespace inside a string or a nowdoc is the contents of the string; if it is deleted, the program produces a different value. For this reason a correction that ignores the token type is dangerous. The source below contains a nowdoc that carries three spaces at the end of a line. The same file is run in three forms: in its original form, corrected with a regular expression that deletes the whitespace at the end of every line, and with the formatter that touches only `T_WHITESPACE` tokens.

```php
<?php
// limit.php — a correction that ignores the token type changes the meaning
declare(strict_types=1);
require 'side.php';
require 'style.php';

$source = "<?php\n"
    . "\$text = <<<'TEXT'\n"
    . "first   \n"
    . "last\n"
    . "TEXT;\n"
    . "echo strlen(\$text), \"\\n\";\n";

$versions = [
    'original' => $source,
    'correction without tokens' => preg_replace('/[ \t]+$/m', '', $source),
    'token-aware formatting' => format($source),
];
foreach ($versions as $name => $text) {
    file_put_contents('text.php', $text);
    echo "$name: ", trim(outputOf('text.php', PROFILES['development'])), "\n";
}
echo 'trailing whitespace counted by the token-aware checker: ', styleCounts($source)['trailingWhitespace'], "\n";
echo 'found by the regular expression without tokens: ', preg_match_all('/[ \t]+$/m', $source), "\n";
```

```text
original: 13
correction without tokens: 10
token-aware formatting: 13
trailing whitespace counted by the token-aware checker: 0
found by the regular expression without tokens: 1
```

The original file and the formatted file produce a string of the same length (13); the correction without tokens deletes the three spaces inside the nowdoc as well and shortens the string. The token-aware checker does not count the nowdoc contents as whitespace; the regular expression does. The difference shows that a style tool must read the source not as text but as a sequence of tokens. The formatter's guarantee also comes from here: a tool that rewrites only `T_WHITESPACE` tokens cannot reach the contents of a string. The limit is this: whether whitespace inside a string or a comment is unnecessary is not a question a token reading can answer.

#### Summary

- Unformatted code runs with the same output in both profiles without producing a notice; the side of this behavior is **not in the run**, and only a style checker reports it.
- Whitespace is a token too (`T_WHITESPACE`): trailing whitespace, consecutive spaces, tab indentation, and space indentation are counted from this token.
- A formatter does not change the meaning when it rewrites only whitespace tokens; the unformatted and the formatted file give the same output in four runs.
- A correction that ignores the token type also deletes trailing whitespace inside a nowdoc or a string and changes the output.
- For whitespace format, the answer is the same in both profiles: a difference in format does not enter the run.
- A character after the closing tag leaks into the output, and a file whose `declare(strict_types=1)` is not the first statement does not compile; both are rules of the language (side: both sides).

#### Next Step

In this lesson the second behavior that the run never sees turned into a report in the hands of the style checker. The next lesson moves to a tool that enters the run but whose behavior changes with the profile: the assertion. A failing assertion throws an `AssertionError` in development; in the production profile the same assertion is not even compiled. We will measure which side this mechanism, on which test frameworks rely, stands on, and what changes when a test is built on its own helper.

### Testing Frameworks

Source: https://academia.sh/en/courses/php-production/testing-frameworks

Measure which profile an assert()-based check catches a failing assertion in, which profile lets everything pass, and why your own comparison helper works on both sides.

The previous lesson showed that the format of the code does not change the run, and that style checking therefore stands outside the run. Does a test stand outside the run or inside it? The answer depends on what the test relies on. The smallest checking tool in PHP is the `assert()` function, and its behavior depends on one of the seven differences between the two configuration files: `zend.assertions`.

Three kinds of tests are separated by the size of the thing under test. A unit test checks a single function or class in isolation. An integration test checks several parts working together, for example the behavior of a function together with a file or a database. A behavior test checks the system from the outside, through the result the user sees. A test framework provides a common skeleton for writing and running these tests: it finds the tests, runs each one separately, collects the results, and reports which passed and which failed. This lesson names no test framework; instead it measures the mechanism test frameworks rely on: the assertion.

The fifth course of the Rust curriculum (M10/K05) searched for where assurance ends. Here the question is not the limit of assurance but the profile in which an assertion is compiled.

#### Testing with assert()

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;
}
```

The code under test is an addition function that is written wrong on purpose: `add()` subtracts instead of adding two numbers. The `assertionCaught()` function runs the assertion `assert(add(2, 3) === 5)` in a `try` block and returns `true` if an `AssertionError` is thrown. The `tally()` function increments a counter every time it is called: it exists to measure how a call that changes state behaves inside an assertion. The third helper, `mustEqual()`, does not use `assert()`; it compares two values with strict equality and throws a `RuntimeException` if they differ.

```php
<?php
// testing.php — a deliberately wrong function and three checking helpers
declare(strict_types=1);

function add(int $a, int $b): int {
    return $a - $b;
}

function assertionCaught(): bool {
    try {
        assert(add(2, 3) === 5);
        return false;
    } catch (AssertionError) {
        return true;
    }
}

$counter = 0;

function tally(): bool {
    global $counter;
    $counter++;
    return true;
}

function mustEqual(mixed $expected, mixed $actual): void {
    if ($expected !== $actual) {
        throw new RuntimeException('expected and actual differ');
    }
}
```

The driver runs three probes in the two profiles. The first asks whether the failing assertion is caught, the second asks whether the call inside the assertion runs, and the third asks whether the same check written with `mustEqual()` catches the mistake.

```php
<?php
// tests.php — three checks, two profiles
declare(strict_types=1);
require 'side.php';

$probes = [
    'a failing assertion is caught' => <<<'PHP'
        <?php
        require 'testing.php';
        echo assertionCaught() ? "yes\n" : "no\n";
        PHP,
    'a call inside the assertion runs' => <<<'PHP'
        <?php
        require 'testing.php';
        assert(tally());
        echo $counter === 1 ? "yes\n" : "no\n";
        PHP,
    'own comparison helper catches the mistake' => <<<'PHP'
        <?php
        require 'testing.php';
        try {
            mustEqual(5, add(2, 3));
            echo "no\n";
        } catch (RuntimeException) {
            echo "yes\n";
        }
        PHP,
];

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

```text
a failing assertion is caught → development only
    development yes · production no
a call inside the assertion runs → development only
    development yes · production no
own comparison helper catches the mistake → both sides
    development yes · production yes
```

The first two probes appear only in development. In the development profile (`zend.assertions=1`) a failing assertion throws an `AssertionError` and the check fails. In the production profile (`zend.assertions=-1`) PHP does not compile the `assert()` call at all; the assertion neither produces a value nor throws an exception. The same wrong function passes the check in the production profile. A test passing does not say that the code is correct; it also says in which profile the test ran.

The second probe shows the detail of this. When the assertion is not compiled, the expression inside it is not evaluated either. The call inside `assert(tally())` increments the counter in development and does not in production. The script below prints the two values directly.

```bash
# counter.sh — in which profile does the call inside the assertion run
cat > counter.php <<'PHP'
<?php
require 'testing.php';
assert(tally());
echo "counter: $counter\n";
PHP
echo "zend.assertions=1:"
php -n -d zend.assertions=1 counter.php
echo "zend.assertions=-1:"
php -n -d zend.assertions=-1 counter.php
```

```text
zend.assertions=1:
counter: 1
zend.assertions=-1:
counter: 0
```

This is the known trap of using an assertion as a checking tool: if a call that makes something happen is put inside the assertion, the work is done in only one profile. The expression inside an assertion should only read a condition, not change state.

#### Your Own Comparison Helper

The third probe gives a different result: `mustEqual()` catches the mistake in both profiles. The helper does not call `assert()` and does not rely on `AssertionError`; it evaluates its own condition and throws its own exception. For this reason the `zend.assertions` setting does not affect it. The comparison methods of test frameworks are not built on `assert()` for the same reason: a test that passes silently in the production profile means that the test checks nothing.

The cost of this is small: a two-line helper does the same job as `assert()` and is independent of the profile. One distinction remains. `assert()` is an assertion written inside the code that questions an assumption while the program runs; it gives extra assurance in development and its cost is reduced to zero in production. A test, by contrast, is a separate program that stands outside the run and calls the code, and it should produce the same result in every profile.

#### What a Test Framework Does

The driver of this lesson is a small test framework. It gathers the probes in a list, writes each one to a separate file, runs each file in a separate process, and reports the result in a single line. Three jobs are also the core of real test frameworks. Finding the tests builds the list. Isolation prevents the state one test leaves behind from affecting another: the counter in the second probe is independent of the counter in the first, because every probe is a new process. Reporting collects the passed and the failed in one place.

Isolation has a cost. A separate process also makes the settings the test sees separate. In this lesson every probe runs with no configuration file, only the profile's directives, because the setting a test runs with determines its result. The same test suite can pass with the development settings and fail with the production settings, or the other way around. The first probe shows exactly this: a check that relies on an assertion finds the mistake only when `zend.assertions=1`.

Two consequences follow in practice. Tests should also be run in a profile close to the configuration that will go to production; otherwise a behavior that changes in production reaches the live system without ever being checked. On the other hand, the assertions themselves should keep running in development, because their job is to check an assumption inside the code. Separating the two jobs, leaving the check to a comparison helper that is independent of the profile and the assumption to `assert()`, is therefore a consistent distinction.

#### Unit and Integration Testing

Going back to the difference between a unit test and an integration test, we measure it with a small example. A pure function, `withTax()`, depends only on the value it receives: the same input gives the same output in every run. A function that writes to a file, `addNote()`, depends on state outside it: a file left over from the previous run changes the result.

The driver runs four probes. The first checks the pure function. The second checks the function that writes to the file, without cleaning the file. The third writes the same check, deleting the file at the start. The fourth asks whether the failure message is the same in the two profiles. The driver deletes the file before every probe; the two runs inside a probe (development, then production) share the same file.

```php
<?php
// unit.php — a pure function and a function that writes to a file
declare(strict_types=1);
require 'side.php';

$probes = [
    'unit: pure function' => <<<'PHP'
        <?php
        require 'testing.php';
        function withTax(int $amount): int {
            return intdiv($amount * 118, 100);
        }
        try {
            mustEqual(118, withTax(100));
            echo "yes\n";
        } catch (RuntimeException) {
            echo "no\n";
        }
        PHP,
    'integration: test that does not clean the file' => <<<'PHP'
        <?php
        function addNote(string $file, string $line): void {
            file_put_contents($file, $line . "\n", FILE_APPEND);
        }
        addNote('note.txt', 'first');
        echo count(file('note.txt')) === 1 ? "yes\n" : "no\n";
        PHP,
    'integration: test that cleans first' => <<<'PHP'
        <?php
        function addNote(string $file, string $line): void {
            file_put_contents($file, $line . "\n", FILE_APPEND);
        }
        @unlink('note.txt');
        addNote('note.txt', 'first');
        echo count(file('note.txt')) === 1 ? "yes\n" : "no\n";
        PHP,
    'the failure message is the same' => <<<'PHP'
        <?php
        require 'testing.php';
        try {
            mustEqual(5, add(2, 3));
            echo "no\n";
        } catch (RuntimeException $e) {
            echo $e->getMessage() === 'expected and actual differ' ? "yes\n" : "no\n";
        }
        PHP,
];

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

```text
unit: pure function → both sides
    development yes · production yes
integration: test that does not clean the file → development only
    development yes · production no
integration: test that cleans first → both sides
    development yes · production yes
the failure message is the same → both sides
    development yes · production yes
```

The check of the pure function passes on both sides: it depends on nothing but its input and output. The integration check that does not clean up, however, passes **only in development**. This is not a profile difference; it is a trace of the driver's run order. The development run creates the file for the first time and sees one line; the production run appends a second line to the same file and sees two lines. If the run order were reversed, the result would be reversed too. The check that cleans at the start therefore passes on both sides.

This also gives the definition of an integration test: a test that uses state outside itself must set up that state and clean it up by itself. Otherwise the test result measures not the correctness of the code but the order in which the tests ran. The result shows why isolation is a basic job of a test framework. The fourth probe looks at another distinction: `mustEqual()` produces the failure message independently of the settings, so the test report carries the same text in the two profiles, and the cause of a failure can be read without looking at the profile.

#### The Bounding Measurement

`zend.assertions` takes three values. `1` compiles and runs the assertion; `-1` does not compile it; `0` compiles it but does not run it. Throughout the lesson the production profile used `-1`. The question to ask is this: does the value `0` silence the assertion as well, and is there a behavioral difference between the two values? The core profile is not changed; the driver calls the `run()` function in `side.php` with a copy of the production profile and changes only the `zend.assertions` value.

```php
<?php
// limit.php — the difference between 0 and -1 for zend.assertions
declare(strict_types=1);
require 'side.php';

$probes = [
    'assertion caught' => <<<'PHP'
        <?php
        require 'testing.php';
        echo assertionCaught() ? "yes\n" : "no\n";
        PHP,
    'assertion caught after enabling at run time' => <<<'PHP'
        <?php
        require 'testing.php';
        ini_set('zend.assertions', '1');
        echo assertionCaught() ? "yes\n" : "no\n";
        PHP,
];

foreach (['0', '-1'] as $value) {
    $profile = ['zend.assertions' => $value] + PROFILES['production'];
    foreach ($probes as $name => $code) {
        file_put_contents('probe.php', $code);
        echo "zend.assertions=$value · $name: ", run('probe.php', $profile) ? 'yes' : 'no', "\n";
    }
}
```

```text
zend.assertions=0 · assertion caught: no
zend.assertions=0 · assertion caught after enabling at run time: yes
zend.assertions=-1 · assertion caught: no
zend.assertions=-1 · assertion caught after enabling at run time: no
```

With the value `0` as well, the failing assertion is not caught: even if the assertion is compiled, it is not run. The difference between the two values appears at run time. With `0` the setting can be turned to `1` with `ini_set()` and the assertion catches from that point on; with `-1` the same call changes nothing, because the assertion was never compiled. This shows that the boundary that turns assertions on and off lies not only in the value of the setting but also in the setting in force at the moment the file is compiled. The production profile choosing `-1` is therefore a hardening decision: the way to turn assertions back on within a request is closed.

#### Summary

- A test framework is a skeleton that finds, runs, and reports tests; unit, integration, and behavior tests are separated by the size of the thing under test.
- An `assert()`-based check catches a failing assertion **only in development**; in the production profile (`zend.assertions=-1`) the assertion is not compiled and wrong code passes the check.
- A call that changes state inside an assertion does not run in production: the counter stays at 1 in development and at 0 in production.
- A comparison helper of your own that does not rely on `AssertionError` works on both sides; for this reason a test relies on such a helper, not on `assert()`.
- `zend.assertions=0` compiles the assertion but does not run it, and it can be turned to `1` at run time. With `-1` this way is closed.

#### Next Step

In this lesson the setting that makes an assertion visible in development silently removed it in production. The same direction applies to the information we see while debugging: the argument values in a trace, the warning printed to the screen, and the variables on the line where a debugger stops. The next lesson asks whether this information seen in development reaches production, and measures where a value is shown or hidden in the exception trace.

### The Debugger

Source: https://academia.sh/en/courses/php-production/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.

## Performance and Operations

The code cache, profiling, memory and connection management, the process manager and the web server, ini directives and the deployment workflow — each as a mechanism that decides whether a behavior stays the same between the development and production profiles.

### Code Caching

Source: https://academia.sh/en/courses/php-production/code-caching

How PHP keeping bytecode in a cache hides file changes in production: when the new version at the same path arrives, and what invalidation solves.

Before PHP runs a file, it translates the source into an intermediate form, the bytecode, and then executes that form. The code cache (opcode cache) keeps the translated bytecode in memory, so later requests skip the translation step. In PHP the OPcache extension does this work. The cache has a price: the copy in memory and the file on disk can drift apart. When you put new code live, which one runs depends on when the cache looks at the disk.

The question that measures this lesson is: does this behavior reach production? The `side.php` below is the shared core of the course. It runs a script first with the development directives and then with the production directives, in separate processes, and reports on which side the behavior was observed. In this lesson the file is only written to disk and produces no output.

```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;
}
```

To see why the cache works so strictly, separate two jobs. The first is finding the file, reading it and translating it to bytecode; the second is running the bytecode. The cache skips only the first, and the second repeats on every request. When an application loads hundreds of files, the skipped work adds up, which is why the cache stays on in production. In development the wish is the opposite: every time you save a file, you want the result to change, so a cache holding an old copy gets in your way.

`opcache.validate_timestamps` is the switch between the two. When it is on, the cache looks at the file's modification time to decide whether its copy is current. When it is off, the copy stays valid until the process restarts or the file is explicitly invalidated. The reason to turn it off in production is that nobody touches the running code except through a deployment: if the code is going to change, that should be a deployment step. The natural consequence is that a cache unaware of the deployment serves old code even when the file has changed.

In the production profile, `opcache.validate_timestamps` is `0`: the cache never looks at the file's modification time again. In the development profile the code cache is off on the command line, so every run reads the file from disk. We measure the visible result of that difference. The script below writes two probe files and runs both of them in the two profiles through `side.php`. Because OPcache does not cache a file that changed within the last two seconds (`opcache.file_update_protection`), the probes move the files' times into the past with `touch()`.

```php
<?php
// cache.php — does a rewritten file run with the old version?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('stale.php', <<<'PHP'
<?php
// stale.php — writes the same path with two versions, loads it twice
$path = __DIR__ . '/version.php';
file_put_contents($path, '<?php return 1;');
touch($path, time() - 60);
$first = include $path;
file_put_contents($path, '<?php return 2;');
touch($path, time() - 30);
$after = include $path;
echo $first === $after ? "yes\n" : "no\n";
PHP);

file_put_contents('invalidate.php', <<<'PHP'
<?php
// invalidate.php — does invalidation bring the new version?
$path = __DIR__ . '/version.php';
file_put_contents($path, '<?php return 1;');
touch($path, time() - 60);
include $path;
file_put_contents($path, '<?php return 2;');
touch($path, time() - 30);
$returned = opcache_invalidate($path, true);
$after = include $path;
echo $returned === true && $after === 2 ? "yes\n" : "no\n";
PHP);

sideReport('a rewritten file runs with the old version', 'stale.php');
sideReport('invalidation returns true and the new version arrives', 'invalidate.php');
```

```
a rewritten file runs with the old version → production only
    development no · production yes
invalidation returns true and the new version arrives → production only
    development no · production yes
```

Before reading the output, look at what the script does, line by line. `stale.php` writes a small file named `version.php` to the same path; the file only returns a number, `1` in the first version and `2` in the second. After each write, `touch()` moves the file's modification time into the past, sixty seconds back for the first and thirty for the second. It matters that the two times differ: had we given the same time, even a cache with checking turned on could not tell the change apart. The file is loaded twice with `include`, and the two return values are compared. If they are the same, meaning the second load did not see the new version, the probe prints `yes`.

`invalidate.php` instead calls `opcache_invalidate($path, true)` after the second write and before the second `include`. The second parameter, `true`, asks the call to treat the cached copy as invalid without looking at the file's time. The probe looks for two conditions together: the call returns `true`, and the second load gives `2`. With the cache off the call returns `false`, because there is no copy to invalidate; this `false` is not an error but a "nothing to do" answer.

On the production side the first probe gives `yes` (**PO1.** The measurement ran in a command-line process, with a single process; under a process manager several worker processes share the same code cache, and this lesson did not set up that arrangement): although the file was rewritten, the second load returned the same value as the first, because the cache already knew the path and did not look at the time. On the development side the new version appeared immediately. The second probe shows the way out: `opcache_invalidate($path, true)` returns `true` in production and the new version arrives in the same request. In development the code cache is off, so the call returns `false` and the file is always fresh anyway. In other words, a deployment script has to invalidate the files that changed; in development this is never noticed.

#### Is the File in the Cache?

So far we have seen the cache's presence only indirectly: the file changed but the result did not. There is also a way to ask directly. `opcache_is_script_cached($path)` tells with a boolean whether the given file is in the cache right now, and `opcache_get_status(false)` returns the extension's state as an array. The array also has fields that carry numbers, but the probe reads only the on/off information and prints `yes` or `no`. Three questions are asked: is the extension on, is the loaded file in the cache, and is the invalidated file still in the cache? All three are written so that `yes` is the positive direction. In the third, `yes` means that in production invalidation removed the copy from the cache; what it means in development is read after the output.

```php
<?php
// status.php — is the file in the cache?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('enabled.php', <<<'PHP'
<?php
// enabled.php — is the code cache on in this process?
$status = opcache_get_status(false);
echo is_array($status) && $status['opcache_enabled'] === true ? "yes\n" : "no\n";
PHP);

file_put_contents('loaded.php', <<<'PHP'
<?php
// loaded.php — does an included file enter the cache?
$path = __DIR__ . '/target.php';
file_put_contents($path, '<?php return 1;');
touch($path, time() - 60);
include $path;
echo opcache_is_script_cached($path) ? "yes\n" : "no\n";
PHP);

file_put_contents('dropped.php', <<<'PHP'
<?php
// dropped.php — does an invalidated file leave the cache?
$path = __DIR__ . '/target2.php';
file_put_contents($path, '<?php return 1;');
touch($path, time() - 60);
include $path;
opcache_invalidate($path, true);
echo !opcache_is_script_cached($path) ? "yes\n" : "no\n";
PHP);

sideReport('the code cache is on', 'enabled.php');
sideReport('an included file enters the cache', 'loaded.php');
sideReport('an invalidated file leaves the cache', 'dropped.php');
```

```
the code cache is on → production only
    development no · production yes
an included file enters the cache → production only
    development no · production yes
an invalidated file leaves the cache → both sides
    development yes · production yes
```

The first line of the output says whether the extension is on in this process. The `no` on the development side agrees with what we said in the previous section: in a command-line process the code cache is off by default, and the development profile does not touch it. The `yes` on the production side is the work of the `opcache.enable_cli=1` directive in the profile. This line is the probe's ground check: while reading the next two lines you need to know that the extension is really on, otherwise you may mistake the `no` answers for behavior.

The second line answers the real question: did the file we included enter the cache? In development `no`, because there is no cache; in production `yes`. What is measured here is that the file has a copy in memory. That copy is the basis of the old-version behavior in the first section: once a file has entered, with `validate_timestamps=0` the disk is never consulted again. Reading the two measurements together completes the chain. First the file enters the cache, then the disk changes, then the old version runs because the cache does not look at the disk.

The third line gives `yes` on both sides, and it has to be read carefully. In production, `yes` shows that the `opcache_invalidate($path, true)` call removed the copy from the cache. In development, `yes` proves nothing: since the cache is off, the file never entered it, so the answer to "did it leave" is positive from the start. This is why the side of the line is **both sides**, and here this side does not mean "the same behavior on both sides"; it means "the measurement says something only in production". To name a side correctly you need to know what the side measures; a probe giving the same result on both sides does not always show that the behavior is the same on both sides.

In a real production setup these three questions are the basis of post-deployment verification. The question of whether the extension is on confirms that the directive reached the process; even when the configuration file is in the right place, the directives have no effect if the extension is not loaded. The question of whether the file is in the cache tells whether warming worked: if the critical files were cached before the first request, the first user does not pay the translation cost. The question of whether the invalidated file left confirms that your deployment script really let go of the old version. This lesson measured in the command line, in a single process; under a process manager every worker process takes these three answers from the same code cache, so the results are shared.


#### The Bounding Measurement

If we add `opcache.validate_timestamps=1` and `opcache.revalidate_freq=0` to the production profile, the cache looks at the file on every request. Does the old-version problem go away? The script below runs the `stale.php` probe with these two directives. `stale.php` remains on disk from the earlier block.

```php
<?php
// bound.php — if file checking is added to production, does the old version remain?
declare(strict_types=1);
require __DIR__ . '/side.php';

$checked = array_replace(PROFILES['production'], [
    'opcache.validate_timestamps' => '1',
    'opcache.revalidate_freq' => '0',
]);

$staleRemained = run('stale.php', $checked);
echo 'did the old version remain: ', $staleRemained ? 'yes' : 'no', "\n";
```

```
did the old version remain: no
```

The probe gave `no`: with file checking on, the cache looks at the modification time on every request, compiles the new version because the rewritten file's time changed, and the old version does not remain. This setting removes the old-version problem. The price of removing it moves elsewhere: on every request the cache goes to the disk to look at the file's modification time. That price was not measured here; only the behavior was. Keeping file checking off in production and calling `opcache_invalidate()` or restarting the process at deployment loads the price onto the moment of deployment instead of every request. Which one to choose depends on what your deployment arrangement allows.


The limit of this measurement should be stated openly. The probe showed that with file checking on, the cache sees the new version; but because the refresh frequency is zero, we asked for every request to go to the disk. If the refresh frequency is raised to a number of seconds above zero, it becomes possible again for the old version to run for that long after a deployment. The lesson's side therefore does not change: the old-version behavior is seen only on the production side, when checking is off or inside the refresh window, and never on the development side. A team that has never seen the problem in the development profile meets it for the first time on the day it goes to production. The habit the measurement teaches is this: do not look for a behavior that appears only in production in development; put into your deployment step an explicit invalidation or restart that closes that behavior, and verify that this step works with the three questions above.

The probes of this lesson were kept small on purpose. The files return a one-line number, because what we want to measure is not the file's content but which version runs. The answer is `yes` or `no`. A byte count, an amount of memory or a timestamp changes from machine to machine; which version ran does not change, and that is what can be repeated.

Finally, we justify the side of the bounding measurement. The production profile with file checking added is still on the production side, but the question it measures is different: its saying `no` to "did the old version remain" does not mean it approaches the behavior of the development profile. This approach is not free; as we said throughout the lesson, the price moves to disk checking. The job of the bounding measurement is to show the condition of the lesson's main claim: the old-version problem depends on checking being off, and when that condition is lifted, the problem is lifted too.

#### Summary

- Bytecode is the intermediate form into which source is translated before it runs; the code cache keeps this form in memory and skips the translation on later requests.
- The side of this lesson is **production only**: with `opcache.validate_timestamps=0`, a rewritten file runs with the old version at the same path, and this is never seen in the development profile.
- `opcache_invalidate($path, true)` returns `true` in production and brings the new version in the same request; in development it returns `false` because the cache is off.
- Adding `opcache.validate_timestamps=1` and `opcache.revalidate_freq=0` to production removes the old-version problem, but moves file checking back onto every request.
- In the measurements the files are moved into the past with `touch()`, because OPcache does not cache a file that changed within the last two seconds.
- The `opcache_is_script_cached()` probe shows in production that the file entered the cache and gives `no` in development; the line showing that the invalidated file left is meaningless in development, so it says something only on the production side (side: both sides, with a limit).

#### Next Step

The code cache decides which version runs; it does not say how much work gets done. To see where a request's time and memory go, you need profiling. The next lesson measures how the same work is counted in the two profiles, and in which profile the act of counting itself can change the result.

### Profiling

Source: https://academia.sh/en/courses/php-production/profiling

Measuring in two profiles how many times a job calls each function and how much memory it holds: the counts stay the same, but the counting code can change with the profile.

When an application looks slow or bloated, the first reaction is to guess. Profiling replaces the guess with a measurement: it records how many times the job calls each function and how much memory it holds while running. The path that is called the most, or that holds the most memory, is called the hot path, and improvement starts there. In this lesson we do not measure time. The numbers are call counts and memory differences, because these do not change from machine to machine; what is measured is the behavior of the job.

The measuring question is the shared question of this course: does this behavior reach production? `side.php` is below verbatim, and in this lesson too it is only written to disk.

```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;
}
```

Before measuring, we limit what we measure. There are two quantities in this lesson: the call count of a function, and the difference a job leaves in memory while running. Both are numbers, and both come out the same when you run the same code with the same input. Time does not have this property: the same job takes one time on one machine and another on another, and it varies even between two runs on the same machine. Building the comparison on numbers therefore makes the measurement repeatable. The two blocks below are written with this logic; the first defines the measured job and the counter, and the second runs that job in the two profiles and compares the results.

First, we write the job we will measure and the counter class. The `twice()` function is called 500 times and counts itself on each call; then an array of 10000 elements is built and the memory difference is taken. The file has no output; the probes load it.

```php
<?php
// counter.php — a small profiler that measures call counts and memory difference
declare(strict_types=1);

final class Counter {
    private static array $counts = [];

    public static function count(string $name): void {
        self::$counts[$name] = (self::$counts[$name] ?? 0) + 1;
    }

    public static function read(string $name): int {
        return self::$counts[$name] ?? 0;
    }
}

function twice(int $n): int {
    Counter::count('twice');
    return $n * 2;
}

/** @return array{calls: int, total: int, memoryDifference: int} */
function doWork(): array {
    $before = memory_get_usage();
    $total = 0;
    for ($i = 0; $i < 500; $i++) {
        $total += twice($i);
    }
    $numbers = range(1, 10000);
    $difference = memory_get_usage() - $before;
    return ['calls' => Counter::read('twice'), 'total' => $total, 'memoryDifference' => $difference];
}
```

Now we write the probe that runs the same job in the two profiles. The probe recognizes the profile from the value of `zend.assertions` (`1` in development, `-1` in production) and writes the result to a file specific to the profile. The driver reads the two files and compares them. For memory, the question is whether the difference is equal in the two profiles.

```php
<?php
// profile.php — does the same job give the same number in two profiles?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('measure.php', <<<'PHP'
<?php
// measure.php — runs the job, writes the result to the profile-specific file
require __DIR__ . '/counter.php';
$profile = ini_get('zend.assertions') === '1' ? 'development' : 'production';
$result = doWork();
file_put_contents("measure-$profile.json", json_encode($result));
echo $result['calls'] === 500 && $result['total'] === 249500 ? "yes\n" : "no\n";
PHP);

$finalSide = sideReport('a job of 500 calls gives the same count', 'measure.php');

$development = json_decode(file_get_contents('measure-development.json'), true);
$production = json_decode(file_get_contents('measure-production.json'), true);
echo 'call count: ', $development['calls'], ' / ', $production['calls'], "\n";
echo 'total: ', $development['total'], ' / ', $production['total'], "\n";
echo 'memory differences equal: ', $development['memoryDifference'] === $production['memoryDifference'] ? 'yes' : 'no', "\n";
```

```
a job of 500 calls gives the same count → both sides
    development yes · production yes
call count: 500 / 500
total: 249500 / 249500
memory differences equal: yes
```

In both profiles the call count came out 500 and the total 249500, and the memory differences stayed equal. The question a profile answers is "how many times and how much memory", and this question gets the same answer on both sides. So the hot path you find in development is also the hot path in production. (**PO2.** The comparison was made in a single process and a single run; the memory difference depends on the job itself, not on the settings of the process the job lives in, and this lesson did not vary those settings separately.)

#### Finding the Hot Path

So far we have counted a single function. A real job is a chain of functions calling one another, and the hot path is the most-called link of that chain. The probe below builds a chain of three functions: `aggregate()` calls `convert()` in a loop, and `convert()` calls `read()` twice on every call. `aggregate(40)` runs five times. The call counts can be worked out beforehand: `aggregate()` 5, `convert()` 40 times 5, that is 200, and `read()` should be 400, since it is called twice in each of the 200 calls. The script raises a counter at the entry of each function, sorts the counts from largest to smallest, and says whether the name in first place is `read`. The distribution is written to a separate file in each profile; the driver compares the two files and prints the sorted distribution. It matters that the numbers can be worked out by hand: if the measurement agrees with our expectation, we also learn that the counter works correctly.

```php
<?php
// hotpath.php — is the call distribution the same in two profiles, which is the hot path?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('hot.php', <<<'PHP'
<?php
// hot.php — extracts the call distribution, finds the most-called function
$counts = [];
function record(string $name): void {
    global $counts;
    $counts[$name] = ($counts[$name] ?? 0) + 1;
}
function read(int $i): int {
    record('read');
    return $i % 7;
}
function convert(int $i): int {
    record('convert');
    return read($i) * 3 + read($i + 1);
}
function aggregate(int $n): int {
    record('aggregate');
    $total = 0;
    for ($i = 0; $i < $n; $i++) {
        $total += convert($i);
    }
    return $total;
}
for ($k = 0; $k < 5; $k++) {
    aggregate(40);
}
arsort($counts);
$profile = ini_get('zend.assertions') === '1' ? 'development' : 'production';
file_put_contents("hot-$profile.json", json_encode($counts));
echo array_key_first($counts) === 'read' ? "yes\n" : "no\n";
PHP);

sideReport('the hot path is the read function', 'hot.php');

$development = file_get_contents('hot-development.json');
$production = file_get_contents('hot-production.json');
foreach (json_decode($production, true) as $name => $count) {
    echo $name, ': ', $count, "\n";
}
echo 'distributions equal: ', $development === $production ? 'yes' : 'no', "\n";
```

```
the hot path is the read function → both sides
    development yes · production yes
read: 400
convert: 200
aggregate: 5
distributions equal: yes
```

We read the output line by line. The first two lines are the standard result of the probe: the claim `the hot path is the read function` gave `yes` in both profiles, meaning the function we expected is at the head of the ordering; the side is **both sides**. The next three lines are the sorted distribution: `read` 400, `convert` 200, `aggregate` 5. These numbers agree exactly with the calculation we made by hand. The agreement is the real sign that the measurement is reliable: had the counter given something different from the expected, we would have questioned first the counter and then the code. The last line says the distribution files of the two profiles are identical byte for byte. Comparing the files as a whole, rather than number by number, confirms in one step that both the numbers and the ordering are the same.

How to interpret this distribution is the real work of profiling. `aggregate()` is the outermost function and is called only five times; someone reading the code might think it important and turn to optimizing it. The distribution shows instead that the load of the job is gathered in the lowest function, `read()`: four hundred calls, eighty times the outer function. The optimization is sought inside this function or in the way it is called, for example caching the result, moving the call out of the loop, or reducing two calls to one. Since we do not measure time in this lesson, we do not say "how long the four hundred calls took"; the call count decides which function to start examining, that is, the order; the decision to improve is made when it is read together with time.

We also say why the side is **both sides**. The counter is an ordinary function call; it uses no path that depends on profile differences such as `zend.assertions` or `error_reporting`. The same chain runs in the same order in both profiles and produces the same numbers. In a real production this is useful in the following way: you can find the hot path on your development machine and trust that the same path is hot in production, but only as long as your counting code does not depend on a profile difference. The next section shows why this condition matters.


#### The Bounding Measurement

The code that does the profiling is itself code, and it may not behave the same in the two profiles. When the value of `zend.assertions` is `-1`, the expression inside an `assert()` call is never evaluated. If we raise the counter inside an `assert()`, do the two profiles give the same call count?

```php
<?php
// bound.php — does a counter inside assert() give the same number in two profiles?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('asserted.php', <<<'PHP'
<?php
// asserted.php — raises the counter inside assert()
$counter = 0;
function tick(): bool {
    global $counter;
    $counter++;
    return true;
}
for ($i = 0; $i < 500; $i++) {
    assert(tick());
}
$profile = ini_get('zend.assertions') === '1' ? 'development' : 'production';
file_put_contents("asserted-$profile.txt", (string) $counter);
echo $counter === 500 ? "yes\n" : "no\n";
PHP);

sideReport('the counter inside assert() rises', 'asserted.php');
echo 'development: ', file_get_contents('asserted-development.txt'), "\n";
echo 'production: ', file_get_contents('asserted-production.txt'), "\n";
```

```
the counter inside assert() rises → development only
    development yes · production no
development: 500
production: 0
```

The counter came out 500 in development and 0 in production; the side was read as **development only**. In the production profile the `assert()` call is not compiled, so the `tick()` inside it never ran. The meaning is clear: if the code holding the measurement is a call that changes state, do not put it inside `assert()`. When you measure the same job in two profiles, the counter must be kept either in an expression that runs in both profiles or outside `assert()`. When profiling, it matters in which profile you profile; measuring in the production profile and interpreting with development assumptions can lead to a wrong number for this reason.

Reading the two measurements side by side shows the division of labor. The counter class counts with an ordinary function call and gives the same result in both profiles; when the counting is pushed into `assert()`, the behavior becomes tied to the profile. Profiler tools work by the same rule: the code that keeps a record runs with the settings of the code it measures. After finding the hot path, verifying the improvement with the same measurement, that is, comparing the same number before and after changing the job, is a sound method.

This measurement also carries a warning. The person who finds the hot path thinks the path found should be improved; yet a function with a high call count may also be cheap. A function of four hundred calls that does a single operation per call may cause far less trouble than a function of five calls. The sound use of profiling is to read the call count together with the memory difference and with time measurement (which this lesson leaves outside). The two measurements of this lesson, the call distribution and the memory difference, are not enough on their own to make a decision, but they are enough to set the order of improvement.

In a real project you do not do this counting by hand; a profiler extension records the calls. But these extensions are themselves loaded into the process, and some are off in the production profile or run without being loaded. Counting by hand is therefore a teaching tool: proof that a piece of counting code gives the same result in two profiles is not proof that the tool doing the measurement gives the same result. Before you run your tool on the production side, verify, as in this lesson, on a small job that it gives the same numbers on both sides.

There is a distinction behind the bounding measurement: treating the counting code itself as part of the job being measured. `assert()` is where this distinction breaks, because its real purpose is to check assumptions during development, and it is ignored in production. The person who puts the counter there sees the expected number in development, sees zero in production, and takes the two for the same code. The fix is short: counting is done in an expression outside `assert()`; `assert()` is used only for correctness checks. This is hard to see in code, because neither profile raises an error; only the number is silently different. The only way to catch silent differences of this kind is to run the measurement on both sides and compare the results.

#### Summary

- Profiling answers the question "which function was called how many times and how much memory was held" with a measurement; the most-called path is the hot path.
- The side of this lesson is **both sides**: a job of 500 calls gave the same call count (500), the same total (249500) and the same memory difference in the development and production profiles.
- Call counts and equality comparisons were used instead of time and byte counts; these are independent of the machine.
- If the counter is raised inside `assert()`, the result is tied to the profile: it comes out 500 in development and 0 in production, because in production the expression inside `assert()` is not evaluated.
- If the code holding the measurement is a call that changes state, it is not put inside `assert()`.
- In the chain of three functions the call distribution came out `read` 400, `convert` 200, `aggregate` 5 and stayed identical byte for byte in both profiles; the hot path was the lowest function, not the outermost (side: both sides).

#### Next Step

The call count and the memory difference show what the job does; they do not show what happens when the memory limit is exceeded. The next lesson measures how a request stops when `memory_limit` is exceeded, how connection objects are closed at the end of the request, and when a circular reference delays the destructor.

### Memory and Connection Management

Source: https://academia.sh/en/courses/php-production/memory-and-connection-management

Measuring how a request stops when the memory limit is exceeded, when a connection object's destructor runs, and how a circular reference delays cleanup.

A PHP request runs with an allotted share of memory. The share is set by the `memory_limit` directive; if it is exceeded, the request stops with an unrecoverable error. A resource as important as memory is the connection: every connection opened to a database or cache server holds a place on the other side until it is closed somewhere. This lesson measures how both are tied to the life of the request. The concept of the request lifetime was established in the previous course; here it is only the subject of measurement.

The measuring question is again the shared question: does this behavior reach production? `side.php` is below verbatim, and in this lesson too it is only written to disk.

```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;
}
```

To understand the behavior of the memory limit, we recall what the limit is for. PHP keeps a single request within its own share; the share is a safeguard that prevents a faulty loop or an unexpectedly large input in one request from consuming the whole machine. When the limit is exceeded, PHP abandons the work halfway, because there is no safe way to continue: no memory is left to allocate. The request stops, but before stopping, PHP calls the shutdown functions. To observe this behavior, the probe script deliberately accumulates one-megabyte strings in an endless loop. Because the strings are held in an array, none of them is freed and the limit is inevitably exceeded.

The first measurement contains two probes. The first pulls the memory limit down to eight megabytes with `ini_set('memory_limit', '8M')` and allocates memory until the limit is exceeded. When the request stops, a shutdown function comes into play and looks at the type of the last error with `error_get_last()`. The second builds a connection object and checks whether its destructor runs after the script ends. The output has no error message text or byte count; the probes print only `yes` or `no`.

```php
<?php
// memory.php — the request stops when the limit is exceeded, the destructor runs at the end of the request
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('limit-exceeded.php', <<<'PHP'
<?php
// limit-exceeded.php — when memory_limit is exceeded, the shutdown function sees the error
ini_set('memory_limit', '8M');
register_shutdown_function(function (): void {
    $last = error_get_last();
    echo $last !== null && $last['type'] === E_ERROR ? "yes\n" : "no\n";
});
$chunks = [];
while (true) {
    $chunks[] = str_repeat('a', 1024 * 1024);
}
PHP);

file_put_contents('destructor.php', <<<'PHP'
<?php
// destructor.php — the connection object's destructor runs after the script
final class Connection {
    public function __destruct() {
        echo "yes\n";
    }
}
$connection = new Connection();
echo "script finished\n";
PHP);

sideReport('a request that exceeds the memory limit stops', 'limit-exceeded.php');
sideReport('the destructor runs when the request ends', 'destructor.php');
```

```
a request that exceeds the memory limit stops → both sides
    development yes · production yes
the destructor runs when the request ends → both sides
    development yes · production yes
```

When the limit was exceeded, the request stopped and the shutdown function saw that the last error was `E_ERROR`; the destructor also ran after the script ended. Both are the same in the two profiles: the side is **both sides**. The error message that appears on screen in development is hidden in production, but the stopping of the request and the calling of the shutdown function do not depend on the settings. So if you want to catch the death of a request and write it to the log, the shutdown function is the path that works in both profiles.

Connections are tied to this lifetime in two ways. An ordinary connection is born with the request and closes when its destructor runs, that is, when the request ends. A persistent connection does not close when the request ends; the process keeps it for the next request. A connection pool goes one step further: it keeps connections in a layer independent of the processes and shares them among requests. Neither was set up in this lesson; they are only mentioned as concepts, and what is measured is the destructor of the ordinary connection.

#### Shutdown Function and Destructor Order

In the previous section we saw two separate mechanisms: the shutdown function that comes into play when the limit is exceeded, and the destructor that runs at the end of an object's life. In a real application the two exist together, and which runs first matters. If you write one last record to the database in the shutdown function, the connection object must not have been destroyed up to that moment. This section measures the order. The connection object's destructor checks whether the shutdown function has run and prints `yes` or `no` accordingly. In the first probe the object lives until the end of the script; in the second, after registering the shutdown function, the script releases the object itself with `unset()`.

```php
<?php
// order.php — in what order do the shutdown function and the destructor run?
declare(strict_types=1);
require __DIR__ . '/side.php';

$classBody = <<<'PHP'
<?php
final class Connection {
    public static bool $shutdownRan = false;
    public bool $shutdownFirst = true;
    public function __destruct() {
        echo self::$shutdownRan === $this->shutdownFirst ? "yes\n" : "no\n";
    }
}
register_shutdown_function(function (): void {
    Connection::$shutdownRan = true;
});
PHP;

file_put_contents('late.php', $classBody . <<<'PHP'

$connection = new Connection();
PHP);

file_put_contents('early.php', $classBody . <<<'PHP'

$connection = new Connection();
$connection->shutdownFirst = false;
unset($connection);
PHP);

sideReport('at the end of the script the shutdown function runs before the destructor', 'late.php');
sideReport('the destructor of an object released with unset() runs before the shutdown', 'early.php');
```

```
at the end of the script the shutdown function runs before the destructor → both sides
    development yes · production yes
the destructor of an object released with unset() runs before the shutdown → both sides
    development yes · production yes
```

We first explain the design of the two probes. In both, the shutdown function does a single job: it sets the `Connection::$shutdownRan` flag to `true`. The destructor looks at the current value of the flag and compares it with the object's expectation of "should the shutdown run first"; if they agree it prints `yes`, if not `no`. In this way the order information becomes the output of the destructor and no separate log file is needed. In the first probe the object lives until the end of the script and the expectation is "shutdown first". In the second, the script releases the object with `unset()`; since no other reference holds the object, the destructor runs immediately, and the expectation is "shutdown after", which is conveyed by setting the `shutdownFirst` field to `false`.

Both lines gave `yes` in both profiles. The meaning of the first line is this: if you let an object live until the end of the script, the shutdown functions run first and the objects are destroyed afterwards. This order means that in the shutdown function you can still use the connection object: you can safely do jobs such as writing the last record to the log, finishing an open transaction, or leaving a job in a queue. The meaning of the second line is the opposite: if you released the object by hand, the destructor runs before the shutdown and the shutdown function can no longer find that connection. Both behaviors are rules of the language and, since they do not depend on the profile setting, they are of the **both sides** kind.

The production counterpart of this is a simple rule. Carry a resource you will use in the shutdown function by binding it with `use` into the anonymous function you register, or as an object property; do not release a global variable with `unset()`. When the memory limit is exceeded the shutdown function still runs, but since memory has run out at that point, the work the function can do is small: something like only writing the error to the log. A large cleanup or opening a new connection often fails at this moment. This is why the shutdown function in our limit-exceeded measurement only read `error_get_last()`, a deliberate choice.


#### The Bounding Measurement

That the destructor "runs at the end of the request" does not mean it runs the moment you release the object. If an object carries a reference that leads back to itself, that is, if two objects hold each other, `unset()` does not destroy either of them immediately. In this example two objects hold each other. The probes were written in the affirmative direction: does the destructor not run immediately after `unset()`, and does it run after `gc_collect_cycles()`?

```php
<?php
// cycle.php — when does the destructor run with a circular reference?
declare(strict_types=1);
require __DIR__ . '/side.php';

$body = <<<'PHP'
<?php
class Node {
    public static int $destroyed = 0;
    public ?Node $neighbor = null;
    public function __destruct() {
        self::$destroyed++;
    }
}
$a = new Node();
$b = new Node();
$a->neighbor = $b;
$b->neighbor = $a;
unset($a, $b);
$afterUnset = Node::$destroyed;
gc_collect_cycles();
$afterCollector = Node::$destroyed;
PHP;

file_put_contents('immediate.php', $body . "\necho \$afterUnset === 0 ? \"yes\\n\" : \"no\\n\";\n");
file_put_contents('collector.php', $body . "\necho \$afterCollector === 2 ? \"yes\\n\" : \"no\\n\";\n");

sideReport('after unset() the destructor does not run immediately', 'immediate.php');
sideReport('after gc_collect_cycles() the destructor runs', 'collector.php');
```

```
after unset() the destructor does not run immediately → both sides
    development yes · production yes
after gc_collect_cycles() the destructor runs → both sides
    development yes · production yes
```

Both probes gave `yes` in both profiles. After `unset()` the destructor did not run, because the two objects hold each other and the reference count did not fall to zero. The `gc_collect_cycles()` call found the cycle and destroyed both, and the destructor ran twice. The practical consequence is this: if an object whose destructor closes the connection is part of a circular structure, the connection closes not with `unset()` but either with a `close()` call you write explicitly or when the cycle collector runs. Entrusting the connection to the destructor means assuming there is no cycle in your object graph. (**PO3.** No directive concerning the cycle collector was changed; the probes measure the default behavior.)

The same lesson comes out on the memory side. A request that exceeds the limit stops by calling the shutdown function; the limit is the share of each request, and when the request ends that share is freed. In a long-lived process, such as a queue worker process, circular structures can accumulate because the process is not reset by finished requests, and `memory_limit` is the last safeguard that catches this accumulation.

We read the bounding measurement with the shared method of this course: we state a claim and make a measurement that could refute it. The claim was "the destructor runs the moment you release the object". The refuting measurement is to tie two objects to each other and call `unset()`. The destructor did not run; so the claim is not true under this condition. The corrected claim is narrower: the destructor runs when the last reference to the object drops. In a circular structure the last reference does not drop, because the objects keep each other alive. This is why, instead of loading the responsibility of closing the connection on the destructor, it is a sounder arrangement for the code that opens the connection to close it explicitly; the destructor remains as a backup safety net.

The limit of the measurement is also clear: the probes count objects, they do not build connections. Seeing the number of open connections on the other side of a real database server requires a separate tool and a separate environment; the measurement of this lesson proves whether the destructor runs, not what running does on the other side. The two are connected but are not the same measurement.

In this section we made the `gc_collect_cycles()` call by hand, because the measurement has to be repeatable. In a real application you generally do not make this call; the cycle collector runs when it reaches its own threshold. In long-lived worker processes, calling it by hand at the end of a large job can be a deliberate choice, but apply it not as a rule but as an exception verified by measurement.

We combine these two concepts. Consider a queue worker process: a long-lived process that builds a connection object on each job and releases it when the job is done. If the connection object and another object hold each other, for example a "session" object holding the connection and the connection holding the session, `unset()` closes no connection. Until the cycle collector runs, the connections stay open on the other side. The collector runs by itself at a certain accumulation threshold, but your code cannot know when it will run. If the connection limit of the other side is small, this uncertainty shows up one morning as a connection error. We did not set up a connection server in this lesson; but the circular reference probe measures the same behavior with an object counter.

Placing the four behaviors tested by the three measurements of the lesson side by side shows a common structure. The limit being exceeded, the destructor timing, the shutdown order and the circular reference are all runtime rules of the language and do not depend on the profile difference of the `php.ini` settings. This is a reassuring finding, because it lets you learn memory and connection behavior in development and expect it unchanged in production. The only thing to watch is the setting itself: the configuration file determines the `memory_limit` value, and the point at which the limit is exceeded depends on that value. In the two ready-made profiles the value is the same, 128M, but a deployment's own configuration file can change it. In this lesson we pulled the limit down to eight megabytes inside the script and made the two profiles arrive at the same point. In a real deployment the command line process and the web process may read different configuration files; in that case testing the queue worker process with the web setting gives false confidence for the worker process. Measure knowingly, with the value of the process you measure, and write the value in your deployment document; when the limit is exceeded one day, that is the first place to look.

#### Summary

- When `memory_limit` is exceeded the request stops; the shutdown function sees through `error_get_last()` that the last error is `E_ERROR`.
- The side of this lesson is **both sides**: the limit-exceeded, destructor behavior and circular reference measurements all gave the same result in the development and production profiles.
- A connection object's destructor runs after the script ends; the persistent connection and the connection pool are concepts that extend this lifetime beyond requests.
- With a circular reference, `unset()` does not run the destructor immediately; `gc_collect_cycles()` does.
- Leaving the closing of the connection to the destructor means trusting that there is no cycle in the object graph.
- The destructor of an object that lives until the end of the script runs after the shutdown function, while the destructor of an object released with `unset()` runs before the shutdown; both orders are the same in the two profiles (side: both sides).

#### Next Step

Memory and connections were managed inside a request. Every behavior measured in this lesson was a rule of the runtime; the behavior measured in the next lesson depends on the settings and on how the process is started. Who serves the requests is a separate question: the web server and the process manager distribute requests to worker processes. The next lesson starts the built-in web server with two workers and measures what is carried across requests and what is not.

### The Process Manager and the Web Server

Source: https://academia.sh/en/courses/php-production/process-manager-and-web-server

Measuring with the built-in web server who serves the requests and what worker processes do not share between requests: why state must be kept outside the process.

When a browser sends a request to a PHP application, two layers come between. The web server accepts the connection and reads the request. The process manager starts the worker processes that run the PHP code, watches them, and distributes requests among them. The manager can increase or decrease the number of workers and can put a new one in place of a worker that crashed. All of the details depend on the production setup; in this lesson, instead of that setup, a small model of the same job is measured: the built-in web server.

The built-in server runs only with `php -S` and is not designed for production; support for multiple worker processes is an experimental feature that came with PHP 7.4.0 and is not supported on Windows. In production, the web server and the process manager are set up with a separate configuration. The built-in server reduces this structure to a measurable scope: the `PHP_CLI_SERVER_WORKERS` environment variable sets the number of worker processes, and each worker is a separate process. The measuring question is the shared question of this course: does this behavior reach production? `side.php` is below verbatim and is only written to disk.

```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;
}
```

First we write the script that starts the server in the two profiles. The server is always opened with `-d display_errors=0` and only on the `127.0.0.1` address. The profile directives are taken from the `PROFILES` table in `side.php`; the one deliberate difference is the `display_errors` directive: since this lesson measures the log, not the response body, it is given as `0` in both profiles, and the development value `1` in the table is not used here. Both profiles start with the same number of workers. The script writes two pages: `counter.php` increments an in-memory counter on every request and prints it, and `warning.php` produces a warning by reading an undefined variable. It waits with `curl` until the server is ready and stops it with `kill` at the end. The script also writes its results to the `server-result.txt` file.

```bash
# server.sh — starts the built-in server with two workers in two profiles
cat > counter.php <<'EOF'
<?php
$GLOBALS['counter'] = ($GLOBALS['counter'] ?? 0) + 1;
echo $GLOBALS['counter'];
EOF
cat > warning.php <<'EOF'
<?php
echo $undefined;
echo 'done';
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"
: > server-result.txt

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  rm -f log-$profile.txt
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 -d log_errors=1 -d error_log=log-$profile.txt \
    $directives -S 127.0.0.1:8471 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8471/counter.php && break; sleep 0.1; done
  first=$(curl -s http://127.0.0.1:8471/counter.php)
  second=$(curl -s http://127.0.0.1:8471/counter.php)
  third=$(curl -s http://127.0.0.1:8471/counter.php)
  status=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8471/warning.php)
  kill $pid; wait $pid 2>/dev/null
  warnings=$(grep -c Warning log-$profile.txt)
  echo "$profile $first $second $third $status $warnings" >> server-result.txt
  echo "$profile: counter $first $second $third · warning page $status · warnings in log $warnings"
done
```

```
development: counter 1 1 1 · warning page 200 · warnings in log 1
production: counter 1 1 1 · warning page 200 · warnings in log 1
```

Now we read the results of the two profiles and derive the side. There are three measuring questions: is the counter `1` on every request, is the warning page `200`, and did the warning reach the log? The script checks whether each is the same in the two profiles and names it with the `side()` function in `side.php`.

```php
<?php
// server-side.php — derives the side of the server measurements
declare(strict_types=1);
require __DIR__ . '/side.php';

$rows = array_map(
    fn(string $row): array => explode(' ', trim($row)),
    file('server-result.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES),
);
[$development, $production] = $rows;

$measurements = [
    'the counter is 1 on every request' => fn(array $r): bool => array_slice($r, 1, 3) === ['1', '1', '1'],
    'a warning page returns 200' => fn(array $r): bool => $r[4] === '200',
    'a warning reaches the log' => fn(array $r): bool => (int) $r[5] === 1,
];

foreach ($measurements as $name => $measurement) {
    echo $name, ' → ', side($measurement($development), $measurement($production)), "\n";
}
```

```
the counter is 1 on every request → both sides
a warning page returns 200 → both sides
a warning reaches the log → both sides
```

The counter came out `1` on every request in both profiles; because the two worker processes carry separate memory, the in-memory counter did not accumulate across requests. The warning page returned `200` in both profiles, and the warning was written to the log, not to the screen. This is why in production a page can look healthy even though it produces a warning: the status code does not report the warning, and the way to see it is the log. (**PO4.** Each profile restarted the server from scratch with its own settings; changing the settings of a running server was not measured.)

#### State per Request

The previous measurement showed that an in-memory counter does not accumulate across requests. This section asks the same question for four different kinds of state. The places where state can live in PHP are known: a class's static property, a global variable, a `static` variable inside a function, and the side effect of a file being loaded with `include_once`. Each of the four places keeps a counter, and the page prints all four at once. Our expectation is that all come out `1`, because PHP builds the execution environment from scratch on every request. Three requests are sent and only the response of the third is printed: if there were accumulation, the numbers would show `3` there.

```bash
# state.sh — what state is carried per request?
cat > loaded.php <<'EOF'
<?php
$GLOBALS['loaded'] = ($GLOBALS['loaded'] ?? 0) + 1;
EOF
cat > state.php <<'EOF'
<?php
final class Holder {
    public static int $n = 0;
}
function tick(): int {
    static $c = 0;
    return ++$c;
}
Holder::$n++;
$GLOBALS['g'] = ($GLOBALS['g'] ?? 0) + 1;
include_once __DIR__ . '/loaded.php';
echo 'static property ', Holder::$n, ' · global ', $GLOBALS['g'],
    ' · static inside function ', tick(), ' · include_once ', $GLOBALS['loaded'];
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 $directives -S 127.0.0.1:8471 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8471/state.php && break; sleep 0.1; done
  curl -s -o /dev/null http://127.0.0.1:8471/state.php
  curl -s -o /dev/null http://127.0.0.1:8471/state.php
  last=$(curl -s http://127.0.0.1:8471/state.php)
  kill $pid; wait $pid 2>/dev/null
  echo "$profile: $last"
done
```

```
development: static property 1 · global 1 · static inside function 1 · include_once 1
production: static property 1 · global 1 · static inside function 1 · include_once 1
```

We read the arrangement the script sets up. `state.php` increments a counter in four separate places. The first is the static property of the `Holder` class; a static property belongs to the class, not to an object, and so one might think it "lives for the whole process". The second is the `g` key in the global array. The third is the `static $c` variable inside the `tick()` function; this variable keeps its value between function calls. The fourth is the `loaded.php` file: `include_once` loads a file once, and the code inside the file increments a global counter. The "once" in the name `include_once` means once within a request; our question is whether the file is loaded again in a new request.

Both lines of the output show all four numbers as `1`. This is the measured form of PHP's request model: every request is built from scratch as far as user code is concerned, even if the worker process stays the same. The static property is reset, because the class definition is created again with its values on every request. The global array is emptied when the request ends. The `static` variable inside the function starts from zero, because the function is defined anew on every request. `include_once` loads the file again on every request and increments the counter inside it from zero. All four are the result of the same rule, and that they are the same in the two profiles shows that the rule does not depend on the settings; this is why the side of the section is **both sides**.

This result has two aspects in real production. The positive aspect is that the dirt a request leaves behind does not spoil the next one: a request that leaks does not affect the variables of the next request. The model that gives this property cheaply takes its price from another place: every request builds the application from scratch. The web application framework reading its configuration, loading classes and opening connections is repeated on every request. The code cache removes only one part of this price, the work of translating files. The remaining part, building the objects, is the natural result of per-request state. Long-lived execution models keep state between requests to reduce this price, and in return they have to manage the risk of leaks themselves. Those models were not set up in this lesson; what was measured is only the classic model of one request per worker process.

#### The Bounding Measurement

If state in memory is not carried across requests, would writing state to a file be a solution? The script below starts the page that keeps the counter in a file in the two profiles and prints the values returned by three requests.

```bash
# bound.sh — if state is written to a file, is it carried across requests?
cat > file-backed.php <<'EOF'
<?php
$path = __DIR__ . '/counter.txt';
$n = (int) @file_get_contents($path) + 1;
file_put_contents($path, (string) $n);
echo $n;
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  rm -f counter.txt
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 $directives -S 127.0.0.1:8472 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8472/file-backed.php && break; sleep 0.1; done
  rm -f counter.txt
  first=$(curl -s http://127.0.0.1:8472/file-backed.php)
  second=$(curl -s http://127.0.0.1:8472/file-backed.php)
  third=$(curl -s http://127.0.0.1:8472/file-backed.php)
  kill $pid; wait $pid 2>/dev/null
  echo "$profile: file counter $first $second $third"
done
```

```
development: file counter 1 2 3
production: file counter 1 2 3
```

The counter in the file came out `1 2 3` in both profiles: the two workers saw the same file, and state was carried across requests. The in-memory counter had stayed `1 1 1`. The difference is not in the worker's memory but in where the state is kept. Variables tied to worker processes, static properties and cached objects are not reliably shared across requests; state that must be shared is kept outside the process, in a file, a database or a cache server. Here we used the file because it is the smallest external store for measurement; three sequential requests do not collide. For two workers writing at the same time, a file lock or a real store is needed, and this was not measured.

These measurements together shed light on the decision about the number of workers. Each worker process runs with its own request memory; two workers can serve two requests at the same time, but they do not see each other's memory. So increasing the number of worker processes increases the number of requests that can be served at the same time and also increases total memory use. Raising the number without knowing the typical memory amount of a worker process can lead to exceeding the machine's memory and slowing the system. Concurrency comes with a number, but memory sets the upper limit of the number. Writing "this much memory per worker, this many workers in total" in your deployment document instead of "two workers" makes the decision explainable afterwards.

(**PO6.** The server's profile directives are the same as the `PROFILES` table in `side.php`, except that `display_errors` is given as `0` in both profiles; the development value in the table is `1`. This lesson measured only the log and the status code, not the response body; the effect of the `display_errors` difference on the response was not measured and is covered in the next configuration tuning lesson.) The built-in server's worker processes were chosen for measurement. How the process manager determines the number of workers in production, after how many requests workers are restarted, and what happens when a worker crashes are configuration topics and are outside the scope of this measurement. The measurement showed that even with two workers the two profiles give the same state and that state is not carried across requests. These two pieces of information will be the ground when discussing configuration differences in the next lesson. In short: state lives outside the worker process, and the number of worker processes is therefore a deployment decision that weighs concurrency against memory.

#### Summary

- The web server takes the connection; the process manager starts the worker processes that run the PHP code, watches them and distributes requests among them.
- The built-in web server runs only with `php -S` and is not for production; in this lesson it was used as a small model with two workers.
- The side of this lesson is **both sides**: the in-memory counter gave `1` on every request in both profiles, the warning page returned `200` in both profiles, and the warning reached the log.
- The status code does not report a warning; in production the way to see the warning is the log.
- State in memory is not carried across requests, state in a file is: state to be shared is kept outside the process.
- The static property, global variable, `static` variable inside a function and `include_once` counters all gave `1` on the third request; per-request state is the same in the two profiles (side: both sides).

#### Next Step

The next lesson starts with the worker process settings: the memory limit, error reporting and code cache directives are given when the process starts. The last lesson writes how the two profiles diverge in seven directives and measures in which profile changing a directive at run time with `ini_set()` is accepted.

### Configuration Tuning

Source: https://academia.sh/en/courses/php-production/configuration-tuning

Measuring the seven directives on which PHP's development and production configuration files diverge: what hardening turns off, and whether it can be turned back on from code with ini_set().

PHP is distributed with two ready-made configuration files: one for development and one for production. The subject of this lesson is the points at which those two files diverge. The divergence is not a list of preferences but hardening: turning off error detail, call arguments and development checks in production, so that an error cannot leak information to the outside. You saw configuration through environment variables earlier in the context of a web application ("Configuration and Environment Variables"); here the setting itself, that is, PHP's directives, is measured.

The measuring question is the shared question of this course: does this behavior reach production? The `side.php` below carries exactly these two files in the `PROFILES` constant. In this lesson too the file is only written to disk.

```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;
}
```

First we extract the seven differences from the `PROFILES` constant. The script compares the values in the two profiles and writes only the directives that diverge. The `opcache.*` directives that exist only in production stay outside this comparison, because they are not a difference between the two files but additions that turn on the code cache on the command line.

```php
<?php
// diff.php — writes the directives on which the two profiles diverge
declare(strict_types=1);
require __DIR__ . '/side.php';

$differing = 0;
foreach (PROFILES['development'] as $name => $value) {
    $production = PROFILES['production'][$name];
    if ($value !== $production) {
        $differing++;
        echo "$name: development $value · production $production\n";
    }
}
echo "number of differing directives: $differing\n";
```

```
display_errors: development 1 · production 0
display_startup_errors: development 1 · production 0
error_reporting: development 32767 · production 24575
mysqlnd.collect_memory_statistics: development 1 · production 0
zend.assertions: development 1 · production -1
zend.exception_ignore_args: development 0 · production 1
zend.exception_string_param_max_len: development 15 · production 0
number of differing directives: 7
```

The seven lines read as follows. The first three decide error visibility: `display_errors` and `display_startup_errors` write the error into the response, and `error_reporting` says which types are reported. `mysqlnd.collect_memory_statistics` makes the database driver collect memory statistics and is off in production. `zend.assertions` decides whether `assert()` calls are compiled. The last two lines belong to the exception trace: `zend.exception_ignore_args` drops the call arguments from the trace, and `zend.exception_string_param_max_len` limits the length of the string arguments that remain.

| Directive | Development | Production |
|---|---|---|
| `display_errors` | `1` | `0` |
| `display_startup_errors` | `1` | `0` |
| `error_reporting` | `32767` | `24575` |
| `mysqlnd.collect_memory_statistics` | `1` | `0` |
| `zend.assertions` | `1` | `-1` |
| `zend.exception_ignore_args` | `0` | `1` |
| `zend.exception_string_param_max_len` | `15` | `0` |

Now we see how the divergence shows up in behavior. The first probe tries `ini_set('zend.assertions', '1')` at run time and asks whether the call was rejected and the value did not change. The second throws an exception with a function call inside and looks at whether the argument was kept in the exception trace. Both probes were written in the affirmative direction: the behavior visible only in production gives `yes`.

```php
<?php
// settings.php — how does hardening show up in behavior?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('assertion.php', <<<'PHP'
<?php
// assertion.php — tries to turn zend.assertions on at run time
$returned = @ini_set('zend.assertions', '1');
echo $returned === false && ini_get('zend.assertions') === '-1' ? "yes\n" : "no\n";
PHP);

file_put_contents('trace.php', <<<'PHP'
<?php
// trace.php — is the call argument kept in the exception trace?
function fail(string $secret): void {
    throw new RuntimeException();
}
try {
    fail('secret value');
} catch (RuntimeException $exception) {
    echo !isset($exception->getTrace()[0]['args']) ? "yes\n" : "no\n";
}
PHP);

sideReport('zend.assertions cannot be turned on at run time', 'assertion.php');
sideReport('the exception trace holds no argument', 'trace.php');
```

```
zend.assertions cannot be turned on at run time → production only
    development no · production yes
the exception trace holds no argument → production only
    development no · production yes
```

Both probes gave `yes` only in production: the side is **production only**. In production, with `zend.assertions` at `-1`, the value tried at run time was rejected and stayed `-1`; this means that `assert()` calls not compiled in production cannot be activated afterwards. The exception trace did not carry the argument; because in development the same trace carries the argument, the stack record of an error message could have taken a password or user data outside. In production these two leak paths are closed. (**PO5.** This probe looks only at the effect of the profile value; whether `zend.exception_ignore_args` can be changed at run time is measured in the access level section below.)

#### The Access Level of a Directive

The configuration file sets a directive, but who can touch the directive afterwards is a separate question. PHP gives every directive an access level: `PHP_INI_USER` marks what a user script can change with `ini_set()`, `PHP_INI_PERDIR` what can be changed in the configuration file or at directory level, and `PHP_INI_SYSTEM` what can be set only in the main configuration file. A directive that carries all three is called `PHP_INI_ALL`. `ini_get_all(null, true)` returns this level for every directive. The probe below writes two things for eight directives: the access level, and whether the `ini_set()` call was accepted. Accepted means the call did not return `false`. Seven of the eight directives are those that differ between profiles; the eighth, `opcache.enable_cli`, is the directive that the production profile adds and whose value turns on the code cache on the command line. `mysqlnd.collect_memory_statistics` was left out of the measurement: the directive depends on the database driver extension, and this lesson measures only the access level of the core and code cache directives. The probe runs in a child process and writes its result to the profile-specific file; the driver compares the two.

In addition, a second probe tests the `ini_set()` behavior directly for `zend.exception_ignore_args`: if, in the production profile where the directive is `1`, it is made `0` from inside the code, does the argument appear in the trace of an exception thrown afterwards?

```php
<?php
// access.php — who can change a directive, does ini_set() accept it?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('access-probe.php', <<<'PHP'
<?php
// access-probe.php — writes the access level and ini_set() acceptance to the profile-specific file
$profile = ini_get('zend.assertions') === '1' ? 'development' : 'production';
$targets = [
    'display_errors' => '1',
    'display_startup_errors' => '1',
    'error_reporting' => '1',
    'zend.assertions' => '0',
    'zend.exception_ignore_args' => '0',
    'zend.exception_string_param_max_len' => '5',
    'opcache.enable_cli' => '1',
    'opcache.validate_timestamps' => '1',
];
$all = ini_get_all(null, true);
$levels = [1 => 'USER', 2 => 'PERDIR', 4 => 'SYSTEM'];
$rows = [];
foreach ($targets as $name => $target) {
    $access = $all[$name]['access'];
    $names = [];
    foreach ($levels as $bit => $level) {
        if (($access & $bit) !== 0) {
            $names[] = $level;
        }
    }
    $accepted = @ini_set($name, $target) !== false ? 'accepted' : 'rejected';
    $rows[] = $name . ': ' . ($access === 7 ? 'ALL' : implode('|', $names)) . ' · ini_set ' . $accepted;
}
file_put_contents("access-$profile.txt", implode("\n", $rows) . "\n");
echo "yes\n";
PHP);

file_put_contents('trace-open.php', <<<'PHP'
<?php
// trace-open.php — if zend.exception_ignore_args is made 0 from code, does the argument show in the trace?
ini_set('zend.exception_ignore_args', '0');
function fail(string $secret): void {
    throw new RuntimeException();
}
try {
    fail('secret value');
} catch (RuntimeException $exception) {
    echo isset($exception->getTrace()[0]['args']) ? "yes\n" : "no\n";
}
PHP);

sideReport('the access measurement completes', 'access-probe.php');
echo file_get_contents('access-production.txt');
$productionRows = file('access-production.txt', FILE_IGNORE_NEW_LINES);
foreach (file('access-development.txt', FILE_IGNORE_NEW_LINES) as $row) {
    if (!in_array($row, $productionRows, true)) {
        echo "different in development: $row\n";
    }
}
sideReport('zend.exception_ignore_args set to 0 from code brings the argument back into the trace', 'trace-open.php');
```

```
the access measurement completes → both sides
    development yes · production yes
display_errors: ALL · ini_set accepted
display_startup_errors: ALL · ini_set accepted
error_reporting: ALL · ini_set accepted
zend.assertions: ALL · ini_set rejected
zend.exception_ignore_args: ALL · ini_set accepted
zend.exception_string_param_max_len: ALL · ini_set accepted
opcache.enable_cli: SYSTEM · ini_set rejected
opcache.validate_timestamps: ALL · ini_set accepted
different in development: zend.assertions: ALL · ini_set accepted
zend.exception_ignore_args set to 0 from code brings the argument back into the trace → both sides
    development yes · production yes
```

We read the table line by line. Seven of the eight directives are at the `ALL` level; that is, all three bits `PHP_INI_USER`, `PHP_INI_PERDIR` and `PHP_INI_SYSTEM` are on, and the directive can be changed from code with `ini_set()`. `opcache.enable_cli` is only at the `SYSTEM` level: it can be given only in the main configuration file or on the command line, and at run time `ini_set()` rejects it. The table shows the difference between `rejected` and `accepted`. `rejected` means the call returned `false` and the value did not change. `accepted` means the call changed the value. In the table there are two `rejected` on the production side: `zend.assertions` and `opcache.enable_cli`. On the development side there is only one: `opcache.enable_cli`.

The only difference between production and development is the `zend.assertions` line, and the driver's last line writes this explicitly: in development the same directive gave `accepted`. The access level appears as `ALL` in both profiles, so on paper the directive can be changed from code; yet in production, with `-1`, it is rejected. The reason is the extra rule PHP puts on this directive: the value `-1` means that `assert()` calls are removed from the code at compile time, and a call that has been removed once cannot be brought back at run time. The access level alone does not give enough information: a directive that says `ALL` can still reject a transition from a certain value to certain values. This is why the measurement looks not only at the table but also at the result of `ini_set()`.

That six directives came out `accepted` in production is an important warning: although directives such as `display_errors`, `error_reporting`, `zend.exception_ignore_args` and `zend.exception_string_param_max_len` are hardened in the configuration file, any line of code can loosen them. The last probe showed this directly for `zend.exception_ignore_args`. In the production profile, when the directive was `1` and was made `0` from inside the code, the argument appeared again in the trace of an exception thrown afterwards. (**PO7.** The probe changed the directive before throwing the exception; the trace of an exception already created does not change afterwards, and this was not measured.) Because this probe gave `yes` in both profiles its side is **both sides**; but the conclusion it draws matters only for production, because in production this call undoes the hardening.

In a real application the counterpart of this is two concrete habits. The first is to search the code regularly for `ini_set()` calls: especially calls inside libraries that loosen error visibility or the exception trace silently override the hardening in the configuration file. The second, if you really want to lock a directive, is to look at the access level: directives at the `PHP_INI_SYSTEM` level cannot be changed from code, those at the `ALL` level can. Some hosting setups restrict the `ini_set()` call itself with `disable_functions` or custom configuration files; these restrictions were not set up or measured in this lesson.


#### The Bounding Measurement

Hardening is done in the configuration file; can it not be undone from code? `display_errors` is one of the directives that can be turned back on with a single line of code. If we start with the production profile and call `ini_set('display_errors', '1')` inside the script, is the error written into the response? The probe captures the error message and does not print its text; it says only whether a warning message was captured.

```php
<?php
// bound.php — does ini_set() in code turn display_errors back on?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('visible.php', <<<'PHP'
<?php
// visible.php — display_errors is turned on from code, the warning is captured
$returned = ini_set('display_errors', '1');
ob_start();
echo $undefined;
$captured = ob_get_clean();
echo $returned !== false && ini_get('display_errors') === '1' && str_contains($captured, 'Warning') ? "yes\n" : "no\n";
PHP);

sideReport('a warning is printed into the response after ini_set() turns display_errors on', 'visible.php');
```

```
a warning is printed into the response after ini_set() turns display_errors on → both sides
    development yes · production yes
```

The probe gave `yes` in both profiles: the process that started with the production profile wrote the warning into the response when `display_errors` was turned on from code. So `display_errors=0` in the configuration file is not a lock; a library or a line of a script can turn it back on. The counter-example was `zend.assertions`: that directive cannot be turned on at run time when it is `-1`. The strength of hardening varies from directive to directive, so relying only on the configuration file in production is not enough. One has to search the code for `ini_set()` calls and check error visibility outside these records as well.

The honest limit of the lesson is this: what was measured is the values of the seven directives and the behaviors those values showed in the probes above. In a production setup other directives are also turned on or off; the list here is the difference between the ready-made files, not a setup checklist.

We tie the measurements of the lesson together. The table of seven directives says where the two ready-made files diverge by profile. The behavior probes show what that divergence concretely turns off in production: the `assert()` check that cannot be turned on at run time, and the exception trace without arguments. The access level measurement tests how robust that closure is: some are locked, some are not. The bounding measurement asks the same question for `display_errors` and shows that the answer is negative. Read together, the result is this: the production configuration is a list of hardening measures, but it would be wrong to assume that every item on it is equally robust.

#### Summary

- The development and production configuration files diverge on seven directives: `display_errors`, `display_startup_errors`, `error_reporting`, `mysqlnd.collect_memory_statistics`, `zend.assertions`, `zend.exception_ignore_args` and `zend.exception_string_param_max_len`.
- The side of this lesson is **production only**: in production `zend.assertions` cannot be turned on at run time and the exception trace holds no argument; in development neither is seen.
- The purpose of the divergence is hardening: error detail, call arguments and development checks do not leak outside in production.
- `display_errors` could be turned back on from code with `ini_set()` (both sides); the value in the configuration file is not a lock for every directive.
- The access level alone is not enough: `zend.assertions` appears as `ALL` but `ini_set()` is rejected in production when it is `-1` and accepted in development; `opcache.enable_cli` is at the `SYSTEM` level in both profiles and is rejected (side: production only).
- `zend.exception_ignore_args` made `0` from code brings the argument back into the exception trace in production (side: both sides).

#### Next Step

The last lesson of this course is the deployment workflow: how, when taking code to production, the configuration, the code cache and the process manager come together in a single flow. Every behavior measured in the previous five lessons corresponds to a step there.

### The Deployment Workflow

Source: https://academia.sh/en/courses/php-production/deployment-workflow

Release directories and the symbolic link switch when taking code to production: why the old version runs after an atomic switch, and which call brings the new version.

The previous five lessons each made a single setting or measurement: the code cache, the process manager, the configuration directives. A deployment tests all of them at once, because new code does not reach production just by being written to disk. In the arrangement called zero-downtime deployment, every release is installed in its own directory, and the path that serves requests is turned to the new directory in a single step. This lesson builds that step and measures how the production settings affect it.

The measuring question is again this: does this behavior reach production? In the DevOps and Platform Engineering curriculum, every deviation between development and production was counted as a hidden defect and the goal was to close it. Here most of the difference is deliberate: PHP is distributed with two configuration files, and the difference comes from those files. This lesson does not close the difference; it names it. The `side.php` below is the shared core of the course; in this lesson too it is only written to disk and has no output.

```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;
}
```

#### Release Directories and the Atomic Switch

The arrangement is this: each release lives in its own directory, such as `release-1` and `release-2`, and the name `current`, which requests use, is a symbolic link that points to the release directory currently live. The new release is prepared on the side, and the running release is never touched. Going live consists of the link pointing to another directory.

The switch has to be atomic: a request reading the link must never see an intermediate state in which it points to neither the old nor the new directory. `symlink()` does not overwrite an existing name; so a new link is first created under the name `current-new`, and then `rename()` moves it over the name `current`. Within the same file system, `rename()` completes in a single step: the name `current` points to either the old or the new directory at every moment. Two helper functions build this arrangement. In this lesson the file is only written to disk.

```php
<?php
// setup.php — two release directories, a link to the first, and the atomic switch
declare(strict_types=1);

function setUpReleases(): void {
    foreach ([1, 2] as $n) {
        @mkdir(__DIR__ . "/release-$n");
        $path = __DIR__ . "/release-$n/hello.php";
        file_put_contents($path, "<?php return $n;");
        touch($path, time() - 60);
    }
    @unlink(__DIR__ . '/current');
    symlink('release-1', __DIR__ . '/current');
}

function switchOver(): void {
    @unlink(__DIR__ . '/current-new');
    symlink('release-2', __DIR__ . '/current-new');
    rename(__DIR__ . '/current-new', __DIR__ . '/current');
}
```

The `hello.php` file of each release only returns the release number. `touch()` moves the files' time into the past; because the cache does not take a file changed in the last seconds, the probes do this step at the start of the code. The first probe asks whether the switch worked on disk, and the second asks what happens when the cache is on. Each probe runs in its own process, in directories set up from scratch with `setUpReleases()`.

```php
<?php
// deploy.php — which version runs after the switch?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('switch.php', <<<'PHP'
<?php
// switch.php — does the link point to the new directory after the switch?
require __DIR__ . '/setup.php';
setUpReleases();
switchOver();
echo is_link(__DIR__ . '/current') && readlink(__DIR__ . '/current') === 'release-2' ? "yes\n" : "no\n";
PHP);

file_put_contents('stale.php', <<<'PHP'
<?php
// stale.php — does the old version run from the same path after the switch?
require __DIR__ . '/setup.php';
setUpReleases();
$before = include __DIR__ . '/current/hello.php';
switchOver();
$after = include __DIR__ . '/current/hello.php';
echo $after === $before ? "yes\n" : "no\n";
PHP);

sideReport('the link points to the new directory after the switch', 'switch.php');
sideReport('the old version runs from the same path after the switch', 'stale.php');
```

```
the link points to the new directory after the switch → both sides
    development yes · production yes
the old version runs from the same path after the switch → production only
    development no · production yes
```

The first probe gave `yes` in both profiles: the switch completes on disk in the same way everywhere. The second probe gave `yes` only in production (**PO8.** The measurement was made in a command line process, with a single process; in a real deployment the switch happens alongside the worker processes that are still serving, and each process sees its own request through the new path; that arrangement was not set up in this lesson). Although the symbolic link pointed to the new directory, the second load from the path `current/hello.php` returned the same value as the first: the cache already knew this path and did not look at the disk. In development the code cache is off, so every load reads the disk and the new version arrives immediately.

#### The Calls That Bring the New Version

The problem is that the cache keeps a path as its key and that this path goes through the link. The deployment script has three candidate calls: `clearstatcache(true)`, which clears the file system check cache, `opcache_reset()`, which empties the whole code cache, and `opcache_invalidate()`, which invalidates a single file. The fourth is not a call but a path: resolving the link in code with `realpath()`. The four probes run with the same setup; in each, `yes` means that what its name says happened.

```php
<?php
// fixes.php — which call brings the new version after the switch?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('stat.php', <<<'PHP'
<?php
// stat.php — does the old version remain after clearstatcache(true)?
require __DIR__ . '/setup.php';
setUpReleases();
include __DIR__ . '/current/hello.php';
switchOver();
clearstatcache(true);
$after = include __DIR__ . '/current/hello.php';
echo $after === 1 ? "yes\n" : "no\n";
PHP);

file_put_contents('reset.php', <<<'PHP'
<?php
// reset.php — opcache_reset() returns true, but does the old version remain in the same request?
require __DIR__ . '/setup.php';
setUpReleases();
include __DIR__ . '/current/hello.php';
switchOver();
$returned = opcache_reset();
$after = include __DIR__ . '/current/hello.php';
echo $returned === true && $after === 1 ? "yes\n" : "no\n";
PHP);

file_put_contents('linkpath.php', <<<'PHP'
<?php
// linkpath.php — does the new version arrive after invalidating through the link path?
require __DIR__ . '/setup.php';
setUpReleases();
$path = __DIR__ . '/current/hello.php';
include $path;
switchOver();
opcache_invalidate($path, true);
$after = include $path;
echo $after === 2 ? "yes\n" : "no\n";
PHP);

file_put_contents('oldpath.php', <<<'PHP'
<?php
// oldpath.php — does invalidating with the resolved path of the old version bring the new one?
require __DIR__ . '/setup.php';
setUpReleases();
$path = __DIR__ . '/current/hello.php';
include $path;
$oldPath = realpath($path);
switchOver();
$returned = opcache_invalidate($oldPath, true);
$after = include $path;
echo $returned === true && $after === 2 ? "yes\n" : "no\n";
PHP);

file_put_contents('resolved.php', <<<'PHP'
<?php
// resolved.php — does the new version arrive when the link is resolved with realpath() before include?
require __DIR__ . '/setup.php';
setUpReleases();
include realpath(__DIR__ . '/current') . '/hello.php';
switchOver();
$after = include realpath(__DIR__ . '/current') . '/hello.php';
echo $after === 2 ? "yes\n" : "no\n";
PHP);

sideReport('the old version remains after clearstatcache(true)', 'stat.php');
sideReport('the new version arrives after invalidating through the link path', 'linkpath.php');
sideReport('invalidating with the resolved path of the old version brings the new version', 'oldpath.php');
sideReport('the new version arrives from the path resolved with realpath()', 'resolved.php');
sideReport('opcache_reset() returns true but the old version remains in the same request', 'reset.php');
```

```
the old version remains after clearstatcache(true) → production only
    development no · production yes
the new version arrives after invalidating through the link path → development only
    development yes · production no
invalidating with the resolved path of the old version brings the new version → production only
    development no · production yes
the new version arrives from the path resolved with realpath() → both sides
    development yes · production yes
opcache_reset() returns true but the old version remains in the same request → production only
    development no · production yes
```

Three results stand out. First, `clearstatcache(true)` did not work: in production the second load again gave the old version. This call clears the cache of file system queries and does not touch the code cache. Second, `opcache_reset()` returned `true` in production but the old version remained in the same request; a call looking successful does not mean that the new version will arrive in that request. This measurement did not show the next request. Third, which path the invalidating call takes matters: after the call made with the link path (`current/hello.php`), the old version remained in production; only development, where the cache is off, saw the new version. The call made with the resolved path of the old version (the result of `realpath()`) brought the new version in production. In the same way, the path resolved with `realpath()` before the include brought the new version in both profiles. These three results are consistent with the assumption that the cache keeps the file by the resolved path and not by the name of the link. In development, `opcache_reset()` and `opcache_invalidate()` return `false` because the cache is off, so the calls of the deployment script cannot be tested in development.

#### The Bounding Measurement

The `realpath()` solution depends on when the path is resolved. The probe resolves the path once before the switch and stores it; after the switch it loads from the stored path. This is what would happen if a long-lived process resolved the path at start-up and kept it in memory.

```php
<?php
// bound.php — does a path resolved before the switch still give the old version after it?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('stored.php', <<<'PHP'
<?php
// stored.php — the resolved path is stored before the switch and used after it
require __DIR__ . '/setup.php';
setUpReleases();
$stored = realpath(__DIR__ . '/current');
$before = include $stored . '/hello.php';
switchOver();
$after = include $stored . '/hello.php';
echo $after === $before ? "yes\n" : "no\n";
PHP);

sideReport('a path resolved before the switch still gives the old version after it', 'stored.php');
```

```
a path resolved before the switch still gives the old version after it → both sides
    development yes · production yes
```

The probe gave `yes` in both profiles: because the stored path points to the old directory, the old version arrived after the switch too, whether the code cache was on or not. So `realpath()` is not a solution by itself but a timing rule: the resolution must be done on every request, immediately before the include. The limit of the measurement is here too: the probe is limited to one process and two releases, and how long a stored path lives in a real worker process was not measured.

#### Summary

- A zero-downtime deployment puts each release in a separate directory and moves a new link created with `symlink()` over the name `current` with `rename()`; the switch (the atomic switch) completes on disk in both profiles.
- The side of this lesson is **production only**: with `opcache.validate_timestamps=0`, the old version runs from the same link path after the switch; in development the code cache is off and the new version arrives.
- `clearstatcache(true)` does not fix this; `opcache_reset()` returns `true` but the old version remains in the same request; in development both `opcache_*` calls return `false`.
- Two ways that bring the new version were measured: the path resolved with `realpath()` before the include, and `opcache_invalidate($path, true)` with the resolved path of the old version. After invalidating through the link path, the old version remained in production.
- The limit: if the result of `realpath()` is stored before the switch, the old version arrives on both sides; the resolution must be done on every request.

#### Course Wrap-Up

The course closes with the measurement of seven directives and four classes of behavior that carry code to production. The measured behavior, the side and the bounding measurement of each lesson are below.

| Lesson | Behavior measured | Side | Bounding measurement |
|---|---|---|---|
| Static Analysis | an undefined function in an uncalled branch does not run, the syntax check looks only at syntax, the token checker finds the `summarize` call | not in the run | if the name is called from a variable the checker found 0, a direct run threw `Error` |
| Code Style Checking | unformatted code runs the same in both profiles, the token-based checker counts line endings, double spaces and indentation | not in the run | a fix without tokens for the trailing whitespace inside a nowdoc shortens the string, a formatter that rewrites only `T_WHITESPACE` leaves the length at 13 |
| Testing Frameworks | an `assert()`-based test catches in development, is not compiled in production (`zend.assertions=-1`) | development only | `zend.assertions=0` does not catch either and can be turned to `1` at run time, with `-1` the path is closed |
| The Debugger | `card-0042` is visible in the exception trace in development and hidden in production | development only | if the value is written into the message, `getMessage()` carries `card-0042` in both profiles |
| Code Caching | with `validate_timestamps=0` a rewritten file runs with the old version, `opcache_invalidate($path, true)` brings the new version | production only | `validate_timestamps=1` with `revalidate_freq=0` removes the old version problem |
| Profiling | 500 calls, total 249500 and an equal memory difference; in the chain of three functions `read` 400, `convert` 200, `aggregate` 5 | both sides | the counter inside `assert()` is 500 in development, 0 in production |
| Memory and Connection Management | when `memory_limit` is exceeded the request stops, the destructor runs when the request ends | both sides | in a circular reference `unset()` does not run the destructor, `gc_collect_cycles()` does |
| The Process Manager and the Web Server | in two worker processes the in-memory counter stays `1` on every request, a warning page returns `200` in both profiles | both sides | if the counter is kept in a file `1 2 3`, in memory `1 1 1` |
| Configuration Tuning | a difference of seven directives; `ini_set('zend.assertions','1')` is accepted in development, `false` in production | production only | `display_errors` could be turned back on with `ini_set()` in production |
| The Deployment Workflow | after the symbolic link switch the old version runs from the same path, the new version arrives from the path resolved with `realpath()` | production only | from a path resolved and stored before the switch the old version arrives in both profiles |

The distribution of the lessons: **3 both sides · 2 development only · 3 production only · 2 not in the run**. The core (`behaviors.php`, run in the Static Analysis lesson): **4 · 4 · 2 · 2**. The two numbers count different things: the first counts the side of the main measurement of ten lessons, the second counts the side of twelve separate behaviors; because a lesson measures more than one behavior and a behavior concerns more than one lesson, the two distributions are not expected to come out the same.

M12 closes here; five courses took PHP from a language to a production system. The PHP Fundamentals course measured through which channel a problem is heard (*through which channel is this problem heard*: silent · return value · warning · stops, 3/4/4/3). The Object-Oriented PHP and Standards course measured where a change reaches (*where does this change reach*: in place · in the link · breaking · implicit, 3/3/3/3). The PHP on the Web: State, Data and Security course measured who can change the result (*who can change this result*: server · environment · client · third party, 5/4/4/2). The Web Application Framework course measured whether a part ran in that request (*did this part run in this request*: ran · deferred · skipped · not bound, 6/5/5/5). The PHP Quality, Performance and Deployment course measured whether a behavior reaches production (*does this behavior reach production*: both sides · development only · production only · not in the run, 3/2/3/2). The next curriculum is Ruby.

