Skip to content
academia.sh

Lesson 09 / 10

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().

Published Updated

In this lesson

  • List the seven directives that separate the development and production configuration files and the value of each
  • Measure that at run time zend.assertions cannot be changed in production and that exception traces hide arguments
  • Interpret how turning display_errors on from code with ini_set() can undo hardening
Contents

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
// 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
// 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
// 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
// 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
// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close