Taming Laravel Monoliths with Modular Architecture
As Laravel projects grow, maintaining a clean structure becomes increasingly difficult. Controllers pile up, models get bloated, and domain logic starts to leak across unrelated parts of the application. You quickly end up with a monolith that’s hard to scale or reason about.
That’s where modular architecture comes in.
Why modular architecture?
The core idea is to break your application into feature-specific modules , each responsible for its own domain logic — authentication, blog, billing, etc. Each module contains its own models, controllers, routes, validation, services, and more.
This approach offers several key benefits:
- Separation of concerns — every module owns its logic
- Improved maintainability — easier to test, extend, or replace parts of the app
- Better scalability — especially with larger teams or growing business domains
Modular architecture is particularly useful in mid-to-large-scale applications, where clear functional boundaries exist across features.
What we’ll build
In this tutorial, I’ll walk you through how to implement a clean modular structure in Laravel using the excellent nwidart/laravel-modules package.
We’ll build two core modules:
- Authentication — handling registration, login, email verification, logout, etc.
- Blog — a feature module containing User, Post, and Comment models, with full REST APIs
Along the way, we’ll cover:
- How to keep modules decoupled but still communicate cleanly
- to centralize authentication logic in the main app
- How to secure all routes from the main app while allowing modules to expose their logic
- How to structure repositories, requests, events, and resources in a scalable and maintainable way
For the business logic and API structure, I’ll build on top of what I introduced in this previous article, adapting it to a modular architecture — so it might be worth giving it a quick read first.
Getting Started: Project Setup and Docker Configuration
Before jumping into modularization, we need a solid Laravel base to work on — ideally with Docker so everything is isolated and reproducible across environments.
If you’re not familiar with Docker or want a ready-to-go setup for Laravel, I recommend checking out this article:
👉 Getting Started with Laravel Projects Using Docker — My Basic Configuration
It walks through a minimal and efficient Docker setup using:
- PHP-FPM container
- Nginx for web server
- MySQL/MariaDB for database
- Mailhog for local email testing
- and volume bindings to keep everything in sync
Once your Docker environment is up and Laravel is running (you can use the make laravel-install) in the container, we can move to the modular part of the project.
I’ve made the full implementation of this tutorial available here: https://github.com/mouize/demo-modular-architecture
1. Install the nwidart/laravel-modules package
We’ll use the excellent nwidart/laravel-modules package to structure our app into independent feature modules.
Install it via Composer:
composer require nwidart/laravel-modules
Then publish the config file:
php artisan vendor:publish --provider="Nwidart\Modules\LaravelModulesServiceProvider"
To ensure Composer can autoload our modules, update your composer.json with:
"autoload": {
"psr-4": {
"Modules\\": "Modules/"
}
},
"extra": {
"merge-plugin": {
"include": [
"Modules/*/composer.json"
]
}
}
And run:
composer dump-autoload
You can also customize how modules are generated by editing config/modules.php — for example, under the generator section, you can define which folders or files should be created by default for each module.
2. Setting up the Authentication Module
The first feature we’re going to modularize is authentication — one of the most critical and commonly reused parts of any application.
We’ll use Laravel Sanctum for token-based authentication, and organize the entire auth logic (registration, login, logout, email verification) inside a dedicated Authentication module.
But before diving into code, let’s understand the reasoning behind it.
Why isolate authentication into a module?
Authentication often touches a lot of parts in an app (user creation, email verification, session/token management, etc.), so it makes sense to extract it into its own self-contained domain.
By doing this:
- We reduce coupling between authentication logic and the rest of the app
- We can reuse or replace the module easily in other Laravel projects
- We keep things organized and scalable — one module, one responsibility
Create the authentication module
php artisan module:make Authentication
This scaffolds a fully isolated folder structure under Modules/Authentication, including space for models, controllers, routes, requests, events, etc.
Create the AuthController
namespace Modules\Authentication\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Event;
use Modules\Authentication\Http\Requests\RegisterRequest;
use Modules\Authentication\Http\Requests\LoginRequest;
use Modules\Authentication\Contracts\UserRepositoryInterface;
use Modules\Authentication\Events\UserRegistered;
use Modules\Authentication\Events\UserVerified;
class AuthController extends Controller
{
public function __construct(protected UserRepositoryInterface $userRepository) {}
public function register(RegisterRequest $request): JsonResponse
{
$user = $this->userRepository->register([
'name' => $request->validated('name'),
'email' => $request->validated('email'),
'password' => Hash::make($request->validated('password'),
]);
Event::dispatch(new UserRegistered($user));
return response()->json([
'message' => 'Account created successfully. Please check your email to verify your account.',
], 201);
}
public function verify(Request $request): JsonResponse
{
$user = $this->userRepository->findOrFail($request->route('id'));
if (! hash_equals((string) $request->route('hash'), sha1($user->getEmail()))) {
return response()->json(['message' => 'Invalid verification link'], 400);
}
Event::dispatch(new UserVerified($user));
return response()->json(['message' => 'Email verified successfully']);
}
public function login(LoginRequest $request): JsonResponse
{
$user = $this->userRepository->getUserByEmail($request->validated('email'));
if (! $user || ! Hash::check($request->validated('password'), $user->getPassword())) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$token = $user->createToken('auth_token');
return response()->json([
'access_token' => $token->plainTextToken,
'token_type' => 'Bearer',
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->tokens()->delete();
return response()->json(['message' => 'Successfully logged out']);
}
}
Add FormRequest validation
To keep our controller clean and follow Laravel best practices, we use FormRequest classes to validate the inputs.
php artisan module:make-request RegisterRequest Authentication
php artisan module:make-request LoginRequest Authentication
Then define rules in each request:
//Modules/Authentication/Http/Requests/RegisterRequest.php
public function rules(): array
{
return [
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'min:8'],
];
}
//Modules/Authentication/Http/Requests/LoginRequest.php
public function rules(): array
{
return [
'email' => ['required', 'email'],
'password' => ['required'],
];
}
Define contracts to decouple logic
To keep our module independent from the actual User implementation, we rely on interfaces.
This allows the main application to define how users are created, authenticated, and managed — while the module simply expects any class that fulfills the contract.
Create two interfaces inside your module:
php artisan module:make-interface UserInterface Authentication
php artisan module:make-interface UserRepositoryInterface Authentication
namespace Modules\Authentication\Contracts;
interface UserInterface
{
public function getId(): int;
public function getEmail(): string;
public function getPassword(): string;
public function createToken(string $name);
}
namespace Modules\Authentication\Contracts;
interface UserRepositoryInterface
{
public function register(array $data): UserInterface;
public function getUserByEmail(string $email): ?UserInterface;
public function findOrFail(int $id): UserInterface;
}
Use events to externalize side-effects
Instead of triggering things like email notifications or verification logic directly inside the controller, we use events to handle side effects.
This gives us:
- Clear separation of responsibilities
- Flexibility to change what happens after registration without touching the controller
php artisan module:make-event UserRegistered Authentication
php artisan module:make-event UserVerified Authentication
Define them like so:
namespace Modules\Authentication\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\Authentication\Interfaces\UserInterface;
class UserRegistered
{
use Dispatchable, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public UserInterface $user) {}
}
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\Authentication\Interfaces\UserInterface;
class UserVerified
{
use Dispatchable, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public UserInterface $user) {}
}
3. Creating the Blog Module
Now that our authentication module is in place, let’s move on to building a second module — the Blog module.
This will represent a real business feature and give us the opportunity to organize models, resources, relationships, validation, and repositories in a modular way.
php artisan module:make Blog
Let’s generate our User, Post, and Comment models using the built-in Nwidart generator:
php artisan module:make-model User Blog -frRc
php artisan module:make-model Post Blog -frRc
php artisan module:make-model Comment Blog -frRc
We can now define for each model the relationships and fillable attributes inside each one.
The -frRc flags we used when generating the models gave us a solid base: eloquent resources, form requests, and RESTful controllers were all scaffolded automatically.
Now we just need to fill in the logic to bring it all together.
Migrations
Create the corresponding migrations in a folder stubs/migrations as shown here. (will be discussed deeply in a next section)
Using Spatie Query Builder for Clean & Flexible Queries
To keep our controllers clean and make our API flexible for consumers, we use Spatie Query Builder.
It allows us to build powerful filters, includes, and sorts with zero extra logic in the controller. All of that is handled inside the repository layer.
composer require spatie/laravel-query-builder
Each module has its own composer.json, so we also need to add the package there:
#Modules/Blog/composer.json
"require": {
"spatie/laravel-query-builder": "*" //You can set a specific version depending on your needs
}
Then, regenerate the autoloader:
composer dump-autoload
Repositories: Centralizing Data Access
We can generate our repositories now:
php artisan module:make-repository UserRepository Blog
php artisan module:make-repository PostRepository Blog
php artisan module:make-repository CommentRepository Blog
Let’s complete them to encapsulate all the logic for querying and persisting data.
Http logics
RESTful controllers, form requests, eloquent resources were also scaffolded, let’s plug them into the repositories and handle basic CRUD operations as shown here.
4. Publishing Module Routes & Migrations
By design, modules should stay generic and reusable. That’s why we avoid hardcoding things like route prefixes or middleware inside the module.
Instead, we let the main application decide :
- how to group or secure the routes
- how to customize the migration structure if needed
To achieve that, we make our routes and migrations publishable.
Making routes publishable
In both the Authentication and Blog modules, define routes in a simple way — no middleware, no prefix:
//Modules/Authentication/Routes/api.php
use Illuminate\Support\Facades\Route;
use Modules\Authentication\Http\Controllers\AuthController;
Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']);
Route::post('logout', [AuthController::class, 'logout']);
Route::get('verify/{id}/{hash}', [AuthController::class, 'verify']);
//Modules/Blog/Routes/api.php
use Illuminate\Support\Facades\Route;
use Modules\Blog\Http\Controllers\UserController;
use Modules\Blog\Http\Controllers\PostController;
use Modules\Blog\Http\Controllers\CommentController;
Route::apiResource('users', UserController::class);
Route::apiResource('posts', PostController::class);
Route::apiResource('comments', CommentController::class);
By avoiding hardcoded route groups, modules stay clean and composable — the main app is free to apply auth, prefixes, or rate limiting globally.
Then in your module service provider, add:
//Modules/Authentication/app/Providers/AuthenticationServiceProvider
$this->publishes([
__DIR__.'/../../routes/api.php' => base_path('routes/modules/Authentication/api.php'),
], 'authentication-routes');
//Modules/Blog/app/Providers/BlogServiceProvider
$this->publishes([
__DIR__.'/../../routes/api.php' => base_path('routes/modules/Blog/api.php'),
], 'blog-routes');
From the main app, you can now publish and control the routes:
php artisan vendor:publish --tag=authentication-routes
php artisan vendor:publish --tag=blog-routes
You’ll get fully editable copies under routes/modules/Blog/api.php and routes/modules/Authentication/api.php.
Once published, the app can load these routes with proper middleware and prefixing.
(We’ll see that in the next section.)
Making migrations publishable
Same idea for migrations. If the app needs to add extra columns or adapt table structures, it can override the module’s migrations.
In the same service providers:
//Modules/Authentication/app/Providers/AuthenticationServiceProvider
$this->publishes([
__DIR__.'/../../stubs/migrations' => database_path('migrations'),
], 'authentication-migrations');
//Modules/Blog/app/Providers/BlogServiceProvider
$this->publishes([
__DIR__.'/../../stubs/migrations' => database_path('migrations'),
], 'blog-migrations');
Important: We moved the migrations to the stubs/migrations folder to prevent them from being executed during a migrate command unless they are explicitly published first.
Then publish them with:
php artisan vendor:publish --tag=authentication-migrations
php artisan vendor:publish --tag=blog-migrations
You’ll get fully editable copies under database/migrations
5. Orchester Modules from the Main Application
Now that our modules are structured and publish their routes and migrations, the main application becomes the central orchestrator. It will:
- Connect the app’s User model to the Authentication module
- Implement the UserRepositoryInterface and UserInterface
- Secure and load the module routes dynamically
- Listen to events (like UserRegistered) and decide how to react
Create the main User model
This model lives in the app and extends the User model from the Blog module:
namespace App\Models;
use App\Mail\CustomVerifyEmail;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Notifications\Notifiable;
use Modules\Authentication\Interfaces\UserInterface;
use Modules\Blog\Models\User as BlogUser;
class User extends BlogUser implements UserInterface, AuthenticatableContract
{
use HasApiTokens, Notifiable, Authenticatable, MustVerifyEmail;
protected $fillable = ['name', 'email', 'password'];
public function getId(): int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function getPassword(): string
{
return $this->password;
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new CustomVerifyEmail);
}
}
This model handles authentication and implements the interface expected by the Authentication module.
Configure Laravel to use this User model
In config/auth.php:
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
Implement the UserRepository
namespace App\Repository;
use App\Models\User;
use Modules\Authentication\Interfaces\UserInterface;
use Modules\Authentication\Interfaces\UserRepositoryInterface;
use Modules\Blog\Repositories\UserRepository as BlogUserRepository;
class UserRepository extends BlogUserRepository implements UserRepositoryInterface
{
protected string $model = User::class;
public function register(array $data): UserInterface
{
return $this->model::create($data);
}
public function getUserByEmail(string $email): ?UserInterface
{
return $this->model::where('email', $email)->first();
}
public function findOrFail(int $id): UserInterface
{
return $this->model::findOrFail($id);
}
}
Then bind the interface in your AppServiceProvider:
use Modules\Authentication\Contracts\UserRepositoryInterface;
use App\Repositories\UserRepository;
public function register()
{
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
}
Listen to events
Create listeners for the events dispatched in the Authentication module.
namespace App\Listeners;
use Modules\Authentication\Events\UserRegistered;
class SendEmailVerificationNotification
{
public function handle(UserRegistered $event): void
{
$event->user->sendEmailVerificationNotification();
}
}
namespace App\Listeners;
use Modules\Authentication\Events\UserVerified;
class MarkEmailAsVerified
{
public function handle(UserVerified $event): void
{
$event->user->markEmailAsVerified();
}
}
Register them in EventServiceProvider (if needed):
use Modules\Authentication\Events\UserRegistered;
use Modules\Authentication\Events\UserVerified;
protected $listen = [
UserRegistered::class => [
\App\Listeners\SendEmailVerificationNotification::class,
],
UserVerified::class => [
\App\Listeners\MarkEmailAsVerified::class,
],
];
Secure routes
Create a new service provider like ModuleRouteServiceProvider:
namespace App\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\Facades\Module;
class ModuleRouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
foreach (Module::allEnabled() as $module) {
$name = $module->getName();
$path = base_path("routes/modules/{$name}/api.php");
if (file_exists($path)) {
Route::prefix('api')
->name('api.')
->group($path);
}
}
}
}
If needed, declare this new ServiceProvider in bootstrap/providers
Once the module routes are published (e.g. routes/modules/Blog/api.php), the main app can customize them freely — including applying authentication middleware.
For example, in the Blog module:
use Illuminate\Support\Facades\Route;
use Modules\Blog\Http\Controllers\PostController;
use Modules\Blog\Http\Controllers\UserController;
use Modules\Blog\Http\Controllers\CommentController;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('users', UserController::class);
Route::apiResource('posts', PostController::class);
Route::apiResource('comments', CommentController::class);
});
And for the Authentication module:
use Illuminate\Support\Facades\Route;
use Modules\Authentication\Http\Controllers\AuthController;
Route::post('register', [AuthenticationController::class, 'register'])
->name('register');
Route::post('login', [AuthenticationController::class, 'login'])
->name('login');
Route::middleware('signed')
->get('/email/verify/{id}/{hash}', [AuthenticationController::class, 'verify'])
->name('verification.verify');
This gives you maximum control over each module’s security behavior, without needing to maintain a custom route loader.
Your app now orchestrates everything:
- It owns the User logic
- Secures and loads module routes dynamically
- Handles auth-specific events
- Keeps the modules decoupled and reusable
And Voilà
Modular architecture brings clarity, separation of concerns, and long-term maintainability to Laravel projects — especially when things start to grow.
By leveraging nwidart/laravel-modules, we’ve been able to:
- Split our application into clearly defined domains
- Keep modules independent and reusable
- Centralize responsibilities like authentication, route security, and event handling in the main app
- Maintain clean API logic using repositories, resources, FormRequests, and Spatie Query Builder
This structure is particularly useful in real-world teams, where different developers work on different parts of the system, or where features evolve independently over time.
Bonus: What about Domain-Driven Design (DDD)?
Modular architecture is often confused with DDD — and while the two can overlap, they’re not the same.
- Modular architecture is about technical organization : splitting your code by feature to make it more maintainable.
- DDD is about business modeling : building your code around the language and rules of your domain.
You can absolutely use both together — in fact, modules are a great place to implement bounded contexts and aggregates if you want to go deeper into DDD later on.
Top comments (0)