fix: resolve module namespaces from what modules declare, not from config('modules.namespace') - #191
Open
tansautn wants to merge 2 commits into
Open
Conversation
…clare, not from config
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thisstill 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-modulessetups 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:
composer.jsonpsr-4)modules/Core/appMyLink\Cerm\Coremodules/CardChecker/appMyLink\Cerm\CardCheckermodules/BetMate/appZuko\Fitly\BetMatemodules/Utils/appZuko\Fitly\Utilsmodules/Observability/appZuko\Fitly\Observabilityvendor/tomatophp/*TomatoPHP\*→ mapped tosrc/, not to an app folderconfig('modules.namespace')isZuko\Fitly, so anything underMyLink\Cerm(and everyvendor module) is mis-derived.
The real class is
MyLink\Cerm\CardChecker\Filament\CardCheckerPlugin.What I investigated
Module::namespace()/appNamespace()macros,Modules::convertPathToNamespace(),Modules::findModuleNameForPath(),Modules::resolveProviderClass(),ModulesPlugin::getModulePlugins()/getModulePanels(),ModulesServiceProvider::attemptToRegisterModuleProviders()/autoDiscoverPanels(),and all
src/Commands/*generators.for:argument:vendor/filament/filament/src/Panel/Concerns/HasComponents.php:531guards discovery withclass_exists(). A wrong namespace therefore produces no error at all — the module'sResources/Pages/Widgets simply never register. That silent half is worse than the fatal one,
because it looks like the module is empty.
ModulesPlugin::register()calls$modulePlugin::make()with noclass_exists()guard (src/ModulesPlugin.php:26-31), so one mis-derived plugin class takesdown the whole panel.
map (
Composer\Autoload\ClassLoader::getPrefixesPsr4()), each module's owncomposer.json(
nwidartalready exposes it viaModule::getComposerAttr()), and thenamespacedeclaration inside the files themselves.
module:make:filament-*converts a path before thefile exists, so reading the file content cannot be the only strategy — a directory-based
PSR-4 lookup is mandatory.
DIRECTORY_SEPARATOR === '\', mixed/and\coming out of
config('modules.paths.*')andglob()), and re-read the string helpers usedin the current implementation.
Root cause
RC1 — the
namespacemacro hardcodes the configured basesrc/ModulesServiceProvider.php:216-223Every
appNamespace()call inherits this, sosrc/Concerns/ModuleFilamentPlugin.php:26-51feeds Filament a
for:namespace that does not exist:→
class_exists()fails → resources silently disappear.RC2 —
convertPathToNamespace()rebuilds the namespace from the same assumptionsrc/Modules.php:68-92Two extra defects in the same chain:
:88—Stringable::rtrim('.php')strips any trailing./p/hcharacters:Sitemap.php→Sitema,Map.php→Ma.:89— the previous line replacesDIRECTORY_SEPARATORwith\, thenexplode()splits onDIRECTORY_SEPARATORagain. On Linux (/) nothing is left to split on, so the wholenamespace is treated as one segment and pushed through
studly().RC3 — no guard before instantiating an auto-discovered plugin
src/ModulesPlugin.php:26-31getModulePlugins()(src/ModulesPlugin.php:83-97) returns raw strings fromconvertPathToNamespace()(:95) with noclass_exists()/instanceof Plugin/module-enabled filtering.
RC4 — module lookup compares paths with different separators
src/Modules.php:94-120$modulesPathcomes fromconfig('modules.paths.modules')(base_path()→D:\…on Windows)while
$directoryis derived fromglob()output built with/. The prefix check fails andthe 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 stringconcatenation; breaks when
modules.paths.app_foldercarries a trailing slash ('app/',which is what
nwidart's own published config uses).src/ModulesPlugin.php:109andsrc/ModulesServiceProvider.php:69-76— same pattern style.src/ModulesServiceProvider.php:106—getExtraPath('app/Providers/Filament')hardcodes theapp folder instead of honouring
modules.paths.app_folder.Two smaller issues fixed along the way
src/ModulesServiceProvider.php:85—ModuleFacade::isEnabled($name)callsfindOrFail()internally and throws
ModuleNotFoundExceptionwhenmodules_statuses.jsonstill lists amodule whose directory is gone (common after moving/removing modules).
src/ModulesServiceProvider.php:101-104—allEnabled()called twice, plus$cacheKeyand$ttlassigned and never used.Changes
New —
src/Support/NamespaceResolver.phpA module namespace is data, not a convention. Sources are consulted in order of reliability:
namespace X;and the declared type name, read withtoken_get_all(). A tokenizer instead of a regex so that namespaces mentioned insidestrings, comments or
::classexpressions cannot produce a false positive(
isTypeDeclaration()also rejectsnew class {}andFoo::class).ClassLoader::getPrefixesPsr4()viaspl_autoload_functions(), falling back tovendor/composer/autoload_psr4.php. Longestdirectory 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).composer.jsonautoload.psr-4, for modules added without acomposer dump-autoload.config('modules.namespace') . '\' . StudlyName.Public surface:
resolveClass(string $path): ?stringnullwhen undecidablemoduleNamespace(Module $m, string $relative = ''): stringcomposerPsr4Prefixes(): arraynormalizePath(string $path): string./..resolution otherwiseflush(): voidPath 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 makesvendor packages registered as modules resolve correctly.
src/Modules.phpconvertPathToNamespace()delegates to the resolver; signature unchanged.resolveClassFromProviderFile()/resolveProviderClass()delegate to the resolver(the old inline
preg_matchat:130is gone).findModuleNameForPath()normalizes both sides before comparing (RC4).getModuleNamespace(),resolveClass(),globPattern(),globFiles().getModulePanels()/getModuleClusters()use the shared glob helpers.Modulesstays constructor-free.src/ModulesServiceProvider.phpnamespacemacro →FilamentModules::getModuleNamespace($this, …)(RC1).attemptToRegisterModuleProviders()— patterns built frommodules.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 ofisEnabled();registers only classes that are
is_subclass_of(ServiceProvider::class).autoDiscoverPanels()— usesappPath('Providers/Filament'), deduplicates, drops the dead$cacheKey/$ttland the duplicatedallEnabled()call.packageRegistered()bindsNamespaceResolverandModulesas singletons so the caches areshared.
src/ModulesPlugin.phpregister()— guards withclass_exists(), skips a plugin whose id is already registered onthe panel (an explicit
->plugins([...])entry wins over auto-registration), and falls backto
app($class)when the class has nomake().getModulePlugins()— filters on module-enabled +class_exists+is_subclass_of(Plugin::class),returns unique class strings.
globModuleFiles()helper shared bygetModulePlugins()/getModulePanels(), coveringboth 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).
php artisan optimize:clearphp artisan route:listphp artisan route:cache/event:cache/filament:optimizephp artisan module:listGET /admin/login(viaartisan serve)GET /adminauthenticated (real HTTP kernel)/admin/dashboard→ HTTP 200Namespace resolution, all modules:
Zuko\Fitly\BetMateMyLink\Cerm\CardCheckerMyLink\Cerm\CoreZuko\Fitly\ObservabilityZuko\Fitly\UtilsTomatoPHP\*src/sub-directory)Before this PR the same app produced
Class "Zuko\Fitly\CardChecker\Filament\CardCheckerPlugin" not foundon every artisan command and on every/adminrequest, and theMyLink\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
nwidartlayout is unchanged:Modules\Blog\→modules/Blog/appis an exact PSR-4match, so the resolver returns exactly what the old code returned, and
config('modules.namespace')remains the final fallback.convertPathToNamespace()keeps itsstringreturn type. It now returns''when the pathcannot be resolved, where it previously returned a plausible-looking but wrong FQCN. All
internal call sites are guarded.
Composer\Autoload\ClassLoaderships with everyComposer project and is only touched through
spl_autoload_functions()).Notes for reviewers
application, where the package copy has no
vendor/of its own. The behaviour was validatedend-to-end against the app instead (table above). CI should be the gate here.
tests/Support/CreatesTestModules.php:composer.jsonmaps a namespace outsideconfig('modules.namespace');src/instead of the app folder;resolveClass()on a path whose file does not exist yet (generator case);Sitemap.php/Map.php(regression for thertrim('.php')bug);findModuleNameForPath()with\-separated input on a/-separated config value.