Why a Modular Monolith?
Microservices solve distribution problems you probably don't have yet. A modular monolith gives you the same conceptual separation — bounded contexts, explicit contracts, isolated domain logic — while keeping a single deployable, a single database transaction scope, and zero network overhead between modules.
The goal is not folder organisation for its own sake. It is preventing accidental coupling so that the Billing team can refactor invoicing without breaking the Shipping team's code.
Module Structure
Each bounded context lives under app/Modules/{Context}/. The internal layout mirrors a mini-application:
app/Modules/
Billing/
Actions/
Data/ # DTOs, value objects
Domain/ # Eloquent models, domain services
Http/ # Controllers, requests, resources
Infrastructure/ # Repositories, external adapters
Providers/
BillingServiceProvider.php
routes.php
Shipping/
...
Each module registers itself via its own service provider, which is loaded from bootstrap/providers.php (Laravel 11+):
// bootstrap/providers.php
return [
App\Modules\Billing\Providers\BillingServiceProvider::class,
App\Modules\Shipping\Providers\ShippingServiceProvider::class,
];
// app/Modules/Billing/Providers/BillingServiceProvider.php
class BillingServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadRoutesFrom(__DIR__ . '/../routes.php');
$this->loadMigrationsFrom(__DIR__ . '/../Infrastructure/Migrations');
}
public function register(): void
{
$this->app->bind(
\App\Modules\Billing\Domain\Contracts\InvoiceRepository::class,
\App\Modules\Billing\Infrastructure\EloquentInvoiceRepository::class,
);
}
}
Internal Contracts: The Boundary Enforcement Mechanism
Modules must never import each other's internal classes directly. Instead, each module exposes a public API interface in a Contracts namespace:
// app/Modules/Billing/Contracts/BillingModuleInterface.php
namespace App\Modules\Billing\Contracts;
use App\Modules\Billing\Data\InvoiceData;
interface BillingModuleInterface
{
public function issueInvoice(int $orderId, int $customerId): InvoiceData;
public function getOutstandingBalance(int $customerId): Money;
}
Shipping resolves this interface through the container — it never touches Billing\Domain\ directly:
// app/Modules/Shipping/Actions/DispatchOrder.php
class DispatchOrder
{
public function __construct(
private readonly BillingModuleInterface $billing,
) {}
public function handle(Order $order): void
{
$balance = $this->billing->getOutstandingBalance($order->customer_id);
if ($balance->isNegative()) {
throw new OutstandingBalanceException();
}
// dispatch logic ...
}
}
Enforcing the Rules with Deptrac
Folder conventions are worthless without tooling. Deptrac analyses static imports and fails CI when a module reaches across a boundary:
# deptrac.yaml
deptrac:
paths:
- app/Modules
layers:
- name: Billing
collectors:
- type: directory
value: app/Modules/Billing/
- name: Shipping
collectors:
- type: directory
value: app/Modules/Shipping/
ruleset:
Shipping:
- Billing # Shipping may only use Billing's public Contracts
Billing: ~
Run ./vendor/bin/deptrac analyse in CI. Any direct import of Billing\Domain\ from Shipping\ becomes a build failure.
Cross-Module Events Instead of Direct Calls
For truly decoupled side-effects, prefer domain events over direct method calls:
// Billing fires:
event(new InvoicePaid($invoiceId, $customerId, $amount));
// Shipping listens in its own EventServiceProvider:
Event::listen(InvoicePaid::class, ReleaseHeldShipments::class);
The event class lives in a shared app/Events/ namespace (or a dedicated app/Shared/ module) so neither context owns it.
Shared Kernel
Some primitives — Money, Address, UserId — are used everywhere. Place them in app/Shared/ and treat that namespace as a read-only dependency for all modules. No module writes back to Shared.
Key Takeaways
- One service provider per module keeps registration isolated and explicit.
- Public contract interfaces are the only surface other modules may depend on.
- Deptrac in CI turns architectural rules into hard failures, not guidelines.
- Domain events decouple side-effects without requiring a message broker.
- Shared kernel is small and stable; resist the urge to dump utilities there.