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.

This 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.

The rule: depend on contracts and events, never on internals

Give every module the same three-part shape:

app/Modules/
  Billing/
    Contracts/        # public: interfaces other modules may type-hint
    Events/           # public: facts other modules may listen to
    Internal/         # private: models, repositories, services, jobs
  Orders/
    Contracts/
    Events/
    Internal/

Then one rule: a module may import anything from itself, but from another module only its Contracts and Events.

This 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.

namespace App\Modules\Billing\Contracts;

interface ChargesCustomers
{
    public function charge(int $customerId, int $amountInCents): string;
}

// Billing's service provider decides the implementation.
$this->app->bind(ChargesCustomers::class, StripeLikeGateway::class);

// Orders depends on the contract only.
namespace App\Modules\Orders\Internal;

use App\Modules\Billing\Contracts\ChargesCustomers;

final class PlaceOrder
{
    public function __construct(private ChargesCustomers $billing) {}
}

Why a test, and why not a package

Tools 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.

The 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.

The test

Requires PHP 8.0+ (it uses the T_NAME_QUALIFIED token) and works with PHPUnit 9, which ships with Laravel 8 and 9 projects.

<?php

use PHPUnit\Framework\TestCase;

/**
 * Fails when a module imports another module's classes outside its public
 * surface (Contracts and Events). Uses PHP's own tokenizer: no extra packages.
 */
final class ModuleBoundaryTest extends TestCase
{
    private const MODULES_PATH = __DIR__ . '/../app/Modules';
    private const PUBLIC_NAMESPACES = ['Contracts', 'Events'];

    public function test_modules_only_depend_on_each_others_public_surface(): void
    {
        $violations = [];

        foreach ($this->phpFiles(self::MODULES_PATH) as $file) {
            $owner = $this->moduleOf($file);
            foreach ($this->importedClasses($file) as $line => $class) {
                if (!preg_match('/^App\\\\Modules\\\\(\w+)\\\\(\w+)/', $class, $m)) {
                    continue; // not a module class
                }
                [, $target, $layer] = $m;
                if ($target !== $owner && !in_array($layer, self::PUBLIC_NAMESPACES, true)) {
                    $violations[] = sprintf('%s:%d imports %s', $this->relative($file), $line, $class);
                }
            }
        }

        $this->assertSame([], $violations, "Module boundary violations:\n" . implode("\n", $violations));
    }

    private function phpFiles(string $dir): iterable
    {
        $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
        foreach ($it as $f) {
            if ($f->getExtension() === 'php') yield realpath($f->getPathname());
        }
    }

    private function moduleOf(string $file): string
    {
        return explode('/', substr($file, strlen(realpath(self::MODULES_PATH)) + 1))[0];
    }

    /** @return array<int,string> line => fully qualified class from top-level `use` statements */
    private function importedClasses(string $file): array
    {
        $tokens = token_get_all(file_get_contents($file));
        $imports = [];
        foreach ($tokens as $i => $token) {
            if (is_array($token) && $token[0] === T_USE && $this->isTopLevelImport($tokens, $i)) {
                $name = '';
                for ($j = $i + 1; isset($tokens[$j]) && $tokens[$j] !== ';' && $tokens[$j] !== ','; $j++) {
                    if (is_array($tokens[$j]) && in_array($tokens[$j][0], [T_NAME_QUALIFIED, T_STRING, T_NS_SEPARATOR], true)) {
                        $name .= $tokens[$j][1];
                    }
                    if (is_array($tokens[$j]) && $tokens[$j][0] === T_AS) break;
                }
                $imports[$token[2]] = ltrim($name, '\\');
            }
        }
        return $imports;
    }

    /** `use` inside a class body is a trait import, not a dependency on another namespace. */
    private function isTopLevelImport(array $tokens, int $index): bool
    {
        $depth = 0;
        for ($k = 0; $k < $index; $k++) {
            if ($tokens[$k] === '{' || (is_array($tokens[$k]) && in_array($tokens[$k][0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) $depth++;
            if ($tokens[$k] === '}') $depth--;
        }
        return $depth === 0;
    }

    private function relative(string $file): string
    {
        return substr($file, strlen(realpath(__DIR__ . '/..')) + 1);
    }
}

What it caught

I 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:

use App\Modules\Billing\Contracts\ChargesCustomers;   // allowed
use App\Modules\Billing\Internal\StripeLikeGateway;   // violation

The output:

1) ModuleBoundaryTest::test_modules_only_depend_on_each_others_public_surface
Module boundary violations:
app/Modules/Orders/Internal/PlaceOrder.php:5 imports App\Modules\Billing\Internal\StripeLikeGateway
Failed asserting that two arrays are identical.

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

File, line, and the offending import: enough to fix it without opening the test. Removing the line turned the run green.

The 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.

Adopting it in a codebase with existing violations

Dropping 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:

  1. Run the test once and save the reported violations into a baseline file, for example tests/module-boundaries.baseline, one line per violation.
  2. Change the assertion to compare against the baseline: new violations fail the build, existing ones are tolerated.
  3. Add a second assertion that fails if a baseline entry no longer occurs. That forces whoever fixes a violation to remove it from the baseline, so the file reflects reality.
  4. Pick the violations that hurt most first. Usually that means direct use of another module's Eloquent models, because those couple you to its schema.
$baseline = file(__DIR__ . '/module-boundaries.baseline', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

$this->assertSame([], array_values(array_diff($violations, $baseline)), 'New module boundary violations');
$this->assertSame([], array_values(array_diff($baseline, $violations)), 'Fixed violations still listed in the baseline');

Fixing the typical violations

ViolationReplace it with
Reading another module's Eloquent modelA query method on that module's contract returning a plain DTO or array
Calling another module's service classAn interface in its Contracts namespace, bound in its service provider
Updating another module's data after your own changeDispatch an event from your module; the other module listens and updates its own data
A belongsTo relationship across modulesStore the foreign ID only; fetch details through the contract when needed
Sharing a helper "because both need it"A deliberately small shared kernel outside app/Modules, or duplication if the helper is trivial

Events 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.

What this test does not cover

  • Fully qualified names in code. Writing \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.
  • Strings. app('billing.gateway') or a class name in config cannot be checked statically. Prefer constructor injection of contracts so dependencies stay visible.
  • The database. Nothing here stops raw SQL joining another module's tables. If that matters to you, give each module's tables a prefix and add a check over migrations and raw queries, or move to separate schemas.
  • Routes, views, and config are outside app/Modules in many projects and are not scanned.

Those 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.

Summary

A 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.

If you are planning a modular monolith, or untangling a Laravel codebase whose modules have quietly merged into one, my scalable API and backend architecture work covers exactly this: defining module boundaries, mapping existing violations, and planning the order to fix them without stopping feature delivery.

Frequently asked questions

Should modules share Eloquent models?

No. Sharing models turns one module’s database schema into another module’s API. Expose a contract that returns plain data instead.

Can I use Deptrac or PHPArkitect instead?

Yes. They express richer rules. A single PHPUnit test is simply a lighter option when the rule is this simple and you want no new tooling.