Skip to content

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

Open
lonnieezell wants to merge 3 commits into
codeigniter4:4.8from
lonnieezell:issue-10615
Open

lonnieezell wants to merge 3 commits into
codeigniter4:4.8from
lonnieezell:issue-10615

Conversation

@lonnieezell

@lonnieezell lonnieezell commented Oct 24, 2026 •

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 probed with getenv() the first time each leaf is seen. Only the names that exist are cached for the process, and their values are re-read on every build, so changed or removed values are never reused. Leaves that first appear on a later instance (for example, added by a constructor or registrar) are probed then. Leaves are only walked again when a class's property values change since its last build. That check is cheap because unchanged arrays share memory. This is the main difference from the issue's proposal, which would have dropped these names.
  • Case-insensitive getenv(): on Windows, and under Apache mod_php (where getenv() reads SetEnv vars, so SetEnv APP_BASEURL matches app_baseURL), only live getenv() calls match names case-insensitively. There the underscore forms are probed live too. This also applies to any SAPI not known to match exactly. The known-exact SAPIs are cli, phpdbg, cli-server, fpm-fcgi and frankenphp: their live getenv() only finds exact names that are also in $_SERVER or the getenv() array, so they skip this step and avoid doubling the getenv() calls on a cold build.
  • 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 change (listed in the changelog and upgrade_480.rst):

A bare putenv(), without a matching $_ENV/$_SERVER write, that adds a new name 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.

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.462 ms 0.077 ms 83%
Warm, no overrides, environment unchanged 0.463 ms 0.034 ms 93%
Warm, 5 overrides, $_SERVER changes per build 0.475 ms 0.117 ms 75%
Warm, 5 overrides, environment unchanged 0.474 ms 0.071 ms 85%
Cold first build, no overrides (classic per-process request) 0.503 ms 0.257 ms 49%
Cold first build, 5 overrides 0.506 ms 0.306 ms 40%

Each figure is the average of two back-to-back before/after rounds. These figures are for the CLI SAPI on macOS. On Windows, and under Apache mod_php or other SAPIs outside the known-exact list, a cold build makes 4 getenv() calls per leaf instead of 2.

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
  • a removed getenv()-only value not being reused
  • an OS-only override for an array leaf that first appears on a later instance

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
@michalsn
michalsn requested a balanced review from Copilot October 26, 2026 07:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The per-class cache can silently miss existing OS-only overrides for array leaves introduced by later instances.

1 open issue
What changed in this PR

Optimizes config construction for the 4.8 release by reusing an environment index, addressing #10615’s repeated lookup costs without config caching and in Worker Mode.

Changes:

  • Adds indexed environment lookups, snapshot-based refresh, and live-lookup fallback for custom hooks.
  • Adds regression coverage for precedence, refresh behavior, array keys, and unusual environment names.
  • Documents changes to late putenv() updates and Windows case sensitivity.

Validation was limited to targeted source inspection; runtime tests remain for CI.

File Description
user_guide_src/​source/​installation/​upgrade_480.rst Explains environment lookup behavior changes.
user_guide_src/​source/​changelogs/​v4.8.0.rst Records the optimization and compatibility notes.
tests/​system/​Config/​fixtures/​Snake_Config.php Provides an underscore-containing class name fixture.
tests/​system/​Config/​fixtures/​EnvIndexConfig.php Provides scalar and array override fixtures.
tests/​system/​Config/​fixtures/​CustomEnvHookConfig.php Provides a custom environment hook fixture.
tests/​system/​Config/​BaseConfigTest.php Tests indexed lookup behavior and precedence.
system/​Config/​BaseConfig.php Implements indexing, caching, and shared lookup logic.

Review effort: Balanced

Comment thread system/Config/BaseConfig.php Outdated
Comment thread system/Config/BaseConfig.php Outdated
Comment on lines +244 to +416
$value = getenv("{$shortPrefix}.{$property}");
$value = $value === false ? getenv("{$shortPrefix}_{$underscoreProperty}") : $value;
$value = $value === false ? getenv("{$prefix}.{$property}") : $value;
$value = $value === false ? getenv("{$prefix}_{$underscoreProperty}") : $value;
// Dot forms always contain "."; underscore forms only need a live
// lookup when the name itself contains " " or "[".
$envKeys = strpbrk($name, ' [') === false
? ["{$shortPrefix}.{$name}", "{$prefix}.{$name}"]
: self::envKeys($name, $prefix, $shortPrefix);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .env template: https://github.com/codeigniter4/CodeIgniter4/blob/develop/env#L24 documents underscore-form names, so taht would be a regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I read this as the Windows case from the description: on Windows, only live getenv() calls match env var names case-insensitively, so an OS-only APP_BASEURL would no longer match app_baseURL through the getenv() array. Please say if you meant something else.

Fixed in ec9311b. On Windows, the underscore forms are now probed live as well, so they match as they did in 4.7. I dropped that behavior change from the changelog and upgrade_480.rst. Other OSes skip this, because those names are already in the getenv() array and probing them live would roughly double the getenv() calls on a cold build (about 0.25 ms vs 0.41 ms in the benchmark). There's a comment in findLiveEnvNames() explaining why. Names containing a space or [ are still probed in every form on all OSes.

I couldn't test the Windows path on macOS, so a check from someone on Windows would be welcome.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this addresses Windows. Underscore separators were introduced for environments such as Docker/Apache (#4026, #5156), and exact-case OS variables such as app_baseURL still work here. The remaining regression is specifically Apache/mod_php: SetEnv APP_BASEURL ... is returned by getenv('app_baseURL'), but that exact key is absent from the enumerated sources. I retested ec9311b: the base branch applies the override, while this PR leaves the default. Could we preserve this SAPI behavior too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for retesting. Reproduced and fixed in e688569.

The cause is the same as on Windows. Apache's getenv() handler reads subprocess_env through apr_table_get(), which is case-insensitive, while the indexed $_SERVER and getenv() array only hold APP_BASEURL. The underscore forms are now probed live on Windows and on any SAPI not on a short list of SAPIs that match exactly. I checked each one in php-src and FrankenPHP:

  • cli, phpdbg, cli-server: no SAPI getenv handler, so only the OS environment.
  • fpm-fcgi: fcgi_hash_get() uses memcmp, and the params are also in $_SERVER.
  • frankenphp: exact hash lookups over the same layers the getenv() array returns.

Anything else, like apache2handler, litespeed or cgi-fcgi, keeps the live probe, so a SAPI we haven't checked falls back to the 4.8 behavior instead of missing overrides. CLI cold builds stay at about 0.26 ms.

Verified with Docker:

Setup 4.8 ec9311b e688569
php:8.3-apache, SetEnv APP_BASEURL http://from-apache/ applied default applied
php:8.3-fpm, FastCGI param app_baseURL applied applied applied
php:8.3-fpm, FastCGI param APP_BASEURL default default default

The Apache test mounts the repo at /app and serves a script that runs require 'system/Test/bootstrap.php' and then echo (new Config\App())->baseURL, with the SetEnv line in conf-enabled/. For FPM I sent the params with cgi-fcgi inside the container. A SAPI check can't be simulated in PHPUnit, so there's no automated test for this one.

Comment thread system/Config/BaseConfig.php Outdated
lonnieezell and others added 2 commits October 27, 2026 23:39
Cache only the names of env vars found with live getenv() calls, per
leaf, and re-read their values on each build, so removed or changed
values are not reused. Leaves first seen on a later instance are now
probed too. On Windows, probe the underscore forms live as well, so
they keep matching case-insensitively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mod_php

Under Apache mod_php, live getenv() reads SetEnv vars case-insensitively,
so `SetEnv APP_BASEURL` matched `app_baseURL` in 4.8, but the indexed
$_SERVER and getenv() array only hold the exact name. Probe the
underscore forms live on any SAPI not known to match names exactly,
keeping the cheaper path for CLI, FPM and FrankenPHP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

4 participants