Modular Monolith in Laravel: Enforcing Bounded Contexts Without a Microservices Tax
#laravel #architecture #ddd #modular-monolith

Modular Monolith in Laravel: Enforcing Bounded Contexts Without a Microservices Tax

3 min read Mohamed Said Mohamed Said

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 SharedKernel for genuine cross-cutting value objects prevents duplication without creating coupling.
  • You can extract any context to a microservice later because the contract already exists.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should each bounded context have its own database schema or tables?
In a modular monolith you typically share one database, but prefix tables per context (e.g. `billing_invoices`, `catalog_products`). Each context's Eloquent models and migrations live inside that context. This makes future extraction to separate databases straightforward without requiring a schema split on day one.
Q02 How do you handle shared Eloquent models like User that multiple contexts need?
The `User` model belongs to the `Identity` context. Other contexts receive a `UserId` value object and call `Identity\IdentityContext::findUser(UserId)` when they need user data. They never import `Identity\Infrastructure\Models\User` directly. This keeps the boundary clean while still allowing cross-context user lookups.
Q03 Can this structure work with Filament admin panels?
Yes. Each context's `UI/` layer can contain its own Filament resources and panels. Register them inside the context's service provider using Filament's `Panel::make()` or by calling `FilamentFacade::serving()`. The panel for `Billing` only registers resources from `Billing\UI\Filament`, never from other contexts.

Continue reading

More Articles

View all