Everyone agrees it beats microservices for small teams. Nobody talks about how it quietly rots into the messy monolith you were trying to escape.

There is a consensus forming in the backend world. Microservices are expensive, operationally painful and mostly unnecessary until you have a team big enough to staff a dedicated platform engineering group. Modular monoliths are the sensible middle ground. Clean module boundaries. Single deployment. No distributed systems headaches.
🚨 HIRING: Tech Talent
💰 $50–$120/hr | 🔥 Multiple Roles
Frontend • Backend • Full Stack • Mobile • AI/ML • DevOps
👉 Apply Here

That is all true. But the part the tutorials skip is what happens after you set it up.
I have worked on Laravel codebases that started as clean modular structures and ended up as a rats’ nest of cross-module imports, silent coupling and “temporary” shortcuts that became permanent six months later. The architecture was right on paper. The execution was a mess. And the reason was not technical incompetence. It was the absence of any real discipline enforcing the boundaries.
This article is not another “how to set up nwidart/laravel-modules” walkthrough. It is about what makes a modular monolith actually hold together over time, and what kills it when nobody is watching.
First, What a Real Module Boundary Means
A module in a modular monolith is not a folder. That is the first thing people get wrong. You can create a modules/ directory with Billing, Orders and Notifications subdirectories, run php artisan make:module Billing and feel very organised. But if BillingController reaches directly into OrderService from the Orders module, you have achieved nothing architecturally. You just have folders.
A real module boundary means that no code inside one module directly instantiates or imports a class from another module’s internal namespace. Modules communicate through contracts: interfaces defined in a shared location, events dispatched via Laravel’s event system or explicit service boundaries.
The rule is simple. If module A needs something from module B, it should depend on an interface that B implements, not on B’s concrete class. This is not a new idea. It is just consistently ignored because it takes more work upfront.
The Folder Structure That Actually Works
There are two schools of thought on Laravel module structure. The first uses nwidart/laravel-modules to scaffold independent module directories at the root level. The second keeps everything under app/ but reorganises by domain rather than by Laravel convention.
Both can work. Here is the structure I default to for new projects, using a src/ directory approach without the nwidart package:
app/
├── Modules/
│ ├── Billing/
│ │ ├── Contracts/
│ │ │ └── BillingServiceInterface.php
│ │ ├── Events/
│ │ │ └── InvoiceGenerated.php
│ │ ├── Http/
│ │ │ └── Controllers/
│ │ ├── Jobs/
│ │ ├── Models/
│ │ ├── Services/
│ │ │ └── BillingService.php
│ │ └── Providers/
│ │ └── BillingServiceProvider.php
│ ├── Orders/
│ │ ├── Contracts/
│ │ ├── Events/
│ │ │ └── OrderPlaced.php
│ │ ├── Http/
│ │ ├── Models/
│ │ └── Services/
│ └── Notifications/
│ ├── Listeners/
│ │ └── SendOrderConfirmation.php
│ └── Services/
├── Shared/
│ ├── Contracts/
│ └── ValueObjects/The Shared/ directory is not a dumping ground. It exists for genuinely cross-cutting concerns: value objects, base interfaces and utilities that have no module allegiance. If you find yourself putting business logic in Shared/, a module boundary decision needs revisiting.
Each module has its own ServiceProvider that registers bindings, listeners and routes. This keeps the main AppServiceProvider clean and gives each module clear ownership of its own wiring.
Cross-Module Communication: The Two Patterns That Work
When modules need to talk to each other, you have two options that keep things clean.
Option 1: Interface contracts
Define the interface in the consuming module or in Shared/Contracts/, and let the providing module implement it. Bind the implementation in the providing module's service provider.
// app/Modules/Billing/Contracts/BillingServiceInterface.php
namespace App\Modules\Billing\Contracts;
interface BillingServiceInterface
{
public function generateInvoice(int $orderId): string;
} // app/Modules/Billing/Services/BillingService.php
namespace App\Modules\Billing\Services;
use App\Modules\Billing\Contracts\BillingServiceInterface;
class BillingService implements BillingServiceInterface
{
public function generateInvoice(int $orderId): string
{
// implementation here
return 'INV-' . $orderId;
}
} // app/Modules/Billing/Providers/BillingServiceProvider.php
namespace App\Modules\Billing\Providers;
use App\Modules\Billing\Contracts\BillingServiceInterface;
use App\Modules\Billing\Services\BillingService;
use Illuminate\Support\ServiceProvider;
class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(BillingServiceInterface::class, BillingService::class);
}
}
Now the Orders module can type-hint BillingServiceInterface and never know that BillingService exists. The concrete class is an implementation detail.
Option 2: Domain events
For scenarios where the originating module should not care who handles the outcome, Laravel events are cleaner.
// app/Modules/Orders/Events/OrderPlaced.php
namespace App\Modules\Orders\Events;
use Illuminate\Foundation\Events\Dispatchable;
class OrderPlaced
{
use Dispatchable;
public function __construct(
public readonly int $orderId,
public readonly int $userId,
public readonly float $total,
) {}
} // app/Modules/Notifications/Listeners/SendOrderConfirmation.php
namespace App\Modules\Notifications\Listeners;
use App\Modules\Orders\Events\OrderPlaced;
class SendOrderConfirmation
{
public function handle(OrderPlaced $event): void
{
// send confirmation email
}
}
The Notifications module imports the OrderPlaced event class from Orders. This is an acceptable exception to the strict no-import rule, because events are data structures, not business logic. The key is that Orders has no idea what Notifications does with the event. It fires and forgets.
Enforcing the Rules (Because You Will Need This)
Here is the uncomfortable truth: without tooling, the boundaries will erode. Not because developers are careless. Because under deadline pressure, the fastest path is always the direct import. “Just this once” becomes the pattern.
Deptrac is the tool that prevents this. It is a static analysis package that lets you define architectural layers and the rules governing which layers can depend on which others. You add it to your project and run it in CI. Violations fail the build.
composer require --dev deptrac/deptracA minimal deptrac.yaml for the structure above:
paths:
- ./app
layers:
- name: Billing
collectors:
- type: directory
regex: app/Modules/Billing/.*
- name: Orders
collectors:
- type: directory
regex: app/Modules/Orders/.*
- name: Notifications
collectors:
- type: directory
regex: app/Modules/Notifications/.*
- name: Shared
collectors:
- type: directory
regex: app/Shared/.*
ruleset:
Billing:
- Shared
Orders:
- Shared
Notifications:
- Shared
- Orders # allowed: Notifications can listen to Order eventsRun vendor/bin/deptrac analyse and it shows you every violation. Add it to your GitHub Actions or whatever CI you are using. Now "just this once" produces a failing build that someone has to consciously override.
This is not bureaucracy. It is the same discipline you apply when you write tests. You are encoding an architectural decision into the pipeline so it does not rely on tribal knowledge or code review vigilance.
The Database Question Nobody Wants to Answer
Most modular monolith guides sidestep the database. They focus on code structure and leave you to figure out whether modules should share tables, own their tables or have separate databases.
The honest answer is: modules should own their tables.
This means the orders table is owned by the Orders module. The invoices table is owned by Billing. If Billing needs to display an order reference number on an invoice, it gets that number through the contract interface, not by querying the orders table directly.
No cross-module joins. Ever.
This is where teams push back hardest. Performance is the usual argument. But the actual cost of a clean boundary is almost never a database round-trip. It is the discipline to not reach for the shortcut. And the performance argument is almost always theoretical. Profile first. Optimise if you have to. Do not compromise the architecture pre-emptively.
If you eventually need to extract a module into its own service, the database separation is what makes it viable. If you have been joining across module boundaries from day one, that extraction becomes a rewrite.
When to Actually Extract a Service
The modular monolith is not a permanent state. It is a starting point with a clear extraction path. But the trigger for extraction should be real, not aspirational.
Extract a module when a specific module has dramatically different scaling requirements from the rest of the application, a module needs to be deployed independently on a different release cycle or a separate team takes ownership of a module and the coordination overhead of a shared codebase becomes the constraint.
Do not extract because you read that services are more scalable, a module got complex (complexity inside a module is a refactoring problem, not a deployment problem) or because you want to use a different tech stack for one module (valid eventually, but not a reason to incur distributed systems complexity early).
The discipline of running a clean modular monolith means that when the real reason to extract arrives, the work is mostly configuration and deployment, not archaeology.
The Part That Actually Fails
I have seen modular monolith setups fail in two specific ways that have nothing to do with the architecture itself.
The first is module sprawl. Teams create a new module for every feature. You end up with twenty-five modules for a mid-sized application, half of which contain three files. The overhead of defining contracts, events and service providers for a feature that is genuinely tiny is not worth it. A module should represent a bounded domain concept, not a feature ticket. When in doubt, add the code to an existing module and extract later if the domain proves it needs independence.
The second is ignoring the Shared/ contract. Teams start putting everything in Shared/ because it is easier. After six months Shared/ is a second app/ directory with no clear ownership. Anything going into Shared/ should require a deliberate conversation. The question is not "can this be shared?" It is "does this genuinely belong to no domain?"
The Actual Takeaway
Set up the module structure. Use service providers per module. Communicate through interfaces and events. Own your tables. Add Deptrac to CI on day one, not after you have noticed the boundaries eroding.
None of this is complicated. The complication is doing it consistently when you are the one developer covering architecture, feature work and production incidents at the same time.
The modular monolith is worth it. Just do not mistake setting it up for maintaining it. The folder structure takes an afternoon. The discipline takes a career.
Thank you for being a part of the community
Before you go:

👉 Be sure to clap and follow the writer ️👏️️
👉 CodeToDeploy Tech Community is live on Discord — Join now!
Disclosure: This post includes affiliate and partnership links.


