Laravel's greatest convenience is that everything is reachable from everywhere. Any model can be imported anywhere, any facade resolves globally, and a controller can query any table. That is wonderful for the first year and corrosive by the third, because nothing stops the billing code from reaching into inspection internals and nobody notices until the two cannot be separated.
A modular monolith keeps the operational simplicity of one deployable while restoring boundaries. The essential point is that the boundary must be enforced by a machine, because a convention that is only in people's heads will lose to a deadline.
Structure the application around the business
Move away from the default layout where every model lives together, every controller lives together, and the structure tells you nothing about what the system does. Group by capability instead — Billing, Inspections, Scheduling — with each module containing its own models, services, controllers, routes and tests, and a small explicitly public surface.
app/Modules/
Billing/
Public/ <- the only namespace other modules may use
BillingFacade.php <- coarse operations, returning DTOs not models
Events/InvoiceIssued.php
DTO/InvoiceSummary.php
Internal/
Models/Invoice.php <- Eloquent models never leave the module
Services/, Policies/, Jobs/
Http/ <- module's own routes and controllers
Database/Migrations/
Tests/
Inspections/
...same shape
# Composer autoload keeps it tidy; the rule is enforced separately.
"autoload": { "psr-4": { "App\\Modules\\": "app/Modules/" } }The rule that carries the most weight: Eloquent models never cross a module boundary. The moment Billing accepts an Inspection model, it depends on that module's schema, its relations and its accessors — and any change there becomes a change here. Pass a DTO with the three fields actually needed.
Enforce it in CI, or do not bother
# deptrac.yaml — the boundary becomes a build failure, not a review comment.
parameters:
layers:
- name: BillingPublic
collectors: [{ type: className, value: ^App\\Modules\\Billing\\Public\\.* }]
- name: BillingInternal
collectors: [{ type: className, value: ^App\\Modules\\Billing\\Internal\\.* }]
- name: InspectionsPublic
collectors: [{ type: className, value: ^App\\Modules\\Inspections\\Public\\.* }]
- name: InspectionsInternal
collectors: [{ type: className, value: ^App\\Modules\\Inspections\\Internal\\.* }]
ruleset:
BillingInternal: [BillingPublic, InspectionsPublic]
InspectionsInternal: [InspectionsPublic, BillingPublic]
# Nothing is permitted to reach any module's Internal namespace.
# A violation fails the pipeline, on the day it is introduced.Add a Pest architecture test alongside it asserting that no module's internal namespace appears in another module's use statements. Two mechanisms are not redundant here — one catches the import, the other catches the string reference and the container binding.
Communication between modules
- Synchronous calls go through the public facade, with DTOs in and out. Coarse-grained operations, not a wrapper per query.
- For anything that does not need a result, publish a domain event and let interested modules subscribe. That keeps the publisher unaware of its consumers.
- Each module owns its tables. No other module writes them, and cross-module reads go through the facade rather than a join.
- Where a join is genuinely needed for a report, build a read projection rather than reaching across the boundary.
- Keep migrations inside the module that owns the tables, so extraction later is a directory move rather than an archaeology exercise.
A module boundary that only exists in the team's heads is a boundary until the first urgent Friday deploy.
The payoff comes when a module genuinely needs to become a service. Because it already communicates through a narrow public surface and owns its own tables, extraction is replacing the facade with an HTTP client and moving a schema — days of work rather than a quarter. That option, held cheaply and exercised only when a real constraint demands it, is the whole argument for building this way.