From a0190c5d509c5294c93eb374becc078b56af92bc Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Sun, 25 Oct 2026 00:32:08 +0200 Subject: [PATCH] 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 =========================================