From 763564f3b5334cb79a51e41509844047bbc84163 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 29 Oct 2025 10:20:00 -0700 Subject: [PATCH 1/5] Support specifying namespace to generate classes in This both fixes an infinite loop caused when the application namespace is an empty string (as it is in a domain-driven codebase like Winter CMS as there is no "root" namespace for the application) while also adding support for the --in option originally proposed in https://github.com/laravel/framework/pull/48571 by @innocenzi --- src/Illuminate/Console/GeneratorCommand.php | 27 ++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index 5b6af51a576b..3df729285a27 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -8,8 +8,11 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Finder\Finder; +use function Laravel\Prompts\text; + abstract class GeneratorCommand extends Command implements PromptsForMissingInput { /** @@ -117,6 +120,8 @@ abstract class GeneratorCommand extends Command implements PromptsForMissingInpu '__TRAIT__', ]; + protected string $rootNamespace; + /** * Create a new generator command instance. * @@ -130,6 +135,13 @@ public function __construct(Filesystem $files) $this->addTestOptions(); } + $this->getDefinition()->addOption(new InputOption( + 'in', + null, + InputOption::VALUE_REQUIRED, + "Specify a namespace to generate the {$this->type} class in" + )); + $this->files = $files; } @@ -433,7 +445,20 @@ protected function getNameInput() */ protected function rootNamespace() { - return $this->laravel->getNamespace(); + if (!empty($this->rootNamespace)) { + return $this->rootNamespace; + } + + $in = $this->option('in') ?? $this->laravel->getNamespace(); + if (empty($in)) { + $in = text( + label: 'What namespace would you like to generate the '.$this->type.' in?', + placeholder: 'App', + validate: fn ($value) => empty($value) ? "The in option is required when the application namespace is empty." : null, + ); + } + + return $this->rootNamespace = trim($in, '\\').'\\'; } /** From 304a35f9b299ade6ef72073ffc045fef2380a2a1 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 29 Oct 2025 13:58:31 -0700 Subject: [PATCH 2/5] Add method to resolve class path using Composer --- src/Illuminate/Console/GeneratorCommand.php | 69 ++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index 3df729285a27..781ed3b29cde 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -2,11 +2,13 @@ namespace Illuminate\Console; +use Composer\Autoload\ClassLoader; use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Contracts\Console\PromptsForMissingInput; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Finder\Finder; @@ -317,9 +319,72 @@ protected function alreadyExists($rawName) */ protected function getPath($name) { - $name = Str::replaceFirst($this->rootNamespace(), '', $name); + try { + return $this->resolvePathForClass($name); + } catch (RuntimeException $e) { + return $this->laravel['path.base'].'/'.str_replace('\\', '/', $name).'.php'; + } + } + + /** + * Resolve the expected path for a class based on Composer autoload mappings. + * + * @throws \RuntimeException if multiple base paths match or none can be resolved + */ + protected function resolvePathForClass(string $class): string + { + $namespaceRoots = []; + + // Collect valid PSR-4 and PSR-0 namespace mappings + foreach (ClassLoader::getRegisteredLoaders() as $loader) { + foreach ([$loader->getPrefixesPsr4(), $loader->getPrefixes()] as $prefixes) { + foreach ($prefixes as $ns => $paths) { + foreach ($paths as $path) { + $real = realpath($path); + if ($real !== false) { + $namespaceRoots[rtrim($ns, '\\')][] = $real; + } + } + } + } + } + + // Sort by namespace depth (deepest first) + uksort($namespaceRoots, fn($a, $b) => + Str::substrCount($b, '\\') <=> Str::substrCount($a, '\\') + ); + + foreach ($namespaceRoots as $prefix => $paths) { + if (!Str::startsWith($class, $prefix)) { + continue; + } + + // Filter duplicates and invalid entries + $paths = array_unique(array_filter($paths)); + + if (count($paths) > 1) { + throw new RuntimeException(sprintf( + 'Multiple base paths found for namespace [%s]: %s', + $prefix, + implode(', ', $paths) + )); + } + + if (empty($paths)) { + continue; + } - return $this->laravel['path'].'/'.str_replace('\\', '/', $name).'.php'; + $basePath = reset($paths); + $relative = ltrim(Str::after($class, $prefix), '\\'); + $relativePath = str_replace(['\\', '_'], DIRECTORY_SEPARATOR, $relative).'.php'; + + return rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$relativePath; + } + + throw new RuntimeException(sprintf( + 'Unable to resolve a base path for class [%s]', + $class + )); } /** From df302f0d56c62c4b43792f2ffde97774391d6334 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 29 Oct 2025 14:07:40 -0700 Subject: [PATCH 3/5] Style fix --- src/Illuminate/Console/GeneratorCommand.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index 781ed3b29cde..fc06082eedd8 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -338,11 +338,11 @@ protected function resolvePathForClass(string $class): string // Collect valid PSR-4 and PSR-0 namespace mappings foreach (ClassLoader::getRegisteredLoaders() as $loader) { foreach ([$loader->getPrefixesPsr4(), $loader->getPrefixes()] as $prefixes) { - foreach ($prefixes as $ns => $paths) { + foreach ($prefixes as $namespace => $paths) { foreach ($paths as $path) { $real = realpath($path); if ($real !== false) { - $namespaceRoots[rtrim($ns, '\\')][] = $real; + $namespaceRoots[rtrim($namespace, '\\')][] = $real; } } } @@ -350,12 +350,10 @@ protected function resolvePathForClass(string $class): string } // Sort by namespace depth (deepest first) - uksort($namespaceRoots, fn($a, $b) => - Str::substrCount($b, '\\') <=> Str::substrCount($a, '\\') - ); + uksort($namespaceRoots, fn ($a, $b) => Str::substrCount($b, '\\') <=> Str::substrCount($a, '\\'); foreach ($namespaceRoots as $prefix => $paths) { - if (!Str::startsWith($class, $prefix)) { + if (! Str::startsWith($class, $prefix)) { continue; } @@ -510,7 +508,7 @@ protected function getNameInput() */ protected function rootNamespace() { - if (!empty($this->rootNamespace)) { + if (! empty($this->rootNamespace)) { return $this->rootNamespace; } @@ -519,7 +517,7 @@ protected function rootNamespace() $in = text( label: 'What namespace would you like to generate the '.$this->type.' in?', placeholder: 'App', - validate: fn ($value) => empty($value) ? "The in option is required when the application namespace is empty." : null, + validate: fn ($value) => empty($value) ? 'The in option is required when the application namespace is empty.' : null, ); } From 12dd21d3ca7ede747c77d7148e64d41fd0c04e61 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 29 Oct 2025 14:09:14 -0700 Subject: [PATCH 4/5] Fix typo --- src/Illuminate/Console/GeneratorCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index fc06082eedd8..6136936ef78b 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -350,7 +350,7 @@ protected function resolvePathForClass(string $class): string } // Sort by namespace depth (deepest first) - uksort($namespaceRoots, fn ($a, $b) => Str::substrCount($b, '\\') <=> Str::substrCount($a, '\\'); + uksort($namespaceRoots, fn ($a, $b) => Str::substrCount($b, '\\') <=> Str::substrCount($a, '\\')); foreach ($namespaceRoots as $prefix => $paths) { if (! Str::startsWith($class, $prefix)) { From 79887234e1df1cb3ce8c39f8d1004dd643b69a4c Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Thu, 30 Oct 2025 13:55:27 -0400 Subject: [PATCH 5/5] Fix failing generator tests by registering App namespace in Orchestra Testbench Problem Context: ----------------- The generator tests were failing because Orchestra Testbench creates a fake Laravel application in vendor/orchestra/testbench-core/laravel/ with its own composer.json that defines "App\\": "app/". However, the tests load Composer's autoloader from the framework's vendor/composer/autoload, which has no knowledge of the testbench app's namespace mappings. The new resolvePathForClass() method (introduced in the composer-based path resolution PR) relies on Composer's ClassLoader having registered namespace mappings to determine where to place generated files. When it can't resolve a namespace, it falls back to: $this->laravel['path.base'] . '/' . str_replace('\\', '/', $name) . '.php' This creates paths like: /vendor/.../laravel/App/View/Components/Foo.php ^^^ Instead of: /vendor/.../laravel/app/View/Components/Foo.php ^^^ The Problem with Test Generation: ---------------------------------- When generators use the CreatesMatchingTest trait with the --test flag, the handleTestCreation() method attempts to extract the relative path using: (new Stringable($path))->after($this->laravel['path']) Where $this->laravel['path'] is "/vendor/.../laravel/app" (lowercase). This fails to find "/app" within "/App/View/Components/Foo.php" (uppercase), so the entire absolute path remains in the test name, creating deeply nested incorrect paths like: /vendor/.../laravel/tests/Feature/vendor/.../laravel/App/View/Components/FooTest.php The Solution: ------------- Register the testbench app's App\\ namespace with Composer's ClassLoader in the test setUp() method. This allows resolvePathForClass() to succeed instead of falling back, generating correct paths that match the filesystem case. Alternative Approaches to Consider: ----------------------------------- 1. Adjust the fallback logic in getPath() to strip the root namespace and use $this->laravel['path'] (preserving pre-PR behavior), but this could break Winter CMS use cases where custom namespaces via --in don't match App\\. 2. Make handleTestCreation() more robust by using case-insensitive matching or by extracting paths differently, though this doesn't address the root cause of the fallback being used unnecessarily. 3. Have Orchestra Testbench itself register the App\\ namespace, though this would require changes in testbench-core and wouldn't help users on older versions. The current approach fixes the root cause in the test environment without affecting production behavior or breaking Winter CMS custom namespace support. --- src/Illuminate/Console/GeneratorCommand.php | 2 +- tests/Integration/Generators/TestCase.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index 6136936ef78b..d32ce8e3cd6f 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -321,7 +321,7 @@ protected function getPath($name) { try { return $this->resolvePathForClass($name); - } catch (RuntimeException $e) { + } catch (RuntimeException) { return $this->laravel['path.base'].'/'.str_replace('\\', '/', $name).'.php'; } } diff --git a/tests/Integration/Generators/TestCase.php b/tests/Integration/Generators/TestCase.php index 19bce44b7ddb..b040d3133989 100644 --- a/tests/Integration/Generators/TestCase.php +++ b/tests/Integration/Generators/TestCase.php @@ -2,9 +2,21 @@ namespace Illuminate\Tests\Integration\Generators; +use Composer\Autoload\ClassLoader; use Orchestra\Testbench\Concerns\InteractsWithPublishedFiles; abstract class TestCase extends \Orchestra\Testbench\TestCase { use InteractsWithPublishedFiles; + + protected function setUp(): void + { + parent::setUp(); + + // Register the App namespace with Composer's autoloader for the testbench laravel app + $appPath = $this->app->path(); + foreach (ClassLoader::getRegisteredLoaders() as $loader) { + $loader->addPsr4('App\\', [$appPath]); + } + } }