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.
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.
TEXTapp/ 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)
PHPnamespace 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.
PHPnamespace 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").
- Create a
modules/Subscriptionsdirectory. - Move your existing
Subscriptionmodel, relatedActions, andServicesinto this new folder. - Create a
SubscriptionFacadeclass that acts as the only entry point for other modules. - Refactor one controller to use the
SubscriptionFacadeinstead of injecting theSubscriptionmodel 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
SharedorCommondirectory for reused code. This quickly becomes a dumping ground for "everything," effectively recreating the monolithic mess you're trying to escape. KeepSharedstrictly 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.
Work with me

Laravel REST API Development
Clean, secure, well-documented Laravel REST APIs — the backend engine for your app, mobile client, or SaaS. Built by an API specialist.

FilamentPHP Admin Panel & Dashboard Development
A powerful admin panel for your Laravel app — built with FilamentPHP so you can manage everything without touching the database.