alizharb/laravel-modular
Laravel Modular is a strictly typed modular system for Laravel 11/12/13 with zero config. Over 29 native Artisan commands support --module, plus dependency-aware boot ordering, caching, auto-discovery, and production diagnostics.
Install and bootstrap in three commands:
composer require alizharb/laravel-modular
php artisan modular:install # publishes config, adds PSR-4 "Modules\\": "modules/", wires composer-merge-plugin
php artisan modular:doctor # confirms your setup is sane before you write any code
modular:install is doing real work behind the scenes — it edits your root composer.json and installs wikimedia/composer-merge-plugin. If it fails or you're retrofitting an existing app, do it manually: add the Modules\\ PSR-4 entry under autoload.psr-4, add the modules/*/composer.json include under extra.merge-plugin, then composer dump-autoload.
First module — pick a leaf feature, not your core domain:
php artisan make:module Blog
php artisan modular:list --tree
Start with something self-contained (Blog, Store, Reporting) so you feel the workflow without fighting dependency ordering on day one. Resist modularising app/ wholesale — the win is extracting features that have their own models, routes, migrations and assets.
Where to look first, in order:
modules/Blog/module.json — the manifest. This is the source of truth for dependencies, middleware, providers, and enable/disable state.modules/Blog/Providers/BlogServiceProvider.php — the generated provider; anything you'd do in AppServiceProvider (routes, views, config merges) happens here, scoped to the module.modules/Blog/composer.json — module-local dependencies, merged into root (or synced out with modular:sync).docs/commands.md — the 29+ command overrides. Read this before reaching for make:*, because the --module flag works on nearly all of them.Canonical first use case: create Blog, generate Post with -mcf inside it, run php artisan modular:migrate Blog, hit the route, then flip it off with module:disable Blog and confirm the app still boots. That single loop — generate, migrate, toggle — is 80% of daily usage.
--moduleThe core ergonomic win is that standard Laravel flags pass straight through. There's no separate module:make-* namespace to learn:
php artisan make:model Post --module=Blog -mcf # model + migration + factory + controller
php artisan make:controller API/PostController --module=Blog --api
php artisan make:model Order --module=Shop -a # everything incl. policy + seeder
php artisan make:job SendNewsletter --module=Blog
Treat --module as muscle memory. If you forget it once and generate into app/, you'll be hand-moving files and fixing namespaces — far more expensive than the flag.
This is the pattern most teams under-use. Because modules can be disabled at runtime, Blade directives become a legitimate feature-flag mechanism and a genuine safety rail — a disabled module's routes don't exist, so linking to them 500s without a guard:
@moduleEnabled('Blog')
<a href="{{ route('blog.index') }}">Read the Blog</a>
@endmoduleEnabled
@moduleDisabled('Store')
<p>Our store is currently offline.</p>
@endmoduleDisabled
Wrap every cross-module link and nav item in these. It's cheap and prevents the most common runtime breakage when toggling modules in staging.
Module config is namespaced and case-insensitive — pick one casing (lowercase is conventional) and stick to it in code review:
config('Blog::settings.per_page'); // same thing
config('blog::settings.per_page');
Config files live in modules/Blog/Config/; the module's provider merges them. Use module_config_path('Blog', 'settings.php') when you need the raw path rather than the values.
module.jsonDeclare middleware in the manifest rather than the provider — it keeps the module's surface area declarative and visible to modular:debug:
"middleware": {
"web": ["Modules\\Blog\\Http\\Middleware\\TrackVisits"],
"blog.admin": "Modules\\Blog\\Http\\Middleware\\AdminGuard"
}
Named groups like blog.admin are the right call for anything module-specific; don't pollute the global web stack with module concerns unless you truly want it applied app-wide.
Declare module dependencies in module.json; the registry resolves boot order topologically so base modules always boot before dependents. Then:
php artisan modular:check # circular dependency detection — run in CI
php artisan modular:list --tree # eyeball the actual boot order
modular:check in CI is the single highest-value command in the package. A circular dependency between modules is easy to introduce accidentally (Blog references User from Auth, Auth references something in Blog) and produces confusing, order-dependent boot failures.
Commands, policies, and event listeners inside a module are auto-discovered. Don't hand-register them in the provider — you're just duplicating the registry's work and increasing cache-bust surface. Reserve manual provider code for things discovery can't infer: routes, view namespaces, custom config merges, Blade components.
Because make:* scans modules/Shop/stubs/ before application stubs, you can enforce a different scaffold for one module without touching the global default:
modules/Shop/stubs/controller.model.stub
modules/Shop/stubs/model.stub
Useful when one module is a public API module (thin controllers, DTOs) and the rest are conventional CRUD.
composer install --no-dev --optimize-autoloader
php artisan modular:sync # fold module deps into root composer.json
php artisan modular:link # publish module assets to public/modules
php artisan modular:cache # discovery cache — mandatory in prod
php artisan config:cache && php artisan route:cache && php artisan view:cache
modular:cache is what makes the "near-zero overhead" claim real. Ship without it and every request pays for directory scans.
// vite.config.js
import { modularLoader } from "./vite.modular.js";
input: [...modularLoader.inputs()],
refresh: [...modularLoader.refreshPaths()],
In Blade, prefer modular_vite('Blog') for full HMR-aware resolution in dev, and module_asset('Blog', 'css/app.css') for static files served out of public/modules. modular:npm Blog build handles per-module workspaces when a module has its own package.json.
php artisan modular:export Blog --path=packages/blog
Good exit hatch for a module that becomes a shared internal package across several apps. Review the exported composer.json — module-local dependencies that were being satisfied by the root app will become hard requirements.
Composer autoload is not magic. make:module updates PSR-4 for you, but if you move a module directory, rename a namespace, or clone a module between projects, run composer dump-autoload. Half of all "class not found" reports with this package are stale autoload maps, not a registry bug.
The merge-plugin is a dev convenience, not a prod feature. composer-merge-plugin re-reads modules/*/composer.json on every composer install, which is slow and can produce non-deterministic lockfiles. Use modular:sync to flatten module dependencies into the root, then disable the merge plugin in production. Keep it enabled locally.
Cache invalidation is your responsibility. make:module clears the modular cache automatically (v1.1.4+), but module:enable, module:disable, adding a command class, or adding a policy may not be picked up without php artisan modular:cache. If something isn't discovered, re-cache before you debug anything else. Conversely — if you're in local development and a change isn't showing, you may have a stale cache; modular:cache:clear (or re-running doctor) is the fix. Never ship a stale cache.
Disabled modules leave dangling references. Middleware declared in module.json for a disabled module, route names, config keys, and Blade components all vanish. @moduleEnabled guards links, but it won't protect a queue job class referenced by string, a scheduled command, or a config() call in a core service. Grep for module namespaces in app/ before disabling anything in a shared environment.
Case-insensitivity of module config is a trap in review. Both Blog:: and blog:: resolve. That means a typo'd casing won't fail loudly — it'll work in dev and confuse the next reader. Standardise on lowercase in your style guide and let code review catch the rest.
Circular dependencies fail weirdly, not cleanly. Topological sort either drops the cycle or throws depending on where it's encountered; the symptom is often a provider that silently doesn't boot. Run php artisan modular:check in CI and modular:list --tree when boot order looks wrong.
modular:doctor before modular:debug. Doctor gives you health scores and flags config problems; modular:debug Blog dumps the resolved config for one module. Reaching for debug first usually means you skip the aggregate view that would have shown you the real problem.
Vite needs both config and asset linking. modularLoader in vite.config.js gets you HMR in dev, but production module_asset() URLs resolve from public/modules — you must run modular:link. A module that renders fine in dev and 404s on images in prod is almost always a missing modular:link.
Nested generator paths assume the module namespace. make:controller API/PostController --module=Blog yields Modules\Blog\Http\Controllers\API\PostController. That's correct, but be consistent about API vs Api — PSR-4 on Linux is case-sensitive even where the framework feels forgiving.
Testing is per-module. php artisan modular:test Blog scopes the run. Wire this into CI as a matrix across modules rather than one monolithic suite — that's the entire point of the architecture, and it gives you fast signal on which module a regression belongs to.
Bus-factor caution. This is a 23-star, single-maintainer package with a recent release cadence and zero dependents. That's a real risk for anything load-bearing. Pin an exact version in composer.json, read the source of the registry and service provider before you rely on edge-case behaviour, and keep a short internal note on what module.json fields you actually use — so a fork or a hand-rolled replacement is a day's work, not a rewrite. The architecture (PSR-4 + a manifest + a registry) is not exotic; the value here is the polish, the 29 command overrides, and the Vite glue. Those are replaceable, and knowing that should make you comfortable adopting it.
Extension points worth knowing. Custom generation stubs (global or per-module), the ModuleRegistry and traits for programmatic module listing/state, module(), module_path(), module_config_path(), module_asset(), modular_vite(), and the Blade directives. If you find yourself reaching for reflection or raw glob() over modules/, you've missed a helper.
How can I help you explore Laravel packages today?