Back to Blog
Lesson 6 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 27, 20263 min read

Modular Monolith Structure: Domain-Driven Scaling in Laravel

Learn to build a Modular Monolith by structuring your Laravel directory by domain, enforcing encapsulation, and defining public interfaces for module communication.

LaravelArchitectureModular MonolithDDDClean Architecturephpbackend

Previously in this course, we explored Defining Bounded Contexts: Architecting for Scale in Laravel. We established the conceptual boundaries for our SaaS; now, we must physically reflect those boundaries in our file system.

In a standard Laravel installation, as discussed in Installing Laravel and Exploring Directory Structure, the app/ directory is organized by technical concern (Controllers, Models, Jobs). This leads to the "God Object" anti-pattern in large systems. A Modular Monolith shifts this paradigm, organizing code by domain rather than by file type.

Structuring by Domain

To achieve a true Modular Monolith, we move away from the flat app/ structure. Instead, we create a src/ or modules/ directory where each folder represents a Bounded Context.

Inside each module, you mirror the MVC structure or, more ideally, the clean architecture pattern we've been building upon.

TEXT
app/
modules/
  Billing/
    Actions/
    Contracts/
    DTOs/
    Models/
    Providers/
    Services/
    routes.php
  Identity/
    Actions/
    Models/
    Services/
    ...

By grouping these files, you ensure that a developer working on the Billing module rarely needs to touch the Identity module. This physical isolation drastically reduces cognitive load and prevents "spaghetti" dependencies.

Defining Public Interfaces for Encapsulation

The greatest risk in a monolith is tight coupling. If your Billing controller directly calls Identity\Models\User::class and manipulates its data, you've created a hidden dependency. When you eventually need to change the User schema, you break Billing silently.

Encapsulation is achieved by defining a "Public API" for each module. Nothing outside the module should touch its internal classes (like Models or internal Services) directly.

Worked Example: Exposing a Module Interface

Let’s define a Billing module that exposes a single entry point for the Identity module to use.

1. The Internal Service (Private)

PHP
namespace Modules\Billing\Services;

class SubscriptionManager {
    #6A9955">// This class should not be injected directly into other modules
    public function createSubscription(int $userId, string $planId): void { ... }
}

2. The Public Facade/Interface (Public) Create a Contract or a Service Provider that exposes only what is necessary.

PHP
namespace Modules\Billing;

use Modules\Billing\Services\SubscriptionManager;

class BillingGateway {
    public function __construct(private SubscriptionManager $manager) {}

    public function subscribeUser(int $userId, string $planId): void {
        $this->manager->createSubscription($userId, $planId);
    }
}

3. Registration via Service Provider Register this gateway in your module's ServiceProvider, ensuring the rest of the application resolves the BillingGateway rather than the SubscriptionManager.

Hands-on Exercise: Modularizing your SaaS

For our running project, identify one core domain (e.g., "Subscriptions").

  1. Create a modules/Subscriptions directory.
  2. Move your existing Subscription model, related Actions, and Services into this new folder.
  3. Create a SubscriptionFacade class that acts as the only entry point for other modules.
  4. Refactor one controller to use the SubscriptionFacade instead of injecting the Subscription model directly.

Common Pitfalls

  • Circular Dependencies: If Module A needs Module B, and Module B needs Module A, your boundaries are wrong. Use an Event-driven approach (which we will cover later in this course) to decouple them.
  • Shared Kernel Bloat: Developers often create a Shared or Common directory for reused code. This quickly becomes a dumping ground for "everything," effectively recreating the monolithic mess you're trying to escape. Keep Shared strictly for truly domain-agnostic utilities (e.g., custom Value Objects or logging helpers).
  • Database Coupling: Even if you organize files, you might still share tables. Ensure your migrations are namespaced to the module. If two modules share a database table, they are not actually separate modules.

Recap

Modular Monolith architecture isn't just about moving folders; it's about enforcing boundaries. By grouping by domain and strictly controlling access via public interfaces, you create a codebase that is easier to reason about, test, and—if the day comes—extract into a microservice. As explored in Project Structure for Large Applications: Domain-Driven Laravel, this structural clarity is the foundation for long-term scalability.

Up next: We will dive into Querying with Strict Eloquent to ensure our domain boundaries are enforced at the database level.

Similar Posts