On this page
Module boundaries
Module A may reach module B only through B's Facade. This page covers everything that enforces that rule, and the
agreements built on top of it: two opt-in analyser rules, a dependency-cycle gate on debug:graph,
a declared rules file both readers share, and a CI review for graph changes.
The rules run under both PHPStan and Psalm. Static analysis covers installing the analysers, the always-on pillar rules, and suppression.
The cross-module rules
This is the one check that cannot be on by default. Nothing in a class name says where a module boundary falls, so the check needs your root namespace.
It comes in two halves. Enable them together.
The first matches the module names a source writes: a new, a static call, a class constant, a static property. The
second, new in 2.1, resolves the receiver of a method call by type. Once dependencies go through Providers and
constructors, that is how code crosses a boundary:
public function __construct(
private readonly InvoiceRepository $invoices, // App\Billing: another module
) {
}
public function createProcessor(): Processor
{
return new Processor($this->invoices->findAll()); // names nothing here
}The class appears once, in a type-hint. A check that matched only written names would report green on exactly the codebases most likely to cross boundaries.
PHPStan
Both rules ship commented out in phpstan-gacela.neon. Register them with your namespaces:
services:
-
class: Gacela\PHPStan\Rules\CrossModuleViaFacadeRule
tags: [phpstan.rules.rule]
arguments:
rootNamespace: App\Modules
modulePathSegments: 1 # how many segments under the root identify a module
sharedNamespaces: # optional shared kernels, exempt from the check
- App\Modules\Shared
-
class: Gacela\PHPStan\Rules\CrossModuleMethodCallRule
tags: [phpstan.rules.rule]
arguments:
rootNamespace: App\Modules
modulePathSegments: 1
sharedNamespaces:
- App\Modules\SharedPsalm Since 2.1
One <crossModule> element enables both halves:
<plugins>
<pluginClass class="Gacela\Psalm\Plugin">
<crossModule rootNamespace="App\Modules" modulePathSegments="1">
<sharedNamespace>App\Modules\Shared</sharedNamespace>
</crossModule>
</pluginClass>
</plugins>A <crossModule> without a rootNamespace is a configuration error and stops the run. A rule that quietly does nothing
is worse than no rule: it reads as a green check, and nothing tells you the boundary went unchecked.
What it accepts
sharedNamespacesentries are exempt in both directions: references into them are always allowed, and classes inside them are not checked. Matching respects namespace boundaries, soApp\Modules\Shareddoes not exemptApp\Modules\SharedFoo.- A call on a
*Facadeor a*FacadeInterfaceis allowed. Consumers type-hint the interface, and that is the same sanctioned crossing. A written reference is allowed only for*Facade, because namingSomeFacadeInterface::classis not a call through one. - A receiver the analyser cannot resolve is not reported. An unknown type is no evidence of a violation, and guessing would turn the rule into noise.
- One line can produce two findings.
(new ShopService())->run()both names the other module and calls into it. Those are two crossings with two corrections, so both are reported.
To see your app's real module dependency graph, run debug:graph.
What a module exports Since 2.4
A module's public surface is wider than its Facade: DTOs, enums, value objects, events and plugin contracts. A Facade that returns an invoice publishes the invoice too, and another module reads it because that is why it was returned. Both cross-module rules leave that surface alone. You declare it in two ways, and both analysers read them the same way.
#[PublicApi]
use Gacela\Framework\Attribute\PublicApi;
#[PublicApi]
final class InvoiceRecord
{
// ...
}It works on classes, interfaces and enums, declared where the class already lives. It is not inherited: publishing
a base class would publish everything that ever extends it, so mark each exported type. Classes written by
dto:generate already carry it.
The namespace convention
A module publishes every class under a list of sub-namespace segment names: by default Shared, Transfer, Dto
and Event. App\Billing\Shared\Invoice and App\Billing\Domain\Dto\Money are both exported with no annotation.
Segments match whole, at any depth, never as prefixes: Event publishes App\Billing\Event\ and leaves
App\Billing\EventHandler\ alone. The convention never publishes a class that sits directly in its module.
Configure it on both cross-module rules:
| PHPStan | Psalm | |
|---|---|---|
| Configured with | publicApiSegments: (a list) |
<publicApiSegment> (one element each) |
| Left out | the default list applies | the default list applies |
| Turned off | an explicit publicApiSegments: [] |
a single empty <publicApiSegment/> |
This differs from sharedNamespaces. A shared namespace is a fully qualified prefix that belongs to no module, exempt
in both directions. A public API segment is a sub-namespace under each module, and a class in it still belongs to the
module that owns it.
Reading the surface back
debug:module Billing prints a Public API section that lists what the module exports,
from the attribute and the convention together, or (none). The --json document carries it under publicApi.
What it does not do
Publishing a class says other modules may touch it without going through the Facade. It does not say two modules
may be coupled at all. The declared rules file answers that, and
#[PublicApi] deliberately does not exempt a class from DeclaredModuleDependencyRule. debug:graph --check enforces
the same rules file from use imports and cannot see an attribute. An exemption in the analyser alone would leave the
editor green and CI red on the same line.
Failing on dependency cycles
debug:graph --check exits non-zero when two modules depend on each other:
vendor/bin/gacela debug:graph --checkA cycle is either a decision somebody made or a mistake nobody noticed. Until someone writes the decision down, you cannot tell them apart. Write it in a JSON file and pass it in:
[
{
"modules": [
"App\\Billing",
"App\\Invoicing"
],
"reason": "reviewed 2026-07: bidirectional by design until the shared kernel lands"
}
]vendor/bin/gacela debug:graph --check --allowed-cycles=allowed-module-cycles.jsonThe allow list is self-invalidating: an entry that no longer matches a real cycle fails the check as loudly as an
undeclared cycle. That is deliberate. An allow list that outlives what it allows stops recording a decision and becomes
a mute button, and nothing would tell you. A reason is required for the same reason: an allowance nobody justified
looks the same as a cycle nobody noticed.
debug:graph without --check stays exit-code-neutral, so adding the gate does not change what the command already
did.
Declaring which modules may depend on which Since 2.2
A cycle is the only thing the graph can refuse on its own. Everything else a team agrees on ("billing must not reach back-office", "reporting reads and nothing more") lives in prose. No tool can see it there, and a violation arrives as one more import in a diff. Write it in a JSON file instead:
{
"rules": [
{
"from": "App\\Payment",
"deny": ["App\\Admin"],
"reason": "reviewed 2026-08: billing must not reach back-office"
},
{
"from": "App\\Reporting",
"allow": ["App\\Shared"],
"reason": "read-only module: the shared kernel and nothing else"
}
]
}denyforbids the listed modules and leaves every other dependency alone.allowis the opposite: the listed modules are the only ones reachable, and anything else is a violation. An emptyallowhas a meaning: a leaf module that may depend on nothing.- One entry cannot carry both, and a rule without a
reasonis refused. - A rule about
App\Paymentalso governsApp\Payment\Refunds. Matching respects namespace boundaries:App\Paynever governsApp\Payment.
Two places read the same file. CI reads it over the whole graph:
vendor/bin/gacela debug:graph --check --rules=module-rules.jsonYour editor reads it per class, through whichever analyser you run:
# phpstan.neon
services:
-
class: Gacela\PHPStan\Rules\DeclaredModuleDependencyRule
tags: [phpstan.rules.rule]
arguments:
rootNamespace: App
rulesFile: %currentWorkingDirectory%/module-rules.json<!-- psalm.xml -->
<pluginClass class="Gacela\Psalm\Plugin">
<moduleRules rootNamespace="App" file="module-rules.json"/>
</pluginClass>One file, two readers, on purpose: nobody trusts a boundary that holds in CI and not in the editor.
The rules are self-invalidating, like the cycle allow list. A from, allow or deny that names a namespace
matching no module fails the check: a rule about a module that no longer exists still reads as a boundary under watch.
A deny that never fires is not an error. That is the rule doing its job.
--rules cannot be combined with a filter argument. In a narrowed graph, a rule about a filtered-out module looks the
same as a rule about a module that no longer exists, and those two must not look alike.
--check --format=json writes the findings as a report instead of lines, for a CI job that wants more than an exit
code:
{
"undeclaredCycles": [],
"staleAllowedCycles": [],
"forbiddenDependencies": [
{"from": "App\\Payment", "to": "App\\Admin", "reason": "reviewed 2026-08: billing must not reach back-office"}
],
"unknownRuleNamespaces": []
}Enforcing the same file from a test method Since 2.4
The rules file has a third reader. Gacela\Console\Testing\ModuleAssertions puts the same graph, cycle detector,
allow list and rule checker behind three PHPUnit assertions. A boundary decision can then live next to the module's own
tests, not only in CI configuration:
self::assertModuleDependsOnlyOn(InvoiceFacade::class, [BillingFacade::class, CustomerFacade::class]);
self::assertNoModuleCycles(__DIR__ . '/allowed-cycles.json');
self::assertModuleRulesHold(__DIR__ . '/module-rules.json');Failures name the offending edge and the use statement behind it, as file:line. See
Testing.
Reviewing graph changes in CI
A new cross-module edge enters a pull request as one more use statement, no more visible than any other import.
--compare-to makes it visible to a reviewer:
# on the base branch
vendor/bin/gacela debug:graph --format=json > base-graph.json
# on the branch under review
vendor/bin/gacela debug:graph --compare-to=base-graph.json > graph-diff.mdThe report is GitHub-flavoured Markdown with a Mermaid block that GitHub renders natively in a comment. It lists new
and removed dependencies and draws only the modules the change touches. When the graph is unchanged, it writes
nothing and exits 0. A CI job can test the file for emptiness and stay quiet on pull requests that did not move
the graph. An unreadable or invalid baseline exits 1: that is a broken setup, not an unchanged graph, and the two must
not look alike.
See also
- Static analysis: analyser setup, the pillar rules, typed accessors, suppression
debug:graph: every flag the graph command accepts