From 33008e9464820bd10573d7320ab51ac33aeb67ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:43:43 +0000 Subject: [PATCH] feat(foundation): load .env files with v13's LoadEnvironmentVariables (task 4.5) The start script loads the application's .env file with v13's LoadEnvironmentVariables bootstrapper and vlucas/phpdotenv, before the L4.2 .env.{env}.php file: - .env is read from the base path, or .env.{env} when --env or APP_ENV names an environment that has its own file, as in v13. Variables already in the process environment are kept. - The .env.{env}.php file still loads afterwards and wins on the keys both files define, so applications without a .env file see no change. - Application gains v13's environmentPath(), useEnvironmentPath(), loadEnvironmentFrom(), environmentFile() and environmentFilePath(). - vlucas/phpdotenv becomes a requirement. It also makes illuminate/support's env() helper usable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0169SsatCE8LhaTsQTPiGtVo --- composer.json | 1 + src/Illuminate/Foundation/Application.php | 70 ++++++++++ .../Bootstrap/LoadEnvironmentVariables.php | 80 +++++++++++ src/Illuminate/Foundation/start.php | 9 +- .../FoundationApplicationBuilderTest.php | 17 +++ ...FoundationLoadEnvironmentVariablesTest.php | 130 ++++++++++++++++++ 6 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php create mode 100644 tests/Foundation/FoundationLoadEnvironmentVariablesTest.php diff --git a/composer.json b/composer.json index 8d4aa832..74d3dacb 100755 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "symfony/process": "^7.4", "symfony/routing": "^7.4", "symfony/translation": "^7.4", + "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "2.0.3" }, "replace": { diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index cc2c6b71..0ee764ff 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -49,6 +49,20 @@ class Application extends Container implements HttpKernelInterface, TerminableIn */ protected $hasBeenBootstrapped = false; + /** + * The custom environment path defined by the developer. + * + * @var string|null + */ + protected $environmentPath; + + /** + * The environment file to load during bootstrapping. + * + * @var string + */ + protected $environmentFile = '.env'; + /** * The array of booting callbacks. * @@ -265,6 +279,62 @@ public function useStoragePath($path) return $this; } + /** + * Get the path to the environment file directory. + * + * @return string + */ + public function environmentPath() + { + return $this->environmentPath ?: $this['path.base']; + } + + /** + * Set the directory for the environment file. + * + * @param string $path + * @return $this + */ + public function useEnvironmentPath($path) + { + $this->environmentPath = $path; + + return $this; + } + + /** + * Set the environment file to be loaded during bootstrapping. + * + * @param string $file + * @return $this + */ + public function loadEnvironmentFrom($file) + { + $this->environmentFile = $file; + + return $this; + } + + /** + * Get the environment file the application is using. + * + * @return string + */ + public function environmentFile() + { + return $this->environmentFile ?: '.env'; + } + + /** + * Get the fully qualified path to the environment file. + * + * @return string + */ + public function environmentFilePath() + { + return $this->environmentPath().DIRECTORY_SEPARATOR.$this->environmentFile(); + } + /** * Get the path to the application configuration files. * diff --git a/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php b/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php new file mode 100644 index 00000000..04ae6c3d --- /dev/null +++ b/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php @@ -0,0 +1,80 @@ +checkForSpecificEnvironmentFile($app); + + $this->createDotenv($app)->safeLoad(); + } + + /** + * Detect if a custom environment file matching the APP_ENV exists. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function checkForSpecificEnvironmentFile($app) + { + if ($app->runningInConsole() && + ($input = new ArgvInput)->hasParameterOption('--env') && + $this->setEnvironmentFilePath($app, $app->environmentFile().'.'.$input->getParameterOption('--env'))) + { + return; + } + + $environment = Env::get('APP_ENV'); + + if ( ! $environment) return; + + $this->setEnvironmentFilePath($app, $app->environmentFile().'.'.$environment); + } + + /** + * Load a custom environment file. + * + * @param \Illuminate\Foundation\Application $app + * @param string $file + * @return bool + */ + protected function setEnvironmentFilePath($app, $file) + { + if (is_file($app->environmentPath().'/'.$file)) + { + $app->loadEnvironmentFrom($file); + + return true; + } + + return false; + } + + /** + * Create a Dotenv instance. + * + * @param \Illuminate\Foundation\Application $app + * @return \Dotenv\Dotenv + */ + protected function createDotenv($app) + { + return Dotenv::create(Env::getRepository(), $app->environmentPath(), $app->environmentFile()); + } + +} diff --git a/src/Illuminate/Foundation/start.php b/src/Illuminate/Foundation/start.php index 910559c4..395455c2 100755 --- a/src/Illuminate/Foundation/start.php +++ b/src/Illuminate/Foundation/start.php @@ -47,6 +47,7 @@ use Illuminate\Foundation\AliasLoader; use Illuminate\Config\EnvironmentVariables; use Illuminate\Foundation\Bootstrap\LoadConfiguration; +use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; /* |-------------------------------------------------------------------------- @@ -94,12 +95,14 @@ | Register The Environment Variables |-------------------------------------------------------------------------- | -| Here we will register all of the $_ENV and $_SERVER variables into the -| process so that they're globally available configuration options so -| sensitive configuration information can be swept out of the code. +| v13's LoadEnvironmentVariables loads the .env file first. The L4.2 +| .env.{env}.php file loads after it, so its values win on the keys both +| files define. | */ +$app->make(LoadEnvironmentVariables::class)->bootstrap($app); + with($envVariables = new EnvironmentVariables( $app->getEnvironmentVariablesLoader()))->load($env); diff --git a/tests/Foundation/FoundationApplicationBuilderTest.php b/tests/Foundation/FoundationApplicationBuilderTest.php index 9a4b5a25..d7587793 100644 --- a/tests/Foundation/FoundationApplicationBuilderTest.php +++ b/tests/Foundation/FoundationApplicationBuilderTest.php @@ -68,6 +68,11 @@ protected function tearDown(): void error_reporting($this->errorReporting); ini_set('display_errors', $this->displayErrors); date_default_timezone_set($this->timezone); + foreach (array('FORK_START_SHARED', 'FORK_START_DOTENV') as $key) + { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); @@ -112,6 +117,18 @@ public function everyConfigurationFileLoadsWithoutAnEnvironmentCascade() $this->assertSame('http://builder.test', $config['app.url']); } + #[Test] + public function theEnvFileLoadsFirstAndTheEnvironmentsPhpFileWins() + { + file_put_contents($this->base.'/.env', "FORK_START_SHARED=dotenv\nFORK_START_DOTENV=dotenv-only\n"); + file_put_contents($this->base.'/.env.testing.php', ' "php");'); + + $this->bootstrapped('testing'); + + $this->assertSame('php', getenv('FORK_START_SHARED')); + $this->assertSame('dotenv-only', getenv('FORK_START_DOTENV')); + } + #[Test] public function theConfigurationLoadsFromTheConfiguredPath() { diff --git a/tests/Foundation/FoundationLoadEnvironmentVariablesTest.php b/tests/Foundation/FoundationLoadEnvironmentVariablesTest.php new file mode 100644 index 00000000..5606f531 --- /dev/null +++ b/tests/Foundation/FoundationLoadEnvironmentVariablesTest.php @@ -0,0 +1,130 @@ +argv = $_SERVER['argv']; + $this->base = sys_get_temp_dir().'/fork-dotenv-'.uniqid(); + mkdir($this->base.'/elsewhere', 0777, true); + } + + protected function tearDown(): void + { + $_SERVER['argv'] = $this->argv; + foreach (array('FORK_DOTENV_VALUE', 'FORK_DOTENV_PROCESS', 'FORK_DOTENV_OTHER', 'APP_ENV') as $key) + { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } + + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->base, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + rmdir($this->base); + } + + #[Test] + public function theEnvFileLoadsIntoTheEnvironment() + { + file_put_contents($this->base.'/.env', "FORK_DOTENV_VALUE=from-dotenv\n"); + + $app = $this->bootstrapped(); + + $this->assertSame('.env', $app->environmentFile()); + $this->assertSame($this->base.'/.env', $app->environmentFilePath()); + $this->assertSame('from-dotenv', getenv('FORK_DOTENV_VALUE')); + $this->assertSame('from-dotenv', $_ENV['FORK_DOTENV_VALUE']); + $this->assertSame('from-dotenv', $_SERVER['FORK_DOTENV_VALUE']); + $this->assertSame('from-dotenv', env('FORK_DOTENV_VALUE')); + } + + #[Test] + public function theEnvOptionPicksTheMatchingEnvFile() + { + file_put_contents($this->base.'/.env', "FORK_DOTENV_VALUE=base\n"); + file_put_contents($this->base.'/.env.staging', "FORK_DOTENV_VALUE=staging\n"); + $_SERVER['argv'] = array('artisan', 'list', '--env=staging'); + + $app = $this->bootstrapped(); + + $this->assertSame('.env.staging', $app->environmentFile()); + $this->assertSame('staging', getenv('FORK_DOTENV_VALUE')); + } + + #[Test] + public function appEnvPicksTheMatchingEnvFile() + { + file_put_contents($this->base.'/.env', "FORK_DOTENV_VALUE=base\n"); + file_put_contents($this->base.'/.env.staging', "FORK_DOTENV_VALUE=staging\n"); + putenv('APP_ENV=staging'); + + $app = $this->bootstrapped(); + + $this->assertSame('.env.staging', $app->environmentFile()); + $this->assertSame('staging', getenv('FORK_DOTENV_VALUE')); + } + + #[Test] + public function anEnvironmentWithoutItsOwnFileFallsBackToTheEnvFile() + { + file_put_contents($this->base.'/.env', "FORK_DOTENV_VALUE=base\n"); + $_SERVER['argv'] = array('artisan', 'list', '--env=missing'); + + $app = $this->bootstrapped(); + + $this->assertSame('.env', $app->environmentFile()); + $this->assertSame('base', getenv('FORK_DOTENV_VALUE')); + } + + #[Test] + public function variablesAlreadyInTheEnvironmentAreKept() + { + file_put_contents($this->base.'/.env', "FORK_DOTENV_PROCESS=from-dotenv\nFORK_DOTENV_OTHER=added\n"); + putenv('FORK_DOTENV_PROCESS=from-the-process'); + + $this->bootstrapped(); + + $this->assertSame('from-the-process', getenv('FORK_DOTENV_PROCESS')); + $this->assertSame('added', getenv('FORK_DOTENV_OTHER')); + } + + #[Test] + public function aMissingEnvFileLoadsNothing() + { + $this->bootstrapped(); + + $this->assertFalse(getenv('FORK_DOTENV_VALUE')); + } + + #[Test] + public function theEnvFileLoadsFromTheConfiguredEnvironmentPath() + { + file_put_contents($this->base.'/.env', "FORK_DOTENV_VALUE=base\n"); + file_put_contents($this->base.'/elsewhere/.env', "FORK_DOTENV_VALUE=elsewhere\n"); + + $app = new Application; + $app->setBasePath($this->base); + $this->assertSame($app, $app->useEnvironmentPath($this->base.'/elsewhere')); + (new LoadEnvironmentVariables)->bootstrap($app); + + $this->assertSame($this->base.'/elsewhere', $app->environmentPath()); + $this->assertSame('elsewhere', getenv('FORK_DOTENV_VALUE')); + } + + private function bootstrapped(): Application + { + $app = new Application; + $app->setBasePath($this->base); + (new LoadEnvironmentVariables)->bootstrap($app); + + return $app; + } +}