Checking dependencies and module boundaries automatically
Architecture rules that only exist in documents get broken. With PHPUnit, PHP-Arch and the Reflection API, layer boundaries, module dependencies and design rules can be formulated as executable tests, tests that fire immediately when a class calls into the wrong layer or a module introduces a forbidden dependency.
Table of Contents
- 1. Why architecture erodes over time without tests
- 2. What architecture tests do and what they do not do
- 3. PHP-Arch: formulating layer rules declaratively
- 4. Reflection API: inspecting dependencies at runtime
- 5. Layer tests for Clean Architecture and DDD
- 6. Checking module dependencies in Magento automatically
- 7. Detecting and resolving cyclic dependencies
- 8. Checking naming conventions and class location
- 9. Comparing tools for architecture tests
- 10. Summary
- 11. FAQ
1. Why architecture erodes over time without tests
Every PHP project starts with a clear architectural idea: a service layer that knows nothing about infrastructure classes. Modules that only talk to each other through defined interfaces. A domain layer that never imports HTTP requests or database connections. Without automatic verification of these rules, shortcuts creep in step by step: a service class imports a repository implementation directly. A Magento module calls classes from another module without a service contract. A year later, the architecture still exists as a diagram, but it is no longer recognizable in the code.
Architecture tests formalize these rules as executable PHPUnit tests. When a class violates an architecture rule, the test fails in the CI pipeline, exactly like a functional test that catches a wrong calculation. The crucial difference from code reviews is continuity: architecture tests run on every commit, not only when an experienced developer happens to have time. They turn implicit architectural knowledge into something explicit and machine-checkable.
2. What architecture tests do and what they do not do
Architecture tests check structural properties of the code: which classes import which other classes? Do classes live in the correct namespace? Do all classes in a given directory implement the expected interface? Do all service classes follow the *Service naming convention? These questions can be answered statically or at runtime via reflection, without ever executing the code.
What architecture tests do not do: they do not check functional correctness or business logic. A class can satisfy every architecture rule and still calculate the wrong result. Architecture tests are an additional safety layer alongside unit and integration tests, not a replacement for them. The PHPUnit pattern for architecture tests is a separate test suite that runs in the CI pipeline after the unit tests and breaks the build on architecture violations. They typically run slower than unit tests because they inspect many classes, but they do not need to run as often.
3. PHP-Arch: formulating layer rules declaratively
PHP-Arch (via the Composer package phpat/phpat) is the most powerful tool for architecture tests in PHP. It lets you formulate rules in a declarative syntax: classes in namespace A must not import classes in namespace B. The rules are written as regular PHPUnit test classes that extend PHPAr\Test\ArchitectureTest. PHP-Arch reads PHP files statically and checks the imports without executing any code, which makes it very fast even for large codebases.
A concrete example for a Magento project: the domain layer of a module must not import Magento framework classes directly. The ViewModel layer must not import repository implementations, only the interfaces. These rules can be expressed in ten lines of PHP-Arch code and are then checked automatically for every new class. This is the architecture test pattern: define the rules once, never check them manually again.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Architecture;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\Rule;
use PHPat\Test\PHPat;
/**
* Architecture rules for the Catalog module.
* Ensures layer boundaries are respected automatically.
*/
final class CatalogArchitectureTest
{
/**
* Domain classes must not depend on infrastructure or Magento framework.
*/
public function testDomainDoesNotDependOnInfrastructure(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('Mironsoft\Catalog\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('Mironsoft\Catalog\Infrastructure'),
Selector::inNamespace('Magento\Framework\App'),
Selector::inNamespace('Magento\Framework\DB'),
)
->because('Domain layer must be framework-agnostic and infrastructure-free');
}
/**
* ViewModels may only use service contracts, not concrete implementations.
*/
public function testViewModelsUseOnlyContracts(): Rule
{
return PHPAt::rule()
->classes(Selector::inNamespace('Mironsoft\Catalog\ViewModel'))
->shouldNotDependOn()
->classes(Selector::inNamespace('Mironsoft\Catalog\Model\ResourceModel'))
->because('ViewModels must depend on Api contracts, not ResourceModel implementations');
}
/**
* All repository implementations must implement the corresponding Api interface.
*/
public function testRepositoriesImplementContracts(): Rule
{
return PHPAt::rule()
->classes(
Selector::inNamespace('Mironsoft\Catalog\Model'),
Selector::classNameMatches('/Repository$/')
)
->shouldImplement()
->classes(Selector::inNamespace('Mironsoft\Catalog\Api'))
->because('Repositories must always expose a service contract interface');
}
}
4. Reflection API: inspecting dependencies at runtime
Without PHP-Arch, architecture tests can be written directly in PHPUnit using the PHP Reflection API. The Reflection API lets you inspect classes, their interfaces, parent classes, constructor parameters and attributes at runtime. The PHPUnit pattern for self-built architecture tests: an abstract base class provides helper methods such as getClassesInNamespace(), assertClassImplements() and assertNoDirectDependency(). Concrete test classes extend it and formulate domain-specific rules in readable PHPUnit code.
A practical example: every class in the ViewModel namespace should implement ArgumentInterface. You iterate over all PHP files in the directory using glob() or the Symfony Finder, load the classes with require, and check with ReflectionClass::implementsInterface(). This fails the moment someone creates a new ViewModel without the interface. This kind of architecture test is simple to write and needs no external dependency.
5. Layer tests for Clean Architecture and DDD
Clean Architecture defines clear dependency rules: dependencies always point from the outside inward. Infrastructure knows the application layer, the application layer knows the domain, the domain knows nobody but itself. These rules can be violated without the code ever failing to run, the compiler or the PHP interpreter does not check them. Only an architecture test makes such a violation visible and keeps it from flowing into the codebase.
The PHPUnit pattern for layer tests: define a namespace path for each layer and write a test method that verifies no use statements from forbidden namespaces exist. This is simpler than it sounds: a tokenizer or static analysis via token_get_all(file_get_contents($file)) yields every use statement in a file without ever loading the class. If one of the use statements points into a forbidden namespace, the test fails and prints the file name.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Layer boundary tests using PHP tokenizer, no external dependencies.
* Ensures Domain layer remains framework-agnostic.
*/
final class LayerBoundaryTest extends TestCase
{
private const DOMAIN_PATH = __DIR__ . '/../../../Domain';
private const FORBIDDEN_IN_DOMAIN = [
'Magento\\Framework',
'Magento\\Catalog',
'Mironsoft\\Catalog\\Infrastructure',
'Mironsoft\\Catalog\\Model\\ResourceModel',
];
public function testDomainLayerHasNoForbiddenImports(): void
{
$violations = [];
foreach ($this->findPhpFiles(self::DOMAIN_PATH) as $file) {
$tokens = token_get_all(file_get_contents($file));
foreach ($this->extractUseStatements($tokens) as $use) {
foreach (self::FORBIDDEN_IN_DOMAIN as $forbidden) {
if (str_starts_with($use, $forbidden)) {
$violations[] = sprintf(
'%s imports forbidden %s',
basename($file),
$use
);
}
}
}
}
self::assertEmpty(
$violations,
"Domain layer boundary violations:\n" . implode("\n", $violations)
);
}
/** @return string[] */
private function findPhpFiles(string $dir): array
{
return glob($dir . '/**/*.php') ?: [];
}
/** @return string[] */
private function extractUseStatements(array $tokens): array
{
$uses = [];
$inUse = false;
$current = '';
foreach ($tokens as $token) {
if (is_array($token) && $token[0] === T_USE) { $inUse = true; $current = ''; continue; }
if ($inUse && is_array($token) && in_array($token[0], [T_STRING, T_NS_SEPARATOR], true)) {
$current .= $token[1];
} elseif ($inUse && $token === ';') {
$uses[] = $current;
$inUse = false;
}
}
return $uses;
}
}
6. Checking module dependencies in Magento automatically
In Magento, the module.xml file declares a module's dependencies. A common problem: a module's code references classes from other modules without that dependency being declared in module.xml. This causes installation problems that only surface at the customer site. An architecture test reads all use statements in a module's PHP files, extracts the referenced vendor namespaces, and compares them against the dependencies declared in module.xml.
The PHPUnit pattern for Magento module dependencies: a test class reads module.xml with SimpleXML, extracts every <module name="..."> entry, and compares it against the module namespaces actually referenced in the code. Undeclared dependencies are reported as PHPUnit failures. This test runs in the CI pipeline and prevents Magento modules from being deployed that need classes from undeclared modules at runtime.
7. Detecting and resolving cyclic dependencies
Cyclic dependencies between PHP classes or modules are one of the most common architecture violations in grown PHP projects. Module A imports a class from module B, module B imports a class from module A. The code works as long as PHP calls the class loader in the right order, but it makes the system hard to test, because every test instantiation has to load the entire cycle. Refactorings become risky, because a change in one module has unexpected effects on the other.
The PHPUnit pattern for cycle detection: build a directed graph of the dependencies and check for cycles with depth-first search (DFS). In PHPUnit, this can be written as a test that reads all PHP files of a project, builds the dependency graph, and fails with the full path when a cycle is found: Cycle detected: ModuleA → ModuleB → ModuleC → ModuleA. This output shows immediately where the dependency needs to be broken. Interfaces acting as a bridge between modules are the most common fix.
8. Checking naming conventions and class location
Naming conventions and class location are often only known implicitly in PHP projects. Magento defines: repository implementations belong in Model/, interfaces in Api/, ViewModels in ViewModel/. A test that flags every class with the suffix Repository outside of Model/ and Api/ keeps new developers from placing classes in the wrong location. The same applies to the Interface suffix: every interface should live in the Api/ namespace.
The PHPUnit pattern for naming tests: iterate over all PHP files via glob, extract the class name and namespace with the tokenizer, and check the match between file content and file name. In addition, you can check whether the full namespace matches the file path, a common mistake with manually created files. These tests are minimal effort to write, yet they cover an entire class of errors that would otherwise only surface at runtime through the autoloader.
9. Comparing tools for architecture tests
Several approaches with different strengths are available for architecture tests in PHP. Choosing the right tool depends on the project: PHP-Arch for declarative rules with little code, PHPStan rules for static analysis, custom PHPUnit tests for domain-specific checks.
| Tool | Strength | Weakness | Use case |
|---|---|---|---|
| PHP-Arch (phpat) | Declarative, fast, readable | Import dependencies only | Layer rules, namespace isolation |
| PHPUnit + Reflection | Very flexible, domain-specific | More boilerplate code | Interface checks, naming conventions |
| PHPStan custom rules | Deep static analysis | Steep learning curve | Type errors, incorrect calls |
| PHPUnit + tokenizer | No extra dependency | Manual, error-prone | Layer imports, cycle detection |
| Deptrac | Visualization, YAML configuration | No PHPUnit reporting | Large projects, team overview |
For Magento projects, one combination has proven itself: PHP-Arch for namespace isolation rules (which module may call which), custom PHPUnit tests with reflection for Magento-specific conventions (ViewModels implement ArgumentInterface, repositories implement their Api interface), and PHPStan level 8+ for static type safety. These three layers cover different classes of architecture erosion.
Mironsoft
Architecture consulting, PHPUnit strategy and Clean Architecture for PHP and Magento
Architecture rules that are actually enforced automatically?
We implement architecture tests for PHP and Magento projects: layer rules with PHP-Arch, module dependency tests and naming convention checks, all as executable PHPUnit tests in the CI pipeline.
Architecture audit
Analyze existing dependencies, identify layer violations and cycles
Implement tests
Introduce PHP-Arch rules, reflection tests and Magento module checks
CI integration
Set up architecture tests as a separate CI job with clear error messages
10. Summary
Architecture tests with PHPUnit turn implicit architectural knowledge into something explicit and machine-checkable. PHP-Arch formulates layer rules in a declarative syntax with little boilerplate. The Reflection API enables domain-specific checks such as interface verification and naming conventions. The PHP tokenizer allows layer boundary tests without any external dependency. Cycle detection via DFS graph finds circular dependencies before they block refactorings. For Magento projects, checking for undeclared module dependencies is especially valuable.
The most important step is not the tool, it is formalizing the architecture rules. Teams that only communicate their architecture rules verbally or keep them in documents will find they no longer hold after a year. Teams that formulate the same rules as PHPUnit tests have permanently anchored them in their development process, with no extra effort for every code review.
Architecture Tests with PHPUnit, The Essentials at a Glance
PHP-Arch
Declarative layer rules in PHPUnit test classes. Classes in namespace A must not import classes in namespace B. No code execution, very fast.
Reflection API
Check interfaces, class location and naming conventions at runtime. No external dependency needed. Very flexible for domain-specific rules.
Magento specifics
Check for undeclared module dependencies, ViewModels against ArgumentInterface, repositories against Api interface. Compare module.xml against actual imports.
Cycle detection
Check the dependency graph for cycles with DFS. Print the full cycle path on failure. Interfaces as a bridge to break the cycle.