From 144862007577bf1ec720bed3cdf99c8f9d490c2b Mon Sep 17 00:00:00 2001 From: Sam Maosa Date: Sat, 13 Jun 2026 17:51:08 +0300 Subject: [PATCH] fix: resolve custom namespace providers and panel module names Parse provider namespaces from source files and module names from module.json so custom namespaces register correctly and panels named like Blog\App work. Document panel scoping, fix theme command filesystem injection, and add tests. Co-authored-by: Cursor --- README.md | 12 +++ .../ModuleMakeFilamentThemeCommand.php | 2 + src/Modules.php | 48 +++++++++++ src/ModulesPlugin.php | 18 +++-- src/ModulesServiceProvider.php | 14 +++- tests/Unit/ModuleProviderResolutionTest.php | 79 +++++++++++++++++++ tests/Unit/ModulesServiceProviderBootTest.php | 20 ++--- 7 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/ModuleProviderResolutionTest.php diff --git a/README.md b/README.md index 29e07fc..9f84a5a 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,18 @@ php artisan module:filament:make-panel ``` Follow the interactive prompts to create a new panel in your module. +### Scoping resources, clusters, and pages to a panel + +Each module can register one or more Filament panels through `module:make:filament-panel`. Resources, pages, widgets, and clusters belong to a panel through that panel's `PanelProvider` — typically via `discoverResources()`, `discoverPages()`, and `discoverWidgets()` inside the provider's `panel()` method. + +To keep a resource or cluster in a single panel: + +1. Generate the resource/cluster inside the target module (and panel subdirectory, if you use per-panel folders). +2. Ensure only the intended module `*PanelProvider` discovers that directory/namespace. +3. Register `ModulesPlugin` on the main/admin panel so module panel links appear in navigation when `filament-modules.mode` supports panels. + +Filament's own panel discovery rules apply; this package wires modules and panels together but does not override Filament's per-panel registration model. + ### Protecting your resources, pages and widgets (Access Control) - WIP diff --git a/src/Commands/ModuleMakeFilamentThemeCommand.php b/src/Commands/ModuleMakeFilamentThemeCommand.php index 4efad19..d896543 100644 --- a/src/Commands/ModuleMakeFilamentThemeCommand.php +++ b/src/Commands/ModuleMakeFilamentThemeCommand.php @@ -18,6 +18,8 @@ class ModuleMakeFilamentThemeCommand extends MakeThemeCommand public function handle(Filesystem $filesystem): int { + $this->filesystem = $filesystem; + $module = $this->getModule(); $this->call('vendor:publish', [ diff --git a/src/Modules.php b/src/Modules.php index 6f6a796..6d22f5d 100644 --- a/src/Modules.php +++ b/src/Modules.php @@ -91,6 +91,54 @@ public function convertPathToNamespace(string $fullPath): string ->implode('\\'); } + public function findModuleNameForPath(string $path): ?string + { + $normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + $modulesPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, config('modules.paths.modules', base_path('Modules'))); + + $directory = is_file($normalizedPath) ? dirname($normalizedPath) : $normalizedPath; + + while (str($directory)->startsWith($modulesPath) && $directory !== $modulesPath) { + $moduleJsonPath = $directory . DIRECTORY_SEPARATOR . 'module.json'; + + if (is_file($moduleJsonPath)) { + $moduleJson = json_decode((string) file_get_contents($moduleJsonPath), true); + + return is_array($moduleJson) ? ($moduleJson['name'] ?? basename($directory)) : basename($directory); + } + + $parentDirectory = dirname($directory); + + if ($parentDirectory === $directory) { + break; + } + + $directory = $parentDirectory; + } + + return null; + } + + public function resolveClassFromProviderFile(string $providerPath): ?string + { + if (! is_file($providerPath)) { + return null; + } + + $content = file_get_contents($providerPath); + + if ($content === false || ! preg_match('/^namespace\s+([^;]+);/m', $content, $matches)) { + return null; + } + + return trim($matches[1]) . '\\' . basename($providerPath, '.php'); + } + + public function resolveProviderClass(string $providerPath): string + { + return $this->resolveClassFromProviderFile($providerPath) ?? $this->convertPathToNamespace($providerPath); + } + public function execCommand(string $command, ?Command $artisan = null): void { $process = Process::fromShellCommandline($command); diff --git a/src/ModulesPlugin.php b/src/ModulesPlugin.php index 4e75682..8e4c82e 100644 --- a/src/ModulesPlugin.php +++ b/src/ModulesPlugin.php @@ -109,16 +109,22 @@ protected function getModulePanels(): array $pattern = $basePath . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . $appFolder . DIRECTORY_SEPARATOR . 'Providers' . DIRECTORY_SEPARATOR . 'Filament' . DIRECTORY_SEPARATOR . '*.php'; $panelPaths = glob($pattern); - $panelIds = collect($panelPaths)->map(fn ($path) => FilamentModules::convertPathToNamespace($path))->map(function ($class) { - // Get the panel ID and check if it is registered - $id = str($class)->afterLast('\\')->before('PanelProvider')->kebab()->lower(); - // get module it belongs to as well - $moduleName = str($class)->after('Modules\\')->before('\\Providers\\Filament'); - $module = ModuleFacade::find($moduleName); + $panelIds = collect($panelPaths)->map(function ($path) { + $class = FilamentModules::resolveProviderClass($path); + + if (! class_exists($class)) { + return null; + } + + $moduleName = FilamentModules::findModuleNameForPath($path); + $module = $moduleName ? ModuleFacade::find($moduleName) : null; + if (! $module) { return null; } + $id = str($class)->afterLast('\\')->before('PanelProvider')->kebab()->lower(); + return str($id)->prepend('-')->prepend($module->getKebabName()); }); diff --git a/src/ModulesServiceProvider.php b/src/ModulesServiceProvider.php index 12cc1b1..1895530 100644 --- a/src/ModulesServiceProvider.php +++ b/src/ModulesServiceProvider.php @@ -79,11 +79,17 @@ public function attemptToRegisterModuleProviders(): void $providers = array_merge($serviceProviders, $panelProviders); foreach ($providers as $provider) { - $namespace = FilamentModules::convertPathToNamespace($provider); - $module = str($namespace)->before('\Providers\\')->afterLast('\\')->toString(); + $namespace = FilamentModules::resolveProviderClass($provider); + $moduleName = FilamentModules::findModuleNameForPath($provider); + + if (! $moduleName || ! ModuleFacade::isEnabled($moduleName)) { + continue; + } + $className = str($namespace)->afterLast('\\')->toString(); - if (str($className)->startsWith($module) && ModuleFacade::isEnabled($module)) { - // register the module service provider + $moduleStudlyName = str($moduleName)->studly()->toString(); + + if (str($className)->startsWith($moduleStudlyName) && class_exists($namespace)) { $this->app->register($namespace); } } diff --git a/tests/Unit/ModuleProviderResolutionTest.php b/tests/Unit/ModuleProviderResolutionTest.php new file mode 100644 index 0000000..30199c0 --- /dev/null +++ b/tests/Unit/ModuleProviderResolutionTest.php @@ -0,0 +1,79 @@ +createTestModule('CustomNsModule'); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'CustomNamespaceServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +toBe('MyCompany\\CRM\\Blog\\Providers\\CustomNamespaceServiceProvider'); + expect(FilamentModules::resolveProviderClass($providerPath)) + ->toBe('MyCompany\\CRM\\Blog\\Providers\\CustomNamespaceServiceProvider'); +}); + +test('can find module name from provider path regardless of namespace', function () { + $module = $this->createTestModule('PathLookupModule'); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'); + + expect(FilamentModules::findModuleNameForPath($providerPath))->toBe('PathLookupModule'); +}); + +test('registers providers that declare custom namespaces when enabled', function () { + $module = $this->createTestModule('CustomProviderModule', enabled: true); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +toBe('CustomProviderModule'); + expect(str($namespace)->afterLast('\\')->startsWith('Blog'))->toBeTrue(); + + require_once $providerPath; + + $this->app->register($namespace); + + expect(collect($this->app->getProviders($namespace)))->not->toBeEmpty(); +}); diff --git a/tests/Unit/ModulesServiceProviderBootTest.php b/tests/Unit/ModulesServiceProviderBootTest.php index 7d9d791..b595e28 100644 --- a/tests/Unit/ModulesServiceProviderBootTest.php +++ b/tests/Unit/ModulesServiceProviderBootTest.php @@ -20,19 +20,19 @@ } file_put_contents($providerPath, <<<'PHP' -