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')); + } }