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 promise isolation but deliver operational complexity. A well-structured modular monolith gives you the same conceptual boundaries — bounded contexts, explicit contracts, independent domain logic — while keeping deployment, transactions, and debugging simple.

The goal is not to split the codebase into packages. It is to enforce rules about who may call whom and how at the PHP level, so the boundaries are real rather than aspirational.


Directory Layout

Each bounded context lives under src/Modules/. The framework's app/ directory becomes a thin bootstrap layer.

src/
  Modules/
    Billing/
      Actions/
      Data/
      Events/
      Http/
      Models/
      Contracts/
        BillingServiceInterface.php
      BillingServiceProvider.php
    Catalog/
      ...
    Identity/
      ...
app/
  Providers/
    AppServiceProvider.php   ← registers module providers only

Each module registers itself via its own ServiceProvider. AppServiceProvider does nothing but call $this->app->register(BillingServiceProvider::class) for each module.


Internal Contracts: The Only Crossing Point

Modules must never import each other's concrete classes. They communicate through interfaces declared in their own Contracts/ namespace.

// src/Modules/Billing/Contracts/BillingServiceInterface.php
namespace Modules\Billing\Contracts;

interface BillingServiceInterface
{
    public function charge(string $customerId, int $amountCents): ChargeResult;
}

The Catalog module depends on this interface, not on Modules\Billing\Services\StripeService.

// src/Modules/Catalog/Actions/PublishProduct.php
namespace Modules\Catalog\Actions;

use Modules\Billing\Contracts\BillingServiceInterface;

final class PublishProduct
{
    public function __construct(
        private readonly BillingServiceInterface $billing
    ) {}

    public function handle(Product $product): void
    {
        $result = $this->billing->charge($product->owner_id, $product->listing_fee_cents);
        // ...
    }
}

Binding the concrete implementation happens inside BillingServiceProvider, invisible to the caller.

// src/Modules/Billing/BillingServiceProvider.php
$this->app->bind(
    \Modules\Billing\Contracts\BillingServiceInterface::class,
    \Modules\Billing\Services\StripeService::class,
);

Cross-Module Communication via Domain Events

When a module needs to notify rather than query, use Laravel's event dispatcher with typed event classes. Each module listens only to events it cares about.

// src/Modules/Identity/Events/UserRegistered.php
namespace Modules\Identity\Events;

final class UserRegistered
{
    public function __construct(
        public readonly string $userId,
        public readonly string $email,
    ) {}
}
// src/Modules/Billing/Listeners/ProvisionFreeTier.php
namespace Modules\Billing\Listeners;

use Modules\Identity\Events\UserRegistered;

final class ProvisionFreeTier
{
    public function handle(UserRegistered $event): void
    {
        // create a free subscription for $event->userId
    }
}

The Identity module fires the event and has zero knowledge of Billing. The listener is registered in BillingServiceProvider:

$this->listen(UserRegistered::class, ProvisionFreeTier::class);

Enforcing Boundaries with Deptrac

Conventions break under deadline pressure. Automate enforcement with Deptrac:

# deptrac.yaml
layers:
  - name: Billing
    collectors:
      - type: className
        regex: ^Modules\\Billing\\
  - name: Catalog
    collectors:
      - type: className
        regex: ^Modules\\Catalog\\
ruleset:
  Catalog:
    - Billing   # Catalog may only use Billing's Contracts, enforced by regex
  Billing: []

Run deptrac analyse in CI. Any direct import of a concrete class across module boundaries fails the build.


Shared Kernel: What Belongs There

Not everything is module-specific. A Shared/ kernel holds:

  • Base value objects (Money, Email, Uuid)
  • Common exceptions (DomainException, ValidationException)
  • Infrastructure interfaces (ClockInterface, UuidGeneratorInterface)

Modules may depend on Shared/. Shared/ must never depend on any module.


Key Takeaways

  • Modules own their service providers — binding, listening, and scheduling stay inside the module.
  • Contracts are the only public API — no concrete class crosses a module boundary.
  • Events decouple producers from consumers — the firing module never imports the listener.
  • Deptrac in CI makes boundaries real — conventions without tooling are just comments.
  • Shared kernel stays thin — value objects and interfaces only, no business logic.
  • Transactions still work — because it is one process and one database connection, DB::transaction() spans modules freely.

Found this useful?

Frequently Asked Questions

3 questions
Q01 How is this different from just organising files into folders?
The difference is enforcement. Folder organisation is cosmetic. Bounded contexts backed by interface-only contracts and Deptrac CI rules make illegal dependencies a build failure, not a code-review suggestion.
Q02 Can modules share an Eloquent model, for example a User?
Avoid it. Instead, each module defines a read-only projection or a lightweight DTO for the data it needs. The Identity module owns the User model; other modules query through a contract method or listen to events.
Q03 Does this approach work with Filament admin panels?
Yes. Each module can register its own Filament resources inside its ServiceProvider using Filament::serving(). The panel itself lives in a dedicated Module\Admin context that imports only contracts from other modules.

Continue reading

More Articles

View all