Why a Modular Monolith?
Microservices solve deployment independence but introduce distributed-systems complexity most teams don't need yet. A modular monolith gives you the bounded-context discipline of microservices while keeping a single deployable unit, shared database transactions, and zero network overhead between modules.
The key discipline: modules must not reach into each other's internals. They communicate through explicit contracts — interfaces, DTOs, and domain events.
Directory Layout
app/
Modules/
Billing/
BillingServiceProvider.php
Actions/
Contracts/
BillingGateway.php
Domain/
Http/
Models/
Catalog/
CatalogServiceProvider.php
Contracts/
ProductRepository.php
...
Shared/
Events/
ValueObjects/
Each module owns its own ServiceProvider, routes, migrations (or migration stubs), and a Contracts/ directory that defines its public API. Nothing outside the module imports from Domain/ or Models/ directly.
Per-Module Service Providers
Register each module provider in bootstrap/providers.php (Laravel 11+):
// bootstrap/providers.php
return [
App\Modules\Billing\BillingServiceProvider::class,
App\Modules\Catalog\CatalogServiceProvider::class,
];
A module provider wires its own internals and publishes only its contracts:
namespace App\Modules\Billing;
use Illuminate\Support\ServiceProvider;
use App\Modules\Billing\Contracts\BillingGateway;
use App\Modules\Billing\Infrastructure\StripeGateway;
class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(BillingGateway::class, StripeGateway::class);
}
public function boot(): void
{
$this->loadRoutesFrom(__DIR__.'/Http/routes.php');
$this->loadMigrationsFrom(__DIR__.'/Database/migrations');
}
}
The Catalog module never imports StripeGateway. It only ever type-hints BillingGateway.
Cross-Module Communication via Domain Events
Direct method calls between modules create hidden coupling. Use Laravel's event dispatcher with typed event classes that live in Shared/Events/:
// Shared/Events/OrderPlaced.php
final readonly class OrderPlaced
{
public function __construct(
public string $orderId,
public string $customerId,
public int $totalCents,
) {}
}
The Orders module fires the event; Billing listens:
// Orders module — fires
event(new OrderPlaced($order->id, $order->customer_id, $order->total_cents));
// Billing module — listens, registered in BillingServiceProvider::boot()
Event::listen(OrderPlaced::class, ChargeCreditCard::class);
Neither module imports the other's classes. The shared event is the contract.
Enforcing Boundaries with Deptrac
Conventions erode without tooling. Deptrac statically analyses PHP imports and fails CI when a module reaches into another's internals.
# deptrac.yaml
layers:
- name: Billing
collectors:
- type: directory
value: app/Modules/Billing
- name: Catalog
collectors:
- type: directory
value: app/Modules/Catalog
ruleset:
Billing:
- Shared
Catalog:
- Shared
Add vendor/bin/deptrac analyse to your CI pipeline. Any import from Billing into Catalog\Domain becomes a build failure.
Shared Kernel vs. Shared Everything
The Shared/ layer should be thin: value objects (Money, Email), base events, and utility interfaces. If you find yourself putting business logic there, it's a sign a new module is trying to emerge.
A Money value object is a legitimate shared kernel member:
namespace App\Modules\Shared\ValueObjects;
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \DomainException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
Key Takeaways
- Each module gets its own
ServiceProvider; only itsContracts/directory is public. - Cross-module calls go through typed interfaces or domain events — never direct class imports.
- Deptrac (or similar) enforces boundaries in CI before humans forget the rules.
- The
Shared/kernel holds value objects and base types, not business logic. - You can extract a module to a microservice later by replacing its service provider with an HTTP/gRPC adapter — the rest of the app never notices.