Why a Modular Monolith?
Microservices solve organisational scale problems. Most teams have a codebase problem: everything is tangled in app/ with no enforced boundaries. A modular monolith gives you the conceptual separation of services — clear ownership, explicit contracts, independent testability — while keeping a single deployable unit and a shared database transaction.
The goal is not folder aesthetics. It is making illegal dependencies impossible to write and legal ones obvious to read.
Directory Layout
Drop the default app/ catch-all and introduce a src/ root with one directory per bounded context:
src/
Billing/
BillingServiceProvider.php
Application/ # use-cases, commands, queries
Domain/ # entities, value objects, domain events
Infrastructure/ # Eloquent models, repositories, payment gateways
UI/ # controllers, Filament resources, API resources
Catalog/
...
Identity/
...
Register src/ in composer.json:
"autoload": {
"psr-4": {
"App\\": "app/",
"Billing\\": "src/Billing/",
"Catalog\\": "src/Catalog/",
"Identity\\": "src/Identity/"
}
}
Each context owns a ServiceProvider that registers its own bindings, routes, and migrations. Boot them in config/app.php or via package auto-discovery if you extract them later.
Internal Contracts: The Boundary Enforcement Mechanism
Contexts must never reach into each other's Domain/ or Infrastructure/ layers directly. Instead, expose a thin facade interface at the context root:
// src/Billing/BillingContext.php
namespace Billing;
interface BillingContext
{
public function chargeSubscription(SubscriptionId $id, Money $amount): ChargeResult;
public function findInvoice(InvoiceId $id): ?InvoiceDto;
}
The concrete implementation lives in Billing\Infrastructure\LaravelBillingContext and is bound in BillingServiceProvider:
$this->app->bind(BillingContext::class, LaravelBillingContext::class);
The Catalog context injects BillingContext, never an Eloquent model from Billing\Infrastructure\Models\Invoice. This is the contract. Violating it is a code-review failure, not a runtime error — unless you add architecture tests.
Enforcing Boundaries with Pest Architecture Tests
Pest's arch() helper lets you codify rules that CI enforces on every push:
// tests/Architecture/BoundaryTest.php
arch('Catalog does not depend on Billing internals')
->expect('Catalog')
->not->toUse('Billing\\Domain')
->not->toUse('Billing\\Infrastructure');
arch('Domain layer stays pure')
->expect('Billing\\Domain')
->not->toUse('Illuminate\\Database')
->not->toUse('Illuminate\\Http');
arch('Infrastructure may use Eloquent')
->expect('Billing\\Infrastructure')
->toUse('Illuminate\\Database\\Eloquent\\Model');
These tests run in milliseconds and catch the "quick fix" that imports an Eloquent model across a boundary.
Cross-Context Communication: Events Over Direct Calls
When Billing needs to notify Catalog that a subscription expired, it dispatches a domain event. Catalog listens — but the listener is registered in Catalog's own service provider:
// In CatalogServiceProvider
Event::listen(
\Billing\Domain\Events\SubscriptionExpired::class,
\Catalog\Application\Listeners\SuspendCatalogListings::class,
);
Billing has no knowledge of Catalog. The event class lives in Billing\Domain\Events and is the only thing Catalog imports from Billing — and only the event DTO, never infrastructure.
Shared Kernel: What Belongs There
Some concepts are genuinely cross-cutting: Money, UserId, Pagination, base DomainEvent. Place these in a SharedKernel/ namespace:
src/
SharedKernel/
ValueObjects/
Money.php
UserId.php
Contracts/
DomainEvent.php
All contexts may depend on SharedKernel. No context may depend on another context's internals. This rule is simple enough to enforce in a team of ten.
Takeaways
- Folder structure alone is not architecture — contracts and architecture tests are what enforce boundaries.
- Each bounded context exposes one interface; callers depend on that interface, never on internal models.
- Pest
arch()tests are cheap to write and eliminate boundary drift in CI. - Domain events decouple contexts at runtime without a message broker.
- A
SharedKernelfor genuine cross-cutting value objects prevents duplication without creating coupling. - You can extract any context to a microservice later because the contract already exists.