{"slug": "enforcing-module-boundaries-in-a-laravel-modular-monolith", "title": "Enforcing Module Boundaries in a Laravel Modular Monolith", "summary": "A developer published a PHPUnit-based test that enforces module boundaries in Laravel modular monoliths, using PHP's built-in tokenizer to extract top-level use imports and flag any module that imports another module's classes outside its public Contracts and Events namespaces. The approach requires PHP 8.0+ and PHPUnit 9, adds no dependencies beyond PHP itself, and was validated against a sample module tree where it caught a deliberate violation. The author argues the test is preferable to tools like Deptrac or PHPArkitect for a single dependency rule because it runs wherever existing tests run.", "body_md": "Every modular monolith starts with good intentions and a folder per module. Six months later, `Orders` calls a private repository inside `Billing` because it was quicker, and the folders are just decoration. Module boundaries in a Laravel monolith are not enforced by the framework. If you want them to survive deadlines and new team members, you have to **enforce module boundaries** with something that fails a build, not with a wiki page.\n\nThis article shows a dependency rule that fits most Laravel modular monoliths, a test that enforces it using nothing but PHP's tokenizer and PHPUnit, and how to adopt it in a codebase that already has violations. The test below is the exact code I ran against a sample module tree while writing this article, including the case where it caught a deliberate violation. If you are still deciding whether a modular monolith is right for you, start with [building a modular monolith in Laravel that can grow](https://parhamtehrani.com/en/blog/modular-monolith-laravel).\n\nGive every module the same three-part shape:\n\n```\napp/Modules/\n  Billing/\n    Contracts/        # public: interfaces other modules may type-hint\n    Events/           # public: facts other modules may listen to\n    Internal/         # private: models, repositories, services, jobs\n  Orders/\n    Contracts/\n    Events/\n    Internal/\n```\n\nThen one rule: *a module may import anything from itself, but from another module only its `Contracts` and `Events`.*\n\nThis captures the two legitimate ways modules interact. A synchronous question goes through an interface the owning module publishes and binds in its service provider. An asynchronous fact goes through an event the owning module dispatches. Everything else, including Eloquent models, is private. That last point is the one teams push back on, and the one that matters most: when `Orders` queries `Billing`'s tables through `Billing`'s models, the database schema becomes a shared API that nobody can change.\n\n``` php\nnamespace App\\Modules\\Billing\\Contracts;\n\ninterface ChargesCustomers\n{\n    public function charge(int $customerId, int $amountInCents): string;\n}\n\n// Billing's service provider decides the implementation.\n$this->app->bind(ChargesCustomers::class, StripeLikeGateway::class);\n\n// Orders depends on the contract only.\nnamespace App\\Modules\\Orders\\Internal;\n\nuse App\\Modules\\Billing\\Contracts\\ChargesCustomers;\n\nfinal class PlaceOrder\n{\n    public function __construct(private ChargesCustomers $billing) {}\n}\n```\n\nTools such as Deptrac or PHPArkitect can express rules like this and more. If your team already runs one, use it. But adding a new tool means new configuration, a new CI step, and a new thing to learn. A single PHPUnit test runs everywhere your tests already run, reads like any other test, and has no dependencies beyond PHP itself. For a rule this simple, that trade-off favours the test.\n\nThe test reads every PHP file under `app/Modules`, extracts top-level `use` imports with `token_get_all()`, and reports any import of another module's class outside the public namespaces. Using the tokenizer rather than a regular expression avoids false matches in comments and strings, and lets the test ignore trait imports inside class bodies, which use the same `use` keyword.\n\nRequires PHP 8.0+ (it uses the `T_NAME_QUALIFIED` token) and works with PHPUnit 9, which ships with Laravel 8 and 9 projects.\n\n``` php\n<?php\n\nuse PHPUnit\\Framework\\TestCase;\n\n/**\n * Fails when a module imports another module's classes outside its public\n * surface (Contracts and Events). Uses PHP's own tokenizer: no extra packages.\n */\nfinal class ModuleBoundaryTest extends TestCase\n{\n    private const MODULES_PATH = __DIR__ . '/../app/Modules';\n    private const PUBLIC_NAMESPACES = ['Contracts', 'Events'];\n\n    public function test_modules_only_depend_on_each_others_public_surface(): void\n    {\n        $violations = [];\n\n        foreach ($this->phpFiles(self::MODULES_PATH) as $file) {\n            $owner = $this->moduleOf($file);\n            foreach ($this->importedClasses($file) as $line => $class) {\n                if (!preg_match('/^App\\\\\\\\Modules\\\\\\\\(\\w+)\\\\\\\\(\\w+)/', $class, $m)) {\n                    continue; // not a module class\n                }\n                [, $target, $layer] = $m;\n                if ($target !== $owner && !in_array($layer, self::PUBLIC_NAMESPACES, true)) {\n                    $violations[] = sprintf('%s:%d imports %s', $this->relative($file), $line, $class);\n                }\n            }\n        }\n\n        $this->assertSame([], $violations, \"Module boundary violations:\\n\" . implode(\"\\n\", $violations));\n    }\n\n    private function phpFiles(string $dir): iterable\n    {\n        $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));\n        foreach ($it as $f) {\n            if ($f->getExtension() === 'php') yield realpath($f->getPathname());\n        }\n    }\n\n    private function moduleOf(string $file): string\n    {\n        return explode('/', substr($file, strlen(realpath(self::MODULES_PATH)) + 1))[0];\n    }\n\n    /** @return array<int,string> line => fully qualified class from top-level `use` statements */\n    private function importedClasses(string $file): array\n    {\n        $tokens = token_get_all(file_get_contents($file));\n        $imports = [];\n        foreach ($tokens as $i => $token) {\n            if (is_array($token) && $token[0] === T_USE && $this->isTopLevelImport($tokens, $i)) {\n                $name = '';\n                for ($j = $i + 1; isset($tokens[$j]) && $tokens[$j] !== ';' && $tokens[$j] !== ','; $j++) {\n                    if (is_array($tokens[$j]) && in_array($tokens[$j][0], [T_NAME_QUALIFIED, T_STRING, T_NS_SEPARATOR], true)) {\n                        $name .= $tokens[$j][1];\n                    }\n                    if (is_array($tokens[$j]) && $tokens[$j][0] === T_AS) break;\n                }\n                $imports[$token[2]] = ltrim($name, '\\\\');\n            }\n        }\n        return $imports;\n    }\n\n    /** `use` inside a class body is a trait import, not a dependency on another namespace. */\n    private function isTopLevelImport(array $tokens, int $index): bool\n    {\n        $depth = 0;\n        for ($k = 0; $k < $index; $k++) {\n            if ($tokens[$k] === '{' || (is_array($tokens[$k]) && in_array($tokens[$k][0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) $depth++;\n            if ($tokens[$k] === '}') $depth--;\n        }\n        return $depth === 0;\n    }\n\n    private function relative(string $file): string\n    {\n        return substr($file, strlen(realpath(__DIR__ . '/..')) + 1);\n    }\n}\n```\n\nI ran it against the sample tree above, with an extra file in `Billing` that imports its own internals, an event from `Orders`, and a trait inside the class body. All three are allowed, and none were reported. Then I added one illegal line to `Orders`:\n\n``` php\nuse App\\Modules\\Billing\\Contracts\\ChargesCustomers;   // allowed\nuse App\\Modules\\Billing\\Internal\\StripeLikeGateway;   // violation\n```\n\nThe output:\n\n```\n1) ModuleBoundaryTest::test_modules_only_depend_on_each_others_public_surface\nModule boundary violations:\napp/Modules/Orders/Internal/PlaceOrder.php:5 imports App\\Modules\\Billing\\Internal\\StripeLikeGateway\nFailed asserting that two arrays are identical.\n\nFAILURES!\nTests: 1, Assertions: 1, Failures: 1.\n```\n\nFile, line, and the offending import: enough to fix it without opening the test. Removing the line turned the run green.\n\nThe first draft of this test had a bug worth mentioning. It compared each file's path against a resolved modules directory, but iterated files through a relative path containing `tests/../`. The module name for each file was therefore computed from the wrong offset. Cross-module violations were still reported, but a module importing its *own* internals could have been flagged as well. Normalising every path with `realpath()` fixed it. It is a good reminder to test your guard-rails against cases that should pass, not only cases that should fail.\n\nDropping this test into a real project will usually fail on day one. Do not disable it. Freeze the current state instead and let the list only shrink:\n\n`tests/module-boundaries.baseline`, one line per violation.\n\n``` php\n$baseline = file(__DIR__ . '/module-boundaries.baseline', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);\n\n$this->assertSame([], array_values(array_diff($violations, $baseline)), 'New module boundary violations');\n$this->assertSame([], array_values(array_diff($baseline, $violations)), 'Fixed violations still listed in the baseline');\n```\n\n| Violation | Replace it with | \n|---|---|\n| Reading another module's Eloquent model | A query method on that module's contract returning a plain DTO or array | \n| Calling another module's service class | An interface in its `Contracts` namespace, bound in its service provider | \n| Updating another module's data after your own change | Dispatch an event from your module; the other module listens and updates its own data | \n| A `belongsTo` relationship across modules | Store the foreign ID only; fetch details through the contract when needed | \n| Sharing a helper \"because both need it\" | A deliberately small shared kernel outside `app/Modules` , or duplication if the helper is trivial | \n\nEvents deserve one caution. An event is part of the publishing module's public API, just like an interface. Keep the payload to primitive values and identifiers, as in `OrderPlaced(int $orderId, int $customerId, int $totalInCents)`, rather than whole models, so listeners don't become coupled to the publisher's internals through the back door.\n\n`\\App\\Modules\\Billing\\Internal\\Gateway::class` inline, without a `use`, bypasses an import-based check. Extend the token scan to `T_NAME_FULLY_QUALIFIED` if your team does this.`app('billing.gateway')` or a class name in config cannot be checked statically. Prefer constructor injection of contracts so dependencies stay visible.`app/Modules` in many projects and are not scanned.\nThose gaps are acceptable. The goal is not a perfect architectural proof; it is making the easy, accidental violation impossible to merge, so that the deliberate exceptions get discussed in review.\n\nA modular monolith keeps its value only while its boundaries hold. One rule (depend only on another module's contracts and events) and one dependency-free test that enforces it in CI turn those boundaries from a convention into something the build checks. Start with a baseline, shrink it over time, and fix the model-sharing violations first.\n\nIf you are planning a modular monolith, or untangling a Laravel codebase whose modules have quietly merged into one, my [scalable API and backend architecture](https://parhamtehrani.com/en/services/api-backend) work covers exactly this: defining module boundaries, mapping existing violations, and planning the order to fix them without stopping feature delivery.\n\n*Originally published on [parhamtehrani.com](https://parhamtehrani.com/en/blog/enforce-laravel-module-boundaries).*\n\n*This article was written by an AI agent (Claude) on the author's behalf. The code was run and its tests passed on the environment described above.*", "url": "https://wpnews.pro/news/enforcing-module-boundaries-in-a-laravel-modular-monolith", "canonical_source": "https://dev.to/parham_tehrani_09963bae0b/enforcing-module-boundaries-in-a-laravel-modular-monolith-46eg", "published_at": "2026-11-05 08:12:35+00:00", "updated_at": "2026-11-05 08:19:39.031916+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Laravel", "PHPUnit", "PHP", "Deptrac", "PHPArkitect"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/enforcing-module-boundaries-in-a-laravel-modular-monolith", "markdown": "https://wpnews.pro/news/enforcing-module-boundaries-in-a-laravel-modular-monolith.md", "text": "https://wpnews.pro/news/enforcing-module-boundaries-in-a-laravel-modular-monolith.txt", "jsonld": "https://wpnews.pro/news/enforcing-module-boundaries-in-a-laravel-modular-monolith.jsonld"}}