Why a Modular Monolith?
Microservices promise isolation but deliver operational complexity. A well-structured modular monolith gives you the same conceptual boundaries — bounded contexts, explicit contracts, independent domain logic — while keeping deployment, transactions, and debugging simple.
The goal is not to split the codebase into packages. It is to enforce rules about who may call whom and how at the PHP level, so the boundaries are real rather than aspirational.
Directory Layout
Each bounded context lives under src/Modules/. The framework's app/ directory becomes a thin bootstrap layer.
src/
Modules/
Billing/
Actions/
Data/
Events/
Http/
Models/
Contracts/
BillingServiceInterface.php
BillingServiceProvider.php
Catalog/
...
Identity/
...
app/
Providers/
AppServiceProvider.php ← registers module providers only
Each module registers itself via its own ServiceProvider. AppServiceProvider does nothing but call $this->app->register(BillingServiceProvider::class) for each module.
Internal Contracts: The Only Crossing Point
Modules must never import each other's concrete classes. They communicate through interfaces declared in their own Contracts/ namespace.
// src/Modules/Billing/Contracts/BillingServiceInterface.php
namespace Modules\Billing\Contracts;
interface BillingServiceInterface
{
public function charge(string $customerId, int $amountCents): ChargeResult;
}
The Catalog module depends on this interface, not on Modules\Billing\Services\StripeService.
// src/Modules/Catalog/Actions/PublishProduct.php
namespace Modules\Catalog\Actions;
use Modules\Billing\Contracts\BillingServiceInterface;
final class PublishProduct
{
public function __construct(
private readonly BillingServiceInterface $billing
) {}
public function handle(Product $product): void
{
$result = $this->billing->charge($product->owner_id, $product->listing_fee_cents);
// ...
}
}
Binding the concrete implementation happens inside BillingServiceProvider, invisible to the caller.
// src/Modules/Billing/BillingServiceProvider.php
$this->app->bind(
\Modules\Billing\Contracts\BillingServiceInterface::class,
\Modules\Billing\Services\StripeService::class,
);
Cross-Module Communication via Domain Events
When a module needs to notify rather than query, use Laravel's event dispatcher with typed event classes. Each module listens only to events it cares about.
// src/Modules/Identity/Events/UserRegistered.php
namespace Modules\Identity\Events;
final class UserRegistered
{
public function __construct(
public readonly string $userId,
public readonly string $email,
) {}
}
// src/Modules/Billing/Listeners/ProvisionFreeTier.php
namespace Modules\Billing\Listeners;
use Modules\Identity\Events\UserRegistered;
final class ProvisionFreeTier
{
public function handle(UserRegistered $event): void
{
// create a free subscription for $event->userId
}
}
The Identity module fires the event and has zero knowledge of Billing. The listener is registered in BillingServiceProvider:
$this->listen(UserRegistered::class, ProvisionFreeTier::class);
Enforcing Boundaries with Deptrac
Conventions break under deadline pressure. Automate enforcement with Deptrac:
# deptrac.yaml
layers:
- name: Billing
collectors:
- type: className
regex: ^Modules\\Billing\\
- name: Catalog
collectors:
- type: className
regex: ^Modules\\Catalog\\
ruleset:
Catalog:
- Billing # Catalog may only use Billing's Contracts, enforced by regex
Billing: []
Run deptrac analyse in CI. Any direct import of a concrete class across module boundaries fails the build.
Shared Kernel: What Belongs There
Not everything is module-specific. A Shared/ kernel holds:
- Base value objects (
Money,Email,Uuid) - Common exceptions (
DomainException,ValidationException) - Infrastructure interfaces (
ClockInterface,UuidGeneratorInterface)
Modules may depend on Shared/. Shared/ must never depend on any module.
Key Takeaways
- Modules own their service providers — binding, listening, and scheduling stay inside the module.
- Contracts are the only public API — no concrete class crosses a module boundary.
- Events decouple producers from consumers — the firing module never imports the listener.
- Deptrac in CI makes boundaries real — conventions without tooling are just comments.
- Shared kernel stays thin — value objects and interfaces only, no business logic.
- Transactions still work — because it is one process and one database connection,
DB::transaction()spans modules freely.