Skip to content

Modular Monolith in Laravel: Enforcing Bounded Contexts with Module Service Providers

Learn how to carve a Laravel application into cohesive bounded contexts using per-module service providers, explicit public APIs, and enforced cross-module contracts — without reaching for microservices.

Published · Updated · 3 min read

Modular Monolith in Laravel: Enforcing Bounded Contexts with Module Service Providers

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 its Contracts/ 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.

Frequently asked questions

Should each module have its own database schema or tables?

Not necessarily. Sharing a database is fine; what matters is that only the owning module's Eloquent models query its tables. Other modules access data through the module's public contract (repository interface or read DTO), never by querying the table directly.

How do I handle database transactions that span two modules?

Wrap the operation in a `DB::transaction()` at the application layer (e.g., an action or command handler) that calls both module contracts. Because it's a monolith with a shared connection, ACID guarantees still apply — this is one of the key advantages over microservices.

Is Deptrac the only way to enforce boundaries?

No. PHPArkitect is a PHP-native alternative with a fluent API. You can also write a custom Pest architecture test using `expect()->classes()->toOnlyUse()` scoped to each module namespace, which integrates naturally if you're already running Pest in CI.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project