From a0190c5d509c5294c93eb374becc078b56af92bc Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Sun, 25 Oct 2026 00:32:08 +0200 Subject: [PATCH 1/4] perf(#10615): scan the environment once when building config 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 --- system/Config/BaseConfig.php | 295 +++++++++++++++--- tests/system/Config/BaseConfigTest.php | 132 ++++++++ .../Config/fixtures/CustomEnvHookConfig.php | 29 ++ .../system/Config/fixtures/EnvIndexConfig.php | 27 ++ tests/system/Config/fixtures/Snake_Config.php | 19 ++ user_guide_src/source/changelogs/v4.8.0.rst | 4 + .../source/installation/upgrade_480.rst | 21 ++ 7 files changed, 480 insertions(+), 47 deletions(-) create mode 100644 tests/system/Config/fixtures/CustomEnvHookConfig.php create mode 100644 tests/system/Config/fixtures/EnvIndexConfig.php create mode 100644 tests/system/Config/fixtures/Snake_Config.php diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 6c90c2394847..a4ef961e1fcc 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -18,6 +18,7 @@ use Config\Modules; use ReflectionClass; use ReflectionException; +use ReflectionMethod; /** * Class BaseConfig @@ -69,6 +70,36 @@ class BaseConfig */ protected static $moduleConfig; + /** + * Environment variables grouped by the text before their first "." or "_", + * then by source ("env", "server", "getenv"). + * + * @var array>>|null + */ + private static ?array $envIndex = null; + + /** + * The [$_ENV, $_SERVER] arrays the index was built from. + * + * @var array{array, array}|null + */ + private static ?array $envSnapshot = null; + + /** + * Env vars found with live getenv() calls on each class's first build, keyed + * by class name. The getenv() array omits names that contain ".", " " or "[". + * + * @var array> + */ + private static array $unlistedEnvVars = []; + + /** + * Whether a class overrides getEnvValue() or initEnvValue(), keyed by class name. + * + * @var array + */ + private static array $overridesEnvHooks = []; + /** * @param array $array */ @@ -106,6 +137,9 @@ public static function reset(): void static::$override = true; static::$didDiscovery = false; static::$moduleConfig = null; + + self::$envIndex = self::$envSnapshot = null; + self::$unlistedEnvVars = []; } /** @@ -124,13 +158,31 @@ public function __construct() $this->registerProperties(); - $properties = array_keys(get_object_vars($this)); + $values = get_object_vars($this); + $properties = array_keys($values); $prefix = static::class; $slashAt = strrpos($prefix, '\\'); $shortPrefix = strtolower(substr($prefix, $slashAt === false ? 0 : $slashAt + 1)); + // Classes with custom env hooks keep the full walk with live lookups. + $sources = (self::$overridesEnvHooks[static::class] ??= self::overridesEnvHooks(static::class)) + ? null + : self::scopedEnvSources($prefix, $shortPrefix); + + if ($sources !== null) { + $sources['getenv'] = (self::$unlistedEnvVars[static::class] ??= self::findUnlistedEnvVars($values, $prefix, $shortPrefix)) + + $sources['getenv']; + + // Every env var that can override this class, for a fast miss check per leaf. + $candidates = $sources['env'] + $sources['server'] + $sources['getenv']; + } + foreach ($properties as $property) { - $this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix); + if ($sources === null) { + $this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix); + } elseif ($candidates !== []) { + $this->initScopedEnvValue($sources, $candidates, $this->{$property}, $property, $prefix, $shortPrefix); + } if ($this instanceof Encryption) { if ($property === 'key') { @@ -179,75 +231,224 @@ protected function initEnvValue(&$property, string $name, string $prefix, string $this->initEnvValue($property[$key], "{$name}.{$key}", $prefix, $shortPrefix); } } elseif (($value = $this->getEnvValue($name, $prefix, $shortPrefix)) !== false && $value !== null) { - if ($value === 'false') { - $value = false; - } elseif ($value === 'true') { - $value = true; - } - if (is_bool($value)) { - $property = $value; + // If the default value of the property is `null` and the type is not + // `string`, TypeError will happen. + // So cannot set `declare(strict_types=1)` in this file. + $property = self::castEnvValue($value, $property); + } + } - return; + /** + * Same as initEnvValue(), but looks values up in the pre-filtered sources + * from scopedEnvSources() instead of the live environment. + * + * @param array{env: array, server: array, getenv: array} $sources + * @param array $candidates All keys of $sources + * @param array|bool|float|int|string|null $property + */ + private function initScopedEnvValue(array $sources, array $candidates, &$property, string $name, string $prefix, string $shortPrefix): void + { + if (is_array($property)) { + foreach (array_keys($property) as $key) { + $this->initScopedEnvValue($sources, $candidates, $property[$key], "{$name}.{$key}", $prefix, $shortPrefix); } - $value = trim($value, '\'"'); + return; + } - if (is_int($property)) { - $value = (int) $value; - } elseif (is_float($property)) { - $value = (float) $value; - } + $keys = self::envKeys($name, $prefix, $shortPrefix); - // If the default value of the property is `null` and the type is not - // `string`, TypeError will happen. - // So cannot set `declare(strict_types=1)` in this file. - $property = $value; + if (! array_key_exists($keys[0], $candidates) && ! array_key_exists($keys[1], $candidates) + && ! array_key_exists($keys[2], $candidates) && ! array_key_exists($keys[3], $candidates)) { + return; + } + + if (($value = self::lookupEnvValue($sources['env'], $sources['server'], $sources['getenv'], $keys)) !== false && $value !== null) { + $property = self::castEnvValue($value, $property); } } /** - * Retrieve an environment-specific configuration setting + * Converts a raw env value to the type of the property's default value. * - * @return string|null + * @param bool|float|int|string|null $default + * + * @return bool|float|int|string */ - protected function getEnvValue(string $property, string $prefix, string $shortPrefix) + private static function castEnvValue(mixed $value, $default) + { + if ($value === 'false') { + return false; + } + + if ($value === 'true') { + return true; + } + + if (is_bool($value)) { + return $value; + } + + $value = trim($value, '\'"'); + + if (is_int($default)) { + return (int) $value; + } + + if (is_float($default)) { + return (float) $value; + } + + return $value; + } + + /** + * The env var names for a property, in lookup order: short prefix dot and + * underscore forms, then full prefix dot and underscore forms. + * + * @return array{string, string, string, string} + */ + private static function envKeys(string $property, string $prefix, string $shortPrefix): array { - $shortPrefix = ltrim($shortPrefix, '\\'); $underscoreProperty = str_replace('.', '_', $property); - switch (true) { - case array_key_exists("{$shortPrefix}.{$property}", $_ENV): - return $_ENV["{$shortPrefix}.{$property}"]; + return [ + "{$shortPrefix}.{$property}", + "{$shortPrefix}_{$underscoreProperty}", + "{$prefix}.{$property}", + "{$prefix}_{$underscoreProperty}", + ]; + } + + /** + * Finds the first of the env var names from envKeys(). For each prefix, + * $_ENV wins over $_SERVER. getenv() is checked last, and is read live + * when $getenv is null. + * + * @param array $env + * @param array $server + * @param array|null $getenv + * @param array{string, string, string, string} $keys + */ + private static function lookupEnvValue(array $env, array $server, ?array $getenv, array $keys): mixed + { + foreach ([[$keys[0], $keys[1]], [$keys[2], $keys[3]]] as $prefixKeys) { + foreach ([$env, $server] as $source) { + foreach ($prefixKeys as $key) { + if (array_key_exists($key, $source)) { + return $source[$key]; + } + } + } + } + + foreach ($keys as $key) { + if (($value = $getenv === null ? getenv($key) : ($getenv[$key] ?? false)) !== false) { + return $value; + } + } + + return null; + } - case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_ENV): - return $_ENV["{$shortPrefix}_{$underscoreProperty}"]; + /** + * Returns the env vars that can override a class with these prefixes, by source. + * + * @return array{env: array, server: array, getenv: array} + */ + private static function scopedEnvSources(string $prefix, string $shortPrefix): array + { + // Unchanged superglobals share memory with the snapshot, so this check is cheap. + if ([$_ENV, $_SERVER] !== self::$envSnapshot) { + self::$envIndex = []; - case array_key_exists("{$shortPrefix}.{$property}", $_SERVER): - return $_SERVER["{$shortPrefix}.{$property}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) + foreach (['env' => $_ENV, 'server' => $_SERVER, 'getenv' => getenv()] as $source => $vars) { + foreach ($vars as $key => $value) { + self::$envIndex[self::envGroup((string) $key)][$source][$key] = $value; + } + } + } + + // Re-share memory even when only equal contents were written. + self::$envSnapshot = [$_ENV, $_SERVER]; + + $sources = ['env' => [], 'server' => [], 'getenv' => []]; + + foreach ([$shortPrefix, $prefix] as $p) { + // The prefix itself may contain "_", so match within its group. + foreach (self::$envIndex[self::envGroup($p)] ?? [] as $source => $vars) { + foreach ($vars as $key => $value) { + if (str_starts_with((string) $key, "{$p}.") || str_starts_with((string) $key, "{$p}_")) { + $sources[$source][$key] = $value; + } + } + } + } - case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_SERVER): - return $_SERVER["{$shortPrefix}_{$underscoreProperty}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) + return $sources; + } - case array_key_exists("{$prefix}.{$property}", $_ENV): - return $_ENV["{$prefix}.{$property}"]; + /** + * Looks up, with getenv(), every leaf's env var names that the getenv() + * array omits. + * + * @param array $values + * + * @return array + */ + private static function findUnlistedEnvVars(array $values, string $prefix, string $shortPrefix, string $parent = ''): array + { + $found = []; - case array_key_exists("{$prefix}_{$underscoreProperty}", $_ENV): - return $_ENV["{$prefix}_{$underscoreProperty}"]; + foreach ($values as $key => $value) { + $name = $parent === '' ? (string) $key : "{$parent}.{$key}"; - case array_key_exists("{$prefix}.{$property}", $_SERVER): - return $_SERVER["{$prefix}.{$property}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) + if (is_array($value)) { + $found += self::findUnlistedEnvVars($value, $prefix, $shortPrefix, $name); - case array_key_exists("{$prefix}_{$underscoreProperty}", $_SERVER): - return $_SERVER["{$prefix}_{$underscoreProperty}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) + continue; + } - default: - $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); - return $value === false ? null : $value; + foreach ($envKeys as $envKey) { + if (($envValue = getenv($envKey)) !== false) { + $found[$envKey] = $envValue; + } + } } + + return $found; + } + + private static function envGroup(string $key): string + { + return substr($key, 0, strcspn($key, '._')); + } + + /** + * @param class-string $class + */ + private static function overridesEnvHooks(string $class): bool + { + return (new ReflectionMethod($class, 'getEnvValue'))->class !== self::class + || (new ReflectionMethod($class, 'initEnvValue'))->class !== self::class; + } + + /** + * Retrieve an environment-specific configuration setting + * + * @return string|null + */ + protected function getEnvValue(string $property, string $prefix, string $shortPrefix) + { + $keys = self::envKeys($property, $prefix, ltrim($shortPrefix, '\\')); + + return self::lookupEnvValue($_ENV, $_SERVER, null, $keys); } /** diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index cb3503c3fb8a..e40c93e9ec27 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -18,8 +18,11 @@ use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; +use CustomEnvHookConfig; use Encryption; +use EnvIndexConfig; use MergeRegistrarConfig; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -27,6 +30,7 @@ use PHPUnit\Framework\MockObject\MockObject; use RegistrarConfig; use SimpleConfig; +use Snake_Config; use Tests\Support\Config\BadRegistrar; use Tests\Support\Config\MergeOrderRegistrar; use Tests\Support\Config\MergePlainRegistrar; @@ -67,6 +71,12 @@ protected function setUp(): void require $this->fixturesFolder . '/Encryption.php'; } + foreach (['EnvIndexConfig', 'Snake_Config', 'CustomEnvHookConfig'] as $class) { + if (! class_exists($class, false)) { + require $this->fixturesFolder . '/' . $class . '.php'; + } + } + BaseConfig::reset(); Services::injectMock('superglobals', new Superglobals()); @@ -93,6 +103,15 @@ private function clearEnvironmentOverrides(): void 'SimpleConfig.QZEROSTR', 'SimpleConfig.QEMPTYSTR', 'SimpleConfig.QFALSE', + 'envindexconfig.name', + 'envindexconfig_name', + 'EnvIndexConfig.name', + 'envindexconfig.list.key', + 'envindexconfig.list.missing', + 'envindexconfig_list_some key', + 'snake_config.name', + 'Snake_Config_name', + 'customenvhookconfig.name', ] as $key) { putenv($key); unset($_ENV[$key]); @@ -425,4 +444,117 @@ public function testRedoingDiscoveryWillStillSetDidDiscoveryPropertyToTrue(): vo $this->assertTrue($this->getPrivateProperty($config, 'didDiscovery')); } + + public function testEnvWritesAfterConstructionArePickedUp(): void + { + $this->assertSame('default', (new EnvIndexConfig())->name); + + $_ENV['envindexconfig.name'] = 'from-env'; + $this->assertSame('from-env', (new EnvIndexConfig())->name); + + unset($_ENV['envindexconfig.name']); + service('superglobals')->setServer('envindexconfig.name', 'from-server'); + $this->assertSame('from-server', (new EnvIndexConfig())->name); + } + + public function testResetPicksUpBarePutenv(): void + { + $this->assertSame('default', (new EnvIndexConfig())->name); + + putenv('envindexconfig.name=from-getenv'); + BaseConfig::reset(); + + $this->assertSame('from-getenv', (new EnvIndexConfig())->name); + } + + public function testOverriddenEnvHookIsCalledAndReadsLiveGetenv(): void + { + $config = new CustomEnvHookConfig(); + + $this->assertSame('from-hook', $config->hooked); + $this->assertSame('default', $config->name); + + putenv('customenvhookconfig.name=live'); + + $this->assertSame('live', (new CustomEnvHookConfig())->name); + } + + public function testShortPrefixWithUnderscore(): void + { + $_ENV['snake_config.name'] = 'short'; + $this->assertSame('short', (new Snake_Config())->name); + + unset($_ENV['snake_config.name']); + $_ENV['Snake_Config_name'] = 'full'; + $this->assertSame('full', (new Snake_Config())->name); + } + + /** + * @param array $env + * @param array $server + * @param array $getenv + */ + #[DataProvider('provideEnvPrecedence')] + public function testEnvPrecedence(array $env, array $server, array $getenv, string $expected): void + { + foreach ($env as $key => $value) { + $_ENV[$key] = $value; + } + + foreach ($server as $key => $value) { + service('superglobals')->setServer($key, $value); + } + + foreach ($getenv as $key => $value) { + putenv("{$key}={$value}"); + } + + $this->assertSame($expected, (new EnvIndexConfig())->name); + } + + /** + * @return iterable, array, array, string}> + */ + public static function provideEnvPrecedence(): iterable + { + yield 'short prefix in $_SERVER beats full prefix in $_ENV' => [ + ['EnvIndexConfig.name' => 'full'], ['envindexconfig.name' => 'short'], [], 'short', + ]; + + yield '$_ENV beats $_SERVER' => [ + ['envindexconfig.name' => 'env'], ['envindexconfig.name' => 'server'], [], 'env', + ]; + + yield '$_SERVER beats getenv()' => [ + [], ['envindexconfig.name' => 'server'], ['envindexconfig.name' => 'getenv'], 'server', + ]; + + yield 'full prefix in $_ENV beats short prefix in getenv()' => [ + ['EnvIndexConfig.name' => 'full'], [], ['envindexconfig.name' => 'getenv'], 'full', + ]; + + yield 'dot form beats underscore form' => [ + ['envindexconfig_name' => 'underscore', 'envindexconfig.name' => 'dot'], [], [], 'dot', + ]; + + yield 'getenv() only' => [ + [], [], ['envindexconfig.name' => 'getenv'], 'getenv', + ]; + } + + public function testArrayOverridesOnlyApplyToExistingKeys(): void + { + $_ENV['envindexconfig.list.key'] = 'overridden'; + $_ENV['envindexconfig.list.missing'] = 'ignored'; + + $this->assertSame(['key' => 'overridden', 'some key' => 'default'], (new EnvIndexConfig())->list); + } + + public function testGetenvOnlyNameWithSpace(): void + { + // The getenv() array omits names with a space, like names with a dot. + putenv('envindexconfig_list_some key=from-getenv'); + + $this->assertSame('from-getenv', (new EnvIndexConfig())->list['some key']); + } } diff --git a/tests/system/Config/fixtures/CustomEnvHookConfig.php b/tests/system/Config/fixtures/CustomEnvHookConfig.php new file mode 100644 index 000000000000..a8fee8ae0301 --- /dev/null +++ b/tests/system/Config/fixtures/CustomEnvHookConfig.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Config\BaseConfig; + +class CustomEnvHookConfig extends BaseConfig +{ + public string $name = 'default'; + public string $hooked = 'default'; + + protected function getEnvValue(string $property, string $prefix, string $shortPrefix) + { + if ($property === 'hooked') { + return 'from-hook'; + } + + return parent::getEnvValue($property, $prefix, $shortPrefix); + } +} diff --git a/tests/system/Config/fixtures/EnvIndexConfig.php b/tests/system/Config/fixtures/EnvIndexConfig.php new file mode 100644 index 000000000000..4e2d7132e0b8 --- /dev/null +++ b/tests/system/Config/fixtures/EnvIndexConfig.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Config\BaseConfig; + +class EnvIndexConfig extends BaseConfig +{ + public string $name = 'default'; + + /** + * @var array + */ + public array $list = [ + 'key' => 'default', + 'some key' => 'default', + ]; +} diff --git a/tests/system/Config/fixtures/Snake_Config.php b/tests/system/Config/fixtures/Snake_Config.php new file mode 100644 index 000000000000..a0f96a32b0fd --- /dev/null +++ b/tests/system/Config/fixtures/Snake_Config.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Config\BaseConfig; + +class Snake_Config extends BaseConfig +{ + public string $name = 'default'; +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index ddfbee6915bb..916769478ed9 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -62,6 +62,10 @@ Behavior Changes - **Commands:** The ``worker:uninstall`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. In non-interactive mode without ``--force`` it now aborts with ``EXIT_ERROR`` instead of prompting, and the redundant ``Uninstalling FrankenPHP Worker Mode`` header was dropped. Scripts that branch on the previous exit code or wording will need updating. - **Commands:** The ``phpini:check`` command's invalid-argument error now prints only ``You must specify a correct argument.``. The usage example and the argument listing moved to ``phpini:check --help``, where the argument is now named ``section``. Scripts that grep the previous output will need updating. - **Config:** ``CodeIgniter\Config\BaseService`` is now ``abstract``. It is intended to be extended (as ``Config\Services`` does) and accessed statically; code that instantiated it directly with ``new BaseService()`` will now throw an ``Error``. +- **Config:** ``BaseConfig`` now scans the environment once instead of checking every property against it, which makes building config classes + several times faster without config caching and in Worker Mode. As a result, a bare ``putenv()`` call made after the first config class was built + may not be seen until ``BaseConfig::reset()`` runs, and on Windows, underscore-form environment variables that exist only in the OS environment + now match case-sensitively. See :ref:`upgrade-480-config-env-lookups`. - **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Debug:** ``CodeIgniter\Debug\Toolbar\Collectors\BaseCollector`` is now ``abstract``. Custom toolbar collectors must extend it; code that instantiated it directly with ``new BaseCollector()`` will now throw an ``Error``. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index c71011e6bc9c..5c721cd37ff2 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -160,6 +160,27 @@ RedirectException Default Status Code protected int $defaultStatusCode = 307; } +.. _upgrade-480-config-env-lookups: + +Config Environment Lookups +========================== + +``BaseConfig`` now reads the environment once and reuses the result, instead of +looking up every property of every config class. The matching rules and their +order are unchanged. Two edge cases behave differently: + +- A ``putenv()`` call that does not also write ``$_ENV`` or ``$_SERVER``, made + after the first config class was built, may not be seen by config classes + built later until ``BaseConfig::reset()`` runs. Values loaded from the + **.env** file are not affected, since they are written to ``$_ENV`` and + ``$_SERVER`` as well. +- On Windows, environment variables in the underscore form (e.g., + ``app_baseURL``) that exist only in the OS environment now match + case-sensitively, like the values from the **.env** file already did. + +Config classes that override ``getEnvValue()`` or ``initEnvValue()`` keep the +previous behavior. + RouteCollection Identifier Transformation ========================================= From ec9311bf6c28312dd767f9cc0f9fa77c80c044c5 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Tue, 27 Oct 2026 23:39:39 +0100 Subject: [PATCH 2/4] fix(#10615): re-read live env vars on every config build 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 --- system/Config/BaseConfig.php | 77 +++++++++++++------ tests/system/Config/BaseConfigTest.php | 29 ++++++- .../Config/fixtures/GrowingEnvConfig.php | 37 +++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 3 +- .../source/installation/upgrade_480.rst | 15 ++-- 5 files changed, 126 insertions(+), 35 deletions(-) create mode 100644 tests/system/Config/fixtures/GrowingEnvConfig.php diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index a4ef961e1fcc..916370ef4bcf 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -86,12 +86,22 @@ class BaseConfig private static ?array $envSnapshot = null; /** - * Env vars found with live getenv() calls on each class's first build, keyed - * by class name. The getenv() array omits names that contain ".", " " or "[". + * Env var names that exist according to live getenv() calls, keyed by class + * name, then by leaf name. Each leaf is probed once; its names are re-read on + * every build. The getenv() array omits names that contain ".", " " or "[", + * and on Windows only live calls match names case-insensitively. * - * @var array> + * @var array>> */ - private static array $unlistedEnvVars = []; + private static array $liveEnvNames = []; + + /** + * The property values of each class's last build, and the env var names of + * all its leaves from $liveEnvNames, keyed by class name. + * + * @var array, list}> + */ + private static array $liveEnvLeaves = []; /** * Whether a class overrides getEnvValue() or initEnvValue(), keyed by class name. @@ -138,8 +148,8 @@ public static function reset(): void static::$didDiscovery = false; static::$moduleConfig = null; - self::$envIndex = self::$envSnapshot = null; - self::$unlistedEnvVars = []; + self::$envIndex = self::$envSnapshot = null; + self::$liveEnvNames = self::$liveEnvLeaves = []; } /** @@ -170,8 +180,21 @@ public function __construct() : self::scopedEnvSources($prefix, $shortPrefix); if ($sources !== null) { - $sources['getenv'] = (self::$unlistedEnvVars[static::class] ??= self::findUnlistedEnvVars($values, $prefix, $shortPrefix)) - + $sources['getenv']; + // Unchanged property values share memory with the stored ones, so the + // leaves are only walked again when a build changes them. + if (($leaves = self::$liveEnvLeaves[static::class] ?? null) === null || $leaves[0] !== $values) { + $leaves = self::$liveEnvLeaves[static::class] = [$values, self::findLiveEnvNames($values, $prefix, $shortPrefix)]; + } + + $live = []; + + foreach ($leaves[1] as $envKey) { + if (($envValue = getenv($envKey)) !== false) { + $live[$envKey] = $envValue; + } + } + + $sources['getenv'] = $live + $sources['getenv']; // Every env var that can override this class, for a fast miss check per leaf. $candidates = $sources['env'] + $sources['server'] + $sources['getenv']; @@ -389,14 +412,15 @@ private static function scopedEnvSources(string $prefix, string $shortPrefix): a } /** - * Looks up, with getenv(), every leaf's env var names that the getenv() - * array omits. + * Returns every leaf's env var names that exist according to live getenv() + * calls. Only leaves not seen before are probed. * + * @param class-string $prefix * @param array $values * - * @return array + * @return list */ - private static function findUnlistedEnvVars(array $values, string $prefix, string $shortPrefix, string $parent = ''): array + private static function findLiveEnvNames(array $values, string $prefix, string $shortPrefix, string $parent = ''): array { $found = []; @@ -404,22 +428,31 @@ private static function findUnlistedEnvVars(array $values, string $prefix, strin $name = $parent === '' ? (string) $key : "{$parent}.{$key}"; if (is_array($value)) { - $found += self::findUnlistedEnvVars($value, $prefix, $shortPrefix, $name); + array_push($found, ...self::findLiveEnvNames($value, $prefix, $shortPrefix, $name)); continue; } - // 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); - - foreach ($envKeys as $envKey) { - if (($envValue = getenv($envKey)) !== false) { - $found[$envKey] = $envValue; + if (! isset(self::$liveEnvNames[$prefix][$name])) { + self::$liveEnvNames[$prefix][$name] = []; + + // Dot forms are never in the getenv() array, and underscore forms + // only miss it when the name has " " or "[". On Windows, env var + // names are case-insensitive, but only live getenv() calls match + // them that way, so every form is probed there. Elsewhere that + // would double the getenv() calls on a cold build for no gain. + $envKeys = PHP_OS_FAMILY === 'Windows' || strpbrk($name, ' [') !== false + ? self::envKeys($name, $prefix, $shortPrefix) + : ["{$shortPrefix}.{$name}", "{$prefix}.{$name}"]; + + foreach ($envKeys as $envKey) { + if (getenv($envKey) !== false) { + self::$liveEnvNames[$prefix][$name][] = $envKey; + } } } + + array_push($found, ...self::$liveEnvNames[$prefix][$name]); } return $found; diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index e40c93e9ec27..31dc1e0c1687 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -21,6 +21,7 @@ use CustomEnvHookConfig; use Encryption; use EnvIndexConfig; +use GrowingEnvConfig; use MergeRegistrarConfig; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -71,7 +72,7 @@ protected function setUp(): void require $this->fixturesFolder . '/Encryption.php'; } - foreach (['EnvIndexConfig', 'Snake_Config', 'CustomEnvHookConfig'] as $class) { + foreach (['EnvIndexConfig', 'Snake_Config', 'CustomEnvHookConfig', 'GrowingEnvConfig'] as $class) { if (! class_exists($class, false)) { require $this->fixturesFolder . '/' . $class . '.php'; } @@ -87,6 +88,7 @@ protected function tearDown(): void parent::tearDown(); $this->clearEnvironmentOverrides(); + GrowingEnvConfig::$grow = false; // This test modifies BaseConfig::$modules, so should reset. BaseConfig::reset(); // This test modifies Services locator, so should reset. @@ -112,6 +114,7 @@ private function clearEnvironmentOverrides(): void 'snake_config.name', 'Snake_Config_name', 'customenvhookconfig.name', + 'growingenvconfig.list.added', ] as $key) { putenv($key); unset($_ENV[$key]); @@ -557,4 +560,28 @@ public function testGetenvOnlyNameWithSpace(): void $this->assertSame('from-getenv', (new EnvIndexConfig())->list['some key']); } + + public function testRemovedGetenvOnlyNameIsNotReused(): void + { + putenv('envindexconfig.name=old'); + $_ENV['envindexconfig.name'] = 'old'; + service('superglobals')->setServer('envindexconfig.name', 'old'); + $this->assertSame('old', (new EnvIndexConfig())->name); + + putenv('envindexconfig.name'); + unset($_ENV['envindexconfig.name']); + service('superglobals')->unsetServer('envindexconfig.name'); + + $this->assertSame('default', (new EnvIndexConfig())->name); + } + + public function testGetenvOnlyNameForLeafAddedOnLaterInstance(): void + { + putenv('growingenvconfig.list.added=from-getenv'); + $this->assertSame(['key' => 'default'], (new GrowingEnvConfig())->list); + + GrowingEnvConfig::$grow = true; + + $this->assertSame(['key' => 'default', 'added' => 'from-getenv'], (new GrowingEnvConfig())->list); + } } diff --git a/tests/system/Config/fixtures/GrowingEnvConfig.php b/tests/system/Config/fixtures/GrowingEnvConfig.php new file mode 100644 index 000000000000..1f4a7948ee99 --- /dev/null +++ b/tests/system/Config/fixtures/GrowingEnvConfig.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Config\BaseConfig; + +/** + * Adds an array leaf only when $grow is set, like a constructor or registrar + * that changes the shape between instances. + */ +class GrowingEnvConfig extends BaseConfig +{ + public static bool $grow = false; + + /** + * @var array + */ + public array $list = ['key' => 'default']; + + public function __construct() + { + if (self::$grow) { + $this->list['added'] = 'default'; + } + + parent::__construct(); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 916769478ed9..153c5439e6db 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -64,8 +64,7 @@ Behavior Changes - **Config:** ``CodeIgniter\Config\BaseService`` is now ``abstract``. It is intended to be extended (as ``Config\Services`` does) and accessed statically; code that instantiated it directly with ``new BaseService()`` will now throw an ``Error``. - **Config:** ``BaseConfig`` now scans the environment once instead of checking every property against it, which makes building config classes several times faster without config caching and in Worker Mode. As a result, a bare ``putenv()`` call made after the first config class was built - may not be seen until ``BaseConfig::reset()`` runs, and on Windows, underscore-form environment variables that exist only in the OS environment - now match case-sensitively. See :ref:`upgrade-480-config-env-lookups`. + may not be seen until ``BaseConfig::reset()`` runs. See :ref:`upgrade-480-config-env-lookups`. - **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Debug:** ``CodeIgniter\Debug\Toolbar\Collectors\BaseCollector`` is now ``abstract``. Custom toolbar collectors must extend it; code that instantiated it directly with ``new BaseCollector()`` will now throw an ``Error``. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index 5c721cd37ff2..222fd94b6ffd 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -167,16 +167,11 @@ Config Environment Lookups ``BaseConfig`` now reads the environment once and reuses the result, instead of looking up every property of every config class. The matching rules and their -order are unchanged. Two edge cases behave differently: - -- A ``putenv()`` call that does not also write ``$_ENV`` or ``$_SERVER``, made - after the first config class was built, may not be seen by config classes - built later until ``BaseConfig::reset()`` runs. Values loaded from the - **.env** file are not affected, since they are written to ``$_ENV`` and - ``$_SERVER`` as well. -- On Windows, environment variables in the underscore form (e.g., - ``app_baseURL``) that exist only in the OS environment now match - case-sensitively, like the values from the **.env** file already did. +order are unchanged. One edge case behaves differently: a ``putenv()`` call +that does not also write ``$_ENV`` or ``$_SERVER``, made after the first config +class was built, may not be seen by config classes built later until +``BaseConfig::reset()`` runs. Values loaded from the **.env** file are not +affected, since they are written to ``$_ENV`` and ``$_SERVER`` as well. Config classes that override ``getEnvValue()`` or ``initEnvValue()`` keep the previous behavior. From e688569e6559eb182ea7fa126a29c31332a8a352 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Wed, 28 Oct 2026 21:55:14 +0100 Subject: [PATCH 3/4] fix(#10615): probe underscore env names live under Apache 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 --- system/Config/BaseConfig.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 916370ef4bcf..bfe40e25a292 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -33,6 +33,13 @@ */ class BaseConfig { + /** + * SAPIs whose live getenv() only finds names, with the exact case, that are + * also in $_SERVER or the getenv() array: the OS environment, plus FastCGI + * params under FPM and FrankenPHP's own env layers. + */ + private const EXACT_ENV_SAPIS = ['cli', 'phpdbg', 'cli-server', 'fpm-fcgi', 'frankenphp']; + /** * An optional array of classes that will act as Registrars * for rapidly setting config class properties. @@ -437,11 +444,15 @@ private static function findLiveEnvNames(array $values, string $prefix, string $ self::$liveEnvNames[$prefix][$name] = []; // Dot forms are never in the getenv() array, and underscore forms - // only miss it when the name has " " or "[". On Windows, env var - // names are case-insensitive, but only live getenv() calls match - // them that way, so every form is probed there. Elsewhere that - // would double the getenv() calls on a cold build for no gain. - $envKeys = PHP_OS_FAMILY === 'Windows' || strpbrk($name, ' [') !== false + // only miss it when the name has " " or "[". Live getenv() calls + // also match names case-insensitively on Windows, and under + // Apache mod_php, where they read SetEnv vars (e.g. APP_BASEURL + // for app_baseURL). So every form is probed there, and under + // any SAPI not known to match exactly. Elsewhere that would + // double the getenv() calls on a cold build for no gain. + $inexactEnv = PHP_OS_FAMILY === 'Windows' || ! in_array(PHP_SAPI, self::EXACT_ENV_SAPIS, true); + + $envKeys = $inexactEnv || strpbrk($name, ' [') !== false ? self::envKeys($name, $prefix, $shortPrefix) : ["{$shortPrefix}.{$name}", "{$prefix}.{$name}"]; From 348bcca5d8c32e99e49470a498a48c2b5b028b4e Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 29 Oct 2026 10:52:32 +0100 Subject: [PATCH 4/4] docs(#10615): clarify putenv() cache limits in Worker Mode Co-Authored-By: Claude Opus 5.5 --- user_guide_src/source/changelogs/v4.8.0.rst | 4 ++-- user_guide_src/source/installation/upgrade_480.rst | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 153c5439e6db..9f6bb977051c 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -63,8 +63,8 @@ Behavior Changes - **Commands:** The ``phpini:check`` command's invalid-argument error now prints only ``You must specify a correct argument.``. The usage example and the argument listing moved to ``phpini:check --help``, where the argument is now named ``section``. Scripts that grep the previous output will need updating. - **Config:** ``CodeIgniter\Config\BaseService`` is now ``abstract``. It is intended to be extended (as ``Config\Services`` does) and accessed statically; code that instantiated it directly with ``new BaseService()`` will now throw an ``Error``. - **Config:** ``BaseConfig`` now scans the environment once instead of checking every property against it, which makes building config classes - several times faster without config caching and in Worker Mode. As a result, a bare ``putenv()`` call made after the first config class was built - may not be seen until ``BaseConfig::reset()`` runs. See :ref:`upgrade-480-config-env-lookups`. + several times faster without config caching and in Worker Mode. As a result, a bare ``putenv()`` call (adding, changing, or removing a variable) made + after the first config class was built may not be seen until ``BaseConfig::reset()`` runs, including in later requests in Worker Mode. See :ref:`upgrade-480-config-env-lookups`. - **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Debug:** ``CodeIgniter\Debug\Toolbar\Collectors\BaseCollector`` is now ``abstract``. Custom toolbar collectors must extend it; code that instantiated it directly with ``new BaseCollector()`` will now throw an ``Error``. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index 222fd94b6ffd..581d8a824ebc 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -170,7 +170,11 @@ looking up every property of every config class. The matching rules and their order are unchanged. One edge case behaves differently: a ``putenv()`` call that does not also write ``$_ENV`` or ``$_SERVER``, made after the first config class was built, may not be seen by config classes built later until -``BaseConfig::reset()`` runs. Values loaded from the **.env** file are not +``BaseConfig::reset()`` runs. This applies whether the variable is added, +changed, or removed. In Worker Mode, this cache persists across requests, so +such changes may not be seen in later requests either. To make the change +visible, also set the value in ``$_ENV`` (or unset it there), or call +``BaseConfig::reset()``. Values loaded from the **.env** file are not affected, since they are written to ``$_ENV`` and ``$_SERVER`` as well. Config classes that override ``getEnvValue()`` or ``initEnvValue()`` keep the