realmrhex/filament-modular
Filament Modular helps organize Laravel Filament applications into self-contained modules, automatically discovering and registering resources, pages, widgets, and plugins so large admin panels stay clean, scalable, and easy to maintain across teams.
Prerequisites (check these first — they are the usual failure point):
composer require nwidart/laravel-modules
php artisan vendor:publish --provider="Nwidart\Modules\LaravelModulesServiceProvider"
composer require realmrhex/filament-modular
You also need a working Filament panel (php artisan filament:install --panels) and a decision on Filament major version — see Gotchas before you commit, because the last release is April 2023.
Minimal setup:
php artisan vendor:publish --tag=filament-modular-config
Confirm the tag from the README — if the package ships no config, skip this and look at its service provider instead (vendor/realmrhex/filament-modular/src/), which will show you exactly which Filament registries it hooks into.php artisan module:make Blog
php artisan module:list # verify it registered
composer dump-autoload
Modules/Blog/Filament/Resources/PostResource.php with namespace Modules\Blog\Filament\Resources).php artisan optimize:clear
php artisan filament:optimize-clear
Where to look first when something doesn't appear: the panel provider's panel() method, then config/modules.php (paths.modules, namespace), then the package service provider. If a resource is missing from the sidebar, it's almost always a namespace/path mismatch between those three, not a package bug.
Ideal first use case: one module owning one bounded context end-to-end — migrations, models, policies, and a Filament resource — so you feel the seam between "module code" and "panel registration" on a small surface before scaling to ten modules.
Let the module own its slice, keep the panel thin. The healthiest pattern with this package is: a module contains Models/, Database/Migrations/, Policies/, and Filament/Resources|Pages|Widgets/. The app/Filament directory shrinks to near-empty or is removed entirely, and panel providers become pure configuration.
Convention-based discovery is the primary workflow. Rather than registering each class, point the panel at module paths:
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->discoverResources(in: module_path('Blog', 'Filament/Resources'), for: 'Modules\\Blog\\Filament\\Resources')
->discoverPages(in: module_path('Blog', 'Filament/Pages'), for: 'Modules\\Blog\\Filament\\Pages')
->discoverWidgets(in: module_path('Blog', 'Filament/Widgets'), for: 'Modules\\Blog\\Filament\\Widgets');
}
If the package already does this for all enabled modules, use its mechanism and skip the boilerplate — but verify the discovery is per-panel, not global, or every panel will show every module's resources.
Register a panel provider per module for large installs. Instead of one AdminPanelProvider discovering everything, give heavy modules their own provider (Modules/Blog/Providers/BlogPanelServiceProvider.php) registered in module.json's providers array. Benefits: modules stay removable, panel-specific navigation/colors/->path() are local, and a broken module can't take down the whole admin.
Multi-panel by audience, not by module. A common mistake is one panel per module. Better: admin (internal staff) and portal (customers), each discovering the same module directories but with different ->login(), ->colors(), and canAccessPanel() gates. Guard access with a role check, not with panel separation alone.
Cross-module references. When Module A's resource needs Module B's model in a Select::make('team_id')->relationship(), depend on the model, not the resource. Keep modules decoupled at the Eloquent layer; use events or a small shared contract module for anything richer. Registering B's resource from A's provider creates a hard load-order dependency that bites during module:disable.
Policies need explicit wiring. Filament's auto-discovery of policies assumes App\Models\*. Module models under Modules\Blog\Models\Post will silently lose authorization. Register explicitly:
// Modules/Blog/Providers/BlogServiceProvider.php
protected function gate(): void
{
Gate::policy(\Modules\Blog\Models\Post::class, \Modules\Blog\Policies\PostPolicy::class);
}
Commands as the daily driver. Wire module generators into your workflow (php artisan module:make-model, module:make-migration, plus the package's Filament generator if present) and standardize on php artisan module:migrate Blog / module:migrate --all. Never run bare php artisan migrate expecting module migrations — depending on config/modules.php they may be excluded from the default path.
Testing per module. Livewire-test each resource inside its own module test suite so a module can be extracted or disabled without dragging tests along:
livewire(ListPosts::class)->assertCanSeeTableRecords($posts);
Run with php artisan test --testsuite=Modules or a path-filtered PHPUnit config.
Navigation as the module's contract. Set protected static ?string $navigationGroup and a module-prefixed $navigationLabel inside each resource. This is the cheapest way to make a 15-module panel navigable without a custom navigation builder.
The big one: version drift. Last release 2023-04-24 means this was built against the Filament v2 era (and possibly early v3). Filament v3 split forms/infolists out of Resource, renamed namespace roots (Filament\Resources\Resource → largely stable, but getForms()/getInfolists() replaced form()/table()-adjacent APIs), and v4 changed more again. Before installing, check composer.json in the package for its filament/filament constraint. If it caps at ^2.0 or ^3.0, treat it as a reference implementation, not a dependency — the discovery code you need is genuinely ~15 lines (see the snippet above).
Composer autoload is not automatic for new modules. module:make writes files; your autoloader may not know about them until composer dump-autoload. Symptom: "Class not found" on a class that visibly exists. If you use a modules/ root with PSR-4 in composer.json, ensure the namespace map matches config/modules.php exactly.
Caches fight with discovery — in three separate layers. Clear all of them when debugging a missing resource:
php artisan optimize:clear # config/route/view
php artisan filament:optimize-clear
php artisan module:clear-compiler 2>/dev/null || true
In production, php artisan filament:optimize and php artisan optimize must both be run after modules are in place, and Filament's component cache will not notice a module enabled post-deploy without a re-optimize.
Config cache breaks dynamic module paths. module_path() calls inside a panel provider are fine, but if the package resolves enabled modules at boot and you cache config, newly enabled modules won't appear until cache clear. Prefer php artisan optimize:clear && php artisan filament:optimize as a single deploy step.
module:disable is not graceful for Filament. Disabling a module whose resources are referenced by other modules' forms yields runtime errors at render time rather than boot time. If removable modules are a real requirement, gate cross-module relationships behind class_exists() checks or shared interface bindings, and add a smoke test that boots the panel with each module disabled.
Asset and Vite paths per module are not handled. Module-scoped CSS/JS needs a custom vite.config.js input and a @vite call in the panel's ->renderHook() or a custom theme. Don't expect the package to solve this.
Slug collisions are silent. Two modules each with a PostResource produce duplicate $slug values and the later registration wins on route resolution. Prefix slugs (blog-posts) or rely on distinct navigation groups plus explicit $slug.
Debugging checklist, in order: php artisan module:list → php artisan about (confirm providers) → php artisan route:list | grep -i blog → dump the panel's registered resources via Filament::getPanel('admin')->getResources() in tinker → check the generated class's namespace against the for: string character-by-character. That last step resolves the majority of "it just doesn't show up."
Bus factor and maintenance. 61 stars, MIT, one release two years stale. Mitigations: pin the exact version, avoid spreading its API across dozens of files (wrap it behind your own ModulePanelRegistrar if you must), and keep the fallback in your back pocket — the equivalent of this package is a handful of ->discoverResources(in:, for:) calls plus a foreach over Module::allEnabled(). If you outgrow the package or need current Filament support, replacing it is an afternoon, not a quarter.
How can I help you explore Laravel packages today?