From f4bedbd7ff8c804f20897a03a9eca0e7ba6291b5 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 12:09:22 +0100 Subject: [PATCH 1/3] fix(modules): read a Boot file's namespace instead of guessing it from the path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModuleScanner derived a class name from the directory a Boot.php sat in — /Core meant Core\, /Mod meant Mod\. That is a convention, not a fact. It held for an application laid out the way the scaffolding lays one out, and broke everywhere else, starting with this package: src/Mod/Trees/Boot.php declares Core\Mod\Trees and the rule produced Mod\Trees\Boot. The failure was not a miss. In an application that owns a module of the same name — host.uk.com has an app/Mod/Trees — the wrong name RESOLVES, so the scanner wired the consumer's unrelated class for a Boot file it had read out of vendor. Verified against that application before the change: Core\Mod\Trees\Boot exists=yes wired=NO, Mod\Trees\Boot exists=yes wired=yes. Nothing failed. The wrong code ran, and this package's own Trees module had never run at all. So the name is now read from the file's own namespace declaration, which is the only thing about a file that is not a guess. The path convention survives only for a Boot.php that declares no namespace at all. Fixing that immediately proved the point twice over. Core\Mod\Trees\Boot began wiring for the first time, its onWebRoutes registers a Livewire component, and three route tests started failing with BindingResolutionException on livewire.finder — because processLivewire guarded on class_exists(Livewire::class), which is true the moment the package is in vendor/ and says nothing about whether its provider has booted. An application shipping Livewire without booting it got an exception where it should have got a no-op, the way processViews already skips a view path that is not there. That guard now asks the container. ModuleRegistry::registerClass() is the second half. Scanning cannot find vendor packages, and cannot be made to: php-uptelligence puts Core\Mod\Uptelligence at its package root, php-commerce keeps Core\Service\Commerce under Service/, php-admin has Core\Mod\Hub under src/Mod/Hub. No directory convention describes all of those. But every one of them is already a ServiceProvider Laravel has constructed, so the name is simply available — static::class, no derivation: public function register(): void { $this->app->make(ModuleRegistry::class)->registerClass(static::class); } Idempotent, so a package that is both scanned and self-registering does not run its handlers twice. Documented in CLAUDE.md as the vendor registration path, naming the trap it closes: a $listens array on a class nothing scans is dead code that reads as live. Deliberately not done: path-based vendor scanning. There is no convention to fit, and a scanner that guesses wrong fails silently, which is the defect above one layer down. ./vendor/bin/pest --testsuite=Feature,Unit 261 -> 267 passed, 0 failed ./vendor/bin/pest --testsuite=Module 448 failed / 308 passed, unchanged vendor/bin/pint --test pass vendor/bin/phpstan analyse no errors vendor/bin/psalm no errors The six new tests are in tests/Feature deliberately, not src/**/Tests: the Module suite is allow_failure in CI by design, and a regression pin belongs in the job that can stop a merge. Co-Authored-By: Virgil --- CLAUDE.md | 31 +++++++++++ src/Core/LifecycleEventProvider.php | 14 ++++- src/Core/ModuleRegistry.php | 58 ++++++++++++++++++++ src/Core/ModuleScanner.php | 76 ++++++++++++++++++++++++--- tests/Feature/ModuleRegistryTest.php | 71 +++++++++++++++++++++++++ tests/Feature/ModuleScannerTest.php | 53 +++++++++++++++++++ tests/Fixtures/Mod/Displaced/Boot.php | 30 +++++++++++ 7 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/Mod/Displaced/Boot.php diff --git a/CLAUDE.md b/CLAUDE.md index 9d27836..b47bff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,37 @@ class Boot Scaffold new modules with artisan: `make:mod`, `make:website`, `make:plug`. +**Modules inside this package or the consuming app** are found by `ModuleScanner`, +which walks the configured `core.module_paths` plus this package's own `src/Core` +and `src/Mod`. Declaring `$listens` is enough. + +**Modules in any other package are not scanned, and must register themselves:** + +```php +class Boot extends ServiceProvider +{ + public static array $listens = [ + AdminPanelBooting::class => 'onAdmin', + ]; + + public function register(): void + { + $this->app->make(ModuleRegistry::class)->registerClass(static::class); + } +} +``` + +A `$listens` array on a class nothing scans is dead code that reads as live: the +handlers are declared, never called, and nothing reports a problem — the feature +is simply absent. `registerClass()` takes the name from `static::class`, so no +directory convention has to be true for it to work. + +Scanning cannot do this job for vendor packages. It has to derive a class name +from a path, and packages lay themselves out differently — `php-uptelligence` +puts `Core\Mod\Uptelligence` at its package root, `php-commerce` keeps +`Core\Service\Commerce` under `Service/`. A derivation that guesses wrong +produces a name that does not exist, and the module is skipped in silence. + ### Namespace Mapping | Path | Namespace | diff --git a/src/Core/LifecycleEventProvider.php b/src/Core/LifecycleEventProvider.php index 23f993f..082a3f3 100644 --- a/src/Core/LifecycleEventProvider.php +++ b/src/Core/LifecycleEventProvider.php @@ -275,10 +275,22 @@ protected static function processViews(LifecycleEvent $event): void /** * Register Livewire components collected by a lifecycle event. + * + * Installed and booted are different questions, and only the second one + * matters here. `class_exists(Livewire::class)` is true the moment the + * package is in vendor/, which says nothing about whether its service + * provider has run — and `Livewire::component()` resolves `livewire.finder` + * out of the container, so on an application that ships Livewire without + * booting it the call throws BindingResolutionException rather than doing + * nothing. + * + * A module asking to register a component in an application that has no + * Livewire should be a no-op, the same way {@see processViews} skips a view + * path that is not there. */ protected static function processLivewire(LifecycleEvent $event): void { - if (! class_exists(Livewire::class)) { + if (! class_exists(Livewire::class) || ! app()->bound('livewire')) { return; } diff --git a/src/Core/ModuleRegistry.php b/src/Core/ModuleRegistry.php index b740eeb..1070f5a 100644 --- a/src/Core/ModuleRegistry.php +++ b/src/Core/ModuleRegistry.php @@ -215,4 +215,62 @@ public function addPaths(array $paths): void } } } + + /** + * Register one Boot class's `$listens` by name, without scanning for it. + * + * This is how a package outside the scanned tree takes part in lifecycle + * events — which in practice means every package in vendor/. + * + * // in the package's Boot::register() + * $this->app->make(ModuleRegistry::class)->registerClass(static::class); + * + * ## Why by name rather than by path + * + * Scanning has to work out a class name from a directory, and a package may + * lay itself out however it likes: php-uptelligence puts `Core\Mod\Uptelligence` + * at its package root, php-commerce keeps `Core\Service\Commerce` under + * `Service/`, php-admin has `Core\Mod\Hub` under `src/Mod/Hub`. No directory + * convention describes all of those, and one that guesses wrong does not + * fail loudly — it produces a name that does not exist, `class_exists()` + * returns false, and the module is skipped in silence. + * + * `static::class` is not a guess. The Boot class already knows what it is + * called, and every one of these packages is already a ServiceProvider that + * Laravel has constructed, so there is a moment where the name is simply + * available. That is the moment to use it. + * + * ## The trap this exists to close + * + * A `$listens` array on a class nothing scans is dead code that reads as + * live. It declares handlers, they are never called, and nothing anywhere + * reports a problem — the feature is just quietly absent. If you are writing + * a package with a `$listens` array, call this; declaring the array is not + * enough on its own. + * + * ## Ordering + * + * Priorities order listeners registered together in one pass. A module that + * registers itself is appended when its provider runs, so its priority + * orders it against others registered in the same call and not against the + * scanned tree. If two modules must run in a fixed order relative to each + * other, that is a reason for them to be scanned together rather than to + * lean on this. + * + * Registering the same class twice is a no-op, so a package that both is + * scanned and calls this does not get its handlers run twice. + * + * @param class-string $class The Boot class to register + */ + public function registerClass(string $class): void + { + foreach ($this->scanner->extractListens($class) as $event => $config) { + if (isset($this->mappings[$event][$class])) { + continue; + } + + $this->mappings[$event][$class] = $config; + Event::listen($event, new LazyModuleListener($class, $config['method'])); + } + } } diff --git a/src/Core/ModuleScanner.php b/src/Core/ModuleScanner.php index 9414f16..43cbdca 100644 --- a/src/Core/ModuleScanner.php +++ b/src/Core/ModuleScanner.php @@ -46,11 +46,12 @@ * * ## Namespace Detection * - * The scanner automatically determines namespaces based on path: - * - `/Core` paths map to `Core\` namespace - * - `/Mod` paths map to `Mod\` namespace - * - `/Website` paths map to `Website\` namespace - * - `/Plug` paths map to `Plug\` namespace + * The class name is read out of the file's own `namespace` declaration, so a + * package may lay itself out however it likes and still be found. Only a + * Boot.php that declares no namespace falls back to the directory convention + * (`/Core` → `Core\`, `/Mod` → `Mod\`, `/Website` → `Website\`, `/Plug` → `Plug\`). + * + * {@see classFromFile} says why the convention stopped being the primary rule. * * ## Usage Example * @@ -170,9 +171,68 @@ private function normalizeListens(array $listens): array } /** - * Derive fully qualified class name from file path. + * Determine the fully qualified class name a Boot.php file declares. + * + * Read out of the file, not guessed from its path. The file says which + * namespace it is in; nothing else has to agree with it. + * + * It used to be derived from the path — `/Core` meant `Core\`, `/Mod` meant + * `Mod\` — and that is a convention rather than a fact. It held for a + * consuming application laid out the way the scaffolding lays one out, and + * broke everywhere else, including in this framework: `src/Mod/Trees/Boot.php` + * declares `Core\Mod\Trees` and the path rule produced `Mod\Trees\Boot`. + * + * That failure was not a miss. In an application that happens to own a + * module of the same name — host.uk.com has an `app/Mod/Trees` — the wrong + * name *resolves*, and the scanner wires the consumer's unrelated class in + * place of this one, silently, for a Boot file it read from vendor. A rule + * that can attribute one package's file to another package's class is not a + * rule worth keeping. * - * Maps file paths to PSR-4 namespaces based on directory structure: + * The path convention survives only as a fallback for a Boot.php with no + * namespace declaration at all. + * + * @param string $file Absolute path to the Boot.php file + * @param string $basePath Base directory path (e.g., app_path('Mod')) + * @return string|null Fully qualified class name, or null if it cannot be determined + */ + private function classFromFile(string $file, string $basePath): ?string + { + $declared = $this->namespaceFromSource($file); + + if ($declared !== null) { + return $declared.'\\'.basename($file, '.php'); + } + + return $this->classFromPath($file, $basePath); + } + + /** + * Read the namespace a file declares, without loading it. + * + * Only the head of the file is read: a namespace declaration is required to + * be the first statement, so 8KB is more than enough, and this runs for + * every Boot.php on every request. + * + * @return string|null the declared namespace, or null for the global one + */ + private function namespaceFromSource(string $file): ?string + { + $head = @file_get_contents($file, false, null, 0, 8192); + + if ($head === false) { + return null; + } + + return preg_match('/^\s*namespace\s+([A-Za-z0-9_\x80-\xff\\\\]+)\s*;/m', $head, $matches) === 1 + ? $matches[1] + : null; + } + + /** + * The old path-derived name, kept for a Boot.php that declares no namespace. + * + * Maps file paths to namespaces by directory convention: * * - `app/Mod/Commerce/Boot.php` becomes `Mod\Commerce\Boot` * - `app/Core/Cdn/Boot.php` becomes `Core\Cdn\Boot` @@ -183,7 +243,7 @@ private function normalizeListens(array $listens): array * @param string $basePath Base directory path (e.g., app_path('Mod')) * @return string|null Fully qualified class name, or null if path doesn't match expected structure */ - private function classFromFile(string $file, string $basePath): ?string + private function classFromPath(string $file, string $basePath): ?string { // Normalise paths $file = str_replace('\\', '/', realpath($file) ?: $file); diff --git a/tests/Feature/ModuleRegistryTest.php b/tests/Feature/ModuleRegistryTest.php index 7dad549..f607c1d 100644 --- a/tests/Feature/ModuleRegistryTest.php +++ b/tests/Feature/ModuleRegistryTest.php @@ -135,4 +135,75 @@ public function test_register_fires_events_to_listeners(): void // The Example module registers views $this->assertNotEmpty($event->viewRequests()); } + + /** + * The vendor registration path: a class registers itself by name. + * + * No path, no directory convention, no guess — the Boot class already knows + * what it is called. + */ + public function test_register_class_wires_a_boot_class_by_name(): void + { + $registry = new ModuleRegistry(new ModuleScanner()); + + $registry->registerClass(\Core\Tests\Fixtures\Mod\Displaced\Boot::class); + + $this->assertContains( + \Core\Tests\Fixtures\Mod\Displaced\Boot::class, + $registry->getModules(), + ); + $this->assertArrayHasKey( + \Core\Tests\Fixtures\Mod\Displaced\Boot::class, + $registry->getListenersFor(WebRoutesRegistering::class), + ); + } + + /** + * The handler actually runs when the event fires — registering is not the + * same claim as being called, which is the whole reason this method exists. + */ + public function test_register_class_listener_runs_when_the_event_fires(): void + { + $registry = new ModuleRegistry(new ModuleScanner()); + $registry->registerClass(\Core\Tests\Fixtures\Mod\Displaced\Boot::class); + + $event = new WebRoutesRegistering(); + Event::dispatch($event); + + $namespaces = array_map(fn (array $request): string => $request[0], $event->viewRequests()); + $this->assertContains('displaced', $namespaces); + } + + /** + * A package that is both scanned and self-registering must not run twice. + */ + public function test_register_class_is_idempotent(): void + { + $registry = new ModuleRegistry(new ModuleScanner()); + + $registry->registerClass(\Core\Tests\Fixtures\Mod\Displaced\Boot::class); + $registry->registerClass(\Core\Tests\Fixtures\Mod\Displaced\Boot::class); + + $event = new WebRoutesRegistering(); + Event::dispatch($event); + + $displaced = array_filter( + $event->viewRequests(), + fn (array $request): bool => $request[0] === 'displaced', + ); + + $this->assertCount(1, $displaced, 'the handler ran more than once'); + } + + /** + * A class with no $listens registers nothing rather than erroring. + */ + public function test_register_class_ignores_a_class_without_listens(): void + { + $registry = new ModuleRegistry(new ModuleScanner()); + + $registry->registerClass(\Mod\NoListens\Boot::class); + + $this->assertSame([], $registry->getModules()); + } } diff --git a/tests/Feature/ModuleScannerTest.php b/tests/Feature/ModuleScannerTest.php index 0f5ad56..71c6e68 100644 --- a/tests/Feature/ModuleScannerTest.php +++ b/tests/Feature/ModuleScannerTest.php @@ -231,4 +231,57 @@ public function test_scan_aggregates_multiple_paths(): void // Should have multiple listeners for WebRoutesRegistering $this->assertGreaterThanOrEqual(2, count($result[WebRoutesRegistering::class])); } + + /** + * The class name comes from the file, not from the directory above it. + * + * A Boot.php under a /Mod path that declares something else entirely is not + * exotic — this framework's own src/Mod/Trees declares Core\Mod\Trees, and + * php-commerce keeps Core\Service\Commerce under Service/. + */ + public function test_scan_reads_the_declared_namespace_not_the_path(): void + { + $modules = $this->scannedClasses([__DIR__.'/../Fixtures/Mod']); + + $this->assertContains( + \Core\Tests\Fixtures\Mod\Displaced\Boot::class, + $modules, + 'the fixture declares its namespace and the scanner must use it', + ); + $this->assertNotContains('Mod\Displaced\Boot', $modules); + } + + /** + * The regression this replaces a convention for. + * + * src/Mod/Trees/Boot.php declares Core\Mod\Trees. The old path rule derived + * Mod\Trees\Boot — a name this package does not own. In a consuming + * application that happens to have its own app/Mod/Trees, that name + * *resolves*, so the scanner wired the consumer's unrelated class for a Boot + * file it read out of vendor. Nothing failed. The wrong code ran. + */ + public function test_scan_does_not_attribute_framework_boot_files_to_consumer_classes(): void + { + $modules = $this->scannedClasses([__DIR__.'/../../src/Mod']); + + $this->assertContains(\Core\Mod\Trees\Boot::class, $modules); + $this->assertNotContains('Mod\Trees\Boot', $modules); + } + + /** + * @param array $paths + * @return array + */ + private function scannedClasses(array $paths): array + { + $classes = []; + + foreach ((new ModuleScanner())->scan($paths) as $listeners) { + foreach (array_keys($listeners) as $class) { + $classes[$class] = true; + } + } + + return array_keys($classes); + } } diff --git a/tests/Fixtures/Mod/Displaced/Boot.php b/tests/Fixtures/Mod/Displaced/Boot.php new file mode 100644 index 0000000..a5dfd89 --- /dev/null +++ b/tests/Fixtures/Mod/Displaced/Boot.php @@ -0,0 +1,30 @@ + 'onWebRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('displaced', __DIR__.'/Views'); + } +} From db5a6e27e0c4f0003f52508830f86b0b006d03f3 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 12:21:02 +0100 Subject: [PATCH 2/3] docs(modules): two corrections CodeRabbit was right about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are accuracy, and the first is a mistake worth naming: I wrote the case for registerClass() using the OLD scanner's limitation, in the same change that removed it. The text said scanning cannot work for vendor packages because it derives class names from paths — but it no longer does. A configured vendor path now resolves correctly, whatever the package's layout. So the honest statement is the one about who carries the burden, not about what is possible. A vendor path CAN be scanned by adding it to core.module_paths; registerClass() is preferred because it asks nothing of the consumer. A package that is installed and not configured looks installed and does nothing, which is the same silent-absence failure one configuration step removed. Second: the processLivewire docblock named livewire.finder, which is a binding internal to one Livewire version. The check is on the facade's own binding precisely so it survives Livewire moving its internals around, and the comment now says that — and says what the check does and does not prove: the registration path is reachable, not that Livewire has finished booting. Doc-only. No behaviour change. vendor/bin/pint --test pass ./vendor/bin/pest --testsuite=Feature,Unit 267 passed, 0 failed Co-Authored-By: Virgil --- CLAUDE.md | 16 ++++++++++------ src/Core/LifecycleEventProvider.php | 15 ++++++++++----- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b47bff9..8bd41e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,11 @@ Scaffold new modules with artisan: `make:mod`, `make:website`, `make:plug`. which walks the configured `core.module_paths` plus this package's own `src/Core` and `src/Mod`. Declaring `$listens` is enough. -**Modules in any other package are not scanned, and must register themselves:** +**Modules in any other package are not scanned by default.** Their path can be +added to `core.module_paths` — the scanner reads each `Boot.php`'s declared +namespace, so a package laid out any way at all resolves correctly once its path +is configured. But that puts the burden on every consumer to know about the +package, so a package should register itself instead: ```php class Boot extends ServiceProvider @@ -121,11 +125,11 @@ handlers are declared, never called, and nothing reports a problem — the featu is simply absent. `registerClass()` takes the name from `static::class`, so no directory convention has to be true for it to work. -Scanning cannot do this job for vendor packages. It has to derive a class name -from a path, and packages lay themselves out differently — `php-uptelligence` -puts `Core\Mod\Uptelligence` at its package root, `php-commerce` keeps -`Core\Service\Commerce` under `Service/`. A derivation that guesses wrong -produces a name that does not exist, and the module is skipped in silence. +`registerClass()` is preferred over configuring a path because it needs nothing +from the consumer: the package declares its own participation, and a consumer +that merely installs it gets working behaviour. Configuring `core.module_paths` +works, but it means every application must be told about every package, and a +package that is installed and not configured looks installed and does nothing. ### Namespace Mapping diff --git a/src/Core/LifecycleEventProvider.php b/src/Core/LifecycleEventProvider.php index 082a3f3..f9d213b 100644 --- a/src/Core/LifecycleEventProvider.php +++ b/src/Core/LifecycleEventProvider.php @@ -276,13 +276,18 @@ protected static function processViews(LifecycleEvent $event): void /** * Register Livewire components collected by a lifecycle event. * - * Installed and booted are different questions, and only the second one + * Installed and wired are different questions, and only the second one * matters here. `class_exists(Livewire::class)` is true the moment the * package is in vendor/, which says nothing about whether its service - * provider has run — and `Livewire::component()` resolves `livewire.finder` - * out of the container, so on an application that ships Livewire without - * booting it the call throws BindingResolutionException rather than doing - * nothing. + * provider has run — and registering a component resolves Livewire's own + * services out of the container, so on an application that ships Livewire + * without booting it the call throws BindingResolutionException rather than + * doing nothing. + * + * The container check is deliberately the facade's own binding rather than + * any particular internal service, so it does not have to be revisited when + * Livewire moves those around between versions. It confirms the registration + * path is reachable; it is not a claim that Livewire has finished booting. * * A module asking to register a component in an application that has no * Livewire should be a no-op, the same way {@see processViews} skips a view From ce4d8bb0373bc16eaad6e959e9bf64eabfb2df07 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 12:29:38 +0100 Subject: [PATCH 3/3] fix(modules): a self-registered class is no longer wired twice by a later scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit found a real bug in the registerClass() I added, and it was mine. register() did `$this->mappings = $this->scanner->scan($paths)` — assignment, not merge. So a package that called registerClass() from its own provider had that record thrown away the moment the framework's scan ran, which meant the idempotency guard inside registerClass() had nothing left to see. If the scan also found the class, it wired a second listener and the handler ran twice on every event. The order is not hypothetical. A package provider's register() runs whenever Laravel gets to it, which may be before LifecycleEventProvider's — so the sequence that breaks is the ordinary one, not a corner. Proven before fixing, with the fixture registering both ways: assertCount(1, $displaced) Failed asserting that actual size 2 matches expected size 1 register() now merges into what is already there and skips a class already registered for that event — the same guard addPaths() has always had, which is where I should have looked before writing a second registration path. ./vendor/bin/pest --testsuite=Feature,Unit 267 -> 268 passed, 0 failed ./vendor/bin/pest --testsuite=Module 448 failed / 308 passed, unchanged vendor/bin/pint --test pass vendor/bin/phpstan analyse no errors Co-Authored-By: Virgil --- src/Core/ModuleRegistry.php | 16 +++++++++++----- tests/Feature/ModuleRegistryTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Core/ModuleRegistry.php b/src/Core/ModuleRegistry.php index 1070f5a..c3ea01d 100644 --- a/src/Core/ModuleRegistry.php +++ b/src/Core/ModuleRegistry.php @@ -106,12 +106,18 @@ public function register(array $paths): void return; } - $this->mappings = $this->scanner->scan($paths); - - foreach ($this->mappings as $event => $listeners) { - $sorted = $this->sortByPriority($listeners); + // Merged into what is already here, not assigned over it. A package that + // calls registerClass() from its own provider may well do so before this + // runs — provider order is Laravel's to decide — and assigning threw that + // record away, so the guard below could not see it. The class was then + // wired a second time and its handler ran twice on every event. + foreach ($this->scanner->scan($paths) as $event => $listeners) { + foreach ($this->sortByPriority($listeners) as $moduleClass => $config) { + if (isset($this->mappings[$event][$moduleClass])) { + continue; + } - foreach ($sorted as $moduleClass => $config) { + $this->mappings[$event][$moduleClass] = $config; Event::listen($event, new LazyModuleListener($moduleClass, $config['method'])); } } diff --git a/tests/Feature/ModuleRegistryTest.php b/tests/Feature/ModuleRegistryTest.php index f607c1d..eef4dfa 100644 --- a/tests/Feature/ModuleRegistryTest.php +++ b/tests/Feature/ModuleRegistryTest.php @@ -206,4 +206,32 @@ public function test_register_class_ignores_a_class_without_listens(): void $this->assertSame([], $registry->getModules()); } + + /** + * A self-registered class must not be registered a second time by a later scan. + * + * register() replaced $mappings wholesale, which threw away the record that + * registerClass() had already wired a class — so the idempotency guard could + * not see it, and a class that both self-registers and is scanned got two + * listeners and ran its handler twice. The order is not hypothetical: a + * package provider's register() runs whenever Laravel gets to it, which may + * be before the framework's own scan. + */ + public function test_register_does_not_duplicate_a_self_registered_class(): void + { + $registry = new ModuleRegistry(new ModuleScanner()); + + $registry->registerClass(\Core\Tests\Fixtures\Mod\Displaced\Boot::class); + $registry->register([__DIR__.'/../Fixtures/Mod']); + + $event = new WebRoutesRegistering(); + Event::dispatch($event); + + $displaced = array_filter( + $event->viewRequests(), + fn (array $request): bool => $request[0] === 'displaced', + ); + + $this->assertCount(1, $displaced, 'the handler ran more than once'); + } }