artem-schander/l5-modular
l5-modular is a Laravel package for building modular applications with self-contained modules. It provides module generation, autoloading, and service provider registration so each feature lives in its own namespace, keeping large projects organized and maintainable.
Install and scaffold in three commands:
composer require artem-schander/l5-modular
php artisan vendor:publish --provider="ArtemSchander\L5Modular\L5ModularServiceProvider"
php artisan make:module Blog
The publish step drops a config file (historically config/modular.php, renamed to config/modules.php in later versions — check which one appears) that controls the module root path (Modules/ by default), the root namespace, and which module service providers are auto-registered. Read that file first; every other convention follows from it.
Where to look after scaffolding: Modules/Blog/Providers/BlogServiceProvider.php (and the generated RouteServiceProvider) is the entry point for the module. That provider is what registers routes, views, translations, and publishes module config. The generated tree looks roughly like:
Modules/Blog/
├── Config/config.php
├── Console/
├── Database/{Migrations,Seeds}/
├── Http/{Controllers,Middleware,Requests}/
├── Models/ (older versions: Entities/)
├── Providers/{BlogServiceProvider,RouteServiceProvider}.php
├── Resources/Views/
├── Routes/{web.php,api.php}
└── Tests/
First real use case: take one cohesive slice of an existing monolith — say, a Billing domain with its own controllers, Eloquent models, migrations, and routes — and move it wholesale into Modules/Billing. Point your app's route file at nothing, let the module's RouteServiceProvider own /billing/*, and verify a request round-trips before touching anything else. Do this for one module, not ten; the generator is fast but you want to validate auto-discovery and route registration first.
Autoloading caveat up front: unless the package's service provider registers a PSR-4 prefix for you (version-dependent), add this to your root composer.json and dump autoload:
"autoload": {
"psr-4": {
"App\\": "app/",
"Modules\\": "Modules/"
}
}
If make:module succeeds but class not found errors appear on the first request, this is almost always the cause.
Domain-per-module, not layer-per-module. The package makes it trivially easy to generate modules named Controllers or Models; don't. Each module should be a bounded context that could theoretically be extracted: Modules/Catalog, Modules/Orders, Modules/Notifications. The value of the pattern comes from the fact that deleting a module directory deletes the feature.
Routes owned by the module. Let the generated RouteServiceProvider load Routes/web.php and Routes/api.php with the module's own prefix and middleware group. A typical edit:
// Modules/Blog/Providers/RouteServiceProvider.php
Route::middleware('web')
->prefix('blog')
->namespace($this->namespace . '\Http\Controllers')
->group(base_path('Modules/Blog/Routes/web.php'));
Keep the app-level routes/web.php for the shell (landing page, auth) and let modules own their surface area. This is the single biggest behavioral change from a standard Laravel app and the one that pays off.
Use the scoped generators, not artisan make. The package's value is that make:module:controller Blog PostController writes to Modules/Blog/Http/Controllers/ with the right namespace already baked in. Mixing in vanilla make:controller means hand-fixing namespaces on every file. Check php artisan list | grep module for the exact commands your version ships — the set has varied across releases (make:module:model, make:module:migration, make:module:request, make:module:seeder, make:module:provider are the usual suspects).
Module-local config with a namespaced key. Each module's Config/config.php is merged under the module name, so you read it as config('blog.per_page') rather than reaching into a global config file. Publish-on-boot with $this->publishes() or merge in the provider. This keeps module settings deletable alongside the module.
Views get a namespace. Reference module views as view('blog::posts.index') — the provider registers Modules/Blog/Resources/Views under the module's lowercased name. This is what lets two modules both own an index.blade.php without collision.
Cross-module communication via contracts or events, never direct model access. If Orders needs catalog data, define an interface in Modules/Catalog/Contracts/ and bind it in CatalogServiceProvider, then resolve it in Orders. The alternative — use Modules\Catalog\Models\Product; from inside Orders — works but quietly recreates the coupling the pattern exists to avoid. Events (OrderPlaced → listener in Notifications) are the cheaper option for one-directional flows.
Per-module migrations. The module generator writes migrations into Modules/Blog/Database/Migrations. Whether php artisan migrate picks them up automatically depends on the provider calling loadMigrationsFrom() — verify this on your first module. Otherwise you'll run php artisan migrate --path=Modules/Blog/Database/Migrations per module, which gets old; consider wrapping it in a small custom command that iterates the module list.
Testing: each module ships with a Tests/ directory. Configure phpunit.xml with a second test suite pointing at Modules/*/Tests so composer test covers modules alongside the app. Module-level feature tests that boot the module's routes are the highest-signal tests here.
The package is stale relative to modern Laravel. Last release was July 2021. Expect friction on Laravel 9+ — particularly around the service provider register()/boot() signatures, string-based route namespace resolution (Laravel 8 changed how $namespace works in RouteServiceProvider), and PHP 8 attribute-era type strictness in the generated stubs. Pin your Laravel version before adopting, and test make:module on a scratch project before committing the team to it. If you hit walls, nwidart/laravel-modules is the actively-maintained alternative with a similar mental model.
Config file naming drift. Different versions publish config/modular.php vs config/modules.php. If config reads return null, check which file actually exists and which key the service provider looks for. This bites people following blog posts from a different release.
Auto-discovery vs explicit registration. Some versions auto-scan Modules/ for *ServiceProvider.php; others require listing modules in config. If a newly generated module's routes 404, the first check is whether the provider is actually registered — php artisan route:list will tell you instantly. The second check is whether the module name in config matches the directory name exactly (case-sensitive on Linux, silent failure elsewhere).
Route caching breaks dynamic discovery. php artisan route:cache in production can drop module routes if providers register routes conditionally or if discovery happens at runtime. Verify route caching in a staging deploy before relying on it.
Namespace collisions with App\. Generated modules live under Modules\, which means any use App\Models\User; inside a module is a cross-boundary dependency. It's fine for the User model in a small app, but decide the policy early — either move User into an Auth/Users module and reference the contract, or accept the exception and document it.
Class name collisions across modules. Two modules can each have Models/Item.php without PHP complaint (different namespaces), but config keys, view namespaces, translation keys, and migration class names all share a global-ish space. Prefix migration class names and translation keys with the module name to avoid Class 'CreateItemsTable' already exists when running migrations from multiple modules in one process.
Extending the generators. If you find yourself editing every scaffolded controller the same way, the package supports custom stubs in some versions — otherwise fork the stub directory or write a thin make:module:crud wrapper command that runs the scoped generators in sequence. The generator is the package's whole value proposition; invest in making the output match your team's conventions rather than hand-editing every time.
Deleting a module safely: remove the directory, remove any config reference to it, clear bootstrap/cache/config.php and route:cache, then composer dump-autoload. Skipping the dump-autoload step is the most common "phantom module" bug — the classmap still points at deleted files and you get confusing errors from composer dump-autoload -o later.
Don't over-modularize. The failure mode of this pattern is a Modules/ directory with 40 modules each containing three files, plus a Shared module that everything depends on. Start with two or three modules matching real team boundaries or real extraction candidates; leave the rest of the app in app/. The generator will still be there when a slice earns its own module.
How can I help you explore Laravel packages today?