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:
- The decisions it owns.
- The data it is allowed to change.
- The operations other modules may request.
- 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:
- A module may use its own internal classes freely.
- Code outside the module may use only its public API, public DTOs, and published events.
- A module may write only the tables it owns.
- Cross module reads go through a query contract or a deliberate reporting projection.
- Events describe completed facts. They do not ask another module to perform part of the current transaction.
- 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, andidentity_credentials. - Catalog owns
catalog_productsandcatalog_prices. - Billing owns
billing_subscriptions,billing_invoices, andbilling_payment_attempts. - Reporting owns
reporting_subscription_summariesandreporting_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:
- Does every module represent a business capability with a clear name?
- Can we state which tables and business decisions each module owns?
- Can outside code use only public contracts, DTOs, queries, and events?
- Do cross module reads have an explicit consistency choice?
- Are Eloquent models and query builders kept behind their owning module?
- Do published events describe completed facts and dispatch after commit when needed?
- Are cross module projections idempotent and repairable?
- Do architecture tests reject forbidden namespace dependencies?
- Can one module change its internals without changing unrelated callers?
- 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:
- Track outbox or queue age so a growing delivery delay becomes visible.
- Measure projection lag from the source event time to the Reporting update time.
- Alert on failed listeners, exhausted retries, and records that move to a dead letter or failed job store.
- Protect every idempotency key with a unique database constraint, not only an application lookup.
- Keep a replay or rebuild command that can reconstruct Reporting from authoritative module data.
- Run reconciliation that compares important source totals with their projections.
- Include the module, event ID, and source entity ID in structured logs without leaking sensitive data.
- Practice failure injection by crashing after the source commit, delivering an event twice, and stopping the projection worker long enough to create measurable lag.
- Document migration order when one module changes a public DTO, event, or shared database constraint.
- 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!