Modular monolith in Laravel: enforce the boundaries in CI
Architecture · Aug 2026 · 18 min read
Independent deploys and clear ownership without a distributed system. Module layout, a contracts-only rule, a failing build when it is broken, and how to extract later.
I have argued elsewhere that most teams asking for microservices want two things they can have inside one codebase: independent ownership and a boundary that holds. This is the build. By the end you will have modules that cannot reach into each other's internals, a CI job that fails when somebody tries, and a clean extraction path for the day a module genuinely needs its own deployment.
The important idea up front: a boundary that is not enforced by a machine is a preference. Code review catches it for about six weeks, and then somebody is shipping at 7pm and the import goes in.
Step 1: give modules a place to live
Move out of the default app/Models, app/Http shape. Each module gets its own tree with a public surface and a private interior, and the distinction has to be visible in the directory names or nobody will respect it.
src/
Billing/
Contracts/ <- the ONLY thing others may import
InvoiceReader.php
Events/InvoiceIssued.php
Internal/ <- models, queries, jobs. private.
Invoice.php
LedgerPoster.php
BillingServiceProvider.php
Catalogue/
Contracts/ ...
Shared/ <- genuinely shared primitives only
// composer.json
"autoload": { "psr-4": { "App\\": "src/" } }
Shared is where discipline goes to die, so put a rule on it now: something belongs in Shared only when two modules already need it and neither could reasonably own it. A value object for money, yes. A User model, almost certainly not — that is a module.
Step 2: write the rule down in one file
Before any tooling, state the rule in a form a person can read in ten seconds. Every argument about a boundary for the next two years will be settled against this file, so it is worth the twenty minutes.
a module may import:
- its own namespace, freely
- another module's Contracts/ namespace
- Shared/
- the framework
a module may NOT import:
- another module's Internal/, ever, for any reason
modules and their dependencies:
Billing -> Shared
Catalogue -> Shared
Ordering -> Shared, Billing::Contracts, Catalogue::Contracts
# note there is no cycle. that is not an accident.
Step 3: make the build fail
Deptrac takes about an hour to configure and turns the document above into a gate. Run it in CI on every pull request, not as a nightly report that everyone learns to ignore.
layers:
- name: Billing.Contracts
collectors: [{ type: directory, value: src/Billing/Contracts/.* }]
- name: Billing.Internal
collectors: [{ type: directory, value: src/Billing/Internal/.* }]
- name: Ordering
collectors: [{ type: directory, value: src/Ordering/.* }]
ruleset:
Ordering: [Shared, Billing.Contracts, Catalogue.Contracts]
Billing.Internal: [Shared, Billing.Contracts]
Billing.Contracts: [Shared]
# anything not listed is forbidden. that is the point.
$ vendor/bin/deptrac analyse --fail-on-uncovered
FAIL Ordering\Checkout -> Billing\Internal\Invoice
Use --fail-on-uncovered from the start. Without it, a file in a directory you forgot to declare is silently unconstrained, and you will find out eighteen months later that a third of the codebase was never checked.
Step 4: let modules talk without touching
Two mechanisms cover almost everything. When you need an answer now, depend on an interface the other module publishes. When you only need to announce something, publish a domain event and let whoever cares subscribe.
// 1. synchronous: an interface in Contracts, bound in the provider
interface InvoiceReader {
public function totalFor(OrderId $id): Money; // no Eloquent
}
// BillingServiceProvider
$this->app->bind(InvoiceReader::class, EloquentInvoiceReader::class);
// 2. asynchronous: a fact, in the past tense
final class InvoiceIssued {
public function __construct(
public readonly string $invoiceId, // ids and scalars
public readonly string $orderId, // never a model
) {}
}
The rule that matters in both: no Eloquent models cross a boundary. The moment Ordering receives a Billing\Internal\Invoice, it can lazy-load relations, mutate it, and save it — and your boundary now exists only in the diagram. Pass ids, scalars, and small read models.
A boundary that passes models is a namespace with good intentions. A boundary that passes data is a boundary.
Step 5: the database is a boundary too
This is the step teams skip, and it is the one that decides whether extraction is a week or a quarter. If Ordering joins directly against billing_invoices, you have a hidden coupling that no static analyser will ever see.
Prefix tables by module: billing_invoices, catalogue_products. Ownership becomes visible in every query and every slow log. No cross-module joins in application code. If Ordering needs invoice totals, it asks InvoiceReader — even though the join would be faster today. Foreign keys across modules are allowed while you are still one database, but write them down as debts. Each one is a thing to unpick at extraction. Each module owns its migrations, in its own directory, registered by its own service provider.
You will pay for this in a handful of places where a join would have been trivial and now costs an extra query. That is the actual price of the boundary, and it is much lower than the price of a network hop, which is the alternative you were considering.
Step 6: pick a boundary and let it prove itself
Do not draw the module map in a workshop and then implement it. Start with one boundary, around something you already believe is separate, and watch it for a quarter. Deptrac gives you the evidence for free: every failing build is a data point about whether the line is in the right place.
$ git log --oneline -S 'deptrac-baseline' -- deptrac.yaml
# a boundary that holds:
# few violations, each fixed by moving the call to a contract
# -> real. extract to a service in an afternoon if you need to.
# a boundary that does not:
# constant violations, each 'fixed' by widening the ruleset
# -> wrong line. move it, or delete it. you just saved a rewrite.
That second case is a success, not a failure. Discovering a boundary is wrong costs you a config change; discovering it after you have deployed it as a separate service with its own database costs you a quarter.
Step 7: extraction, when it is actually warranted
Extract when there is a reason you can name out loud: a different scaling curve, a different failure tolerance, or a genuinely separate team with its own on-call. If a boundary has held for two quarters, the work is mostly mechanical.
Replace the in-process binding of the contract with an HTTP or queue-backed implementation. Callers do not change. Move the module's tables to their own schema; every cross-module foreign key you wrote down in step 5 becomes a task here. Domain events become published messages. The consumers already exist and already assume they may run twice. Keep the module in the monolith repository until the second team genuinely needs an independent release cadence.
What you keep by not extracting until then is worth listing, because it is invisible until it is gone: one migration story, real transactions across the whole request, one trace per request, and a local environment a new hire can run before lunch. Those are not consolation prizes. They are the things teams miss most about six months after the split.
References
Takeaways
Split each module into Contracts/ and Internal/, and let only Contracts/ be importable from outside. Enforce it with deptrac in CI using --fail-on-uncovered; an unenforced boundary is a preference. Never pass Eloquent models across a boundary, and prefix tables by module so database coupling is visible. Let a boundary prove itself for two quarters before extracting; constant violations mean the line is wrong, which is cheap to learn now.
All notes · Shehzad Aslam