Merge branch '7.4' into 8.0

* 7.4:
  [VarDumper] Wrong dumper output for Accept: aplication/json requests
  [HttpKernel] Reset router locale to default when finishing main request
  Only decrement pendingRequests when it's more than zero
  [Dotenv] Fix self-referencing variables with defaults and env key resolution during deferred expansion
  Improve Bulgarian translations in validators.bg.xlf
  [Cache] Fix ChainAdapter ignoring item expiry when propagating to earlier adapters
  [Form] Fix typed property initialization in ValidatorExtension
  [Messenger] Fix duplicate pending messages in Redis transport with batch handlers
  Fix deprecation notices for "@method" annotations when implementing interfaces directly
This commit is contained in:
Nicolas Grekas
2026-03-17 15:00:10 +01:00
2 changed files with 171 additions and 0 deletions

View File

@@ -113,11 +113,16 @@ final class Dotenv
if (null === $env = $_SERVER[$k] ?? $_ENV[$k] ?? null) {
$this->populate([$k => $env = $defaultEnv], $overrideExistingVars);
} elseif (str_contains($env, '$') || str_contains($env, "\x00") || str_contains($env, '\\')) {
$env = $this->resolveEnvKey($env, $k);
}
if (!\in_array($env, $testEnvs, true) && is_file($p = "$path.local")) {
$this->doLoad($overrideExistingVars, [$p]);
$env = $_SERVER[$k] ?? $_ENV[$k] ?? $env;
if (str_contains($env, '$') || str_contains($env, "\x00") || str_contains($env, '\\')) {
$env = $this->resolveEnvKey($env, $k);
}
}
if ('local' === $env) {
@@ -642,6 +647,50 @@ final class Dotenv
}
}
/**
* Eagerly resolves a raw env key value so that loadEnv() can determine
* which additional .env files to load before full deferred resolution.
*/
private function resolveEnvKey(string $value, string $name): string
{
$loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? ''));
unset($loadedVars['']);
// Save and clear own value so self-referencing defaults work
$envBackup = $_ENV[$name] ?? null;
$serverBackup = $_SERVER[$name] ?? null;
unset($_ENV[$name], $_SERVER[$name]);
if ($this->usePutenv) {
$getenvBackup = (string) getenv($name);
putenv($name);
}
$this->values = [];
$this->path = '';
$this->data = '';
$this->lineno = 0;
$this->cursor = 0;
$this->end = 0;
$resolved = $this->resolveCommands($value, $loadedVars);
$resolved = $this->resolveVariables($resolved, $loadedVars);
$resolved = str_replace(["\x00", '\\\\'], ['$', '\\'], $resolved);
if (null !== $envBackup) {
$_ENV[$name] = $envBackup;
}
if (null !== $serverBackup) {
$_SERVER[$name] = $serverBackup;
}
if ($this->usePutenv) {
putenv("$name=$getenvBackup");
}
$this->values = [];
return $resolved;
}
private function resolveLoadedVars(): void
{
$loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? ''));
@@ -654,6 +703,20 @@ final class Dotenv
$this->cursor = 0;
$this->end = 0;
// Detect variables that were originally defined as self-referencing
// (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;
}
$value = $_ENV[$name] ?? '';
if (str_contains($value, '$') && preg_match('/\$\{?'.preg_quote($name, '/').'(?![A-Za-z0-9_])/', $value)) {
$selfReferencingVars[$name] = true;
}
}
for ($pass = 0; $pass < 5; ++$pass) {
$resolved = [];
foreach ($loadedVars as $name => $_) {
@@ -663,8 +726,32 @@ final class Dotenv
if (!str_contains($value = $_ENV[$name] ?? '', '$')) {
continue;
}
if (isset($selfReferencingVars[$name])) {
$envBackup = $_ENV[$name] ?? null;
$serverBackup = $_SERVER[$name] ?? null;
unset($_ENV[$name], $_SERVER[$name]);
if ($this->usePutenv) {
$getenvBackup = $this->usePutenv ? (string) getenv($name) : null;
putenv($name);
}
}
$resolvedValue = $this->resolveCommands($value, $loadedVars);
$resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars);
if (isset($selfReferencingVars[$name])) {
if (null !== $envBackup) {
$_ENV[$name] = $envBackup;
}
if (null !== $serverBackup) {
$_SERVER[$name] = $serverBackup;
}
if ($this->usePutenv) {
putenv("$name=$getenvBackup");
}
}
if ($value !== $resolvedValue) {
$resolved[$name] = $resolvedValue;
}

View File

@@ -542,6 +542,90 @@ class DotenvTest extends TestCase
rmdir($tmpdir);
}
public function testLoadEnvSelfReferencingVariableWithDefault()
{
$resetContext = static function (): void {
unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['MY_VAR'], $_ENV['TEST_APP_ENV']);
unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['MY_VAR'], $_SERVER['TEST_APP_ENV']);
putenv('SYMFONY_DOTENV_VARS');
putenv('MY_VAR');
putenv('TEST_APP_ENV');
};
@mkdir($tmpdir = sys_get_temp_dir().'/dotenv');
$path = tempnam($tmpdir, 'sf-');
// Self-referencing variable with default value
file_put_contents($path, 'MY_VAR="${MY_VAR:-default_value}"');
$resetContext();
(new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV');
$this->assertSame('default_value', getenv('MY_VAR'));
// When host env is set, it should take precedence
$resetContext();
putenv('MY_VAR=host_value');
$_ENV['MY_VAR'] = 'host_value';
(new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV');
$this->assertSame('host_value', getenv('MY_VAR'));
// Self-referencing variable with := (assign default)
file_put_contents($path, 'MY_VAR="${MY_VAR:=fallback}"');
$resetContext();
(new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV');
$this->assertSame('fallback', getenv('MY_VAR'));
$resetContext();
putenv('MY_VAR');
unlink($path);
@rmdir($tmpdir);
}
public function testLoadEnvSelfReferencingEnvKeyControlsFileLoading()
{
$resetContext = static function (): void {
unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['TEST_APP_ENV'], $_ENV['FOO']);
unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['TEST_APP_ENV'], $_SERVER['FOO']);
putenv('SYMFONY_DOTENV_VARS');
putenv('TEST_APP_ENV');
putenv('FOO');
};
@mkdir($tmpdir = sys_get_temp_dir().'/dotenv');
$path = tempnam($tmpdir, 'sf-');
// APP_ENV with self-referencing default must control which .env files are loaded
file_put_contents($path, 'TEST_APP_ENV="${TEST_APP_ENV:-dev}"'."\nFOO=bar");
file_put_contents("$path.dev", 'FOO=devbar');
$resetContext();
(new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV');
$this->assertSame('dev', getenv('TEST_APP_ENV'));
$this->assertSame('devbar', getenv('FOO'));
// Host env should override the default and control file loading
$resetContext();
file_put_contents("$path.prod", 'FOO=prodbar');
putenv('TEST_APP_ENV=prod');
$_ENV['TEST_APP_ENV'] = 'prod';
(new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV');
$this->assertSame('prod', getenv('TEST_APP_ENV'));
$this->assertSame('prodbar', getenv('FOO'));
$resetContext();
putenv('TEST_APP_ENV');
@unlink("$path.dev");
@unlink("$path.prod");
unlink($path);
@rmdir($tmpdir);
}
public function testLoadEnvThrowsOnCircularVariableReferences()
{
$resetContext = static function (): void {