Skip to content

perf: scan the environment once when building config classes - #10657

Open
lonnieezell wants to merge 1 commit into
codeigniter4:4.8from
lonnieezell:issue-10615
Open

lonnieezell wants to merge 1 commit into
codeigniter4:4.8from
lonnieezell:issue-10615

Conversation

@lonnieezell

Copy link
Copy Markdown
Member

Description
Fixes #10615

Without config caching, and on every request in FrankenPHP Worker Mode, BaseConfig::__construct() checks every leaf property against the environment. That is 8 failed array_key_exists() calls plus 4 failed getenv() calls per leaf, even though most properties have no override.

This PR indexes the environment once and reuses it:

  • Index: $_ENV, $_SERVER and getenv() are grouped by the text before their first . or _. Each class only looks at the keys that match its short or full prefix. A leaf is skipped unless one of its 4 candidate names exists.
  • Freshness: the index keeps a [$_ENV, $_SERVER] snapshot and is rebuilt when either changes (once per request in Worker Mode) or on BaseConfig::reset(). Copy-on-write makes the unchanged check cheap.
  • Names getenv() doesn't list: PHP's getenv() array, $_ENV and $_SERVER all leave out OS env var names containing ., a space or [. For example, app.baseURL set through Docker or systemd is only readable with getenv('app.baseURL'). These names are looked up with getenv() on each class's first build and cached for the process, so they keep working. This is the main difference from the issue's proposal, which would have dropped them.
  • Custom hooks: classes that override getEnvValue() or initEnvValue() keep the previous live lookups. This is detected with reflection once per class.
  • Precedence is unchanged: short prefix over full prefix, $_ENV over $_SERVER over getenv(), dot form over underscore form, and arrays only accept existing keys. getEnvValue() and the new path now share a single lookup.

There is no new public or protected API.

Behavior changes (listed in the changelog and upgrade_480.rst):

  1. A bare putenv(), without a matching $_ENV/$_SERVER write, made after the first config class was built may not be seen until BaseConfig::reset() runs. Values from .env are unaffected, because DotEnv also writes $_ENV and $_SERVER.
  2. On Windows, underscore-form env vars that exist only in the OS environment now match case-sensitively.

Benchmark

Time to build the 20 core config classes from the issue: PHP 8.3.23 CLI on macOS, OPcache off, testing environment. Warm rows are the median of 9 rounds of 500 builds. Cold rows are the median of 41 fresh processes, each timing its first build after autoloading and Registrar discovery. "5 overrides" means app.baseURL plus four database.default.* keys in $_ENV, $_SERVER and putenv().

Scenario Before After Saved
Warm, no overrides, $_SERVER changes per build (Worker Mode) 0.456 ms 0.073 ms 84%
Warm, no overrides, environment unchanged 0.456 ms 0.031 ms 93%
Warm, 5 overrides, $_SERVER changes per build 0.467 ms 0.110 ms 76%
Warm, 5 overrides, environment unchanged 0.471 ms 0.067 ms 86%
Cold first build, no overrides (classic per-process request) 0.493 ms 0.205 ms 58%
Cold first build, 5 overrides 0.505 ms 0.267 ms 47%

The cold gain is smaller because each class's first build still makes 2 getenv() calls per leaf for the names that getenv() doesn't list. I also tried keeping live getenv() calls for those names on every build instead of caching them. That came out slower than the current code (about 0.59 ms), so the per-process cache is needed. Requests served from config caching (FactoriesCache) already skip this work and gain nothing.

Benchmark scripts

Warm (php bench-config-env.php /path/to/CodeIgniter4):

<?php

// Usage: php bench-config-env.php /path/to/CodeIgniter4
// Times building 20 core config classes under three scenarios.

chdir($argv[1]);
require 'system/Test/bootstrap.php';

use CodeIgniter\Config\BaseConfig;

$classes = [
    Config\App::class, Config\Database::class, Config\Mimes::class, Config\Cache::class,
    Config\Logger::class, Config\Security::class, Config\Session::class, Config\Cookie::class,
    Config\Filters::class, Config\Routing::class, Config\Toolbar::class, Config\Email::class,
    Config\Exceptions::class, Config\Paths::class, Config\Kint::class, Config\Format::class,
    Config\Validation::class, Config\View::class, Config\Feature::class, Config\Encryption::class,
];

$overrides = [
    'app.baseURL'                => 'https://example.com/',
    'database.default.hostname'  => 'db',
    'database.default.database'  => 'ci4',
    'database.default.username'  => 'root',
    'database.default.password'  => 'secret',
];

// Warm up autoloading and registrar discovery.
foreach ($classes as $class) {
    new $class();
}

function bench(array $classes, bool $touchServer): float
{
    $iterations = 500;
    $start      = hrtime(true);

    for ($i = 0; $i < $iterations; $i++) {
        if ($touchServer) {
            // Simulates a new request: $_SERVER changes, as in Worker Mode.
            $_SERVER['REQUEST_TIME_FLOAT'] = $i;
        }

        foreach ($classes as $class) {
            new $class();
        }
    }

    return (hrtime(true) - $start) / $iterations / 1e6;
}

function median(callable $fn): float
{
    $runs = [];

    for ($r = 0; $r < 9; $r++) {
        $runs[] = $fn();
    }

    sort($runs);

    return $runs[4];
}

$results = [];

$results['No overrides, $_SERVER changes per iteration'] = median(static fn () => bench($classes, true));
$results['No overrides, environment unchanged']           = median(static fn () => bench($classes, false));

foreach ($overrides as $key => $value) {
    putenv("{$key}={$value}");
    $_ENV[$key]    = $value;
    $_SERVER[$key] = $value;
}

$results['Typical overrides, $_SERVER changes per iteration'] = median(static fn () => bench($classes, true));
$results['Typical overrides, environment unchanged']           = median(static fn () => bench($classes, false));

// Sanity check that overrides are applied.
if ((new Config\Database())->default['hostname'] !== 'db' || (new Config\App())->baseURL !== 'https://example.com/') {
    fwrite(STDERR, "Overrides not applied!\n");

    exit(1);
}

foreach ($results as $label => $ms) {
    printf("%-52s %.4f ms\n", $label, $ms);
}

Cold (php bench-cold.php /path/to/CodeIgniter4 [overrides], run 41 times and take the median):

<?php

// Usage: php bench-cold.php /path/to/CodeIgniter4 [overrides]
// Times the first build of 20 config classes in a fresh process
// (classic per-request PHP without config caching).

chdir($argv[1]);
require 'system/Test/bootstrap.php';

$classes = [
    Config\App::class, Config\Database::class, Config\Mimes::class, Config\Cache::class,
    Config\Logger::class, Config\Security::class, Config\Session::class, Config\Cookie::class,
    Config\Filters::class, Config\Routing::class, Config\Toolbar::class, Config\Email::class,
    Config\Exceptions::class, Config\Paths::class, Config\Kint::class, Config\Format::class,
    Config\Validation::class, Config\View::class, Config\Feature::class, Config\Encryption::class,
];

if (($argv[2] ?? '') === 'overrides') {
    foreach ([
        'app.baseURL'               => 'https://example.com/',
        'database.default.hostname' => 'db',
        'database.default.database' => 'ci4',
        'database.default.username' => 'root',
        'database.default.password' => 'secret',
    ] as $key => $value) {
        putenv("{$key}={$value}");
        $_ENV[$key]    = $value;
        $_SERVER[$key] = $value;
    }
}

// Autoload the classes and run registrar discovery outside the timing.
foreach ($classes as $class) {
    class_exists($class);
}
new Config\Pager();

$start = hrtime(true);

foreach ($classes as $class) {
    new $class();
}

echo (hrtime(true) - $start) / 1e6, "\n";

Tests

New tests in BaseConfigTest cover:

  • $_ENV/$_SERVER writes after a build being picked up
  • reset() picking up a bare putenv()
  • overridden hooks still being called with live getenv()
  • class names containing _
  • the precedence matrix
  • array keys
  • an OS-only name containing a space

The full suite has the same failures as plain 4.8 locally: SQLSRV and Predis are unavailable, and a few FeatureTestTrait tests fail only when run in the full suite.

Checklist:

  • Securely signed commits
  • Component(s) with PHPDoc blocks, only if necessary or adds value (without duplication)
  • Unit testing, with >80% coverage (tests added; coverage not measured locally, no coverage driver)
  • User guide updated
  • Conforms to style guide

🤖 Generated with Claude Code

…fig classes

`BaseConfig::__construct()` checked every leaf property against `$_ENV`,
`$_SERVER` and four `getenv()` calls, so the common no-override path was
also the slowest. The environment is now indexed once, grouped by the text
before the first "." or "_", and each class only walks the keys that match
its prefixes. The index is rebuilt when `$_ENV` or `$_SERVER` changes
(once per request in Worker Mode) or on `BaseConfig::reset()`.

PHP's `getenv()` array omits names containing ".", " " or "[", so those
are looked up with `getenv()` on each class's first build and cached for
the process. Classes that override `getEnvValue()` or `initEnvValue()`
keep the previous live lookups. Lookup precedence is unchanged.

Building 20 core config classes (PHP 8.3 CLI, no opcache, median):

| Scenario                              | Before   | After    |
|---------------------------------------|----------|----------|
| Warm, no overrides, new $_SERVER      | 0.456 ms | 0.073 ms |
| Warm, no overrides, unchanged env     | 0.456 ms | 0.031 ms |
| Warm, 5 overrides, new $_SERVER       | 0.467 ms | 0.110 ms |
| Warm, 5 overrides, unchanged env      | 0.471 ms | 0.067 ms |
| Cold first build, no overrides        | 0.493 ms | 0.205 ms |
| Cold first build, 5 overrides         | 0.505 ms | 0.267 ms |

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@carson-codeigniter4 carson-codeigniter4 Bot added 4.8 PRs that target the `4.8` branch. enhancement PRs that improve existing functionalities labels Oct 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.8 PRs that target the `4.8` branch. enhancement PRs that improve existing functionalities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant