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):

  1. Create the module and move its models, migrations and services.
  2. Keep class aliases in the old namespaces temporarily, so the rest of the app keeps working.
  3. Write the contract other modules need, replace direct usages one by one.
  4. 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