Architecture Tests with PHPUnit: Checking Dependencies and Module Boundaries
AI generated
@test
assert
PHPUnit · Architecture · PHP-Arch · Magento
Architecture Tests with PHPUnit
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.

18 min read PHP-Arch · Reflection · module boundaries · layer tests PHPUnit 10/11 · PHP 8.2+ · Magento 2

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.

11. FAQ: Architecture Tests with PHPUnit

1What are architecture tests?
PHPUnit tests that check structural code properties: namespace dependencies, interfaces, class location. They make implicit architecture rules machine-checkable.
2What is PHP-Arch?
A Composer library for declarative architecture tests. Rules as PHPUnit test methods. Reads PHP files statically without executing code. Very fast even for large codebases.
3Architecture tests without an external library?
Yes. The Reflection API and the PHP tokenizer are enough. ReflectionClass checks interfaces. token_get_all() extracts use statements without loading the class.
4Detecting cyclic dependencies?
Build a dependency graph, run depth-first search for cycles. PHPUnit fails with the full cycle path. Interfaces as the fix to break the cycle.
5Checking Magento module dependencies?
Read module.xml, extract declared dependencies. Check PHP files for referenced namespaces. Report undeclared dependencies as PHPUnit failures.
6Where do architecture tests run in CI?
As a separate CI job after the unit tests. Slower, do not need to run on pre-commit. A PR-bound run is enough.
7Difference between architecture tests and PHPStan?
PHPStan checks type correctness. Architecture tests check layer rules, dependencies, naming. They complement each other, use both in the CI pipeline.
8Formulating layer rules in PHP-Arch?
PHPAt::rule()->classes(Selector::inNamespace('A'))->shouldNotDependOn()->classes(Selector::inNamespace('B')). The return value is a Rule object that PHP-Arch evaluates.
9Which naming conventions are checkable?
All of them. Suffix rules, interface implementation, namespace location. The tokenizer extracts class names, reflection checks interfaces and parent classes.
10Do architecture tests replace code reviews?
For the rules they check, yes. No reviewer needs to manually check naming conventions and dependencies anymore. Frees up reviewer time for content-level aspects.