Skip to content

fix: resolve module namespaces from what modules declare, not from config('modules.namespace') - #191

Open
tansautn wants to merge 2 commits into
coolsam726:5.xfrom
tansautn:fixes/get-module-plugin-w-correct-namespace
Open

fix: resolve module namespaces from what modules declare, not from config('modules.namespace')#191
tansautn wants to merge 2 commits into
coolsam726:5.xfrom
tansautn:fixes/get-module-plugin-w-correct-namespace

Conversation

@tansautn

Copy link
Copy Markdown

Summary

Fixes #154 properly. PR #177 patched the provider-registration path, but every other
path→namespace conversion still assumes that all modules live under
config('modules.namespace'). In an app hosting modules from more than one vendor this
still breaks — silently for Filament discovery, fatally for plugin auto-registration.

This PR replaces the convention-based guessing with a single resolver that reads what each
module actually declares, and keeps the old behaviour as the last fallback so default
nwidart/laravel-modules setups are unaffected.

Environment that reproduces it

Laravel 13 · Filament 4 · nwidart/laravel-modules 13 · PHP 8.3, with two custom base
namespaces plus vendor packages that are also modules:

Module Path Declared namespace (composer.json psr-4)
Core modules/Core/app MyLink\Cerm\Core
CardChecker modules/CardChecker/app MyLink\Cerm\CardChecker
BetMate modules/BetMate/app Zuko\Fitly\BetMate
Utils modules/Utils/app Zuko\Fitly\Utils
Observability modules/Observability/app Zuko\Fitly\Observability
FilamentUsers, FilamentTypes, … vendor/tomatophp/* TomatoPHP\* → mapped to src/, not to an app folder

config('modules.namespace') is Zuko\Fitly, so anything under MyLink\Cerm (and every
vendor module) is mis-derived.

Class "Zuko\Fitly\CardChecker\Filament\CardCheckerPlugin" not found
  at src/ModulesPlugin.php:29

The real class is MyLink\Cerm\CardChecker\Filament\CardCheckerPlugin.

What I investigated

  • Traced every call site that turns a path or a module into a namespace:
    Module::namespace() / appNamespace() macros, Modules::convertPathToNamespace(),
    Modules::findModuleNameForPath(), Modules::resolveProviderClass(),
    ModulesPlugin::getModulePlugins() / getModulePanels(),
    ModulesServiceProvider::attemptToRegisterModuleProviders() / autoDiscoverPanels(),
    and all src/Commands/* generators.
  • Confirmed how Filament v4 consumes the for: argument:
    vendor/filament/filament/src/Panel/Concerns/HasComponents.php:531 guards discovery with
    class_exists(). A wrong namespace therefore produces no error at all — the module's
    Resources/Pages/Widgets simply never register. That silent half is worse than the fatal one,
    because it looks like the module is empty.
  • Confirmed the fatal path: ModulesPlugin::register() calls $modulePlugin::make() with no
    class_exists() guard (src/ModulesPlugin.php:26-31), so one mis-derived plugin class takes
    down the whole panel.
  • Checked what data is actually authoritative and available at boot: Composer's runtime PSR-4
    map (Composer\Autoload\ClassLoader::getPrefixesPsr4()), each module's own composer.json
    (nwidart already exposes it via Module::getComposerAttr()), and the namespace
    declaration inside the files themselves.
  • Verified the generators' constraint: module:make:filament-* converts a path before the
    file exists, so reading the file content cannot be the only strategy — a directory-based
    PSR-4 lookup is mandatory.
  • Cross-checked path handling on Windows (DIRECTORY_SEPARATOR === '\', mixed / and \
    coming out of config('modules.paths.*') and glob()), and re-read the string helpers used
    in the current implementation.

Root cause

RC1 — the namespace macro hardcodes the configured base

src/ModulesServiceProvider.php:216-223

$base = trim(config('modules.namespace', 'Modules'), '\\');   // :218
...
return str($base)->append('\\')->append($studlyName)->append('\\')->append($relativeNamespace)...

Every appNamespace() call inherits this, so src/Concerns/ModuleFilamentPlugin.php:26-51
feeds Filament a for: namespace that does not exist:

discoverResources(in: modules/Core/app/Filament/Resources, for: Zuko\Fitly\Core\Filament\Resources)
                                                                ^^^^^^^^^^^^^^^^^ actual: MyLink\Cerm\Core

class_exists() fails → resources silently disappear.

RC2 — convertPathToNamespace() rebuilds the namespace from the same assumption

src/Modules.php:68-92

->prepend(config('modules.namespace', 'Modules'))   // :85  wrong base for foreign modules
->rtrim('.php')                                     // :88  charlist rtrim, not a suffix strip
->explode(DIRECTORY_SEPARATOR)                      // :89  separators were already turned into '\'

Two extra defects in the same chain:

  • :88Stringable::rtrim('.php') strips any trailing ./p/h characters:
    Sitemap.phpSitema, Map.phpMa.
  • :89 — the previous line replaces DIRECTORY_SEPARATOR with \, then explode() splits on
    DIRECTORY_SEPARATOR again. On Linux (/) nothing is left to split on, so the whole
    namespace is treated as one segment and pushed through studly().

RC3 — no guard before instantiating an auto-discovered plugin

src/ModulesPlugin.php:26-31

$plugins = $this->getModulePlugins();
foreach ($plugins as $modulePlugin) {
    $panel->plugin($modulePlugin::make());   // :29  fatal if the class name was mis-derived
}

getModulePlugins() (src/ModulesPlugin.php:83-97) returns raw strings from
convertPathToNamespace() (:95) with no class_exists() / instanceof Plugin /
module-enabled filtering.

RC4 — module lookup compares paths with different separators

src/Modules.php:94-120

while (str($directory)->startsWith($modulesPath) && $directory !== $modulesPath) {   // :101

$modulesPath comes from config('modules.paths.modules') (base_path()D:\… on Windows)
while $directory is derived from glob() output built with /. The prefix check fails and
the module — and therefore its providers — is skipped.

RC5 — glob patterns are hand-concatenated and partly hardcoded

  • src/ModulesPlugin.php:92->replace('//', '/') patching up a pattern built by string
    concatenation; breaks when modules.paths.app_folder carries a trailing slash ('app/',
    which is what nwidart's own published config uses).
  • src/ModulesPlugin.php:109 and src/ModulesServiceProvider.php:69-76 — same pattern style.
  • src/ModulesServiceProvider.php:106getExtraPath('app/Providers/Filament') hardcodes the
    app folder instead of honouring modules.paths.app_folder.

Two smaller issues fixed along the way

  • src/ModulesServiceProvider.php:85ModuleFacade::isEnabled($name) calls findOrFail()
    internally and throws ModuleNotFoundException when modules_statuses.json still lists a
    module whose directory is gone (common after moving/removing modules).
  • src/ModulesServiceProvider.php:101-104allEnabled() called twice, plus $cacheKey and
    $ttl assigned and never used.

Changes

New — src/Support/NamespaceResolver.php

A module namespace is data, not a convention. Sources are consulted in order of reliability:

  1. The file itselfnamespace X; and the declared type name, read with
    token_get_all(). A tokenizer instead of a regex so that namespaces mentioned inside
    strings, comments or ::class expressions cannot produce a false positive
    (isTypeDeclaration() also rejects new class {} and Foo::class).
  2. Composer's runtime PSR-4 mapClassLoader::getPrefixesPsr4() via
    spl_autoload_functions(), falling back to vendor/composer/autoload_psr4.php. Longest
    directory prefix wins. This is the map PHP itself autoloads with, and it is the only source
    that works for files that do not exist yet (the module:make:filament-* generators).
  3. The module's own composer.json autoload.psr-4, for modules added without a
    composer dump-autoload.
  4. Legacy config('modules.namespace') . '\' . StudlyName.

Public surface:

Method Purpose
resolveClass(string $path): ?string path → FQCN, null when undecidable
moduleNamespace(Module $m, string $relative = ''): string module → base namespace
composerPsr4Prefixes(): array normalized prefix list, longest dir first
normalizePath(string $path): string realpath when possible, lexical ./.. resolution otherwise
flush(): void drop the per-request caches (tests)

Path comparison is separator- and case-normalized (case-insensitive only when
DIRECTORY_SEPARATOR === '\'), and results are cached per path / per module for the request.

Module base-namespace resolution also handles packages that map their root namespace onto a
sub-directory ("Acme\\Blog\\": "src/") rather than onto an app folder — that is what makes
vendor packages registered as modules resolve correctly.

src/Modules.php

  • convertPathToNamespace() delegates to the resolver; signature unchanged.
  • resolveClassFromProviderFile() / resolveProviderClass() delegate to the resolver
    (the old inline preg_match at :130 is gone).
  • findModuleNameForPath() normalizes both sides before comparing (RC4).
  • New: getModuleNamespace(), resolveClass(), globPattern(), globFiles().
  • getModulePanels() / getModuleClusters() use the shared glob helpers.
  • The resolver is obtained through a lazy getter, so Modules stays constructor-free.

src/ModulesServiceProvider.php

  • namespace macro → FilamentModules::getModuleNamespace($this, …) (RC1).
  • attemptToRegisterModuleProviders() — patterns built from modules.paths.app_folder,
    covering both the app-folder layout (modules/Blog/app/Providers) and the flat one
    (modules/Blog/Providers); ModuleFacade::find() + null check instead of isEnabled();
    registers only classes that are is_subclass_of(ServiceProvider::class).
  • autoDiscoverPanels() — uses appPath('Providers/Filament'), deduplicates, drops the dead
    $cacheKey/$ttl and the duplicated allEnabled() call.
  • packageRegistered() binds NamespaceResolver and Modules as singletons so the caches are
    shared.

src/ModulesPlugin.php

  • register() — guards with class_exists(), skips a plugin whose id is already registered on
    the panel (an explicit ->plugins([...]) entry wins over auto-registration), and falls back
    to app($class) when the class has no make().
  • getModulePlugins() — filters on module-enabled + class_exists + is_subclass_of(Plugin::class),
    returns unique class strings.
  • New globModuleFiles() helper shared by getModulePlugins() / getModulePanels(), covering
    both module layouts.

Verification

Validated against the real multi-namespace app described above (5 local modules across 2 custom
base namespaces + 5 vendor packages registered as modules).

Channel Command Result
Console php artisan optimize:clear exit 0
Console php artisan route:list 199 routes, exit 0
Console php artisan route:cache / event:cache / filament:optimize OK
Console php artisan module:list 10 modules resolved
Web GET /admin/login (via artisan serve) HTTP 200
Web GET /admin authenticated (real HTTP kernel) 302 → /admin/dashboard → HTTP 200

Namespace resolution, all modules:

Module Resolved base namespace Source
BetMate Zuko\Fitly\BetMate PSR-4 map (app folder)
CardChecker MyLink\Cerm\CardChecker PSR-4 map (app folder)
Core MyLink\Cerm\Core PSR-4 map (app folder)
Observability Zuko\Fitly\Observability PSR-4 map (app folder)
Utils Zuko\Fitly\Utils PSR-4 map (app folder)
FilamentSettingsHub / FilamentTranslations / FilamentTypes / FilamentUsers / FilamentDeveloperGate TomatoPHP\* PSR-4 map (src/ sub-directory)

Before this PR the same app produced Class "Zuko\Fitly\CardChecker\Filament\CardCheckerPlugin" not found on every artisan command and on every /admin request, and the MyLink\Cerm\*
modules contributed zero Filament components even with the panel booting.

Panel contents after the fix: 17 Resources / 17 Pages / 18 Widgets / 18 plugins, with the one
disabled module contributing nothing — as expected.

Backward compatibility

  • Default nwidart layout is unchanged: Modules\Blog\modules/Blog/app is an exact PSR-4
    match, so the resolver returns exactly what the old code returned, and
    config('modules.namespace') remains the final fallback.
  • convertPathToNamespace() keeps its string return type. It now returns '' when the path
    cannot be resolved, where it previously returned a plausible-looking but wrong FQCN. All
    internal call sites are guarded.
  • No config changes, no new dependency (Composer\Autoload\ClassLoader ships with every
    Composer project and is only touched through spl_autoload_functions()).

Notes for reviewers

  • I did not run the package's own Pest suite: this work was done from inside a consuming
    application, where the package copy has no vendor/ of its own. The behaviour was validated
    end-to-end against the app instead (table above). CI should be the gate here.
  • Suggested unit coverage to add — all cheap against the existing tests/Support/CreatesTestModules.php:
    • a module whose composer.json maps a namespace outside config('modules.namespace');
    • a module whose psr-4 target is src/ instead of the app folder;
    • resolveClass() on a path whose file does not exist yet (generator case);
    • a class file named Sitemap.php / Map.php (regression for the rtrim('.php') bug);
    • findModuleNameForPath() with \-separated input on a /-separated config value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Module Providers Auto-Registration Fails with Custom Namespaces

1 participant