August 21, 2026
· 14 min readYou Don't Need Microservices. You Need Boundaries.
Microservices won't fix a monolith that lacks boundaries — they'll just move the coupling onto a network. Break a Laravel app into Billing, Catalog, Identity, and Reporting modules with owned data, public contracts, and Pest-enforced dependency rules, and only extract to a service when a measured problem demands it.

Your app didn't get hard to change because it's a monolith. It got hard to change because everything inside can reach everything else — and you're about to ship that coupling over a network and call it architecture.
A controller updates a subscription, reads a product price, flips a user status, and writes a report row. An Eloquent relationship crosses three business areas. A service class holds fifteen model dependencies. To ship one small billing change, you need someone who understands half the application.
That's the exact moment a team reaches for microservices — if Billing, Catalog, Identity, and Reporting each lived in their own deployment, the boundaries would finally get clear.
Usually the opposite happens. You move the same unclear dependencies behind HTTP calls, queues, retries, and timeouts. You keep the coupling and add a network.
Microservices Won't Fix a Monolith — They Amplify the Mess
Here's the uncomfortable truth that the industry keeps rediscovering the hard way: a monolith isn't a pejorative. It's a deployment shape — the application is built and shipped as one unit. That says nothing about how the code is organized.
You can have a beautifully structured monolith with crisp domain boundaries, or a perfectly deployed microservices cluster that has to roll out in lockstep because every service shares a database. The architecture isn't the deployment boundary. It's the code boundary. And that's the part you've been avoiding.
Can one business capability change without me having to understand or modify all the others?
If your answer is no, splitting the deployment will not fix the design. It'll make the hidden dependencies slower and more expensive. This is the Monolith First case Martin Fowler has made for a decade: nearly every successful microservices system started as a monolith that got too big, then peeled off the pieces that earned their own deployment.
A modular monolith is where you solve those dependencies first — while calls are still local and refactors are still cheap. One repository, one deployable Laravel app, often one database. Each module owns a business capability and exposes a small contract to the rest of the system.
The Real Problem Is Folders by Technical Layer
Your SaaS app grew the normal way — customers and products first, then subscriptions, invoices, permissions, reports:
app/
├── Actions/
├── Console/
├── Events/
├── Http/Controllers/
├── Jobs/
├── Listeners/
├── Models/
├── Policies/
└── Services/Nothing's wrong with these folders in the abstract — they follow Laravel's technical vocabulary and work for tiny apps. The problem: to understand subscription activation, you jump between SubscriptionController, BillingService, Product, User, Invoice, SubscriptionStarted, and half a dozen listeners. The classes are sorted; the business capability is scattered.
Nothing stops one feature from reaching into another. Reporting joins Billing, Catalog, and Identity tables. Any model exposes a relationship to any other. The database has become the public API for the whole app.
The arrows are the architecture. The folder names are not. This can keep working for years — but every new dependency raises the cost of change, and that debt compounds.
Start With Business Capabilities, Not File Layout
A module should represent a business capability — the same principle behind bounded contexts in domain-driven design. For a growing SaaS, four boundaries start showing up:
- Identity — accounts, users, credentials, profile data
- Catalog — products, prices, availability, product config
- Billing — subscriptions, invoices, payment attempts, credits, billing rules
- Reporting — read models for dashboards, exports, historical analysis
These names matter because the business already uses them. A product manager says "a Billing change." An operator investigates a "Catalog import." An engineer says "the report is stale" without implying the Billing transaction is wrong.
Avoid Models, Repositories, Helpers, Infrastructure — those are technical categories, and they own no business decision. And be wary of a generic Shared module. It's a magnet that often becomes a route around every real boundary. A tiny shared kernel for genuinely stable concepts like TenantId or Money is defensible; keep it small, dependency-free, and boring.
💡 Ownership test. For every candidate module, write down four things: the decisions it owns, the data it may change, the operations others may request, and the facts it publishes. If you can't answer all four, you don't have a module — you have a folder name.
What Makes a Boundary Real
A real module boundary carries rules, not just a namespace:
- A module may use its own internals freely.
- Outside code touches only its public API, DTOs, and events.
- A module may write only the tables it owns.
- Cross-module reads go through a query contract or a deliberate projection.
- Events describe completed facts — they don't ask another module to do part of the current transaction.
- Dependencies point one direction — and architecture tests enforce it.
Organizing a Module Inside Laravel
Laravel doesn't mandate one layout — the point is to keep the framework inside a business boundary:
app/Modules/Billing/
├── Application/ # Commands, Queries, Handlers
├── Contracts/ # DTOs, Events, Billing.php ← public surface
├── Domain/ # Entities, Exceptions, ValueObjects
├── Infrastructure/ # Persistence (Eloquent lives here)
├── Presentation/ # Http, routes.php
└── BillingServiceProvider.phpOutside code may import from Billing\Contracts and nothing else. Everything under Application, Domain, Infrastructure, and Presentation is Billing's private world. Controllers can stay plain invokable classes; persistence can still use Eloquent. A modular monolith works with Laravel — it doesn't pretend the framework doesn't exist.
Design the Public API Before the Implementation
The contract describes what others may ask Billing to do — not how it does it. Keep it small. It must not leak an Eloquent model to callers.
// Billing\Contracts\Billing.php
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;
}Callers depend on a stable business operation. Billing can swap its model, schema, transaction, or payment gateway without touching any caller. Don't build one fifty-method facade — let focused contracts grow with real use cases.
Keep the Implementation Behind the Contract
The handler coordinates the use case but never imports another module's model:
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 {
// writes billing_subscriptions only
SubscriptionStarted::dispatch(...);
});
}
}Billing needs product info, but imports zero of Catalog's internals — it asks Catalog's public API for a purpose-built snapshot. This is a valid dependency, through a contract. The goal isn't zero dependencies; it's making each one explicit, narrow, and owned by the module being called.
Cross-Module Reads Go Through a Contract
For anything Billing needs from Catalog, Catalog exposes exactly that:
interface ProductCatalog
{
public function productForBilling(string $productId): ProductForBilling;
}Returning a DTO instead of an Eloquent model is a deliberate choice, not housekeeping. A model carries writable state, relationships, scopes, and persistence knowledge — hand it out and Billing could call $product->update() or load a nested relation. A DTO is a snapshot for one purpose. Catalog can rename a column or split a table while the contract survives intact.
Database Ownership Inside a Single Database
Here's what surprises most people: a modular monolith can use one database. Separate servers are not required for data ownership.
Ownership means one module writes a table:
- Identity —
identity_accounts,identity_users,identity_credentials - Catalog —
catalog_products,catalog_prices - Billing —
billing_subscriptions,billing_invoices,billing_payment_attempts - Reporting —
reporting_subscription_summaries,reporting_revenue_daily
Prefixes aren't mandatory, but they surface accidental violations. Billing may store account_id and product_id as references — it must never update Identity or Catalog tables. And avoid Eloquent relationships that return foreign-module models; that convenience silently turns an internal model into a cross-module API. Store the identifier, ask the owning module when fresh data matters, use a projection when a screen spans modules.
⚠️ A fair warning on foreign keys. One database makes them work, but a cross-module FK couples your migration order and deletion rules. Keep it when referential integrity is worth that coupling. Drop it only for a concrete reason — never to look "more like microservices."
Reporting Is a Different Kind of Read
A revenue dashboard shows account names, product names, subscription status, invoice totals, and payment dates. Call four module APIs per row and you've rebuilt the N+1 problem locally.
Instead, build a reporting projection: Reporting listens to published facts and stores a read model shaped to its queries. When a subscription starts, it writes the IDs and status. When a product renames, it updates the denormalized name.
💡 The trade-off: Reporting becomes eventually consistent — a dashboard can lag the transaction by seconds. Usually fine for reports and exports; never fine for the rule that decides whether a subscription may start. Use the consistency requirement to pick your communication style.
Events Publish Facts After Commit
Billing publishes a stable fact without exposing its model:
final class SubscriptionStarted implements ShouldDispatchAfterCommit
{
use Dispatchable;
public function __construct(
public readonly string $subscriptionId,
public readonly string $accountId,
public readonly string $productId,
) {}
}ShouldDispatchAfterCommit matters — Reporting should never hear about a subscription that later rolls back. But be honest about the gap: dispatch-after-commit guarantees ordering, not delivery. The app can commit and crash before the event reaches a listener. For a projection that must be recoverable, use a durable handoff — an outbox record written in the same transaction, then a relay with retries — plus a way to reconstruct from an authoritative module.
🚨 Make projections idempotent. A listener must converge to the same result if it sees the same fact twice, and replay must not create duplicates. Protect the
subscription_idwith a unique database constraint, not just app-level logic, or concurrent deliveries both observe a missing row and insert twice.
And don't turn every method call into an event. Use a direct module API when you need a result, a validation error, or an immediate guarantee. Events describe completed facts — never vague SomethingChanged notifications.
Keep Transactions Inside the Owner
One database makes it technically possible to wrap Billing, Catalog, Identity, and Reporting in one transaction. That doesn't mean you should.
A transaction is also an ownership boundary. Starting a subscription can atomically create the subscription and its first invoice because Billing owns both — but updating a Catalog product and a Reporting summary in the same transaction makes Billing responsible for two foreign domains. If a workflow really needs several modules, put the coordination in a thin application flow that calls public module APIs, and be honest about partial failure.
This discipline pays off later: the day Billing moves to its own deployment, the coordinator already depends on its public contract. The transaction boundary is visible and auditable.
Enforce It in CI — Boundaries That Aren't Tested Don't Exist
Structure helps; tests stop regression. Laravel's recommended framework is Pest, and its architecture testing is where a boundary becomes law:
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('billing contracts do not depend on billing internals')
->expect('App\Modules\Billing\Contracts')
->not->toUse([...]);
arch('billing does not depend on reporting')
->expect('App\Modules\Billing')
->not->toUse('App\Modules\Reporting');These tests can't see a raw SQL write, a shared cache key, or a silent HTTP call. But they stop the easiest form of erosion — an accidental import surfaces while the fix is still cheap.
Test the Contract, Not Just the Implementation
Follow up: a case test can exercise the public behavior through the container and fake the events:
it('starts a subscription through the billing contract', function () {
Event::fake([SubscriptionStarted::class]);
// ...create an active Catalog product...
$subscriptionId = resolve(Billing::class)->startSubscription($command);
expect($subscriptionId->value)->not->toBeEmpty();
Event::assertDispatched(SubscriptionStarted::class);
});Don't mock every internal class — the module boundary is the valuable seam. Test most internals normally, and fake the contract only when a consuming domain needs isolation.
Migrate Incrementally, Don't Rewrite
Turning a layered app into a modular monolith is an incremental refactor, and the app stays deployable the whole way — that's the entire advantage of fixing the monolith before extracting services.
- Map the current dependencies — every controller, command, job, model, table, event, and integration touching Billing.
- Define ownership before moving files — Billing owns subscriptions and payment rules; note what it needs from the rest.
- Introduce the public API around one use case — wrap
StartSubscription, keep the old controller working through it. - Move one vertical slice — command, handler, model, tests, route together. This builds a vertical slice end to end.
- Replace foreign-model access — swap Catalog/Identity model imports for query contracts and DTOs.
- Add architecture tests — now prevent every removed edge from returning.
- Repeat by use case — one at a time, no placeholder abstractions.
When Does a Module Earn a Microservice?
A modular monolith isn't a promise to stay one deployment forever — it's a way to delay that cost until the boundary and a measured reason both exist. A module becomes a strong extraction candidate only when concrete signals appear:
- Its deployment cadence is repeatedly blocked by the rest of the app.
- It has a distinct scaling profile the shared process can't serve cheaply.
- Its failures must be isolated — they're currently taking down unrelated features.
- A team owns it end to end, and monolith coordination is visibly slowing them down.
- It needs independent scaling, datastore, region, or compliance boundaries.
- Its public API and data ownership have been stable long enough to survive a network boundary.
- The org can operate another service — monitoring, on-call, deployment, security, incidents.
Notice what isn't on the list: "it has a lot of files," "we hired more engineers," "microservices are more modern." Those are vibes, not requirements. You're paying for a network contract, so you'd better have a network-solved problem signed by a reason.
And know the real price of the move: every call becomes partial, requests time out, events arrive twice or out of order, data goes stale, and you now maintain compatibility across independent versions. Those costs can be worth it — a modular monolith lets you know exactly where and why.
The 10-Question Architecture Audit
Before you call an application modular, run this:
- Does every module represent a business capability with a clear name?
- Can you state, for each, the tables and business decisions it owns?
- Can outside code use only public contracts, DTOs, queries, and events?
- Is each cross-module read a deliberate consistency choice?
- Are Eloquent models and query builders sealed behind the owning module?
- Do events describe completed facts and dispatch after commit when needed?
- Are projections idempotent and repairable?
- Do architecture tests block forbidden namespace dependencies?
- Can one module change its internals without breaking unrelated callers?
- Is any proposed extraction backed by evidence — scaling, isolation, ownership, operations?
Fail any of these and you don't have a microservices problem.
You have a map of the boundaries to draw.
Final Thoughts
The pattern isn't hard — you've been avoiding it — but it's the difference between a codebase that stays cheap to change and one that slowly petrifies:
- Microservices don't fix a tangled monolith — they just move the coupling onto a network.
- A monolith is a deployment shape, not a verdict — organize by capability and the app stays fast.
- Folders by technical layer hide business capabilities — draw boundaries along business decisions.
- The module contract is the API — commands and queries over the surface, facts over events.
- Owned data is the strongest boundary you can draw — enforce with table prefixes, DB permissions, and tests.
- Use a projection when a screen spans modules — four same-holder calls per row is a local N+1.
- Transactions are ownership boundaries — the module that owns the invariant owns the transaction.
- Work with Laravel, not around it — service providers, resolved contracts, events after commit, Pest tests.
- Migrate incrementally, in one deployable slice at a time — the app stays shippable while you fix it.
- Extract with your evidence, not ceremony — scaling, isolation, ownership, and operational readiness decide.
👉 Pick the one feature in your codebase that already has a recognizable boundary. Wrap it in a public contract, exercise it with one Pest arch test, and ship it as a single vertical slice — watch how much clearer every later refactor becomes.
FAQ
Is a modular monolith just a monolith with folders?
No — folders are cosmetic until they carry rules. A modular monolith gives each business capability owned data, a public contract, and architecture tests that reject forbidden dependencies.
Real boundaries are about ownership and allowed dependency direction, not namespace layout.
When should a module actually become a microservice?
Only when it shows a concrete signal: a deployment cadence the rest of the app keeps blocking, a distinct scaling profile, failures that must be isolated, a team owning it end to end, or a different runtime/datastore/compliance need.
The public API and data ownership must already be stable enough to survive a network boundary before you extract.
Can I still use one database with a modular monolith?
Yes. Separate database servers are not required for data ownership — what matters is that only one module is allowed to write each table.
Use table prefixes or separate schemas to make accidental ownership violations visible.
Does Laravel support modular architecture natively?
Not with a checkbox, but the framework gives you everything you need: service providers to wire module contracts, the container to resolve them, events that dispatch after commit, Eloquent kept behind boundaries, and Pest architecture tests to enforce dependency rules.