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

Modular Monolith in Laravel: Enforcing Bounded Contexts Without a Framework

3 min read Mohamed Said Mohamed Said

Why a Modular Monolith?

Microservices solve distribution problems you probably don't have yet. A well-structured monolith that respects domain boundaries gives you most of the organisational benefits — independent deployability aside — without the operational overhead. The goal is to make cross-module coupling visible and painful, so it stays rare.

Directory Convention First

Start with a modules/ directory at the project root. Each module is a self-contained PHP namespace:

modules/
  Billing/
    src/
      BillingServiceProvider.php
      Domain/
        Invoice.php
        InvoiceRepository.php
      Application/
        CreateInvoiceAction.php
      Infrastructure/
        EloquentInvoiceRepository.php
    composer.json
  Catalog/
    src/
      CatalogServiceProvider.php
      ...
    composer.json

Each module has its own composer.json declaring its namespace. The root composer.json pulls them in as path repositories:

{
  "repositories": [
    {"type": "path", "url": "modules/Billing"},
    {"type": "path", "url": "modules/Catalog"}
  ],
  "require": {
    "acme/billing": "@dev",
    "acme/catalog": "@dev"
  }
}

Composer symlinks each module into vendor/, so autoloading works identically to a real package. The boundary is now enforced by Composer's dependency graph, not just a gentleman's agreement.

Module Service Providers

Each module registers its own bindings, routes, and migrations:

// modules/Billing/src/BillingServiceProvider.php
namespace Acme\Billing;

use Illuminate\Support\ServiceProvider;
use Acme\Billing\Domain\InvoiceRepository;
use Acme\Billing\Infrastructure\EloquentInvoiceRepository;

class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(InvoiceRepository::class, EloquentInvoiceRepository::class);
    }

    public function boot(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
        $this->loadRoutesFrom(__DIR__.'/../routes/api.php');
    }
}

Register it in bootstrap/providers.php (Laravel 11+) or config/app.php:

return [
    Acme\Billing\BillingServiceProvider::class,
    Acme\Catalog\CatalogServiceProvider::class,
];

Enforcing Boundaries with Deptrac

Conventions break under deadline pressure. Deptrac statically analyses use statements and fails CI when a module imports from a sibling it shouldn't:

# deptrac.yaml
layers:
  - name: Billing
    collectors:
      - type: namespace
        value: Acme\\Billing
  - name: Catalog
    collectors:
      - type: namespace
        value: Acme\\Catalog
ruleset:
  Billing:
    - Catalog   # Billing MAY depend on Catalog's public API
  Catalog: []   # Catalog must not depend on anything else

Run deptrac analyse in your pipeline. A use Acme\Billing\... inside Catalog now breaks the build.

Cross-Module Communication

Modules talk through three mechanisms only:

  1. Public contracts — interfaces and DTOs in a Contracts/ namespace that other modules may import.
  2. Laravel events — fire a domain event; other modules listen without coupling to the source.
  3. The service container — resolve a contract, never a concrete class from another module.
// Catalog fires an event; Billing listens
event(new ProductPurchased($productId, $userId));

// In BillingServiceProvider::boot()
Event::listen(ProductPurchased::class, GenerateInvoiceListener::class);

Shared Kernel

Put truly cross-cutting concerns — Money, UserId, base exceptions — in a SharedKernel module that every other module may depend on. Keep it tiny.

Testing in Isolation

Because each module is a Composer package, you can run Pest inside the module directory with its own phpunit.xml:

cd modules/Billing && vendor/bin/pest

Mock the InvoiceRepository interface; never touch the database in unit tests. Integration tests live at the root and boot the full application.

Key Takeaways

  • Use Composer path repositories to make module boundaries real, not aspirational.
  • Each module owns its service provider, migrations, and routes.
  • Deptrac in CI prevents accidental cross-module coupling before it becomes technical debt.
  • Cross-module communication flows through events, contracts, and the container — never direct class imports.
  • A SharedKernel module holds value objects and base types; keep it deliberately small.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need a dedicated package like nwidart/laravel-modules?
No. Composer path repositories plus a per-module ServiceProvider give you the same autoloading and isolation without an additional dependency. Third-party module packages add conventions you may not agree with; rolling your own keeps full control.
Q02 How do I handle shared database tables across modules?
Prefer each module owning its own tables. When two modules genuinely share data, expose it through a repository contract in the SharedKernel or the owning module's public API. Direct cross-module Eloquent model imports are a coupling smell.
Q03 Can this structure work with Filament admin panels?
Yes. Each module can register its own Filament resources inside its ServiceProvider using Filament::serving() or a dedicated panel provider. This keeps admin UI co-located with the domain it manages.

Continue reading

More Articles

View all