Why a Modular Monolith?
Microservices promise isolation but deliver operational overhead. A well-structured modular monolith gives you the same bounded-context discipline inside a single deployable unit. The key is treating module boundaries as real contracts enforced by tooling, not just folder conventions.
Directory Structure
Organise each module under src/Modules/{Context}/ with a predictable internal layout:
src/
Modules/
Billing/
Actions/
Data/ # DTOs
Domain/ # Entities, value objects
Http/
Infrastructure/ # Eloquent models, repositories
Providers/
BillingServiceProvider.php
routes.php
Catalog/
...
Each module registers itself. BillingServiceProvider is the only entry point the framework touches:
// src/Modules/Billing/Providers/BillingServiceProvider.php
namespace App\Modules\Billing\Providers;
use Illuminate\Support\ServiceProvider;
class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
\App\Modules\Billing\Domain\Contracts\PaymentGateway::class,
\App\Modules\Billing\Infrastructure\StripeGateway::class,
);
}
public function boot(): void
{
$this->loadRoutesFrom(__DIR__ . '/../routes.php');
$this->loadMigrationsFrom(__DIR__ . '/../Infrastructure/Migrations');
}
}
Register all module providers in bootstrap/providers.php (Laravel 11+) or config/app.php.
Cross-Boundary Communication via Internal Contracts
Modules must never import each other's Eloquent models directly. Define a thin contract in the consuming module:
// src/Modules/Catalog/Domain/Contracts/ProductPricingPort.php
namespace App\Modules\Catalog\Domain\Contracts;
interface ProductPricingPort
{
public function priceForProduct(string $productId): Money;
}
The Billing module provides the adapter:
// src/Modules/Billing/Infrastructure/CatalogPricingAdapter.php
namespace App\Modules\Billing\Infrastructure;
use App\Modules\Catalog\Domain\Contracts\ProductPricingPort;
use App\Modules\Catalog\Domain\ValueObjects\Money;
class CatalogPricingAdapter implements ProductPricingPort
{
public function priceForProduct(string $productId): Money
{
// Billing queries its own read model, not Catalog's Eloquent model
$row = \DB::table('billing_product_prices')
->where('product_id', $productId)
->sole();
return new Money($row->amount_cents, $row->currency);
}
}
Binding lives in BillingServiceProvider::register(). Catalog never knows which module satisfies the port.
Enforcing Boundaries with Deptrac
Folder conventions break under deadline pressure. Deptrac statically analyses use statements and fails CI when a boundary is crossed:
# deptrac.yaml
deptrac:
paths:
- src/Modules
layers:
- name: Billing
collectors:
- type: directory
value: src/Modules/Billing/.*
- name: Catalog
collectors:
- type: directory
value: src/Modules/Catalog/.*
ruleset:
Billing:
- Catalog # Billing may depend on Catalog contracts only
Catalog: ~ # Catalog depends on nothing
Run deptrac analyse in your GitHub Actions pipeline. Any direct use App\Modules\Catalog\Infrastructure\ inside Billing fails the build.
Testing Module Isolation with Pest
Test each module in isolation by binding fakes in the test service provider:
// tests/Modules/Billing/ChargeCustomerActionTest.php
use App\Modules\Billing\Actions\ChargeCustomerAction;
use App\Modules\Billing\Domain\Contracts\PaymentGateway;
use App\Modules\Billing\Tests\Fakes\FakePaymentGateway;
beforeEach(function () {
$this->fake = new FakePaymentGateway();
app()->instance(PaymentGateway::class, $this->fake);
});
it('charges the correct amount', function () {
$action = app(ChargeCustomerAction::class);
$action->execute(customerId: 'cus_123', amountCents: 4999);
expect($this->fake->charges())->toHaveCount(1)
->and($this->fake->charges()[0]->amountCents)->toBe(4999);
});
No database, no HTTP — the module is a self-contained unit.
Key Takeaways
- One service provider per module is the only seam the framework touches.
- Ports and adapters prevent Eloquent models from leaking across boundaries.
- Deptrac in CI turns architectural rules into failing builds, not suggestions.
- Pest fakes let you test domain logic without a running database.
- The modular monolith is a stepping stone: each module can become a microservice later with minimal refactoring because the contracts already exist.