Modular Monolithic Architecture in Laravel 12 – Scalable PHP App Design | 200OK Solutions

Modular Monolithic Architecture in Laravel 12

Share this post on:

If you’ve been building Laravel applications for a while, you’ve probably hit that point where the codebase starts feeling like a tangled mess – controllers importing models from everywhere, business logic scattered across files, and a folder structure that required a mental map to navigate. The advice is usually: keep a simple monolith, or go microservices. But there’s a third path that most teams overlook.

The Laravel modular monolith sits right in the middle – one deployed application, internally structured into self-contained modules with enforced boundaries. You get clean architecture without the operational overhead of microservices.

This guide walks through building a real Laravel 12 application with this architecture – folder structure, service providers, cross-module communication via Events, and FormRequest validation. Every pattern here comes from actually building it.

1. Monolith vs Modular Monolith vs Microservices

Diagram comparing Monolithic Architecture, Microservices Architecture, and Modular Monolith showing system structure, services, APIs, and databases.

Before writing a single line of code, it helps to understand where a modular monolith fits. A standard Laravel app is clean at first – but as it grows, there are no enforced boundaries, and any controller can freely import any model. This is the “big ball of mud” problem.

Microservices solve the coupling problem but introduce network latency, distributed tracing, complex deployments, and multiple codebases. For a team of two or three developers, that’s bringing a crane to move a couch.

A modular monolith gives you the organisational benefits of microservices – enforced boundaries, independent domains – while staying as a single deployable application.

 Monolith Modular Monolith Microservices 
Deployment Single Single Multiple 
Codebase One One Multiple 
Boundaries None Enforced Enforced 
Complexity Low Medium High 
Team fit Solo/Small Small/Medium Medium/Large 
Best for MVPs Growing products Large-scale systems 

When Should You Use It?

A modular monolith is a strong fit when your app has clear domain boundaries (Users, Orders, Payments), your team is small-to-medium, and you want clean architecture without microservices overhead. It is not the right choice for a simple side project or a basic CRUD app with two models β€” a standard Laravel structure is perfectly fine in those cases.

2. Setting Up the Project Structure

A standard Laravel app organises files by type (all controllers together, all models together). A modular monolith flips this entirely – you organise by domain. Every module owns its own controllers, models, services, migrations, and routes.

Create the Folder Structure

Run this once per module. The modules/ directory lives at the project root alongside app/:

#User module creation  
mkdir modules\User\Controllers modules\User\Models modules\User\Services modules\User\Migrations modules\User\Requests modules\User\Providers 
 
mkdir modules\User\Routes  
type nul > modules\User\Routes\api.php 
 
type nul > modules\User\Providers\UserServiceProvider.php 
#Order module creation 
 
mkdir modules\Order\Controllers modules\Order\Models modules\Order\Services modules\Order\Migrations modules\Order\Requests modules\Order\Providers 
 
mkdir modules\Order\Routes 
 
type nul > modules\Order\Routes\api.php 
 
type nul > modules\Order\Providers\UserServiceProvider.php

Register the Namespace in composer.json

Open composer.json and add the Modules\\ namespace to the PSR-4 autoload map, then run composer dump-autoload:

"autoload": { 
    "psr-4": { 
        "App\\": "app/", 
        "Modules\\": "modules/"   // Add this line 
    } 
} 

Create the Module Service Provider

Every module self-registers its routes and migrations through its own ServiceProvider. Create modules/User/Providers/UserServiceProvider.php:

public function boot(): void 
{ 
    $this->loadRoutesFrom(__DIR__ . '/../Routes/api.php'); 
    $this->loadMigrationsFrom(__DIR__ . '/../Migrations'); 
}

Then register it in bootstrap/providers.php (Laravel 12):

return [ 
    App\Providers\AppServiceProvider::class, 
    Modules\User\Providers\UserServiceProvider::class, 
    Modules\Order\Providers\OrderServiceProvider::class, 
];
Laravel modular monolith folder structure showing modules like Order and User with controllers, models, migrations, routes, and services.

3. Building a Module: The Service Layer Pattern

Every module follows the same three-layer pattern: Controller β†’ Service β†’ Model. Controllers accept requests and delegate. Services hold all business logic. Models are internal implementation details that never leak outside the module.

The service layer is the most important piece. Here is the UserService – notice how each public method maps to a clear business operation:

// modules/User/Services/UserService.php 
class UserService 
{ 
    public function getAllUsers() 
    { 
        return User::all(); 
    } 
    public function createUser(array $data): User 

    { 
        return User::create([ 
            'name'     => $data['name'], 
            'email'    => $data['email'], 
            'password' => bcrypt($data['password']), 
        ]); 
    } 

Key rule: Business logic lives in the Service. The Controller’s only job is: receive a validated request, call the service, return the response. Nothing more.

4. Cross-Module Communication: The Right Way

This is where most modular monolith implementations break down. When two modules need to communicate, the temptation is to reach directly into another module’s model. This destroys the architecture entirely.

The Wrong Way -Tight Coupling

Adding an Eloquent relationship like Order β†’ belongsTo(\Modules\User\Models\User::class) seems harmless, but the Order module has now imported the User module’s internal model. The two modules are no longer independent:

β€’ If you refactor the User model, the Order module breaks too

β€’ You cannot test the Order module in isolation

β€’ Extracting Orders into its own service later means untangling a hard dependency

β€’ Developers have no clear signal about where module boundaries are

Tight coupling vs loose coupling diagram showing Order module directly accessing User model versus using a User service public API.

Pattern 1 – Service Injection

When the Order module needs user data, inject UserService into OrderService via the constructor. Laravel’s container resolves this automatically – no manual binding required. The Order module consumes the public interface (the service) rather than the internal implementation (the model):

// modules/Order/Services/OrderService.php 
class OrderService 
{ 
    public function __construct( 
        protected UserService $userService  // Injected automatically 
    ) {} 
    public function getOrderWithUser(int $orderId): array 
    { 
        $order = Order::find($orderId); 
        $user  = $this->userService->findUser($order->user_id); // Via service 
        return ['order' => $order, 'user' => $user]; 
    } 
}

Pattern 2 – Events and Listeners

Service injection handles “I need data from another module.” But what about “something happened, and other modules should react”? That’s where Laravel Events come in.

When an order is placed, the Order module shouldn’t know or care that a confirmation email gets sent -it just fires an event and moves on. The User module’s listener reacts independently:

// Step 1 β€” Fire the event from OrderService (Order module) 
public function createOrder(array $data): Order 
{ 
    $order = Order::create([...]); 
    event(new OrderCreated($order));   // Fire and forget 
    return $order; 
} 

// Step 2 β€” Register the listener in AppServiceProvider 
Event::listen(OrderCreated::class, SendOrderConfirmation::class); 

// Step 3 β€” User module reacts independently 
// modules/User/Listeners/SendOrderConfirmation.php 
public function handle(OrderCreated $event): void 

{ 
    $user = User::find($event->order->user_id); 
    // Send confirmation email / log / update loyalty points... 
} 

The payoff: The Order module never imports anything from the User module. Tomorrow, if you want to also send an SMS or update a loyalty counter – add another listener. The Order module code does not change at all.

5. FormRequest Validation Per Module

Without validation, store() methods accept raw $request->all() with zero checks. FormRequests move validation into its own dedicated class per module, and a shared BaseRequest guarantees a consistent error format across the entire application:

// app/Http/Requests/BaseRequest.php  β€” shared by ALL modules 
class BaseRequest extends FormRequest 
{ 
    protected function failedValidation(Validator $validator): void 
    { 
        $firstError = collect($validator->errors()->all())->first(); 
        throw new HttpResponseException( 
            response()->json([ 
                'success'    => false, 
                'statusCode' => 422, 
                'message'    => $firstError, 
                'data'       => [] 
            ], 422) 
        ); 
    } 
} 

Each module then extends BaseRequest and defines only its own rules. Every failed validation response site-wide now returns the same JSON shape automatically – no duplication, no inconsistency.

Consistent error response: { “success”: false, “statusCode”: 422, “message”: “Email is required”, “data”: [] }

6. Artisan Gotchas You’ll Hit in Practice

Building a modular monolith without a dedicated package comes with a few rough edges. Know these upfront:

β€’ make:migration works cleanly with –path=modules/User/Migrations

β€’ make:model, make:controller, make:request, make:event always generate inside app/ – create these files manually

β€’ Always check the namespace at the top of any artisan-generated file – it sometimes defaults to App\\ regardless of path

β€’ Delete app/Models/User.php once you create your module’s User model – the duplicate causes confusing auth guard errors

β€’ If php artisan migrate misses your module migrations, double-check loadMigrationsFrom() path in the ServiceProvider’s boot() method

β€’ Consider nwidart/laravel-modules if your team grows – it adds module-aware artisan commands and scaffolding

Conclusion

A Laravel modular monolith sits in the pragmatic middle ground – one deployed application with the organisational discipline of independent services. Here is everything this guide covered:

1. A modular folder structure where each domain owns its Controllers, Models, Services, Requests, Routes, and Providers

2. Module Service Providers that self-register routes and migrations – zero central configuration required

3. A service layer that keeps controllers thin and business logic testable in isolation

4. Loose cross-module coupling via Service Injection (for data) and Events (for side-effects)

5. A shared BaseRequest pattern for consistent FormRequest validation across all modules

When to use it: Your app has clear domain boundaries, your team is small-to-medium, and you want clean architecture without microservices overhead.

When not to use it: A simple CRUD app, a blog, a small side project – standard Laravel is perfectly fine. Architecture should solve real problems,

Complete Laravel modular monolith architecture diagram showing User and Order modules with services, models, repositories, and event bus communication.

You may also like: Laravel Performance Optimization: 10 Proven Tips to Make Your Application 10x Faster 

Piyush Solanki

PHP Tech Lead & Backend Architect

10+ years experience
UK market specialist
Global brands & SMEs
Full-stack expertise

Core Technologies

PHP 95%
MySQL 90%
WordPress 92%
AWS 88%
  • Backend: PHP, MySQL, CodeIgniter, Laravel
  • CMS: WordPress customization & plugin development
  • APIs: RESTful design, microservices architecture
  • Frontend: React, TypeScript, modern admin panels
  • Cloud: AWS S3, Linux deployments
  • Integrations: Stripe, SMS/OTP gateways
  • Finance: Secure payment systems & compliance
  • Hospitality: Booking & reservation systems
  • Retail: E-commerce platforms & inventory
  • Consulting: Custom business solutions
  • Food Services: Delivery & ordering systems
  • Modernizing legacy systems for scalability
  • Building secure, high-performance products
  • Mobile-first API development
  • Agile collaboration with cross-functional teams
  • Focus on operational efficiency & innovation

Piyush Solanki is a seasoned PHP Tech Lead with 10+ years of experience architecting and delivering scalable web and mobile backend solutions for global brands and fast-growing SMEs.

He specializes in PHP, MySQL, CodeIgniter, WordPress, and custom API development, helping businesses modernize legacy systems and launch secure, high-performance digital products.

He collaborates closely with mobile teams building Android & iOS apps, developing RESTful APIs, cloud integrations, and secure payment systems. With extensive experience in the UK market and across multiple sectors, Piyush Solanki is passionate about helping SMEs scale technology teams and accelerate innovation through backend excellence.