khaleelibrahim/laravel-modular

Modular architecture scaffolding for Laravel: self-contained feature modules with repositories, services, tag-based caching, and Artisan generators.

Maintainers

Package info

github.com/khaleelibrahim054/laravel-modular

pkg:composer/khaleelibrahim/laravel-modular

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-04 02:08 UTC

This package is auto-updated.

Last update: 2026-08-04 02:36:45 UTC


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 default App\app/ mapping covers App\Modules out of the box).
  • path — the filesystem directory modules are written to. Must correspond to namespace.
  • 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 BaseRepository and 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() uses Cache::tags([$this->tag()])->remember(...) when the configured cache store extends Illuminate\Cache\TaggableStore (array, database, memcached, redis, dynamodb), and falls back to a plain Cache::remember(...) otherwise.
  • flush() clears only this repository's tag when the store is taggable, and falls back to a full Cache::flush() otherwise.
  • Override protected int $cacheTTL in a repository to change its TTL.
  • cacheKey() already scopes by tenant (via app('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.