Our subscription platform started as a standard Laravel application. Five years later it had about 400
Eloquent models in app/Models, controllers that reached into any table, and a PaymentService imported
by 90 files. A change to how invoices were numbered once broke a marketing report and a partner webhook.
Splitting into microservices was discussed and rejected: the team was twelve people, and most bugs came
from unclear boundaries, not from deployment coupling.
We chose a modular monolith instead: one deployable Laravel application, divided into modules with explicit
boundaries. We used nwidart/laravel-modules for the mechanics. This post covers what worked, what we
would do differently, and when modules are not worth it.
What a module is in this setup
laravel-modules gives each module its own directory with the usual Laravel structure, its own service
provider, routes, migrations, config and tests:
Modules/
Billing/
app/
Http/Controllers/
Models/
Services/
Providers/BillingServiceProvider.php
Contracts/ # what other modules may use
Events/ # what other modules may listen to
database/migrations/
routes/api.php
tests/
composer.json
module.json
Catalog/
Customers/
Notifications/
composer require nwidart/laravel-modules
php artisan module:make Billing
php artisan module:make-model Invoice Billing -m
The package handles discovery: module service providers are registered, routes and migrations loaded. The architecture rules are up to you, and they are what matters.
Rule 1: a module owns its data
Each table belongs to exactly one module. Only that module's code reads or writes it. Other modules never
query invoices directly, never define a relationship to Billing\Models\Invoice, and never join it in
reports.
This is the rule that gives most of the benefit, and the one that hurts most at first, because Eloquent makes cross-module relationships so easy:
// before: Customers module reaching into Billing's table
public function invoices(): HasMany
{
return $this->hasMany(\Modules\Billing\Models\Invoice::class);
}
Rule 2: modules talk through contracts
When the Customers module needs billing information, it asks Billing through an interface that Billing publishes:
// Modules/Billing/app/Contracts/BillingSummary.php
namespace Modules\Billing\Contracts;
interface BillingSummary
{
/** @return array{outstanding: Money, last_paid_at: ?\DateTimeImmutable} */
public function forCustomer(int $customerId): array;
}
// Modules/Billing/app/Providers/BillingServiceProvider.php
public function register(): void
{
$this->app->bind(BillingSummary::class, EloquentBillingSummary::class);
}
// Modules/Customers/app/Http/Controllers/CustomerController.php
public function show(int $id, BillingSummary $billing)
{
return view('customers::show', [
'customer' => Customer::findOrFail($id),
'billing' => $billing->forCustomer($id),
]);
}
The contract returns plain data or value objects, not Eloquent models, so the caller cannot lazy-load its way back into Billing's tables. Billing can change its schema freely as long as the contract holds.
Rule 3: side effects through events
When something happens that other modules care about, the owning module dispatches a domain event, and other modules listen:
// Billing dispatches
InvoicePaid::dispatch($invoice->id, $invoice->customer_id, $invoice->total);
// Notifications listens, in its own service provider
Event::listen(InvoicePaid::class, SendPaymentReceipt::class);
Event classes live in the publishing module's Events/ directory and carry ids and values, not models.
Listeners that do slow work implement ShouldQueue. Events make dependencies point the right way: Billing
does not need to know that Notifications exists.
Rule 4: a small shared kernel
Some code genuinely belongs to everyone: the Money value object, the base API response format, tenant
resolution, logging conventions. That lives in a Shared module (or app/Support) with a strict rule: it
contains no business logic and depends on no other module. If something in Shared starts needing Billing,
it belongs in Billing.
Enforcing the rules
Conventions decay unless tools enforce them. We use an architecture test with Pest:
// tests/Architecture/ModuleBoundariesTest.php
arch('customers does not use billing internals')
->expect('Modules\Customers')
->not->toUse(['Modules\Billing\Models', 'Modules\Billing\Services']);
arch('modules only use other modules through contracts and events')
->expect('Modules\Notifications')
->toOnlyUse([
'Modules\Notifications', 'Modules\Shared',
'Modules\Billing\Contracts', 'Modules\Billing\Events',
'Illuminate', 'Psr',
]);
These tests failed in 140 places when we first wrote them. We marked existing violations as known with an allow-list and fixed them over the next quarters; new violations fail CI.
Migrating an existing app
We moved one domain at a time, starting with the one with the clearest boundary (Notifications), then the one causing the most incidents (Billing):
- Create the module and move its models, migrations and services.
- Keep class aliases in the old namespaces temporarily, so the rest of the app keeps working.
- Write the contract other modules need, replace direct usages one by one.
- Remove the aliases when no external code uses the internals.
Moving migrations needs care: existing migrations already ran in production. We left historical migrations
in database/migrations and put only new migrations in module directories, which avoids re-running or
renaming anything.
Testing per module
Each module's tests live with it and can run alone:
php artisan test Modules/Billing/tests
Contracts get their own tests in the owning module. Consumers test against fakes of the contract, so the Customers tests do not need Billing's tables at all.
Queues, scheduled tasks and modules
Background jobs and scheduled commands belong to the module whose data they change. Each module's service provider registers its own scheduled tasks:
public function boot(): void
{
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
$schedule->job(new GenerateMonthlyInvoices)->monthlyOn(1, '01:00')->onOneServer();
});
}
We also gave the busiest modules their own queue names (billing, notifications), which made it easy to
scale workers per module and see which module a backlog belongs to.
When modules are the wrong tool
- Small apps. Below a few dozen models and a handful of developers, module boilerplate costs more than it saves.
- Unclear domains. If you cannot say which module owns a table, wait; drawing boundaries too early produces modules that constantly call each other.
- Expecting independent scaling. A modular monolith still deploys and scales as one unit. If a part truly needs separate scaling or release cycles, that is a reason for a separate service, and a clean module makes that later extraction much easier.
What changed for the team
Six months in, the effects were measurable. Pull requests touching more than one module dropped from about
40% to 12%, which made reviews faster and ownership clearer: each module has a code owner in the
repository's CODEOWNERS file. Incidents caused by a change in one area breaking another became rare, and
when the analytics team later wanted Billing data, they got a contract and an event stream instead of read
access to Billing's tables.
Common mistakes
| Mistake | Why it hurts | Do this instead |
|---|---|---|
| Eloquent relationships across modules | Hidden coupling to another module's schema | Contracts returning plain data |
| Contracts returning models | Callers lazy-load into other modules | Return arrays, DTOs or value objects |
| A growing Shared module | Becomes the new big ball of mud | No business logic in Shared |
| Rules only in a wiki | Violations creep back | Architecture tests in CI |
| Moving historical migrations | Risk of re-running in production | New migrations only in modules |
Key takeaways
- A modular monolith fixes unclear boundaries without the cost of distributed systems.
- Each module owns its tables; nobody else queries them.
- Modules use each other through contracts and events, never internals.
- Keep the shared kernel small and free of business logic.
- Enforce boundaries with architecture tests, and migrate one domain at a time.
References
- nwidart/laravel-modules documentation: https://laravelmodules.com/docs/
- Laravel, Service container and binding interfaces: https://laravel.com/docs/12.x/container
- Laravel, Events: https://laravel.com/docs/12.x/events
- Pest, Architecture testing: https://pestphp.com/docs/arch-testing
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.