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:
- Public contracts — interfaces and DTOs in a
Contracts/namespace that other modules may import. - Laravel events — fire a domain event; other modules listen without coupling to the source.
- 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
SharedKernelmodule holds value objects and base types; keep it deliberately small.