DEV Community

saad mouizina
saad mouizina

Posted on • Originally published at Medium on

Modular Architecture with Laravel

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
Enter fullscreen mode Exit fullscreen mode

Then publish the config file:

php artisan vendor:publish --provider="Nwidart\Modules\LaravelModulesServiceProvider"
Enter fullscreen mode Exit fullscreen mode

To ensure Composer can autoload our modules, update your composer.json with:

"autoload": {
    "psr-4": {
        "Modules\\": "Modules/"
    }
},
"extra": {
    "merge-plugin": {
        "include": [
            "Modules/*/composer.json"
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

And run:

composer dump-autoload
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'],
    ];
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Then, regenerate the autoloader:

composer dump-autoload
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    ],
],
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    ],
];
Enter fullscreen mode Exit fullscreen mode

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);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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)