Skip to content

Commit be304e7

Browse files
committed
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 <virgil@lethean.io>
1 parent fab9318 commit be304e7

2 files changed

Lines changed: 163 additions & 58 deletions

File tree

‎src/Core/LifecycleEventProvider.php‎

Lines changed: 86 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,79 @@ protected static function processMiddleware(Events\LifecycleEvent $event): void
260260
}
261261
}
262262

263+
/**
264+
* Register view namespaces collected by a lifecycle event.
265+
*/
266+
protected static function processViews(Events\LifecycleEvent $event): void
267+
{
268+
foreach ($event->viewRequests() as [$namespace, $path]) {
269+
if (is_dir($path)) {
270+
view()->addNamespace($namespace, $path);
271+
}
272+
}
273+
}
274+
275+
/**
276+
* Register Livewire components collected by a lifecycle event.
277+
*/
278+
protected static function processLivewire(Events\LifecycleEvent $event): void
279+
{
280+
if (! class_exists(Livewire::class)) {
281+
return;
282+
}
283+
284+
foreach ($event->livewireRequests() as [$alias, $class]) {
285+
Livewire::component($alias, $class);
286+
}
287+
}
288+
289+
/**
290+
* Deduplicate route names and refresh router lookups.
291+
*
292+
* Called after every route-registering fire* method so that multi-domain
293+
* registrations of the same route file do not produce duplicate names,
294+
* and so that name/action lookups reflect the newly added routes.
295+
*/
296+
protected static function refreshRoutes(): void
297+
{
298+
static::deduplicateRouteNames();
299+
300+
$routes = app('router')->getRoutes();
301+
$routes->refreshNameLookups();
302+
$routes->refreshActionLookups();
303+
}
304+
305+
/**
306+
* Strip duplicate route names from the route collection.
307+
*
308+
* When the same route file is registered on multiple domains, each domain
309+
* gets identical route names (e.g. 'hub.dashboard' appears for core.test,
310+
* hub.core.test, core.localhost). Laravel's route:cache fails with
311+
* "Another route has already been assigned name" when duplicates exist.
312+
*
313+
* This keeps the name on the first registered route and strips it from
314+
* subsequent duplicates, allowing route:cache to succeed.
315+
*/
316+
protected static function deduplicateRouteNames(): void
317+
{
318+
$routes = app('router')->getRoutes();
319+
$seen = [];
320+
321+
foreach ($routes->getRoutes() as $route) {
322+
$name = $route->getName();
323+
324+
if ($name === null || $name === '') {
325+
continue;
326+
}
327+
328+
if (isset($seen[$name])) {
329+
unset($route->action['as']);
330+
} else {
331+
$seen[$name] = true;
332+
}
333+
}
334+
}
335+
263336
/**
264337
* Fire WebRoutesRegistering and process collected requests.
265338
*
@@ -280,29 +353,14 @@ public static function fireWebRoutes(): void
280353
event($event);
281354

282355
static::processMiddleware($event);
356+
static::processViews($event);
357+
static::processLivewire($event);
283358

284-
// Process view namespace requests
285-
foreach ($event->viewRequests() as [$namespace, $path]) {
286-
if (is_dir($path)) {
287-
view()->addNamespace($namespace, $path);
288-
}
289-
}
290-
291-
// Process Livewire component requests
292-
foreach ($event->livewireRequests() as [$alias, $class]) {
293-
if (class_exists(Livewire::class)) {
294-
Livewire::component($alias, $class);
295-
}
296-
}
297-
298-
// Process route requests
299359
foreach ($event->routeRequests() as $callback) {
300360
Route::middleware('web')->group($callback);
301361
}
302362

303-
// Refresh route lookups after adding routes
304-
app('router')->getRoutes()->refreshNameLookups();
305-
app('router')->getRoutes()->refreshActionLookups();
363+
static::refreshRoutes();
306364
}
307365

308366
/**
@@ -327,38 +385,21 @@ public static function fireAdminBooting(): void
327385
event($event);
328386

329387
static::processMiddleware($event);
388+
static::processViews($event);
330389

331-
// Process view namespace requests
332-
foreach ($event->viewRequests() as [$namespace, $path]) {
333-
if (is_dir($path)) {
334-
view()->addNamespace($namespace, $path);
335-
}
336-
}
337-
338-
// Process translation requests
339390
foreach ($event->translationRequests() as [$namespace, $path]) {
340391
if (is_dir($path)) {
341392
app('translator')->addNamespace($namespace, $path);
342393
}
343394
}
344395

345-
// Process Livewire component requests
346-
foreach ($event->livewireRequests() as [$alias, $class]) {
347-
if (class_exists(Livewire::class)) {
348-
Livewire::component($alias, $class);
349-
}
350-
}
396+
static::processLivewire($event);
351397

352-
// Process route requests with admin middleware
353398
foreach ($event->routeRequests() as $callback) {
354399
Route::middleware('admin')->group($callback);
355400
}
356401

357-
// Note: Navigation is handled via AdminMenuProvider interface.
358-
// Modules implementing that interface will have their navigation
359-
// registered through the existing AdminMenuRegistry::register() call.
360-
// The $event->navigation() requests are available for future use
361-
// when we move away from the AdminMenuProvider pattern.
402+
static::refreshRoutes();
362403
}
363404

364405
/**
@@ -377,29 +418,14 @@ public static function fireClientRoutes(): void
377418
event($event);
378419

379420
static::processMiddleware($event);
421+
static::processViews($event);
422+
static::processLivewire($event);
380423

381-
// Process view namespace requests
382-
foreach ($event->viewRequests() as [$namespace, $path]) {
383-
if (is_dir($path)) {
384-
view()->addNamespace($namespace, $path);
385-
}
386-
}
387-
388-
// Process Livewire component requests
389-
foreach ($event->livewireRequests() as [$alias, $class]) {
390-
if (class_exists(Livewire::class)) {
391-
Livewire::component($alias, $class);
392-
}
393-
}
394-
395-
// Process route requests with client middleware
396424
foreach ($event->routeRequests() as $callback) {
397425
Route::middleware('client')->group($callback);
398426
}
399427

400-
// Refresh route lookups after adding routes
401-
app('router')->getRoutes()->refreshNameLookups();
402-
app('router')->getRoutes()->refreshActionLookups();
428+
static::refreshRoutes();
403429
}
404430

405431
/**
@@ -419,10 +445,11 @@ public static function fireApiRoutes(): void
419445

420446
static::processMiddleware($event);
421447

422-
// Process route requests with api middleware
423448
foreach ($event->routeRequests() as $callback) {
424449
Route::middleware('api')->group($callback);
425450
}
451+
452+
static::refreshRoutes();
426453
}
427454

428455
/**
@@ -442,10 +469,11 @@ public static function fireMcpRoutes(): void
442469

443470
static::processMiddleware($event);
444471

445-
// Process route requests with mcp middleware
446472
foreach ($event->routeRequests() as $callback) {
447473
Route::middleware('mcp')->group($callback);
448474
}
475+
476+
static::refreshRoutes();
449477
}
450478

451479
/**

‎tests/Feature/LifecycleEventProviderTest.php‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
namespace Core\Tests\Feature;
66

7+
use Core\Events\ApiRoutesRegistering;
78
use Core\Events\FrameworkBooted;
9+
use Core\Events\WebRoutesRegistering;
810
use Core\LifecycleEventProvider;
911
use Core\ModuleRegistry;
1012
use Core\ModuleScanner;
1113
use Core\Tests\TestCase;
14+
use Illuminate\Support\Facades\Event;
15+
use Illuminate\Support\Facades\Route;
1216

1317
class LifecycleEventProviderTest extends TestCase
1418
{
@@ -79,4 +83,77 @@ public function test_fire_mcp_tools_returns_array(): void
7983
$handlers = LifecycleEventProvider::fireMcpTools();
8084
$this->assertIsArray($handlers);
8185
}
86+
87+
public function test_fire_web_routes_deduplicates_route_names_across_domains(): void
88+
{
89+
// Register the same named route on two different domains
90+
Event::listen(WebRoutesRegistering::class, function (WebRoutesRegistering $event) {
91+
$event->routes(fn () => Route::domain('example.test')
92+
->name('hub.')
93+
->group(function () {
94+
Route::get('/dashboard', fn () => 'ok')->name('dashboard');
95+
}));
96+
97+
$event->routes(fn () => Route::domain('hub.example.test')
98+
->name('hub.')
99+
->group(function () {
100+
Route::get('/dashboard', fn () => 'ok')->name('dashboard');
101+
}));
102+
});
103+
104+
LifecycleEventProvider::fireWebRoutes();
105+
106+
$routes = app('router')->getRoutes();
107+
$named = collect($routes->getRoutes())
108+
->filter(fn ($r) => $r->getName() === 'hub.dashboard');
109+
110+
$this->assertCount(1, $named, 'Only one route should keep the name "hub.dashboard"');
111+
112+
// Both routes should still exist (just one unnamed)
113+
$allDashboard = collect($routes->getRoutes())
114+
->filter(fn ($r) => $r->uri() === 'dashboard');
115+
$this->assertCount(2, $allDashboard, 'Both domain routes should still be registered');
116+
}
117+
118+
public function test_fire_api_routes_deduplicates_route_names_across_domains(): void
119+
{
120+
Event::listen(ApiRoutesRegistering::class, function (ApiRoutesRegistering $event) {
121+
$event->routes(fn () => Route::domain('api.example.test')
122+
->name('api.')
123+
->group(function () {
124+
Route::get('/users', fn () => 'ok')->name('users.index');
125+
}));
126+
127+
$event->routes(fn () => Route::domain('api.hub.example.test')
128+
->name('api.')
129+
->group(function () {
130+
Route::get('/users', fn () => 'ok')->name('users.index');
131+
}));
132+
});
133+
134+
LifecycleEventProvider::fireApiRoutes();
135+
136+
$routes = app('router')->getRoutes();
137+
$named = collect($routes->getRoutes())
138+
->filter(fn ($r) => $r->getName() === 'api.users.index');
139+
140+
$this->assertCount(1, $named, 'Only one route should keep the name "api.users.index"');
141+
}
142+
143+
public function test_deduplication_preserves_unique_route_names(): void
144+
{
145+
Event::listen(WebRoutesRegistering::class, function (WebRoutesRegistering $event) {
146+
$event->routes(fn () => Route::domain('example.test')
147+
->group(function () {
148+
Route::get('/home', fn () => 'ok')->name('home');
149+
Route::get('/about', fn () => 'ok')->name('about');
150+
}));
151+
});
152+
153+
LifecycleEventProvider::fireWebRoutes();
154+
155+
$routes = app('router')->getRoutes();
156+
$this->assertNotNull($routes->getByName('home'));
157+
$this->assertNotNull($routes->getByName('about'));
158+
}
82159
}

0 commit comments

Comments
 (0)