khaleelibrahim / laravel-modular
Modular architecture scaffolding for Laravel: self-contained feature modules with repositories, services, tag-based caching, and Artisan generators.
Package info
github.com/khaleelibrahim054/laravel-modular
pkg:composer/khaleelibrahim/laravel-modular
Requires
- php: ^8.1
- illuminate/cache: ^10.0|^11.0|^12.0
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
README
A modular architecture system for Laravel applications. Run php artisan make:module {Name} and get a fully-wired, self-contained feature module — controller, service, repository (with tag-based, tenant-scoped caching), Form Requests, API Resource, and its own Service Provider — instead of hand-rolling everything under app/Http and app/Services.
Installation
composer require khaleelibrahim/laravel-modular
The package's Service Provider is auto-discovered. Publish the config and/or stub templates:
php artisan vendor:publish --tag=modular-config php artisan vendor:publish --tag=modular-stubs
Publishing modular-stubs copies the generator templates to stubs/laravel-modular/ in your application, where you can edit them freely — the package always prefers a published stub over its own internal copy.
Configuration (config/modular.php)
return [ 'namespace' => env('MODULAR_NAMESPACE', 'App\\Modules'), 'path' => env('MODULAR_PATH', app_path('Modules')), 'auto_register_providers' => env('MODULAR_AUTO_REGISTER', true), ];
namespace— the PSR-4 root under which modules are generated. Must correspond to a namespace your app actually autoloads (the defaultApp\→app/mapping coversApp\Modulesout of the box).path— the filesystem directory modules are written to. Must correspond tonamespace.auto_register_providers— whether the package should automatically register every module's Service Provider on boot (see below). Disable if you'd rather list them manually.
The module folder convention
Every module lives at {path}/{ModuleName}/ with this structure:
| Folder | Purpose |
|---|---|
Controllers/ |
Thin HTTP controllers — input, delegate to the service, respond. No business logic, no direct DB/repository access. |
DTO/ |
Data Transfer Objects for passing structured data between layers. |
Enums/ |
Module-scoped enums (only when applicable). |
Models/ |
Eloquent models for this module. |
Observers/ |
Model observers for lifecycle hooks (only when applicable). |
Providers/ |
The module's Service Provider — binds interfaces to implementations, loads the module's routes. |
Repositories/ |
DB operations + cache management. Extends the package's BaseRepository. No business logic. |
Requests/ |
Laravel Form Requests for input validation. |
Resources/ |
API response transformers (Laravel API Resources). |
Routes/ |
Module-scoped route definitions, loaded by the module's own Service Provider. |
Services/ |
All business logic. Injected with repository interfaces, never concrete classes. Never queries the DB directly. |
Rules the generators encode:
- Repositories extend
BaseRepositoryand implement their{Entity}RepositoryInterface.tag()returns a lowercase, hyphenated string. All reads go through$this->remember($this->cacheKey(...), fn () => ...); all writes call$this->flush()afterward.cacheKey()already scopes by tenant — never scope cache keys manually elsewhere. - Services implement their
{Entity}ServiceInterface, constructor-inject the repository interface, contain all business logic, and throw domain-specific exceptions. - Controllers constructor-inject the service interface, use Form Requests for input validation, and never touch the repository or database directly.
Artisan commands
| Command | What it does |
|---|---|
make:module {Name} |
Scaffolds the full folder tree, the module's Service Provider, and an empty Routes/api.php. |
make:module-model {Module} {Entity} |
Generates Models/{Entity}.php. |
make:module-repository {Module} {Entity} |
Generates Repositories/Contracts/{Entity}RepositoryInterface.php and Repositories/{Entity}Repository.php, then inserts a $this->app->bind(...) line into the module's Service Provider (idempotent). |
make:module-service {Module} {Entity} |
Generates Services/Contracts/{Entity}ServiceInterface.php and Services/{Entity}Service.php (constructor-injecting the matching repository interface), then inserts its own bind line into the Service Provider (idempotent). |
make:module-controller {Module} {Entity} |
Generates Controllers/{Entity}Controller.php, constructor-injecting {Entity}ServiceInterface. |
make:module-request {Module} {Entity} |
Generates Requests/Create{Entity}Request.php and Requests/Update{Entity}Request.php. |
make:module-resource {Module} {Entity} |
Generates Resources/{Entity}Resource.php. |
make:module-enum {Module} {Name} |
Generates Enums/{Name}.php. |
make:module-observer {Module} {Entity} |
Generates Observers/{Entity}Observer.php. |
module:cache |
Discovers module Service Providers and caches the list to bootstrap/cache/modules.php. |
module:clear |
Clears that cache. |
Every generator command accepts --force to overwrite existing files, normalizes names with Str::studly(), and pulls templates from your published stubs/laravel-modular/ directory when present, falling back to the package's built-in stubs otherwise.
Caching
BaseRepository gives every module repository tag-based, tenant-scoped caching for free:
abstract class BaseRepository { protected int $cacheTTL = 600; // override per-repository if needed abstract protected function tag(): string; protected function cacheKey(string $suffix): string { /* scopes by app('tenant') automatically */ } protected function remember(string $key, Closure $callback) { /* ... */ } protected function flush(): void { /* ... */ } }
remember()usesCache::tags([$this->tag()])->remember(...)when the configured cache store extendsIlluminate\Cache\TaggableStore(array, database, memcached, redis, dynamodb), and falls back to a plainCache::remember(...)otherwise.flush()clears only this repository's tag when the store is taggable, and falls back to a fullCache::flush()otherwise.- Override
protected int $cacheTTLin a repository to change its TTL. cacheKey()already scopes by tenant (viaapp('tenant')when bound, else'system') — never add manual tenant scoping elsewhere.
Provider auto-discovery
On boot, the package scans config('modular.path') for */Providers/*ServiceProvider.php files, resolves their fully-qualified class name from config('modular.namespace'), and registers each one with the container — so you never list module providers in bootstrap/providers.php by hand.
To avoid a filesystem scan on every request in production, cache the discovered list (mirroring Laravel's own package:discover cache):
php artisan module:cache # scan and cache to bootstrap/cache/modules.php php artisan module:clear # clear the cache, forcing a fresh scan next boot
Set MODULAR_AUTO_REGISTER=false (or 'auto_register_providers' => false in the config) to disable auto-discovery entirely and register module providers yourself.
Quick start
php artisan make:module Blog php artisan make:module-model Blog Post php artisan make:module-repository Blog Post php artisan make:module-service Blog Post php artisan make:module-controller Blog Post php artisan make:module-request Blog Post php artisan make:module-resource Blog Post
This produces app/Modules/Blog/ with a PostController, PostService/PostServiceInterface, PostRepository/PostRepositoryInterface (both bound automatically in BlogServiceProvider), Post model, CreatePostRequest/UpdatePostRequest, and PostResource — all wired together.
Add the standard CRUD routes to the generated (empty) app/Modules/Blog/Routes/api.php:
use App\Modules\Blog\Controllers\PostController; use Illuminate\Support\Facades\Route; Route::prefix('v1/posts') ->middleware(['api', 'auth:sanctum']) ->group(function () { Route::get('/', [PostController::class, 'index']); Route::post('/', [PostController::class, 'store']); Route::get('/{id}', [PostController::class, 'show']); Route::put('/{id}', [PostController::class, 'update']); Route::delete('/{id}', [PostController::class, 'destroy']); });
Because BlogServiceProvider is auto-discovered and its boot() already calls loadRoutesFrom(__DIR__ . '/../Routes/api.php'), these routes are immediately reachable at /api/v1/posts — no manual registration required.
Testing
composer install vendor/bin/phpunit
Tests use Orchestra Testbench and cover the generator commands, the bind-insertion behavior, BaseRepository caching against both a taggable and non-taggable store, and provider auto-discovery.
License
MIT.