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.