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 domain isolation of microservices while keeping a single deployment unit, a shared database transaction, and zero network overhead between modules. The trick is enforcing the boundaries so the monolith never becomes a big ball of mud.

Directory Layout

Organise by bounded context, not by technical layer:

app/
  Modules/
    Billing/
      Actions/
      Data/          # DTOs, value objects
      Events/
      Http/
      Models/
      Providers/
      Contracts/     # public interface for other modules
    Inventory/
      ...
    Shared/
      ...

Each module owns its own ServiceProvider. The Shared module holds cross-cutting concerns (money value objects, pagination DTOs, etc.) that every module may import. Nothing outside Contracts/ is a stable API.

Registering Modules Automatically

Add a ModuleServiceProvider that discovers and boots every module:

// app/Providers/ModuleServiceProvider.php
class ModuleServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        foreach (glob(app_path('Modules/*/Providers/*ServiceProvider.php')) as $file) {
            $class = $this->classFromPath($file);
            $this->app->register($class);
        }
    }

    private function classFromPath(string $path): string
    {
        return str_replace(
            [app_path() . '/', '/', '.php'],
            ['App/', '\\', ''],
            $path
        );
    }
}

Register ModuleServiceProvider in bootstrap/providers.php (Laravel 11+) and every new module is picked up without touching any central file.

Defining a Public Contract

A module exposes only what other modules need:

// app/Modules/Billing/Contracts/BillingService.php
interface BillingService
{
    public function charge(CustomerId $customer, Money $amount): Receipt;
    public function currentPlan(CustomerId $customer): Plan;
}

The concrete implementation lives inside Billing and is bound in BillingServiceProvider:

$this->app->bind(BillingService::class, StripeBillingService::class);

The Inventory module injects BillingService, never StripeBillingService. This is the boundary.

Preventing Accidental Coupling with Pest Architecture Tests

Pest's arch() helper lets you codify boundaries as executable tests:

// tests/Architecture/ModuleBoundariesTest.php
arch('Inventory does not reach into Billing internals')
    ->expect('App\Modules\Inventory')
    ->not->toUse('App\Modules\Billing\Actions')
    ->not->toUse('App\Modules\Billing\Models');

arch('Billing only exposes its Contracts namespace')
    ->expect('App\Modules\Billing\Actions')
    ->not->toBeUsedIn('App\Modules\Inventory');

arch('Shared module has no module-specific imports')
    ->expect('App\Modules\Shared')
    ->not->toUse('App\Modules\Billing')
    ->not->toUse('App\Modules\Inventory');

These tests run in milliseconds and fail the CI pipeline the moment a developer reaches across a boundary.

Cross-Module Communication via Events

When Billing needs to tell Inventory that a subscription was cancelled, it dispatches a domain event rather than calling an Inventory class directly:

// Billing dispatches:
event(new SubscriptionCancelled($customerId, $plan, now()));

// Inventory listens:
class FreezeInventoryAllotment
{
    public function handle(SubscriptionCancelled $event): void
    {
        // Inventory-specific logic only
    }
}

The event lives in Billing\Events (the source of truth). Inventory depends on the event class — that's acceptable because events are part of the public contract surface.

Database Boundaries

You share one database, but each module should prefix its tables (billing_invoices, inventory_products). Avoid cross-module Eloquent relationships; use IDs and re-query inside the target module. This keeps migrations independent and makes a future extraction to a separate service mechanical rather than surgical.

Takeaways

  • Organise by bounded context; let each module own its ServiceProvider.
  • Expose only Contracts/ interfaces — never concrete classes or internal models.
  • Use Pest arch() tests to make boundary violations a CI failure, not a code-review debate.
  • Communicate across modules with domain events, not direct method calls.
  • Prefix tables per module so schema ownership is unambiguous.
  • A modular monolith is the right default; extract to microservices only when you have a proven scaling or team-autonomy reason.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can modules share an Eloquent model, for example a shared User?
Avoid it. Instead, each module that needs user data should define a thin read model or query its own projection. If a shared User is unavoidable, place it in the Shared module and treat it as a stable, rarely-changing contract.
Q02 How do Pest architecture tests differ from PHPStan for enforcing boundaries?
PHPStan catches type errors and undefined references. Pest arch() tests express intentional architectural rules — 'this namespace must not use that namespace' — which PHPStan has no concept of. Both tools are complementary; use PHPStan for type safety and Pest arch() for boundary enforcement.
Q03 When should I actually split a module into a separate service?
When a module has a genuinely different scaling profile, needs independent deployment cadence, or a separate team owns it end-to-end. Organisational and operational reasons justify the distribution tax; technical complexity alone rarely does.

Continue reading

More Articles

View all