From bc8f130bfd0e120a92aa3f840c514dc111cf9c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:14:12 +0000 Subject: [PATCH] feat(foundation): load configuration like v13's LoadConfiguration, with no environment cascade (task 4.5) The start script loads the application's configuration with v13's LoadConfiguration bootstrapper instead of the fork's cascading FileLoader: - Every *.php file under the configuration directory loads as a group, and files in subdirectories load as dotted groups, as in v13. config/{env}/ files are no longer merged over the base files. - The configuration directory is v13's config/ under the base path, bound as path.config. Application gains v13's configPath() and useConfigPath(). - Application gains v13's afterBootstrapping(). The start script fires "bootstrapped: LoadConfiguration" once the configuration is loaded, before any service provider registers. - The timezone is set from app.timezone by LoadConfiguration, as in v13. - Config\FileLoader, Config\LoaderInterface and getConfigLoader() are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0169SsatCE8LhaTsQTPiGtVo --- src/Illuminate/Config/FileLoader.php | 259 ------------------ src/Illuminate/Config/LoaderInterface.php | 52 ---- src/Illuminate/Foundation/Application.php | 50 +++- .../Bootstrap/LoadConfiguration.php | 80 ++++++ src/Illuminate/Foundation/start.php | 39 +-- tests/Config/ConfigFileLoaderTest.php | 109 -------- .../FoundationApplicationBuilderTest.php | 101 ++++++- 7 files changed, 223 insertions(+), 467 deletions(-) delete mode 100755 src/Illuminate/Config/FileLoader.php delete mode 100755 src/Illuminate/Config/LoaderInterface.php create mode 100644 src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php delete mode 100755 tests/Config/ConfigFileLoaderTest.php diff --git a/src/Illuminate/Config/FileLoader.php b/src/Illuminate/Config/FileLoader.php deleted file mode 100755 index 3563f0777..000000000 --- a/src/Illuminate/Config/FileLoader.php +++ /dev/null @@ -1,259 +0,0 @@ -files = $files; - $this->defaultPath = $defaultPath; - } - - /** - * Load the given configuration group. - * - * @param string $environment - * @param string $group - * @param string $namespace - * @return array - */ - public function load($environment, $group, $namespace = null) - { - $items = array(); - - // First we'll get the root configuration path for the environment which is - // where all of the configuration files live for that namespace, as well - // as any environment folders with their specific configuration items. - $path = $this->getPath($namespace); - - if (is_null($path)) - { - return $items; - } - - // First we'll get the main configuration file for the groups. Once we have - // that we can check for any environment specific files, which will get - // merged on top of the main arrays to make the environments cascade. - $file = "{$path}/{$group}.php"; - - if ($this->files->exists($file)) - { - $items = $this->getRequire($file); - } - - // Finally we're ready to check for the environment specific configuration - // file which will be merged on top of the main arrays so that they get - // precedence over them if we are currently in an environments setup. - $file = "{$path}/{$environment}/{$group}.php"; - - if ($this->files->exists($file)) - { - $items = $this->mergeEnvironment($items, $file); - } - - return $items; - } - - /** - * Merge the items in the given file into the items. - * - * @param array $items - * @param string $file - * @return array - */ - protected function mergeEnvironment(array $items, $file) - { - return array_replace_recursive($items, $this->getRequire($file)); - } - - /** - * Determine if the given group exists. - * - * @param string $group - * @param string $namespace - * @return bool - */ - public function exists($group, $namespace = null) - { - $key = $group.$namespace; - - // We'll first check to see if we have determined if this namespace and - // group combination have been checked before. If they have, we will - // just return the cached result so we don't have to hit the disk. - if (isset($this->exists[$key])) - { - return $this->exists[$key]; - } - - $path = $this->getPath($namespace); - - // To check if a group exists, we will simply get the path based on the - // namespace, and then check to see if this files exists within that - // namespace. False is returned if no path exists for a namespace. - if (is_null($path)) - { - return $this->exists[$key] = false; - } - - $file = "{$path}/{$group}.php"; - - // Finally, we can simply check if this file exists. We will also cache - // the value in an array so we don't have to go through this process - // again on subsequent checks for the existing of the config file. - $exists = $this->files->exists($file); - - return $this->exists[$key] = $exists; - } - - /** - * Apply any cascades to an array of package options. - * - * @param string $env - * @param string $package - * @param string $group - * @param array $items - * @return array - */ - public function cascadePackage($env, $package, $group, $items) - { - // First we will look for a configuration file in the packages configuration - // folder. If it exists, we will load it and merge it with these original - // options so that we will easily "cascade" a package's configurations. - $file = "packages/{$package}/{$group}.php"; - - if ($this->files->exists($path = $this->defaultPath.'/'.$file)) - { - $items = array_merge( - $items, $this->getRequire($path) - ); - } - - // Once we have merged the regular package configuration we need to look for - // an environment specific configuration file. If one exists, we will get - // the contents and merge them on top of this array of options we have. - $path = $this->getPackagePath($env, $package, $group); - - if ($this->files->exists($path)) - { - $items = array_merge( - $items, $this->getRequire($path) - ); - } - - return $items; - } - - /** - * Get the package path for an environment and group. - * - * @param string $env - * @param string $package - * @param string $group - * @return string - */ - protected function getPackagePath($env, $package, $group) - { - $file = "packages/{$package}/{$env}/{$group}.php"; - - return $this->defaultPath.'/'.$file; - } - - /** - * Get the configuration path for a namespace. - * - * @param string $namespace - * @return string - */ - protected function getPath($namespace) - { - if (is_null($namespace)) - { - return $this->defaultPath; - } - elseif (isset($this->hints[$namespace])) - { - return $this->hints[$namespace]; - } - } - - /** - * Add a new namespace to the loader. - * - * @param string $namespace - * @param string $hint - * @return void - */ - public function addNamespace($namespace, $hint) - { - $this->hints[$namespace] = $hint; - } - - /** - * Returns all registered namespaces with the config - * loader. - * - * @return array - */ - public function getNamespaces() - { - return $this->hints; - } - - /** - * Get a file's contents by requiring it. - * - * @param string $path - * @return mixed - */ - protected function getRequire($path) - { - return $this->files->getRequire($path); - } - - /** - * Get the Filesystem instance. - * - * @return \Illuminate\Filesystem\Filesystem - */ - public function getFilesystem() - { - return $this->files; - } - -} diff --git a/src/Illuminate/Config/LoaderInterface.php b/src/Illuminate/Config/LoaderInterface.php deleted file mode 100755 index d4b6a8f97..000000000 --- a/src/Illuminate/Config/LoaderInterface.php +++ /dev/null @@ -1,52 +0,0 @@ -instance('path.base', $basePath); $this->instance('path', $basePath.'/app'); + $this->instance('path.config', $basePath.'/config'); $this->instance('path.public', $basePath.'/public'); $this->instance('path.storage', $basePath.'/storage'); $this->instance('path.lang', $basePath.'/app/lang'); @@ -265,6 +265,30 @@ public function useStoragePath($path) return $this; } + /** + * Get the path to the application configuration files. + * + * @param string $path + * @return string + */ + public function configPath($path = '') + { + return $this['path.config'].($path != '' ? DIRECTORY_SEPARATOR.$path : ''); + } + + /** + * Set the configuration directory. + * + * @param string $path + * @return $this + */ + public function useConfigPath($path) + { + $this->instance('path.config', $path); + + return $this; + } + /** * Get the path to the resources directory. * @@ -826,6 +850,20 @@ public function booted($callback) if ($this->isBooted()) $this->fireAppCallbacks(array($callback)); } + /** + * Register a callback to run after a bootstrapper. + * + * The start script fires it for the bootstrapper it runs, LoadConfiguration. + * + * @param string $bootstrapper + * @param \Closure $callback + * @return void + */ + public function afterBootstrapping($bootstrapper, Closure $callback) + { + $this['events']->listen('bootstrapped: '.$bootstrapper, $callback); + } + /** * Handle the incoming HTTP request and send the response to the browser. * @@ -1054,16 +1092,6 @@ public function abort($code, $message = '', array $headers = array()) throw new HttpException($code, $message, null, $headers); } - /** - * Get the configuration loader instance. - * - * @return \Illuminate\Config\LoaderInterface - */ - public function getConfigLoader() - { - return new FileLoader(new Filesystem, $this['path'].'/config'); - } - /** * Get the environment variables loader instance. * diff --git a/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php new file mode 100644 index 000000000..68163f0dc --- /dev/null +++ b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php @@ -0,0 +1,80 @@ +instance('config', $config = new Repository); + + foreach ($this->getConfigurationFiles($app) as $name => $path) + { + $config->set($name, (fn () => require $path)()); + } + + date_default_timezone_set($config->get('app.timezone', 'UTC')); + + mb_internal_encoding('UTF-8'); + } + + /** + * Get all of the configuration files for the application. + * + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getConfigurationFiles(Application $app) + { + $files = array(); + + $configPath = realpath($app->configPath()); + + if ( ! $configPath) return array(); + + foreach (Finder::create()->files()->name('*.php')->in($configPath) as $file) + { + $directory = $this->getNestedDirectory($file, $configPath); + + $files[$directory.basename($file->getRealPath(), '.php')] = $file->getRealPath(); + } + + ksort($files, SORT_NATURAL); + + return $files; + } + + /** + * Get the configuration file nesting path. + * + * @param \SplFileInfo $file + * @param string $configPath + * @return string + */ + protected function getNestedDirectory(SplFileInfo $file, $configPath) + { + $directory = $file->getPath(); + + if ($nested = trim(str_replace($configPath, '', $directory), DIRECTORY_SEPARATOR)) + { + $nested = str_replace(DIRECTORY_SEPARATOR, '.', $nested).'.'; + } + + return $nested; + } + +} diff --git a/src/Illuminate/Foundation/start.php b/src/Illuminate/Foundation/start.php index 1b07e5378..910559c4f 100755 --- a/src/Illuminate/Foundation/start.php +++ b/src/Illuminate/Foundation/start.php @@ -46,7 +46,7 @@ use Illuminate\Support\Facades\Facade; use Illuminate\Foundation\AliasLoader; use Illuminate\Config\EnvironmentVariables; -use Illuminate\Config\Repository as Config; +use Illuminate\Foundation\Bootstrap\LoadConfiguration; /* |-------------------------------------------------------------------------- @@ -108,29 +108,15 @@ | Register The Configuration Repository |-------------------------------------------------------------------------- | -| The configuration repository is used to lazily load in the options for -| this application from the configuration files. The files are easily -| separated by their concerns so they do not become really crowded. +| v13's LoadConfiguration loads every file in the configuration directory, +| with no environment cascade. Callbacks registered with afterBootstrapping +| run next, before any service provider registers. | */ -// v13 Config\Repository takes a pre-loaded array (no loader/env-cascade). Keep the -// fork FileLoader for the L4.2 env-cascade + eagerly load every group into the array. -$loader = $app->getConfigLoader(); +$app->make(LoadConfiguration::class)->bootstrap($app); -$groups = []; -foreach (array_merge( - glob($app['path'].'/config/*.php'), - glob($app['path'].'/config/'.$env.'/*.php') -) as $file) -{ - $groups[basename($file, '.php')] = true; -} - -$items = array(); -foreach (array_keys($groups) as $group) $items[$group] = $loader->load($env, $group); - -$app->instance('config', $config = new Config($items)); +$app['events']->dispatch('bootstrapped: '.LoadConfiguration::class, array($app)); /* |-------------------------------------------------------------------------- @@ -147,21 +133,8 @@ if ($env != 'testing') ini_set('display_errors', 'Off'); -/* -|-------------------------------------------------------------------------- -| Set The Default Timezone -|-------------------------------------------------------------------------- -| -| Here we will set the default timezone for PHP. PHP is notoriously mean -| if the timezone is not explicitly set. This will be used by each of -| the PHP date and date-time functions throughout the application. -| -*/ - $config = $app['config']['app']; -date_default_timezone_set($config['timezone']); - /* |-------------------------------------------------------------------------- | Register The Alias Loader diff --git a/tests/Config/ConfigFileLoaderTest.php b/tests/Config/ConfigFileLoaderTest.php deleted file mode 100755 index 7e47a1700..000000000 --- a/tests/Config/ConfigFileLoaderTest.php +++ /dev/null @@ -1,109 +0,0 @@ -getLoader(); - $this->assertEquals([], $loader->load('local', 'group', 'namespace')); - } - - - public function testBasicArrayIsReturned() - { - $loader = $this->getLoader(); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/app.php')->andReturn(true); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/local/app.php')->andReturn(false); - $loader->getFilesystem()->shouldReceive('getRequire')->once()->with(__DIR__.'/app.php')->andReturn( - ['foo' => 'bar'] - ); - $array = $loader->load('local', 'app', null); - - $this->assertEquals(['foo' => 'bar'], $array); - } - - - public function testEnvironmentArrayIsMerged() - { - $loader = $this->getLoader(); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/app.php')->andReturn(true); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/local/app.php')->andReturn(true); - $loader->getFilesystem()->shouldReceive('getRequire')->once()->with(__DIR__.'/app.php')->andReturn( - ['foo' => 'bar'] - ); - $loader->getFilesystem()->shouldReceive('getRequire')->once()->with(__DIR__.'/local/app.php')->andReturn( - ['foo' => 'blah', 'baz' => 'boom'] - ); - $array = $loader->load('local', 'app', null); - - $this->assertEquals(['foo' => 'blah', 'baz' => 'boom'], $array); - } - - - public function testGroupExistsReturnsTrueWhenTheGroupExists() - { - $loader = $this->getLoader(); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/app.php')->andReturn(true); - $this->assertTrue($loader->exists('app')); - } - - - public function testGroupExistsReturnsTrueWhenNamespaceGroupExists() - { - $loader = $this->getLoader(); - $loader->addNamespace('namespace', __DIR__.'/namespace'); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/namespace/app.php')->andReturn(true); - $this->assertTrue($loader->exists('app', 'namespace')); - } - - - public function testGroupExistsReturnsFalseWhenNamespaceHintDoesntExists() - { - $loader = $this->getLoader(); - $this->assertFalse($loader->exists('app', 'namespace')); - } - - - public function testGroupExistsReturnsFalseWhenNamespaceGroupDoesntExists() - { - $loader = $this->getLoader(); - $loader->addNamespace('namespace', __DIR__.'/namespace'); - $loader->getFilesystem()->shouldReceive('exists')->with(__DIR__.'/namespace/app.php')->andReturn(false); - $this->assertFalse($loader->exists('app', 'namespace')); - } - - - public function testCascadingPackagesProperlyLoadsFiles() - { - $loader = $this->getLoader(); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/packages/dayle/rees/group.php')->andReturn(true); - $loader->getFilesystem()->shouldReceive('getRequire')->once()->with(__DIR__.'/packages/dayle/rees/group.php')->andReturn( - ['bar' => 'baz'] - ); - $loader->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__.'/packages/dayle/rees/local/group.php')->andReturn(true); - $loader->getFilesystem()->shouldReceive('getRequire')->once()->with(__DIR__.'/packages/dayle/rees/local/group.php')->andReturn( - ['foo' => 'boom'] - ); - $items = $loader->cascadePackage('local', 'dayle/rees', 'group', ['foo' => 'bar']); - - $this->assertEquals(['foo' => 'boom', 'bar' => 'baz'], $items); - } - - - protected function getLoader() - { - return new Illuminate\Config\FileLoader(m::mock(Filesystem::class), __DIR__); - } - -} diff --git a/tests/Foundation/FoundationApplicationBuilderTest.php b/tests/Foundation/FoundationApplicationBuilderTest.php index bc0e9b243..9a4b5a250 100644 --- a/tests/Foundation/FoundationApplicationBuilderTest.php +++ b/tests/Foundation/FoundationApplicationBuilderTest.php @@ -5,10 +5,12 @@ use Illuminate\Foundation\AliasLoader; use Illuminate\Foundation\Application; use Illuminate\Foundation\Artisan; +use Illuminate\Foundation\Bootstrap\LoadConfiguration; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; use Illuminate\Support\Facades\Facade; +use Illuminate\Support\ServiceProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\ArrayInput; @@ -18,6 +20,7 @@ class FoundationApplicationBuilderTest extends TestCase private string $base; private int $errorReporting; private string $displayErrors; + private string $timezone; private int $bootstraps = 0; private ?AliasLoader $aliasLoader = null; @@ -25,28 +28,30 @@ protected function setUp(): void { $this->errorReporting = error_reporting(); $this->displayErrors = (string) ini_get('display_errors'); + $this->timezone = date_default_timezone_get(); $this->aliasLoader = AliasLoader::getInstance(); AliasLoader::setInstance(new AliasLoader); $this->base = sys_get_temp_dir().'/fork-builder-'.uniqid(); - mkdir($this->base.'/app/config', 0777, true); + mkdir($this->base.'/config', 0777, true); mkdir($this->base.'/app/start', 0777, true); mkdir($this->base.'/storage/meta', 0777, true); - file_put_contents($this->base.'/app/config/app.php', 'writeConfig('app', array( 'debug' => false, 'url' => 'http://builder.test', 'timezone' => date_default_timezone_get(), 'aliases' => array(), 'providers' => array(), 'manifest' => $this->base.'/storage/meta', - ), true).';'); + )); file_put_contents($this->base.'/storage/meta/services.json', json_encode(array('providers' => array(), 'eager' => array(), 'deferred' => array()))); file_put_contents($this->base.'/app/routes.php', 'get("/probe", fn () => "probed in ".$app["env"]);'); file_put_contents($this->base.'/app/start/artisan.php', 'errorReporting); ini_set('display_errors', $this->displayErrors); + date_default_timezone_set($this->timezone); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); @@ -80,9 +86,73 @@ public function configureBindsTheV13PathsUnderTheBasePath() $this->assertSame($this->base.'/public', $app['path.public']); $this->assertSame($this->base.'/storage', $app['path.storage']); $this->assertSame($this->base.'/app/lang', $app['path.lang']); + $this->assertSame($this->base.'/config', $app['path.config']); + $this->assertSame($this->base.'/config/app.php', $app->configPath('app.php')); $this->assertSame($app, $app->useStoragePath('/elsewhere/storage')); $this->assertSame('/elsewhere/storage', $app['path.storage']); + + $this->assertSame($app, $app->useConfigPath('/elsewhere/config')); + $this->assertSame('/elsewhere/config', $app['path.config']); + $this->assertSame('/elsewhere/config', $app->configPath()); + } + + #[Test] + public function everyConfigurationFileLoadsWithoutAnEnvironmentCascade() + { + $this->writeConfig('services', array('probe' => array('a' => 1, 'b' => 2))); + $this->writeConfig('testing/services', array('probe' => array('b' => 3))); + $this->writeConfig('nested/deep/thing', array('x' => 1)); + + $config = $this->bootstrapped('testing')['config']; + + $this->assertSame(array('a' => 1, 'b' => 2), $config['services.probe']); + $this->assertSame(3, $config['testing.services.probe.b']); + $this->assertSame(1, $config['nested.deep.thing.x']); + $this->assertSame('http://builder.test', $config['app.url']); + } + + #[Test] + public function theConfigurationLoadsFromTheConfiguredPath() + { + mkdir($this->base.'/app/config', 0777, true); + file_put_contents($this->base.'/app/config/app.php', file_get_contents($this->base.'/config/app.php')); + file_put_contents($this->base.'/app/config/services.php', ' "app/config");'); + + $app = $this->configure('testing'); + $app->useConfigPath($this->base.'/app/config'); + $app->make(Kernel::class)->bootstrap(); + + $this->assertSame('app/config', $app['config']['services.from']); + } + + #[Test] + public function theConfiguredTimezoneIsSet() + { + $timezone = date_default_timezone_get() === 'Asia/Tokyo' ? 'Europe/Paris' : 'Asia/Tokyo'; + $this->writeConfig('app', array_merge(require $this->base.'/config/app.php', array('timezone' => $timezone))); + + $this->bootstrapped('testing'); + + $this->assertSame($timezone, date_default_timezone_get()); + } + + #[Test] + public function callbacksAfterLoadingTheConfigurationRunBeforeTheProvidersRegister() + { + $received = null; + file_put_contents($this->base.'/storage/meta/services.json', json_encode(array('providers' => array(BuilderTestProvider::class), 'eager' => array(BuilderTestProvider::class), 'deferred' => array()))); + $app = $this->configure('testing'); + $app->afterBootstrapping(LoadConfiguration::class, function ($app) use (&$received) { + $received = $app; + $app['config']->set('app.providers', array(BuilderTestProvider::class)); + $app['config']->set('services.word', 'from the callback'); + }); + + $app->make(Kernel::class)->bootstrap(); + + $this->assertSame($app, $received); + $this->assertSame('from the callback', BuilderTestProvider::$registeredWith); } #[Test] @@ -187,6 +257,21 @@ public function theLaravel42EntryPointsAreGone() $this->assertFalse(method_exists(Application::class, 'bindInstallPaths')); } + private function writeConfig(string $name, array $items): void + { + if ( ! is_dir($directory = dirname($this->base.'/config/'.$name))) mkdir($directory, 0777, true); + + file_put_contents($this->base.'/config/'.$name.'.php', 'configure($env); + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + private function configure(string $env, ?callable $middleware = null, array $commands = array()): Application { $app = Application::configure($this->base)->withMiddleware($middleware)->withCommands($commands)->create(); @@ -208,6 +293,16 @@ public function handle($request, Closure $next) } } +class BuilderTestProvider extends ServiceProvider +{ + public static ?string $registeredWith = null; + + public function register() + { + static::$registeredWith = $this->app['config']['services.word']; + } +} + class BuilderTestGreeting { public function __construct(public string $word = 'autowired')