TL;DR — I started a modular monolith today and drew eleven bounded contexts before writing a single class in any of them. Then I tried to enforce the boundaries with Pest's arch() helpers and couldn't, because arch() needs classes to exist and mine didn't yet. So the boundary test reads use statements out of the source text instead. It's cruder than reflection and it catches the violation on the day the boundary is drawn, which is the only day that matters.
The failure mode I was trying to avoid
Every modular monolith I've seen go bad went bad the same way. Somebody draws the modules in a wiki page. Everybody agrees. Six weeks later a controller in Billing does Catalogue\Models\Course::find($id) because it was two in the morning and the alternative was writing an action. Nobody notices, because nothing was watching.
The wiki page is still there. It's just not true any more.
So the rule I actually care about isn't "draw good boundaries." It's a boundary that isn't executable is a preference, not a boundary. Same class of thing as a comment that says // don't call this directly.
The structure I settled on is ordinary — eleven modules under app/Platform/, each shaped the same way:
app/Platform/<Module>/
├── Actions/ public entry points — the only way in from another module
├── Contracts/ interfaces this module owns
├── Events/ what it publishes
├── Models/ private to this module
└── README.md what it owns and publishes
Three rules, and I wanted all three green from the first commit:
- A module may not reference another module's
Models. - Cross-module access goes through the owning module's
Actions,ContractsorEvents. - No domain action may import a vendor SDK — it calls a contract.
Where arch() couldn't help
Pest's architecture testing is the obvious tool here. You write something like:
arch('billing does not touch catalogue models')
->expect('App\Platform\Billing')
->not->toUse('App\Platform\Catalogue\Models');
Clean. Reads like the rule. And on day one it does nothing at all, because arch() works off the class map: it resolves classes, walks their dependencies, and reasons about real symbols. Point it at a namespace with no classes in it and there's nothing to reason about. The expectation passes on emptiness.
That's fine when you're retrofitting boundaries onto a codebase that already has 400 classes. It's exactly wrong when you're declaring boundaries on a codebase that has none, which is the moment the boundary is cheapest to hold and the moment the first violation is most likely — the very first class someone drops into Billing/Models is the one that decides what "normal" looks like in that folder.
I wanted the test to be red on a violation the same afternoon I drew the lines. So I gave up on reflection and went to the text.
Reading use statements like a linter
The whole enforcement is a Pest feature test that globs the filesystem and regexes imports:
/** @return array<int, string> Module names, discovered from the filesystem. */
function modules(): array
{
$names = array_map('basename', glob(app_path('Platform/*'), GLOB_ONLYDIR) ?: []);
sort($names);
return $names;
}
/** @return array<int, string> The fully-qualified imports in a file. */
function importsIn(string $path): array
{
preg_match_all('/^use\s+(?:function\s+)?([^\s;]+)/m', (string) file_get_contents($path), $m);
return $m[1] ?? [];
}
Note modules() discovers from the filesystem rather than a hardcoded list. That matters for the rule itself — a new module folder is automatically subject to every rule below, so nobody can opt out by creating a twelfth context quietly.
The Models rule then falls out in about fifteen lines:
it('never references another module\'s models', function () {
$violations = [];
foreach (modules() as $module) {
foreach (moduleFiles($module) as $file) {
foreach (importsIn($file) as $import) {
if (! str_starts_with($import, 'App\\Platform\\')) {
continue;
}
[$owner, $layer] = [explode('\\', $import)[2] ?? '', explode('\\', $import)[3] ?? ''];
// Reaching into another module is allowed only via its public
// Actions, Contracts or Events — never its Models.
if ($owner !== $module && ! in_array($layer, ['Actions', 'Contracts', 'Events'], true)) {
$violations[] = sprintf(
'%s imports %s (reach %s through its Actions, Contracts or Events)',
str_replace(app_path().'/', 'app/', $file),
$import,
$owner,
);
}
}
}
}
expect($violations)->toBe([], "cross-module boundary violations:\n".implode("\n", $violations));
});
Two deliberate choices in there worth calling out.
Collect, then assert once. The naive version asserts inside the loop and dies on the first violation. If you've just merged a branch that broke the rule in nine places, you want all nine in one failure message, not nine round trips through the suite. The message is written as an instruction — reach Catalogue through its Actions — because the person reading it at 2am is the person the rule exists for.
The allowed layers are a whitelist, not a blacklist. I'm not banning Models; I'm permitting Actions, Contracts, Events and nothing else. When someone adds a Support/ or Queries/ folder to a module later, it is private by default and the test says so. A blacklist would have silently let it through.
The vendor-SDK rule is the same shape with a prefix allowlist:
$allowed = ['App\\', 'Illuminate\\', 'Carbon\\', 'Spatie\\LaravelData\\'];
Anything else imported inside an Actions/ directory fails. That's what keeps the payment provider, the storage provider and the video provider behind contracts, which in turn is what lets the whole suite run offline with fake drivers — and makes swapping a provider a driver change rather than a migration project.
The test that asserts documentation exists
This one felt slightly ridiculous when I wrote it and I've come around on it:
it('gives every module a README stating what it owns and publishes', function () {
foreach (modules() as $module) {
$readme = app_path('Platform/'.$module.'/README.md');
expect(is_file($readme))->toBeTrue("module [{$module}] has no README");
expect((string) file_get_contents($readme))->toContain('**Owns:**', '**Publishes:**');
}
});
It's a very dumb check — the README could say **Owns:** stuff. It isn't checking quality. What it's actually enforcing is that creating a module is a decision you have to write down, and the two headings force the only two sentences that matter: what data is yours, and what other modules are allowed to react to.
I've written a lot of architecture docs that rotted. This one can't rot to absent, which turns out to be most of the rot.
What this buys, and what it doesn't
Be clear-eyed about the trade. Text-level checking is weaker than reflection in specific, knowable ways:
-
Inline FQCNs slip through.
\App\Platform\Catalogue\Models\Course::find(1)has nousestatement, so the regex never sees it. In practice this is rare in a codebase with a formatter and an IDE that auto-imports, but it is a real hole. -
Grouped and aliased imports need care.
use App\Platform\Catalogue\{Models\Course, Actions\Publish};isn't parsed by that pattern. - It knows nothing about runtime. A container binding, a string class name, an event listener resolved by name — all invisible.
If you want those closed properly, the answer is a real parser (nikic/php-parser) or deptrac, and at some point I'll probably graduate to one. But the honest cost/benefit today: the fifteen-line regex version was working before lunch, it fails loudly, it has zero dependencies, and it covers the way violations actually get written. A perfect checker I hadn't finished yet would have caught nothing.
There's also a deliberate hole I want: the shared kernel. App\Models, App\Enums, App\Contracts, App\Support are usable from anywhere. App\Models\User in particular is shared across all eleven modules, because every persona in the system is a user and duplicating identity per module would be worse than sharing it. Purists will wince. I'd rather have one honest shared kernel that everyone knows about than eleven private User copies pretending to be independent.
The bit I keep coming back to
Directories appear as the first class lands in them. An empty module is a declared boundary, not dead scaffolding.
Eleven folders with nothing in them but a README looks like over-engineering right up until you notice the test is already guarding them. The boundaries are drawn where a service split would go, so that option stays open — and unexercised, which is the point. I'm not running eleven services. I'm keeping the seam visible so that if one of these ever needs to leave, it can leave without a six-month untangling.
The general version, and the reason this is worth 150 lines of test:
Every architectural rule you can state in a sentence should be a failing test before it is a paragraph in a README. If you can't make it fail, you haven't decided anything — you've expressed a hope.
What's next
Rule four is written down but not yet enforced: every entitlement decision must route through one module. It needs classes to exist before it means anything, so that one genuinely does wait. When it lands it'll probably be an arch() test, and that'll be the right tool by then — which is the whole shape of this post. Use reflection when there's something to reflect on. Until then, read the text.
Top comments (0)