From be304e7b1a543ec13f443f6ce5e329a4ad9510ed Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Mar 2026 23:19:30 +0000 Subject: [PATCH] fix(lifecycle): deduplicate route names from multi-domain registrations When the same route file is registered on multiple domains (e.g. core.test, hub.core.test, core.localhost), Laravel's route:cache fails with "Another route has already been assigned name". Add deduplicateRouteNames() to strip names from duplicate routes, keeping only the first registration. Extract processViews(), processLivewire(), and refreshRoutes() helpers to reduce duplication across fire* methods. Co-Authored-By: Virgil --- src/Core/LifecycleEventProvider.php | 144 +++++++++++-------- tests/Feature/LifecycleEventProviderTest.php | 77 ++++++++++ 2 files changed, 163 insertions(+), 58 deletions(-) diff --git a/src/Core/LifecycleEventProvider.php b/src/Core/LifecycleEventProvider.php index b514e08..8ff9ee9 100644 --- a/src/Core/LifecycleEventProvider.php +++ b/src/Core/LifecycleEventProvider.php @@ -260,6 +260,79 @@ class LifecycleEventProvider extends ServiceProvider } } + /** + * Register view namespaces collected by a lifecycle event. + */ + protected static function processViews(Events\LifecycleEvent $event): void + { + foreach ($event->viewRequests() as [$namespace, $path]) { + if (is_dir($path)) { + view()->addNamespace($namespace, $path); + } + } + } + + /** + * Register Livewire components collected by a lifecycle event. + */ + protected static function processLivewire(Events\LifecycleEvent $event): void + { + if (! class_exists(Livewire::class)) { + return; + } + + foreach ($event->livewireRequests() as [$alias, $class]) { + Livewire::component($alias, $class); + } + } + + /** + * Deduplicate route names and refresh router lookups. + * + * Called after every route-registering fire* method so that multi-domain + * registrations of the same route file do not produce duplicate names, + * and so that name/action lookups reflect the newly added routes. + */ + protected static function refreshRoutes(): void + { + static::deduplicateRouteNames(); + + $routes = app('router')->getRoutes(); + $routes->refreshNameLookups(); + $routes->refreshActionLookups(); + } + + /** + * Strip duplicate route names from the route collection. + * + * When the same route file is registered on multiple domains, each domain + * gets identical route names (e.g. 'hub.dashboard' appears for core.test, + * hub.core.test, core.localhost). Laravel's route:cache fails with + * "Another route has already been assigned name" when duplicates exist. + * + * This keeps the name on the first registered route and strips it from + * subsequent duplicates, allowing route:cache to succeed. + */ + protected static function deduplicateRouteNames(): void + { + $routes = app('router')->getRoutes(); + $seen = []; + + foreach ($routes->getRoutes() as $route) { + $name = $route->getName(); + + if ($name === null || $name === '') { + continue; + } + + if (isset($seen[$name])) { + unset($route->action['as']); + } else { + $seen[$name] = true; + } + } + } + /** * Fire WebRoutesRegistering and process collected requests. * @@ -280,29 +353,14 @@ class LifecycleEventProvider extends ServiceProvider event($event); static::processMiddleware($event); + static::processViews($event); + static::processLivewire($event); - // Process view namespace requests - foreach ($event->viewRequests() as [$namespace, $path]) { - if (is_dir($path)) { - view()->addNamespace($namespace, $path); - } - } - - // Process Livewire component requests - foreach ($event->livewireRequests() as [$alias, $class]) { - if (class_exists(Livewire::class)) { - Livewire::component($alias, $class); - } - } - - // Process route requests foreach ($event->routeRequests() as $callback) { Route::middleware('web')->group($callback); } - // Refresh route lookups after adding routes - app('router')->getRoutes()->refreshNameLookups(); - app('router')->getRoutes()->refreshActionLookups(); + static::refreshRoutes(); } /** @@ -327,38 +385,21 @@ class LifecycleEventProvider extends ServiceProvider event($event); static::processMiddleware($event); + static::processViews($event); - // Process view namespace requests - foreach ($event->viewRequests() as [$namespace, $path]) { - if (is_dir($path)) { - view()->addNamespace($namespace, $path); - } - } - - // Process translation requests foreach ($event->translationRequests() as [$namespace, $path]) { if (is_dir($path)) { app('translator')->addNamespace($namespace, $path); } } - // Process Livewire component requests - foreach ($event->livewireRequests() as [$alias, $class]) { - if (class_exists(Livewire::class)) { - Livewire::component($alias, $class); - } - } + static::processLivewire($event); - // Process route requests with admin middleware foreach ($event->routeRequests() as $callback) { Route::middleware('admin')->group($callback); } - // Note: Navigation is handled via AdminMenuProvider interface. - // Modules implementing that interface will have their navigation - // registered through the existing AdminMenuRegistry::register() call. - // The $event->navigation() requests are available for future use - // when we move away from the AdminMenuProvider pattern. + static::refreshRoutes(); } /** @@ -377,29 +418,14 @@ class LifecycleEventProvider extends ServiceProvider event($event); static::processMiddleware($event); + static::processViews($event); + static::processLivewire($event); - // Process view namespace requests - foreach ($event->viewRequests() as [$namespace, $path]) { - if (is_dir($path)) { - view()->addNamespace($namespace, $path); - } - } - - // Process Livewire component requests - foreach ($event->livewireRequests() as [$alias, $class]) { - if (class_exists(Livewire::class)) { - Livewire::component($alias, $class); - } - } - - // Process route requests with client middleware foreach ($event->routeRequests() as $callback) { Route::middleware('client')->group($callback); } - // Refresh route lookups after adding routes - app('router')->getRoutes()->refreshNameLookups(); - app('router')->getRoutes()->refreshActionLookups(); + static::refreshRoutes(); } /** @@ -419,10 +445,11 @@ class LifecycleEventProvider extends ServiceProvider static::processMiddleware($event); - // Process route requests with api middleware foreach ($event->routeRequests() as $callback) { Route::middleware('api')->group($callback); } + + static::refreshRoutes(); } /** @@ -442,10 +469,11 @@ class LifecycleEventProvider extends ServiceProvider static::processMiddleware($event); - // Process route requests with mcp middleware foreach ($event->routeRequests() as $callback) { Route::middleware('mcp')->group($callback); } + + static::refreshRoutes(); } /** diff --git a/tests/Feature/LifecycleEventProviderTest.php b/tests/Feature/LifecycleEventProviderTest.php index 30cd75a..200da76 100644 --- a/tests/Feature/LifecycleEventProviderTest.php +++ b/tests/Feature/LifecycleEventProviderTest.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace Core\Tests\Feature; +use Core\Events\ApiRoutesRegistering; use Core\Events\FrameworkBooted; +use Core\Events\WebRoutesRegistering; use Core\LifecycleEventProvider; use Core\ModuleRegistry; use Core\ModuleScanner; use Core\Tests\TestCase; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Route; class LifecycleEventProviderTest extends TestCase { @@ -79,4 +83,77 @@ class LifecycleEventProviderTest extends TestCase $handlers = LifecycleEventProvider::fireMcpTools(); $this->assertIsArray($handlers); } + + public function test_fire_web_routes_deduplicates_route_names_across_domains(): void + { + // Register the same named route on two different domains + Event::listen(WebRoutesRegistering::class, function (WebRoutesRegistering $event) { + $event->routes(fn () => Route::domain('example.test') + ->name('hub.') + ->group(function () { + Route::get('/dashboard', fn () => 'ok')->name('dashboard'); + })); + + $event->routes(fn () => Route::domain('hub.example.test') + ->name('hub.') + ->group(function () { + Route::get('/dashboard', fn () => 'ok')->name('dashboard'); + })); + }); + + LifecycleEventProvider::fireWebRoutes(); + + $routes = app('router')->getRoutes(); + $named = collect($routes->getRoutes()) + ->filter(fn ($r) => $r->getName() === 'hub.dashboard'); + + $this->assertCount(1, $named, 'Only one route should keep the name "hub.dashboard"'); + + // Both routes should still exist (just one unnamed) + $allDashboard = collect($routes->getRoutes()) + ->filter(fn ($r) => $r->uri() === 'dashboard'); + $this->assertCount(2, $allDashboard, 'Both domain routes should still be registered'); + } + + public function test_fire_api_routes_deduplicates_route_names_across_domains(): void + { + Event::listen(ApiRoutesRegistering::class, function (ApiRoutesRegistering $event) { + $event->routes(fn () => Route::domain('api.example.test') + ->name('api.') + ->group(function () { + Route::get('/users', fn () => 'ok')->name('users.index'); + })); + + $event->routes(fn () => Route::domain('api.hub.example.test') + ->name('api.') + ->group(function () { + Route::get('/users', fn () => 'ok')->name('users.index'); + })); + }); + + LifecycleEventProvider::fireApiRoutes(); + + $routes = app('router')->getRoutes(); + $named = collect($routes->getRoutes()) + ->filter(fn ($r) => $r->getName() === 'api.users.index'); + + $this->assertCount(1, $named, 'Only one route should keep the name "api.users.index"'); + } + + public function test_deduplication_preserves_unique_route_names(): void + { + Event::listen(WebRoutesRegistering::class, function (WebRoutesRegistering $event) { + $event->routes(fn () => Route::domain('example.test') + ->group(function () { + Route::get('/home', fn () => 'ok')->name('home'); + Route::get('/about', fn () => 'ok')->name('about'); + })); + }); + + LifecycleEventProvider::fireWebRoutes(); + + $routes = app('router')->getRoutes(); + $this->assertNotNull($routes->getByName('home')); + $this->assertNotNull($routes->getByName('about')); + } }