Repository navigation
perf: scan the environment once when building config classes - #10657
lonnieezell wants to merge 3 commits into
Conversation
…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>
There was a problem hiding this comment.
🟡 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
| $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); |
There was a problem hiding this comment.
The .env template: https://github.com/codeigniter4/CodeIgniter4/blob/develop/env#L24 documents underscore-form names, so taht would be a regression.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 SAPIgetenvhandler, so only the OS environment.fpm-fcgi:fcgi_hash_get()usesmemcmp, and the params are also in$_SERVER.frankenphp: exact hash lookups over the same layers thegetenv()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.
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>

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 failedarray_key_exists()calls plus 4 failedgetenv()calls per leaf, even though most properties have no override.This PR indexes the environment once and reuses it:
$_ENV,$_SERVERandgetenv()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.[$_ENV, $_SERVER]snapshot and is rebuilt when either changes (once per request in Worker Mode) or onBaseConfig::reset(). Copy-on-write makes the unchanged check cheap.getenv()doesn't list: PHP'sgetenv()array,$_ENVand$_SERVERall leave out OS env var names containing., a space or[. For example,app.baseURLset through Docker or systemd is only readable withgetenv('app.baseURL'). These names are probed withgetenv()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.getenv(): on Windows, and under Apache mod_php (wheregetenv()readsSetEnvvars, soSetEnv APP_BASEURLmatchesapp_baseURL), only livegetenv()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 arecli,phpdbg,cli-server,fpm-fcgiandfrankenphp: their livegetenv()only finds exact names that are also in$_SERVERor thegetenv()array, so they skip this step and avoid doubling thegetenv()calls on a cold build.getEnvValue()orinitEnvValue()keep the previous live lookups. This is detected with reflection once per class.$_ENVover$_SERVERovergetenv(), 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/$_SERVERwrite, that adds a new name after the first config class was built may not be seen untilBaseConfig::reset()runs. Values from.envare unaffected, becauseDotEnvalso writes$_ENVand$_SERVER.Benchmark
Time to build the 20 core config classes from the issue: PHP 8.3.23 CLI on macOS, OPcache off,
testingenvironment. 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" meansapp.baseURLplus fourdatabase.default.*keys in$_ENV,$_SERVERandputenv().$_SERVERchanges per build (Worker Mode)$_SERVERchanges per buildEach 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 thatgetenv()doesn't list. I also tried keeping livegetenv()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):Cold (
php bench-cold.php /path/to/CodeIgniter4 [overrides], run 41 times and take the median):Tests
New tests in
BaseConfigTestcover:$_ENV/$_SERVERwrites after a build being picked upreset()picking up a bareputenv()getenv()_getenv()-only value not being reusedThe full suite has the same failures as plain
4.8locally: SQLSRV and Predis are unavailable, and a fewFeatureTestTraittests fail only when run in the full suite.Checklist:
🤖 Generated with Claude Code