From 51f3f7c93c7ef576bdc82deb8e41161dd9586b6b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 6 Apr 2026 11:26:53 +0200 Subject: [PATCH 1/6] Add deprecationTrigger ignoreUndefinedTriggers="true" in phpunit.xml.dist files --- phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fdbf278..5040e32 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,7 +20,7 @@ - + trigger_deprecation Doctrine\Deprecations\Deprecation::trigger Doctrine\Deprecations\Deprecation::triggerIfCalledFromOutside From 551a80e5c89c074ccafbcaf93f21bd0cb307ac3a Mon Sep 17 00:00:00 2001 From: Pascal CESCON - Amoifr Date: Mon, 6 Apr 2026 19:12:55 +0200 Subject: [PATCH 2/6] [Dotenv] Fix self-referencing variable resolution with suffix/prefix --- Dotenv.php | 27 ++++++++++++++++++---- Tests/DotenvTest.php | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/Dotenv.php b/Dotenv.php index fa19650..b116112 100644 --- a/Dotenv.php +++ b/Dotenv.php @@ -36,6 +36,7 @@ final class Dotenv private string $data; private int $end; private array $values = []; + private array $overriddenValues = []; private string $envKey; private string $debugKey; private array $prodEnvs = ['prod']; @@ -644,7 +645,15 @@ private function doLoad(bool $overrideExistingVars, array $paths): void throw new FormatException('Loading files containing NUL bytes is not supported.', new FormatExceptionContext($data, $path, 1, 0)); } - $this->populate($this->parseRaw($data, $path), $overrideExistingVars); + $values = $this->parseRaw($data, $path); + + foreach ($values as $name => $_) { + if (!isset($this->overriddenValues[$name]) && isset($_ENV[$name])) { + $this->overriddenValues[$name] = $_ENV[$name]; + } + } + + $this->populate($values, $overrideExistingVars); } } @@ -731,10 +740,19 @@ private function resolveLoadedVars(): void if (isset($selfReferencingVars[$name])) { $envBackup = $_ENV[$name] ?? null; $serverBackup = $_SERVER[$name] ?? null; - unset($_ENV[$name], $_SERVER[$name]); + if (isset($this->overriddenValues[$name])) { + $_ENV[$name] = $this->overriddenValues[$name]; + $_SERVER[$name] = $this->overriddenValues[$name]; + } else { + unset($_ENV[$name], $_SERVER[$name]); + } if ($this->usePutenv) { - $getenvBackup = $this->usePutenv ? (string) getenv($name) : null; - putenv($name); + $getenvBackup = (string) getenv($name); + if (isset($this->overriddenValues[$name])) { + putenv("$name={$this->overriddenValues[$name]}"); + } else { + putenv($name); + } } } @@ -782,6 +800,7 @@ private function resolveLoadedVars(): void } $this->values = []; + $this->overriddenValues = []; unset($this->path, $this->data, $this->lineno, $this->cursor, $this->end); } } diff --git a/Tests/DotenvTest.php b/Tests/DotenvTest.php index 1cab74e..3de9d21 100644 --- a/Tests/DotenvTest.php +++ b/Tests/DotenvTest.php @@ -589,6 +589,60 @@ public function testLoadEnvSelfReferencingVariableWithDefault() @rmdir($tmpdir); } + public function testLoadSelfReferencingVariableWithSuffix() + { + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['MY_VAR']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['MY_VAR']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('MY_VAR'); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $basePath = tempnam($tmpdir, 'sf-'); + $overridePath = tempnam($tmpdir, 'sf-'); + + // Base file sets original value, override file appends suffix + file_put_contents($basePath, 'MY_VAR=original'); + file_put_contents($overridePath, 'MY_VAR="${MY_VAR}_suffix"'); + + $resetContext(); + $dotenv = (new Dotenv())->usePutenv(); + $dotenv->load($basePath); + $dotenv->load($overridePath); + + $this->assertSame('original_suffix', getenv('MY_VAR')); + + // Test with prefix instead of suffix + file_put_contents($overridePath, 'MY_VAR="prefix_${MY_VAR}"'); + + $resetContext(); + $dotenv = (new Dotenv())->usePutenv(); + $dotenv->load($basePath); + $dotenv->load($overridePath); + + $this->assertSame('prefix_original', getenv('MY_VAR')); + + // Test chained loads (three files) + $thirdPath = tempnam($tmpdir, 'sf-'); + file_put_contents($overridePath, 'MY_VAR="${MY_VAR}_middle"'); + file_put_contents($thirdPath, 'MY_VAR="${MY_VAR}_end"'); + + $resetContext(); + $dotenv = (new Dotenv())->usePutenv(); + $dotenv->load($basePath); + $dotenv->load($overridePath); + $dotenv->load($thirdPath); + + $this->assertSame('original_middle_end', getenv('MY_VAR')); + + $resetContext(); + unlink($basePath); + unlink($overridePath); + unlink($thirdPath); + @rmdir($tmpdir); + } + public function testLoadEnvSelfReferencingEnvKeyControlsFileLoading() { $resetContext = static function (): void { From 6e7b71881506c8a211a0e5ca93b3413d9151d99f Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 13 Apr 2026 14:48:23 +0200 Subject: [PATCH 3/6] [CS] Back config from 8.1 and apply heredoc_indentation rule --- Command/DebugCommand.php | 10 +++++----- Command/DotenvDumpCommand.php | 14 +++++++------- Tests/Command/DebugCommandTest.php | 22 +++++++++++----------- Tests/Command/DotenvDumpCommandTest.php | 14 +++++++------- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Command/DebugCommand.php b/Command/DebugCommand.php index b461c01..5592842 100644 --- a/Command/DebugCommand.php +++ b/Command/DebugCommand.php @@ -58,15 +58,15 @@ protected function configure(): void new InputArgument('filter', InputArgument::OPTIONAL, 'The name of an environment variable or a filter.', null, $this->getAvailableVars(...)), ]) ->setHelp(<<<'EOT' -The %command.full_name% command displays all the environment variables configured by dotenv: + The %command.full_name% command displays all the environment variables configured by dotenv: - php %command.full_name% + php %command.full_name% -To get specific variables, specify its full or partial name: + To get specific variables, specify its full or partial name: - php %command.full_name% FOO_BAR + php %command.full_name% FOO_BAR -EOT + EOT ); } diff --git a/Command/DotenvDumpCommand.php b/Command/DotenvDumpCommand.php index 815c8e4..998c395 100644 --- a/Command/DotenvDumpCommand.php +++ b/Command/DotenvDumpCommand.php @@ -54,10 +54,10 @@ protected function configure(): void ]) ->addOption('empty', null, InputOption::VALUE_NONE, 'Ignore the content of .env files') ->setHelp(<<<'EOT' -The %command.name% command compiles .env files into a PHP-optimized file called .env.local.php. + The %command.name% command compiles .env files into a PHP-optimized file called .env.local.php. - %command.full_name% -EOT + %command.full_name% + EOT ) ; } @@ -85,13 +85,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $vars = var_export($vars, true); $vars = <<writeln(\sprintf('Successfully dumped .env files in .env.local.php for the %s environment.', $env)); diff --git a/Tests/Command/DebugCommandTest.php b/Tests/Command/DebugCommandTest.php index 78e2b97..c03106b 100644 --- a/Tests/Command/DebugCommandTest.php +++ b/Tests/Command/DebugCommandTest.php @@ -50,17 +50,17 @@ public function testEmptyDotEnvVarsList() $tester = new CommandTester($command); $tester->execute([]); $expectedFormat = <<<'OUTPUT' -%a - ---------- ------- ------------ ------%S - Variable Value .env.local .env%S - ---------- ------- ------------ ------%S - FOO baz bar%S - TEST123 n/a true%S - ---------- ------- ------------ ------%S - - // Note that values might be different between web and CLI.%S -%a -OUTPUT; + %a + ---------- ------- ------------ ------%S + Variable Value .env.local .env%S + ---------- ------- ------------ ------%S + FOO baz bar%S + TEST123 n/a true%S + ---------- ------- ------------ ------%S + + // Note that values might be different between web and CLI.%S + %a + OUTPUT; $this->assertStringMatchesFormat($expectedFormat, $tester->getDisplay()); } diff --git a/Tests/Command/DotenvDumpCommandTest.php b/Tests/Command/DotenvDumpCommandTest.php index 44fc304..31807e9 100644 --- a/Tests/Command/DotenvDumpCommandTest.php +++ b/Tests/Command/DotenvDumpCommandTest.php @@ -21,14 +21,14 @@ class DotenvDumpCommandTest extends TestCase protected function setUp(): void { file_put_contents(__DIR__.'/.env', <<createCommand(); From ac4ed3ddc8d94851d8673d471c50bbd4addff4fe Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 14 Apr 2026 09:52:37 +0200 Subject: [PATCH 4/6] [Dotenv] Fix variable corruption when loading env more than once `resolveLoadedVars()` iterated over every name in `SYMFONY_DOTENV_VARS`, including vars resolved by a previous `load()`/`loadEnv()` call. By then, the `\x00` markers protecting literal `$` (from single-quoted values) had already been converted back to `$`, so a value like `This$isokay` was re-interpreted as a `$isokay` variable reference and truncated to `This`. Track the names actually populated during `doLoad()` and limit `resolveLoadedVars()` to that set, leaving previously-resolved values untouched. --- Dotenv.php | 26 ++++++++++++++------------ Tests/DotenvTest.php | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/Dotenv.php b/Dotenv.php index b116112..f9a08bd 100644 --- a/Dotenv.php +++ b/Dotenv.php @@ -37,6 +37,7 @@ final class Dotenv private int $end; private array $values = []; private array $overriddenValues = []; + private array $loadedRawVars = []; private string $envKey; private string $debugKey; private array $prodEnvs = ['prod']; @@ -647,10 +648,16 @@ private function doLoad(bool $overrideExistingVars, array $paths): void $values = $this->parseRaw($data, $path); + $loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '')); + unset($loadedVars['']); + foreach ($values as $name => $_) { if (!isset($this->overriddenValues[$name]) && isset($_ENV[$name])) { $this->overriddenValues[$name] = $_ENV[$name]; } + if (isset($loadedVars[$name]) || $overrideExistingVars || !isset($_ENV[$name])) { + $this->loadedRawVars[$name] = true; + } } $this->populate($values, $overrideExistingVars); @@ -706,6 +713,10 @@ private function resolveLoadedVars(): void $loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '')); unset($loadedVars['']); + $rawVars = $this->loadedRawVars; + $this->loadedRawVars = []; + unset($rawVars['SYMFONY_DOTENV_VARS']); + $this->values = []; $this->path = ''; $this->data = ''; @@ -717,10 +728,7 @@ private function resolveLoadedVars(): void // (e.g. MY_VAR="${MY_VAR:-default}") so their own raw value is hidden // during resolution, allowing the default to trigger correctly. $selfReferencingVars = []; - foreach ($loadedVars as $name => $_) { - if ('SYMFONY_DOTENV_VARS' === $name) { - continue; - } + foreach ($rawVars as $name => $_) { $value = $_ENV[$name] ?? ''; if (str_contains($value, '$') && preg_match('/\$\{?'.preg_quote($name, '/').'(?![A-Za-z0-9_])/', $value)) { $selfReferencingVars[$name] = true; @@ -729,10 +737,7 @@ private function resolveLoadedVars(): void for ($pass = 0; $pass < 5; ++$pass) { $resolved = []; - foreach ($loadedVars as $name => $_) { - if ('SYMFONY_DOTENV_VARS' === $name) { - continue; - } + foreach ($rawVars as $name => $_) { if (!str_contains($value = $_ENV[$name] ?? '', '$')) { continue; } @@ -786,10 +791,7 @@ private function resolveLoadedVars(): void // Restore literal $ signs and unescape backslashes $restored = []; - foreach ($loadedVars as $name => $_) { - if ('SYMFONY_DOTENV_VARS' === $name) { - continue; - } + foreach ($rawVars as $name => $_) { $value = $_ENV[$name] ?? ''; if ($value !== $newValue = str_replace(["\x00", '\\\\'], ['$', '\\'], $value)) { $restored[$name] = $newValue; diff --git a/Tests/DotenvTest.php b/Tests/DotenvTest.php index 3de9d21..c2b2468 100644 --- a/Tests/DotenvTest.php +++ b/Tests/DotenvTest.php @@ -238,6 +238,41 @@ public function testLoad() $this->assertSame('BAZ', $bar); } + public function testLoadDoesNotReResolveAlreadyLoadedVars() + { + unset($_ENV['FOO'], $_ENV['BAR'], $_ENV['SYMFONY_DOTENV_VARS']); + unset($_SERVER['FOO'], $_SERVER['BAR'], $_SERVER['SYMFONY_DOTENV_VARS']); + putenv('FOO'); + putenv('BAR'); + putenv('SYMFONY_DOTENV_VARS'); + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + + $path1 = tempnam($tmpdir, 'sf-'); + $path2 = tempnam($tmpdir, 'sf-'); + + file_put_contents($path1, "FOO='This\$isokay'"); + file_put_contents($path2, "BAR='hello'"); + + try { + (new Dotenv())->load($path1); + $this->assertSame('This$isokay', $_ENV['FOO']); + + (new Dotenv())->load($path2); + $this->assertSame('This$isokay', $_ENV['FOO']); + $this->assertSame('hello', $_ENV['BAR']); + } finally { + unset($_ENV['FOO'], $_ENV['BAR'], $_ENV['SYMFONY_DOTENV_VARS']); + unset($_SERVER['FOO'], $_SERVER['BAR'], $_SERVER['SYMFONY_DOTENV_VARS']); + putenv('FOO'); + putenv('BAR'); + putenv('SYMFONY_DOTENV_VARS'); + unlink($path1); + unlink($path2); + rmdir($tmpdir); + } + } + public function testLoadEnv() { $resetContext = static function (): void { From 3630228f1c442bc5b95772eb353138349e94c5fc Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 18 Apr 2026 15:16:27 +0200 Subject: [PATCH 5/6] Update XSD references in phpunit.xml.dist files --- phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5040e32..758fd35 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ Date: Thu, 23 Apr 2026 10:52:23 +0200 Subject: [PATCH 6/6] [Dotenv] Strip NUL byte placeholder from values passed to `putenv()` --- Dotenv.php | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/Dotenv.php b/Dotenv.php index f9a08bd..fb68999 100644 --- a/Dotenv.php +++ b/Dotenv.php @@ -42,6 +42,8 @@ final class Dotenv private string $debugKey; private array $prodEnvs = ['prod']; private bool $usePutenv = false; + private bool $deferPutenv = false; + private array $pendingPutenv = []; public function __construct(string $envKey = 'APP_ENV', string $debugKey = 'APP_DEBUG') { @@ -83,8 +85,13 @@ public function usePutenv(bool $usePutenv = true): static */ public function load(string $path, string ...$extraPaths): void { - $this->doLoad(false, \func_get_args()); - $this->resolveLoadedVars(); + $this->deferPutenv = true; + try { + $this->doLoad(false, \func_get_args()); + $this->resolveLoadedVars(); + } finally { + $this->deferPutenv = false; + } } /** @@ -104,6 +111,7 @@ public function load(string $path, string ...$extraPaths): void */ public function loadEnv(string $path, ?string $envKey = null, string $defaultEnv = 'dev', array $testEnvs = ['test'], bool $overrideExistingVars = false): void { + $this->deferPutenv = true; try { $k = $envKey ?? $this->envKey; @@ -139,7 +147,11 @@ public function loadEnv(string $path, ?string $envKey = null, string $defaultEnv $this->doLoad($overrideExistingVars, [$p]); } } finally { - $this->resolveLoadedVars(); + try { + $this->resolveLoadedVars(); + } finally { + $this->deferPutenv = false; + } } } @@ -180,8 +192,13 @@ public function bootEnv(string $path, string $defaultEnv = 'dev', array $testEnv */ public function overload(string $path, string ...$extraPaths): void { - $this->doLoad(true, \func_get_args()); - $this->resolveLoadedVars(); + $this->deferPutenv = true; + try { + $this->doLoad(true, \func_get_args()); + $this->resolveLoadedVars(); + } finally { + $this->deferPutenv = false; + } } /** @@ -207,7 +224,11 @@ public function populate(array $values, bool $overrideExistingVars = false): voi } if ($this->usePutenv) { - putenv("$name=$value"); + if ($this->deferPutenv) { + $this->pendingPutenv[$name] = true; + } else { + putenv("$name=$value"); + } } $_ENV[$name] = $value; @@ -801,6 +822,13 @@ private function resolveLoadedVars(): void $this->populate($restored, true); } + if ($this->usePutenv && $this->pendingPutenv) { + foreach ($this->pendingPutenv as $name => $_) { + putenv($name.'='.($_ENV[$name] ?? '')); + } + $this->pendingPutenv = []; + } + $this->values = []; $this->overriddenValues = []; unset($this->path, $this->data, $this->lineno, $this->cursor, $this->end);