Introduction

Many applications do not become hard to change because they are monoliths. They become hard to change because everything inside them can reach everything else.

A controller updates a subscription, reads a product price, changes a user status, and writes a report row. An Eloquent relationship crosses three business areas. A service called ApplicationService depends on fifteen models. A small billing change needs someone who understands half of the application.

At that point, microservices can look like the obvious escape. If Billing, Catalog, Identity, and Reporting lived in separate deployments, surely the boundaries would become clear.

Usually, the opposite happens. The same unclear dependencies are moved behind HTTP calls, queues, retries, timeouts, and several deployment pipelines. We keep the coupling and add a network.

A modular monolith takes a different path. It keeps one deployable application while dividing its business capabilities into modules with explicit APIs, owned data, and enforceable dependency rules. The deployment boundary stays simple, but the code boundary becomes real.

In this article, let's refactor a growing Laravel SaaS application into Billing, Catalog, Identity, and Reporting modules. We will define what each module owns, decide how modules communicate, test the dependency rules, and finish with the concrete signals that tell us when a module is ready to become a service.

A Monolith Is a Deployment Shape

The word monolith is often used as if it describes bad architecture. It does not.

A monolith only tells us that the application is built and deployed as one unit. It says nothing about how the code inside that unit is organized.

We can have:

  • a well structured monolith with clear domain boundaries
  • a tangled monolith where every feature reaches every table
  • well isolated microservices with stable contracts
  • distributed services that share data and must deploy together

The real question is not "monolith or microservices?" The useful question is:

Can one business capability change without understanding or modifying all the others?

If the answer is no, splitting the deployment will not fix the design. It will only make the hidden dependencies slower and more expensive.

A modular monolith gives us a place to solve those dependencies first. We still have one repository, one deployable Laravel application, and often one database. PHP-FPM workers, queue workers, and the scheduler may run in separate processes, but they ship as one application. Inside that deployment, each module owns a business capability and exposes a small contract to the rest of the system.

The Problem With Folders by Technical Layer

Let's imagine a SaaS application that started with customers and products. Over time, it added subscriptions, invoices, permissions, and reports.

Its structure probably looks familiar:

app/
├── Actions/
├── Console/
├── Events/
├── Http/Controllers/
├── Jobs/
├── Listeners/
├── Models/
├── Policies/
└── Services/

There is nothing inherently wrong with these folders. They follow Laravel's technical vocabulary and work well for small applications. The problem appears when the business has grown but the structure still groups code only by what framework role it plays.

To understand subscription activation, we now jump between SubscriptionController, BillingService, Product, User, Invoice, SubscriptionStarted, and several listeners. The classes are organized, but the business capability is scattered.

More importantly, nothing prevents one area from reaching into another:

HTTP request                 shared application code                 shared database
────────────                 ───────────────────────                 ───────────────

┌──────────────────┐         ┌────────────────────┐                 ┌──────────────┐
│ BillingController│ ──────▶ │ BillingService     │ ──────────────▶ │ subscriptions│
└──────────────────┘         └──────┬─────────────┘                 └──────────────┘

                                   ├────▶ Product model ──────────▶ products

                                   ├────▶ User model ─────────────▶ users

                                   └────▶ Report model ───────────▶ report_rows

ReportingController ────────▶ Subscription + Product + User models

The arrows are the architecture. The folder names are not.

Billing can write Reporting data. Reporting can join Billing, Catalog, and Identity tables. Any model can expose a relationship to any other model. The database has quietly become the public API for the whole application.

This design can keep working for years, but every new dependency raises the cost of change. Before we move anything, we need to decide what the business boundaries actually are.

Start With Business Capabilities

A module should represent a business capability, not a technical layer.

For our SaaS application, four capabilities are emerging:

  • Identity owns accounts, users, credentials, and access related profile data.
  • Catalog owns products, prices, availability, and product configuration.
  • Billing owns subscriptions, invoices, payment attempts, credits, and billing rules.
  • Reporting owns read models designed for dashboards, exports, and historical analysis.

These names are useful because the business uses them. A product manager can ask for a Billing change. An operator can investigate a Catalog import. An engineer can say that a report is stale without implying that the Billing transaction is wrong.

Avoid modules such as Models, Repositories, Helpers, or Infrastructure. Those are technical categories. They do not own a business decision.

Also be careful with a generic Shared module. Shared code often becomes a route around every real boundary. A tiny shared kernel for stable concepts such as TenantId or Money can be reasonable, but it should stay small, dependency free, and intentionally boring.

For every candidate module, write down four things:

  1. The decisions it owns.
  2. The data it is allowed to change.
  3. The operations other modules may request.
  4. The facts it publishes after something important happens.

If we cannot answer those questions, we do not have a module yet. We have a folder name.

What Makes a Boundary Real?

A real module boundary has more than a namespace. It has rules.

For this application, we will use these rules:

  1. A module may use its own internal classes freely.
  2. Code outside the module may use only its public API, public DTOs, and published events.
  3. A module may write only the tables it owns.
  4. Cross module reads go through a query contract or a deliberate reporting projection.
  5. Events describe completed facts. They do not ask another module to perform part of the current transaction.
  6. Dependencies point in one known direction and architecture tests enforce that direction.

The resulting communication model looks like this:

Billing endpoint
       │ command

Billing public API ── synchronous query ──▶ Catalog public API ──▶ catalog_*

       ├── transaction ──────────────────────────────────────────▶ billing_*

       └── SubscriptionStarted after commit ──▶ Reporting projector ──▶ reporting_*

Identity public API ─────────────────────────────────────────────▶ identity_*

The important part is not that every call has an interface. The important part is that each arrow crosses through something the owning module deliberately exposes.

Now let's build that boundary around Billing.

Organizing a Module in Laravel

Laravel does not require one specific modular structure. We can keep the framework conventions while placing them inside a business boundary:

app/Modules/Billing/
├── Application/
│   ├── Commands/
│   ├── Queries/
│   └── Handlers/
├── Contracts/
│   ├── DTOs/
│   ├── Events/
│   └── Billing.php
├── Domain/
│   ├── Entities/
│   ├── Exceptions/
│   └── ValueObjects/
├── Infrastructure/
│   └── Persistence/
├── Presentation/
│   ├── Http/
│   └── routes.php
└── BillingServiceProvider.php

The exact folder names matter less than the split between public contracts and internal implementation.

In this example, code outside Billing may import classes from Billing\Contracts. Everything in Application, Domain, Infrastructure, and Presentation belongs to the implementation of Billing.

This does not mean the module must recreate every framework abstraction. Its HTTP controllers can be plain invokable classes that Laravel resolves and dispatches. Its persistence can use Eloquent. Its provider can register container bindings and routes. A modular monolith should work with the framework, not pretend the framework does not exist.

The boundary is about business ownership, not framework avoidance.

Design the Public API First

The public API should describe what another part of the application is allowed to ask Billing to do. It should not expose how Billing stores or implements that work.

Let's start with a command:

namespace App\Modules\Billing\Contracts\DTOs;

final readonly class StartSubscription
{
    public function __construct(
        public string $accountId,
        public string $productId,
        public string $paymentMethodId,
    ) {}
}

The result is another small value:

namespace App\Modules\Billing\Contracts\DTOs;

final readonly class SubscriptionId
{
    public function __construct(
        public string $value,
    ) {}
}

Then Billing exposes one application facing contract:

namespace App\Modules\Billing\Contracts;

use App\Modules\Billing\Contracts\DTOs\StartSubscription;
use App\Modules\Billing\Contracts\DTOs\SubscriptionId;

interface Billing
{
    public function startSubscription(StartSubscription $command): SubscriptionId;
}

This contract is intentionally small. It does not return an Eloquent Subscription model. It does not expose a query builder. It does not let the caller choose which table to update or which event to dispatch.

That protects both sides:

  • Callers depend on a stable business operation.
  • Billing can change its model, schema, transaction, or payment implementation without changing every caller.

Do not create one giant facade with fifty unrelated methods. A module API can contain several focused command and query contracts. The public surface should grow with real use cases, not with every method that happens to exist internally.

Keep the Implementation Behind the Contract

The internal application service coordinates the use case:

namespace App\Modules\Billing\Application\Handlers;

use App\Modules\Billing\Contracts\Billing;
use App\Modules\Billing\Contracts\DTOs\StartSubscription;
use App\Modules\Billing\Contracts\DTOs\SubscriptionId;
use App\Modules\Billing\Contracts\Events\SubscriptionStarted;
use App\Modules\Billing\Domain\Exceptions\ProductCannotBeSubscribedTo;
use App\Modules\Billing\Infrastructure\Persistence\Subscription;
use App\Modules\Catalog\Contracts\ProductCatalog;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class StartSubscriptionHandler implements Billing
{
    public function __construct(
        private ProductCatalog $catalog,
    ) {}

    public function startSubscription(StartSubscription $command): SubscriptionId
    {
        $product = $this->catalog->productForBilling($command->productId);

        if (! $product->canBeSubscribedTo) {
            throw new ProductCannotBeSubscribedTo($command->productId);
        }

        return DB::transaction(function () use ($command, $product): SubscriptionId {
            $subscriptionId = new SubscriptionId((string) Str::uuid());

            Subscription::query()->create([
                'id' => $subscriptionId->value,
                'account_id' => $command->accountId,
                'product_id' => $command->productId,
                'price_in_cents' => $product->priceInCents,
                'currency' => $product->currency,
                'payment_method_id' => $command->paymentMethodId,
                'status' => 'active',
            ]);

            SubscriptionStarted::dispatch(
                subscriptionId: $subscriptionId->value,
                accountId: $command->accountId,
                productId: $command->productId,
            );

            return $subscriptionId;
        });
    }
}

Billing still needs product information, but it does not import Catalog's Eloquent model. It asks the Catalog public API for a purpose built snapshot.

This is a valid dependency from Billing to Catalog. The goal is not zero dependencies. A useful application has dependencies. The goal is to make each dependency explicit, narrow, and owned by the module being called.

Cross Module Reads Need a Contract

Catalog can expose the information Billing is allowed to use:

namespace App\Modules\Catalog\Contracts;

use App\Modules\Catalog\Contracts\DTOs\ProductForBilling;

interface ProductCatalog
{
    public function productForBilling(string $productId): ProductForBilling;
}

The not found case is part of that public contract too:

namespace App\Modules\Catalog\Contracts\Exceptions;

use RuntimeException;

final class ProductNotFound extends RuntimeException
{
    public static function forId(string $productId): self
    {
        return new self("Product [{$productId}] was not found.");
    }
}
namespace App\Modules\Catalog\Contracts\DTOs;

final readonly class ProductForBilling
{
    public function __construct(
        public string $id,
        public int $priceInCents,
        public string $currency,
        public bool $canBeSubscribedTo,
    ) {}
}

Catalog implements that contract using its own persistence:

namespace App\Modules\Catalog\Application\Queries;

use App\Modules\Catalog\Contracts\DTOs\ProductForBilling;
use App\Modules\Catalog\Contracts\Exceptions\ProductNotFound;
use App\Modules\Catalog\Contracts\ProductCatalog;
use App\Modules\Catalog\Infrastructure\Persistence\Product;

final class EloquentProductCatalog implements ProductCatalog
{
    public function productForBilling(string $productId): ProductForBilling
    {
        $product = Product::query()->find($productId)
            ?? throw ProductNotFound::forId($productId);

        return new ProductForBilling(
            id: (string) $product->getKey(),
            priceInCents: $product->price_in_cents,
            currency: $product->currency,
            canBeSubscribedTo: $product->is_active,
        );
    }
}

Returning a DTO rather than the model is more than a style preference.

An Eloquent model carries writable state, relationships, scopes, persistence methods, casts, and knowledge about its table. Returning it would let Billing call $product->update(), load an internal relationship, or slowly depend on every Catalog column.

The DTO is a snapshot for one known purpose. Catalog can rename a column, split a table, or calculate availability differently while preserving this contract. Translating the missing product into ProductNotFound also prevents an Eloquent exception tied to Catalog's internal model from leaking through the boundary.

Synchronous queries are the right choice when the caller needs fresh information instead of an eventually consistent local copy. They are not automatically atomic across modules. In our handler, the product can change after Catalog returns the snapshot and before Billing commits the subscription.

If that race is acceptable, Billing can record the exact price snapshot it used. If the invariant requires stronger consistency, make that guarantee explicit. Catalog could return a version that Billing verifies, expose a price reservation operation, or participate in a carefully documented shared database transaction. A normal query contract cannot promise more consistency than its implementation provides.

Database Ownership Inside One Database

A modular monolith can use one database. Separate database servers are not required to establish data ownership.

Ownership means that only one module is allowed to write a table and define the business rules around that data:

  • Identity owns identity_accounts, identity_users, and identity_credentials.
  • Catalog owns catalog_products and catalog_prices.
  • Billing owns billing_subscriptions, billing_invoices, and billing_payment_attempts.
  • Reporting owns reporting_subscription_summaries and reporting_revenue_daily.

The table prefixes are not mandatory, but they make accidental ownership violations easier to see. Separate schemas can make the rule stronger when the database supports them. The architectural rule stays the same either way.

Billing may store account_id and product_id as references, but it should not update Identity or Catalog tables. It should also avoid Eloquent relationships that return foreign module models:

// Avoid exposing another module's model through Billing persistence.
public function product(): BelongsTo
{
    return $this->belongsTo(Product::class);
}

That relationship looks convenient, but it turns an internal model into a cross module API. Soon a Billing view calls $subscription->product->name, a queue loads nested Catalog relationships, and a delete rule depends on model events from both modules.

Store the identifier. Ask Catalog when current Catalog data is required. Use a reporting projection when a screen needs data from many modules.

Database foreign keys can still be useful for referential integrity, especially while everything uses one database. Just recognize the tradeoff. A cross module foreign key couples migrations and deletion order. Keep it when that integrity is more valuable than independent schema evolution. Remove it only for a concrete reason, not to make the design look more like microservices.

Reporting Is a Different Kind of Read

Some screens genuinely need data from several modules. A revenue dashboard may show account names, product names, subscription status, invoice totals, and payment dates.

Calling four module APIs for every row can create a local version of the N+1 problem. Letting Reporting join every operational table makes it dependent on every internal schema.

For important cross module reports, build a reporting projection.

Reporting listens to published facts and stores a read model shaped for its queries. When a subscription starts, it can store the subscription ID, account ID, product ID, and status. When a product is renamed, it can update the denormalized product name. When an invoice is paid, it can update revenue totals.

This design has a clear tradeoff. Reporting becomes eventually consistent. A dashboard can be a few seconds behind the Billing transaction.

That is often acceptable for reports, exports, and analytics. It is usually not acceptable for the business rule deciding whether the subscription may start.

Use the consistency requirement to choose the communication style:

  • Use a synchronous module query for a decision that needs current data.
  • Use a published event for reactions to a completed fact.
  • Use a projection for repeated cross module reads that tolerate controlled delay.
  • Use a direct, documented SQL query only when the operational benefit outweighs the schema coupling.

There is no prize for making every read asynchronous. Boundaries should clarify tradeoffs, not hide them.

Events Publish Facts After Commit

Billing can publish a stable fact without exposing its model:

namespace App\Modules\Billing\Contracts\Events;

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;

final class SubscriptionStarted implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public readonly string $subscriptionId,
        public readonly string $accountId,
        public readonly string $productId,
    ) {}
}

ShouldDispatchAfterCommit matters because Reporting should never receive a fact about a subscription that later rolls back. If the Billing transaction fails, Laravel discards the pending event.

It does not guarantee delivery after the commit. The application can commit Billing and crash before the event reaches a listener or queue. A synchronous listener can also fail after Billing has already committed, leaving Reporting stale.

For a projection that must be recoverable, use a durable handoff. Billing can write an outbox record in the same transaction as the subscription, then a relay publishes it with retries. Reporting should also support replay or a full rebuild from authoritative module APIs. A queued listener improves retry behavior, but it does not close the crash gap between the source commit and queue dispatch by itself.

Reporting owns its reaction:

namespace App\Modules\Reporting\Application\Listeners;

use App\Modules\Billing\Contracts\Events\SubscriptionStarted;
use App\Modules\Reporting\Infrastructure\Persistence\SubscriptionSummary;

final class ProjectStartedSubscription
{
    public function handle(SubscriptionStarted $event): void
    {
        SubscriptionSummary::query()->updateOrCreate(
            ['subscription_id' => $event->subscriptionId],
            [
                'account_id' => $event->accountId,
                'product_id' => $event->productId,
                'status' => 'active',
            ],
        );
    }
}

The listener uses updateOrCreate() because delivery may happen more than once if it is later queued or replayed. The reporting_subscription_summaries table must also have a unique constraint on subscription_id. Without that constraint, concurrent deliveries can both observe a missing row and insert duplicates. The database constraint makes the invariant race safe, while the listener treats a duplicate key from a competing delivery as an already applied event. In production code, catch only that specific constraint violation and reload the projection instead of swallowing unrelated database errors.

A projection should converge on the same result when it sees the same fact again. It should also be possible to compare it with the source, replay missed facts, and rebuild it when the projection logic changes.

Events are useful, but they can also hide control flow. Do not turn every method call into an event. Use a direct module API when the caller needs a result, validation error, or immediate guarantee.

Also keep events stable and small. Published events are part of the module API. Prefer identifiers, values, and completed facts over internal models or vague notifications such as SomethingChanged.

Keep Transactions Inside the Owner

One database makes it technically possible to update Billing, Catalog, Identity, and Reporting in one transaction. That does not mean every workflow should.

A transaction is also an ownership boundary. The code that owns an invariant should own the transaction that protects it.

Starting a subscription can atomically create the subscription and its first invoice because Billing owns both. Updating a Catalog product and a Reporting summary in that same transaction would make Billing responsible for two foreign modules.

If a use case coordinates several modules synchronously, place the coordination in a thin application workflow that calls public module APIs. Be honest about partial failure. A database rollback can help while all modules share one connection, but the workflow should not rely on private models or direct table writes.

This discipline gives us a cleaner extraction path later. When Billing moves into a separately deployed service, the coordinator already depends on its public contract. The transaction boundary is visible, so we know which guarantees must become retries, idempotency, or compensation.

Integrate Each Module With Laravel

A service provider gives each module one place to register its framework integration.

Billing can bind its public contract in register() and load its routes in boot():

namespace App\Modules\Billing;

use App\Modules\Billing\Application\Handlers\StartSubscriptionHandler;
use App\Modules\Billing\Contracts\Billing;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;

final class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(Billing::class, StartSubscriptionHandler::class);
    }

    public function boot(): void
    {
        Route::middleware('web')
            ->prefix('billing')
            ->name('billing.')
            ->group(__DIR__.'/Presentation/routes.php');
    }
}

Catalog registers its ProductCatalog implementation. Reporting registers its event listeners. Each module becomes responsible for wiring its own implementation into Laravel.

The application registers the providers in bootstrap/providers.php:

<?php

return [
    App\Providers\AppServiceProvider::class,
    App\Modules\Identity\IdentityServiceProvider::class,
    App\Modules\Catalog\CatalogServiceProvider::class,
    App\Modules\Billing\BillingServiceProvider::class,
    App\Modules\Reporting\ReportingServiceProvider::class,
];

This keeps framework bootstrapping explicit. We can open one provider and see which contract the module implements, which routes it owns, and which events it consumes.

Do not resolve services or perform business work in register(). Use it for container bindings. Use boot() for routes, listeners, and framework integration that needs the registered container.

The HTTP Layer Calls the Module API

An endpoint inside Billing may use internal application classes because it is part of the same module. An endpoint outside Billing should use the public contract.

For example, a checkout controller can remain thin:

namespace App\Modules\Billing\Presentation\Http;

use App\Modules\Billing\Contracts\Billing;
use App\Modules\Billing\Contracts\DTOs\StartSubscription;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

final readonly class StartSubscriptionController
{
    public function __construct(
        private Billing $billing,
    ) {}

    public function __invoke(Request $request): RedirectResponse
    {
        $subscriptionId = $this->billing->startSubscription(
            new StartSubscription(
                accountId: (string) $request->user()->account_id,
                productId: $request->string('product_id')->toString(),
                paymentMethodId: $request->string('payment_method_id')->toString(),
            ),
        );

        return redirect()->route('billing.subscriptions.show', $subscriptionId->value);
    }
}

In a complete application, validation and authorization would live in a Form Request or policy. The important point here is the call site. The controller asks Billing to start a subscription. It does not know which models, tables, payment gateway, or events make that happen.

That is the same API another module, an Artisan command, or a queued job can use.

Enforce Boundaries With Architecture Tests

A boundary that exists only in a diagram will eventually be crossed under deadline pressure.

Code review helps, but automated architecture tests make the rule continuous. Pest can assert that internal namespaces are used only inside their owning module:

arch('billing internals stay inside billing')
    ->expect([
        'App\Modules\Billing\Application',
        'App\Modules\Billing\Domain',
        'App\Modules\Billing\Infrastructure',
        'App\Modules\Billing\Presentation',
    ])
    ->toOnlyBeUsedIn('App\Modules\Billing');

arch('catalog internals stay inside catalog')
    ->expect([
        'App\Modules\Catalog\Application',
        'App\Modules\Catalog\Domain',
        'App\Modules\Catalog\Infrastructure',
        'App\Modules\Catalog\Presentation',
    ])
    ->toOnlyBeUsedIn('App\Modules\Catalog');

arch('billing contracts do not depend on billing internals')
    ->expect('App\Modules\Billing\Contracts')
    ->not->toUse([
        'App\Modules\Billing\Application',
        'App\Modules\Billing\Domain',
        'App\Modules\Billing\Infrastructure',
        'App\Modules\Billing\Presentation',
    ]);

We can also reject a dependency that should never exist:

arch('billing does not depend on reporting')
    ->expect('App\Modules\Billing')
    ->not->toUse('App\Modules\Reporting');

arch('catalog does not depend on billing')
    ->expect('App\Modules\Catalog')
    ->not->toUse('App\Modules\Billing');

These tests do not prove the entire architecture. They cannot see a raw SQL query that writes another module's table, a shared cache key, or an undocumented HTTP call. We still need behavior tests and code review.

They do stop the easiest form of erosion. If Reporting imports Billing's internal Subscription model, the build fails while the dependency is still cheap to fix.

Test the Public Contract Too

Architecture tests tell us which dependencies are allowed. Behavior tests tell us whether the module keeps its promise.

For Billing, useful tests include:

  • a product that is available can start a subscription
  • an unavailable product is rejected before Billing writes anything
  • the recorded price matches the Catalog snapshot used for the decision
  • a failed transaction does not publish SubscriptionStarted
  • a simulated listener failure is retried or repaired without changing Billing
  • replaying the Reporting projection does not create duplicate rows
  • concurrent projection delivery cannot bypass the unique subscription constraint
  • callers receive a stable SubscriptionId, not an internal model

A focused feature test can exercise the public contract through Laravel's container:

use App\Modules\Billing\Contracts\Billing;
use App\Modules\Billing\Contracts\DTOs\StartSubscription;
use App\Modules\Billing\Contracts\Events\SubscriptionStarted;
use App\Modules\Catalog\Infrastructure\Persistence\Product;
use Illuminate\Support\Facades\Event;

it('starts a subscription through the billing contract', function (): void {
    Event::fake([SubscriptionStarted::class]);

    $product = Product::factory()->active()->create([
        'price_in_cents' => 2_900,
        'currency' => 'USD',
    ]);

    $subscriptionId = resolve(Billing::class)->startSubscription(
        new StartSubscription(
            accountId: 'account-123',
            productId: (string) $product->getKey(),
            paymentMethodId: 'payment-method-456',
        ),
    );

    expect($subscriptionId->value)->not->toBeEmpty();

    Event::assertDispatched(SubscriptionStarted::class);
});

The example uses a Catalog factory because both modules still run inside one test process and one database. That is fine. The production dependency still crosses the public contract, and a Catalog contract test can protect the snapshot behavior.

Do not mock every internal class. The module boundary is the valuable seam. Test most internal behavior normally, and use a fake public contract when a consuming module needs isolation.

Migrating Without a Rewrite

Turning a layered application into a modular monolith should be an incremental refactor, not a new application hidden inside the old one.

Start with one capability that already has a recognizable boundary. Billing is a good candidate because subscriptions, invoices, and payments belong together and already have important business rules.

1. Map the current dependencies

List the controllers, commands, jobs, models, tables, events, and external integrations involved in Billing. Search for every place that reads or writes those tables.

The goal is not to design the perfect module. It is to discover the real dependency graph, including the inconvenient parts.

2. Define ownership before moving files

Decide that Billing owns subscriptions, invoices, payment attempts, and the rules that change them. Write down what it needs from Catalog and Identity.

File moves without ownership rules only create deeper paths.

3. Introduce the public API around one use case

Wrap StartSubscription behind the Billing contract. Keep the old controller working through that contract. At first, the handler may still call existing models in their old namespaces.

This creates a seam before a large move.

4. Move one vertical slice

Move the command, handler, model, tests, and route for starting a subscription into Billing. Update imports. Keep behavior unchanged.

A vertical slice proves the module structure end to end. Moving every model first usually leaves the application broken between two architectures for too long.

5. Replace foreign model access

Find Billing imports of Catalog and Identity models. Replace them with focused query contracts and DTOs. Find other modules importing Billing models and give those callers a public query or event.

This is where the boundary becomes real.

6. Add architecture tests

Once a dependency has been removed, prevent it from returning. Architecture tests are especially valuable during migration because old patterns still exist elsewhere and are easy to copy.

7. Repeat by use case

Move invoice creation, payment recording, renewal, cancellation, and credits one use case at a time. Do not create placeholder abstractions for features that have not moved yet.

The application remains deployable throughout the migration. That is a major advantage of improving the monolith before considering process extraction.

Common Failure Modes

Modular monoliths can fail in predictable ways.

Modules are only namespaces

If App\Modules\Billing still imports every model and writes every table, the folder changed but the architecture did not.

Track dependencies and ownership, not just file locations.

The database remains a global API

Direct SQL and cross module Eloquent relationships can bypass perfect PHP namespace rules.

Use table naming, database permissions where practical, query review, and tests around important data ownership. Treat another module's table like a private class.

Everything moves into Shared

Shared, Common, and Core can become dependency magnets. If Billing and Catalog need similar code, small duplication may be cheaper than coupling their business rules through a generic abstraction.

Share only concepts that are truly stable and mean the same thing in every module.

Events replace understandable calls

An event called StartSubscriptionRequested with five listeners can make the main workflow difficult to trace and impossible to reason about transactionally.

Use direct calls for commands and required answers. Publish events for completed facts and independent reactions.

Every module gets enterprise ceremony

Not every module needs repositories, factories, aggregates, ports, adapters, command buses, and an interface for every class.

Add structure where it protects a boundary or a business rule. Keep straightforward code straightforward.

The team plans extraction too early

Designing every module as if it will become a service can force serialization, asynchronous messaging, and duplicated data before those costs solve a real problem.

Build a good local module API first. Extraction should respond to evidence.

When Microservices Become Justified

A modular monolith is not a promise to keep one deployment forever. It is a way to delay the cost of another independently operated service until the boundary and the reason are both clear.

A module becomes a strong extraction candidate when several concrete signals appear:

  • It needs a deployment cadence that is repeatedly blocked by the rest of the application.
  • It has a distinct scaling profile that cannot be handled economically in the shared process.
  • Its failures must be isolated because they currently threaten unrelated capabilities.
  • A team owns it end to end and coordination through the monolith is slowing delivery.
  • It needs a different runtime, data store, region, or compliance boundary.
  • Its public API and data ownership have been stable long enough to survive a network boundary.
  • The organization can operate another service with monitoring, on call ownership, deployment, security, and incident response.

The decision changes the system like this:

MODULAR MONOLITH
Billing lives inside the Laravel application
One deployment, local calls, shared operations

        │ pressure builds

BOUNDARY PROVEN
Stable public API and owned data
Independent scaling, team ownership, operational readiness

        │ extraction solves a measured problem

SEPARATE SERVICE
Billing has its own deployment, operations, and network contract

Extract only when the boundary is already real and the service boundary solves a measured problem.

Notice what is not on the list: the module has many files, the company hired more developers, or microservices are considered more modern.

Extraction makes every call partial. Requests can time out. Events can arrive twice or out of order. Data becomes stale. Deployments become independent, which also means compatibility must be maintained between versions.

Those costs can be worth paying. A modular monolith helps us know exactly where and why to pay them.

A Practical Architecture Checklist

Before calling an application modular, I would ask:

  1. Does every module represent a business capability with a clear name?
  2. Can we state which tables and business decisions each module owns?
  3. Can outside code use only public contracts, DTOs, queries, and events?
  4. Do cross module reads have an explicit consistency choice?
  5. Are Eloquent models and query builders kept behind their owning module?
  6. Do published events describe completed facts and dispatch after commit when needed?
  7. Are cross module projections idempotent and repairable?
  8. Do architecture tests reject forbidden namespace dependencies?
  9. Can one module change its internals without changing unrelated callers?
  10. Is any proposed service extraction backed by scaling, isolation, ownership, or operational evidence?

If the application fails these checks, that is not an argument for microservices. It is a map of the boundaries we should improve while calls are still local and refactors are still cheap.

A Practical Operational Checklist

A module boundary that publishes events or maintains projections also needs an operational plan:

  1. Track outbox or queue age so a growing delivery delay becomes visible.
  2. Measure projection lag from the source event time to the Reporting update time.
  3. Alert on failed listeners, exhausted retries, and records that move to a dead letter or failed job store.
  4. Protect every idempotency key with a unique database constraint, not only an application lookup.
  5. Keep a replay or rebuild command that can reconstruct Reporting from authoritative module data.
  6. Run reconciliation that compares important source totals with their projections.
  7. Include the module, event ID, and source entity ID in structured logs without leaking sensitive data.
  8. Practice failure injection by crashing after the source commit, delivering an event twice, and stopping the projection worker long enough to create measurable lag.
  9. Document migration order when one module changes a public DTO, event, or shared database constraint.
  10. Give every alert and repair command a clear owning team.

These checks turn eventual consistency from a vague warning into behavior the team can observe and repair.

Conclusion

A modular monolith is not a smaller version of microservices. It is a monolith with deliberate business boundaries.

Billing owns billing rules and data. Catalog owns product decisions. Identity owns accounts and users. Reporting owns projections built for cross module reads. Each module exposes a focused API, publishes stable facts, and keeps its persistence private.

Laravel remains useful throughout the design. Service providers wire module contracts, the container resolves implementations, events publish facts after commit, Eloquent stays inside the persistence boundary, and Pest architecture tests stop dependencies from quietly growing back.

The best time to discover whether Billing can stand alone is before putting it on another server. Build the boundary inside the application, test it, let it survive real changes, and extract it only when a separate deployment solves a problem you can name and measure.

That path gives us the simplicity of one application today and a credible route to services tomorrow, without paying the distributed systems cost before we need it.

I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!