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 distribution problems you probably don't have yet. A modular monolith gives you the same conceptual separation — bounded contexts, explicit contracts, isolated domain logic — while keeping a single deployable, a single database transaction scope, and zero network overhead between modules.

The goal is not folder organisation for its own sake. It is preventing accidental coupling so that the Billing team can refactor invoicing without breaking the Shipping team's code.


Module Structure

Each bounded context lives under app/Modules/{Context}/. The internal layout mirrors a mini-application:

app/Modules/
  Billing/
    Actions/
    Data/          # DTOs, value objects
    Domain/        # Eloquent models, domain services
    Http/          # Controllers, requests, resources
    Infrastructure/ # Repositories, external adapters
    Providers/
      BillingServiceProvider.php
    routes.php
  Shipping/
    ...

Each module registers itself via its own service provider, which is loaded from bootstrap/providers.php (Laravel 11+):

// bootstrap/providers.php
return [
    App\Modules\Billing\Providers\BillingServiceProvider::class,
    App\Modules\Shipping\Providers\ShippingServiceProvider::class,
];
// app/Modules/Billing/Providers/BillingServiceProvider.php
class BillingServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadRoutesFrom(__DIR__ . '/../routes.php');
        $this->loadMigrationsFrom(__DIR__ . '/../Infrastructure/Migrations');
    }

    public function register(): void
    {
        $this->app->bind(
            \App\Modules\Billing\Domain\Contracts\InvoiceRepository::class,
            \App\Modules\Billing\Infrastructure\EloquentInvoiceRepository::class,
        );
    }
}

Internal Contracts: The Boundary Enforcement Mechanism

Modules must never import each other's internal classes directly. Instead, each module exposes a public API interface in a Contracts namespace:

// app/Modules/Billing/Contracts/BillingModuleInterface.php
namespace App\Modules\Billing\Contracts;

use App\Modules\Billing\Data\InvoiceData;

interface BillingModuleInterface
{
    public function issueInvoice(int $orderId, int $customerId): InvoiceData;
    public function getOutstandingBalance(int $customerId): Money;
}

Shipping resolves this interface through the container — it never touches Billing\Domain\ directly:

// app/Modules/Shipping/Actions/DispatchOrder.php
class DispatchOrder
{
    public function __construct(
        private readonly BillingModuleInterface $billing,
    ) {}

    public function handle(Order $order): void
    {
        $balance = $this->billing->getOutstandingBalance($order->customer_id);

        if ($balance->isNegative()) {
            throw new OutstandingBalanceException();
        }

        // dispatch logic ...
    }
}

Enforcing the Rules with Deptrac

Folder conventions are worthless without tooling. Deptrac analyses static imports and fails CI when a module reaches across a boundary:

# deptrac.yaml
deptrac:
  paths:
    - app/Modules
  layers:
    - name: Billing
      collectors:
        - type: directory
          value: app/Modules/Billing/
    - name: Shipping
      collectors:
        - type: directory
          value: app/Modules/Shipping/
  ruleset:
    Shipping:
      - Billing  # Shipping may only use Billing's public Contracts
    Billing: ~

Run ./vendor/bin/deptrac analyse in CI. Any direct import of Billing\Domain\ from Shipping\ becomes a build failure.


Cross-Module Events Instead of Direct Calls

For truly decoupled side-effects, prefer domain events over direct method calls:

// Billing fires:
event(new InvoicePaid($invoiceId, $customerId, $amount));

// Shipping listens in its own EventServiceProvider:
Event::listen(InvoicePaid::class, ReleaseHeldShipments::class);

The event class lives in a shared app/Events/ namespace (or a dedicated app/Shared/ module) so neither context owns it.


Shared Kernel

Some primitives — Money, Address, UserId — are used everywhere. Place them in app/Shared/ and treat that namespace as a read-only dependency for all modules. No module writes back to Shared.


Key Takeaways

  • One service provider per module keeps registration isolated and explicit.
  • Public contract interfaces are the only surface other modules may depend on.
  • Deptrac in CI turns architectural rules into hard failures, not guidelines.
  • Domain events decouple side-effects without requiring a message broker.
  • Shared kernel is small and stable; resist the urge to dump utilities there.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need separate databases per module?
No. A modular monolith shares one database. The boundary is in code, not infrastructure. You can extract a module to a microservice later if needed, but separate databases are not a prerequisite.
Q02 How do I handle shared Eloquent models that multiple modules need?
Place truly shared models (e.g. User) in a Shared or Core module. Each consuming module accesses them through that shared namespace, never by reaching into another module's Domain folder.
Q03 Is Deptrac the only option for enforcing boundaries?
PHPArkitect is a PHP-native alternative with a fluent API. Both integrate well with GitHub Actions or any CI pipeline and serve the same purpose: turning architectural conventions into automated checks.

Continue reading

More Articles

View all