fix(lifecycle): deduplicate route names from multi-domain registrations
Some checks failed
CI / PHP 8.4 (pull_request) Failing after 2m3s
CI / PHP 8.3 (pull_request) Failing after 2m18s
CI / PHP 8.4 (push) Failing after 2m2s
CI / PHP 8.3 (push) Failing after 2m20s
Publish Composer Package / publish (push) Failing after 10s

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>
This commit is contained in:
Snider 2026-03-21 23:19:30 +00:00
parent fab9318f64
commit be304e7b1a
2 changed files with 163 additions and 58 deletions

View file

@ -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();
}
/**

View file

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