laravel-modular maintained by dem1-off
dem1-off/laravel-modular
Modular architecture tooling for Laravel. DDD modules (Domain / Application / Infrastructure) that are real Composer packages from day one, so any module can be promoted to a standalone package with zero code churn.
Highlights
- Attribute-driven wiring — declare bindings and listeners with
#[Bind]and#[Listen], or let an implementation auto-bind itself with#[Provides](Symfony-style autoconfigure for Laravel modules). Config, migrations, views and routes load by convention, so the common provider is empty. - Fast by design —
module:cachecompiles discovery and attributes into one PHP file (zero reflection, zero filesystem scanning at runtime), wired intophp artisan optimize. - A designed-in promotion path — module namespaces never change, so moving a module into its own repo is a Composer change, not a refactor.
- Familiar layout — works with the
Modules/directory,module.json,modules_statuses.json, andmodule_path()conventions, so existing projects interoperate.
A module at a glance
#[Bind(PostRepositoryInterface::class, EloquentPostRepository::class)]
#[Listen(ChapterPublished::class, SendDigest::class)]
final class BlogServiceProvider extends ModuleServiceProvider {}
Installation
composer require dem1-off/laravel-modular
php artisan vendor:publish --tag=modules-config
config/modules.php uses conventional module config keys (namespace,
paths, statuses_file), so an existing modular project migrates without
editing modules.
Creating a module
php artisan make:module Blog
Produces a promotion-ready package:
Modules/Blog/
├── composer.json # type: laravel-module, PSR-4 Modules\Blog\
├── module.json # module manifest
├── config/blog.php
├── database/{migrations,factories,seeders}/
├── resources/views/
├── src/{Domain,Application,Infrastructure}/
│ └── Infrastructure/Providers/BlogServiceProvider.php
└── tests/
Configuring a module
Convention loads, attributes wire. Config, migrations, views and routes load automatically when their folders exist. Declare container bindings and listeners with attributes:
use Dem1Off\LaravelModular\Module\Attributes\Bind;
use Dem1Off\LaravelModular\Module\Attributes\Listen;
use Dem1Off\LaravelModular\Module\ModuleServiceProvider;
#[Bind(PostRepositoryInterface::class, EloquentPostRepository::class)]
#[Bind(FeedCache::class, RedisFeedCache::class, singleton: true)]
#[Listen(ChapterPublished::class, SendDigest::class)]
final class BlogServiceProvider extends ModuleServiceProvider {}
Need more than attributes? Override register()/boot() and call the parent —
it's a normal Laravel provider. See the
docs.
Performance
Attributes reflect in development. In production, php artisan module:cache
compiles discovery and attributes into one PHP file — a request does zero
reflection and zero filesystem scanning. It's wired into php artisan optimize.
Runtime API
use Dem1Off\LaravelModular\Facades\Modules;
Modules::all(); // every module, keyed by name
Modules::enabled(); // only enabled ones
Modules::find('Blog'); // ModuleDescriptor|null
Modules::isEnabled('Blog');
Modules::path('Blog'); // absolute path
module_path('Blog', 'resources/views'); // path helper
Customising behaviour
A module provider is a normal Laravel ServiceProvider. For anything beyond
attributes, override register()/boot() and call the parent:
public function boot(): void
{
parent::boot();
Livewire::component('blog.feed', Feed::class);
}
Keep anything proprietary (navigation, mailing, metrics, …) in your application,
invoked from the module's boot() — never inside this package.
Promoting a module to a standalone package
Because a module is already a Composer package and its namespace
(Modules\Blog\) never changes, promotion is mechanical:
-
Move the directory to its own git repo
git subtree split --prefix=Modules/Blog -b blog-module # push that branch to a new repo, or copy Modules/Blog out -
Point the app at it via Composer — swap the path entry for a VCS/version constraint in the root
composer.json:// before — local development "repositories": [{ "type": "path", "url": "Modules/*" }], "require": { "acme/blog-module": "*" } // after — promoted package "repositories": [{ "type": "vcs", "url": "git@github.com:acme/blog-module.git" }], "require": { "acme/blog-module": "^1.0" } -
composer update acme/blog-module— done.
No namespace changes, no provider rewrites: Laravel package auto-discovery reads
extra.laravel.providers from the module's own composer.json, exactly as it
did in-app. Tests, static-analysis config, and the module_path() helper all
keep working because the module's internal layout is unchanged.
Tip: develop with
"type": "path"+"url": "Modules/*"and"symlink": trueso in-app modules and promoted packages behave identically during development.
License
MIT.