Code reviews are a crucial part of software development, ensuring code quality, maintainability, and adherence to best practices. However, as applications grow in complexity, manual reviews become increasingly time-consuming and prone to oversight. While static analysis and coding standards enforcement help catch common issues, they often fall short in verifying adherence to architectural principles.
This article explores how automated architecture testing can streamline the code review process by enforcing design rules and preventing structural inconsistencies. By leveraging specialized tools, teams can define and enforce architectural constraints, ensuring long-term code sustainability without adding extra burden to reviewers.
We will introduce key architecture testing tools for PHP, PHPat and PestPHP, and compare their capabilities. Through practical examples, we’ll demonstrate how these tools can help maintain clean, modular, and scalable codebases, ultimately making code reviews more efficient and effective.
Code analysis tools
Before going into details about the architecture testing, we believe that it would help to have a look at the broader subject of code analysis tools and try to identify several types of tools, based on their functionality, then position architecture tests alongside the other categories:
- Static analysis tools: they analyze the source code, without executing it, to find potential errors.
- Code standard and style checkers: they ensure that your code adheres to specific coding standards and style guidelines, usually the best practices of the application framework you are using, but also rules defined by the development team.
- Code quality and maintainability tools: these tools look for bad quality code in your application, being able to spot issues such as unnecessary complexity, dead code and code smells, such as classes that are too long, methods with too many lines, classes with too many properties or excessive coupling between classes.
- Application architecture testing tools: the tools that are in our spotlight for this article allow you to define a set of rules regarding the desired application architecture and enforce them on the newly written code in order to ensure that any new code adheres to the principles agreed upon, provided either by the web framework best practices, or by the team’s standard.
We’ll focus next on the application architecture testing tools for PHP applications. Continuously testing the architecture as your application grows in size and features is crucial, especially in case it develops beyond the standardised structure of web application frameworks, often biased towards simple CRUD actions. If we just described your application, the please continue reading.
Testing application architecture
In this chapter we aim at defining what an architecture testing tool can do for you, what tools are there available with their strengths and weaknesses, what type of tests these tools usually allow, and how you can run them as part of the team’s daily routine.
Let’s put aside for now the discussion about the actual tools, and reason a bit about what we would normally expect from such type of tests. When testing architecture, unlike with the static analysis testing, we would expect to be able to test the bigger picture, in other words to focus on the larger code constructs such as namespaces and classes or interfaces, and the way they are declared and related.
The rules we are about to add to our code analysis must allow us to:
- Control the dependency on other classes
- Enforce class inheritance, interface implementation, or trait use
- Check the single responsibility of classes
- Check that a given namespace contains interfaces, abstract classes, traits or enums
- Check that specialized classes use consistent naming
- Check that specialized classes have the requested attributes applied
- Check that some classes are or aren’t leaf classes
- Check that some classes produce immutable objects
Now, the above list is a good starting point for specifying what kind of tests we would write to test the architecture, and we’ll come back to these later, after we introduce the tools, so that we can also offer some real-life examples for each of them.
Let’s continue by defining the architecture tests building blocks: rules, selectors, and assertions or expectations.
Selectors, as their name implies, are used to select a set of namespaces, classes, interfaces, traits or functions. The tools we’ll be looking at later define various ways to select a set of subjects to apply assertions on, but don’t worry, we will look into that when we talk about each of the tools in particular. Technically speaking, selectors are methods you call passing a string that identifies a fully qualified name or a regular expression pattern.
Assertions/expectations are applied onto selected classes and similar constructs. The full set of assertions in such a tool represents what actual checks it allows you to apply on your code. Technically, these assertions are methods that are made available to you to call once you have a result of a call to a selector.
Rules represent the individual tests in our suite of architecture tests. They typically consist of a chain of alternate calls to selector()
and assertion()
either as a unary check: selector("A\B\C")->unaryAssertion()
, or as a binary check: selector("A\B\C")->binaryAssertion()->selector("X\Y\Z")
. Of course, each tool adds its own bells and whistles to help with some niche use cases, but the overall idea is the simple one explained here.
We have selected two architecture testing tools to focus on, namely:
- PHP Architecture Tester (PHPat) which is available as a PHPStan extension
- PestPHP Architecture Testing, which is one of the types of testing that PestPHP offers.
In addition to these being ones of the most mature options out there, they both extend or are part of very popular tools. Therefore, in the majority of cases, adding architecture tests to your project boils down to just adding some extra config to the tools you already use, since PHPStan and PestPHP might already be part of your project tools.
Let’s assume that we have a PHP application that uses Composer for package management, the source files are placed under a src
directory and the tests are under tests
directory, and Composer is configured to autoload classes under these two paths, the namespace prefix App\
for src
and the namespace prefix App\Tests
for tests
. As a matter of fact, we tested the examples in this article with a Symfony demo application, but rest assured that architecture testing is not bound to using any particular framework.
Introducing PHPat
PHPat quick facts:
- Installs as a PHPStan extension
- Easy configuration within the PHPStan configuration file:
phpstan.neon
(phpstan.neon.dist
) - Tests consist of classes defining public methods, each method defining a test rule
- Runs as part of PHPStan analyse command:
vendor/bin/phpstan analyse
In order to install PHPat we first need PHPStan installed:
composer require --dev phpstan/phpstan phpstan/extension-installer
The above will install latest PHPStan package including the extension installer which facilitates automatic discovery of PHPat as an extension later on.
Then we install PHPat:
composer require --dev phpat/phpat
The next step is to add a little configuration to have the PHPat test included in the tool suite of tests (in phpstan.neon.dist
):
services:
- class: App\Tests\Architecture\PHPat\MainTest
tags: [ phpat.test ]
We make sure that PHPStan can discover our sources (in phpstan.neon.dist
):
parameters:
bootstrapFiles:
- vendor/autoload.php
And we make sure that PHPStan scans and analyzes the paths where we put our code (in phpstan.neon.dist
):
parameters:
paths:
- src
- tests
Let’s test our setup by creating the MainTest
class at path tests/Architecture/PHPat/MainTest.php
. To start with, we will add just one test that checks whether all our controller classes contain “Controller” suffix in their name:
<?php declare(strict_types=1); namespace App\Tests\Architecture\PHPat; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule; use PHPat\Test\PHPat; class MainTest { public function test_controllers_consistent_naming(): Rule { return PHPat::rule() ->classes(Selector::inNamespace('App\Controller'))
->shouldBeNamed('/.+Controller$/', true)
->because('Controllers must be named consistently');
}
}
Final step is to run the test and see if passes:
vendor/bin/phpstan analyse
And if everything goes well, we should see a thick green row in our terminal with the message:
[OK] No errors
Note that PHPStan also runs the static analysis on the source files when you execute the above command, which is its core functionality. In case you got a series of static analysis errors, that means that it found issues with your code, based on the configured analysis level, errors unrelated to our architecture test. In order to bypass those errors for now and have them fixed at a later time, you can generate a baseline file which instructs the tool to ignore detected errors and only show new ones on the subsequent runs. Here is how you do that:
vendor/bin/phpstan analyse --generate-baseline
The output will be something like the following (N
is a placeholder here for the actual count):
[OK] Baseline generated with N errors.
Now check your project root for the phpstan-baseline.neon
file, as this was generated and contains ignore instructions for the tool. The last step is to include this file in the main configuration. For that, add to phpstan.neon.dist
the following:
includes:
- phpstan-baseline.neon
Running vendor/bin/phpstan analyse
in command line now will return "No errors"
, so you can keep adding architecture tests without any issues.
That’s it! You have successfully run the first architecture test with PHPat. It required a bit of setup steps, but the green result of your first architecture test is so fulfilling, isn’t it?
Introducing PestPHP architecture tests
PestPHP architecture tests quick facts:
- They are one of the multiple features of PestPHP tool
- If you are already using PestPHP (instead of PHPUnit) to run your unit tests, then using it for architecture testing is given
- Familiar configuration through
phpunit.xml
(phpunit.xml.dist
), as PestPHP aims at replacing PHPUnit for your project, but share its configuration file and core libraries. - Tests consist of PHP files with rules defined through the use of
arch()
function - Runs as part of PestPHP command:
vendor/bin/pest
We are going to install the latest PestPHP version, which at the time of writing this article has 3 as major number. This one gives us the fluent interface for writing architecture tests.
composer require pestphp/pest --dev --with-all-dependencies
Then Composer will ask us if we want to allow and trust the pestphp/pest-plugin
that comes with the required package. This plugin is not necessary for architecture testing, but in real-life scenario you would probably need to support plugins for other purposes.
Next, we need to do an initialization by running:
vendor/bin/pest --init
And the output informs us of some files being created (if they didn’t exist):
phpunit.xml ................................... File created.
tests/Pest.php ................................ File created.
tests/TestCase.php ............................ File created.
tests/Unit/ExampleTest.php .................... File created.
tests/Feature/ExampleTest.php ................. File created.
For the purpose of our usage scenario we will ignore everything but the phpunit.xml
file. We’ll add a new directory tests/Architecture/Pest
and a PHP file MainTest.php
inside it. There we will write our first architecture tests to be run by PestPHP:
<?php
arch('controllers consistent naming')
->expect('App\Controller')->classes()
->toHaveSuffix('Controller');
Just one more step before we can run out test, so that we make running the architecture tests independent of the unit and feature tests. Therefore, we will define a testsuite
in our phpunit.xml
file, that refers to the architecture tests alone:
<testsuites>
<testsuite name="ArchitectureTests">
<directory suffix="Test.php">./tests/Architecture/Pest</directory>
</testsuite>
...
</testsuites>
Finally, we can run the arch test and see some results:
vendor/bin/pest --testsuite="ArchitectureTests"
PASS Tests\Architecture\Pest\MainTest
✓ controllers consistent naming 0.03s
Tests: 1 passed (1 assertions)
Duration: 0.07s
And that’s it! The same simple assertion on the naming of the controllers, now applied through PestPHP. The good things are yet to come, as we will delve into both of the presented tools and compare their features.
Hands-on writing architecture tests
Building class selectors
Before writing assertions we need to select the subjects to apply those assertions (or expectations) upon.
In PHPat you select a set of classes by making use of PHPat::rule()->classes(...)
, where the call to classes()
takes as parameter an object implementing SelectorInterface
. Such objects can be created with static methods of the PHPat\Selector\Selector
class. Here are some examples:
PHPat::rule()->classes(Selector::inNamespace('App\Controller')) // all classes in App\Controller namespace
PHPat::rule()->classes(Selector::classname('App\Controller\UserController')) // the UserController class
PHPat::rule()->classes(Selector::classname(UserController::class)) // the UserController class, given there is a use statement provided
PHPat::rule()->classes(Selector::classname('/.+Controller$/',true)) // all classes ending in 'Controller', using regular expression
PHPat::rule()->classes(Selector::extends('App\Controller\AbstractController')) // all classes that extend the AbstractController
PHPat::rule()->classes(Selector::isInterface()); // all interfaces
PHPat::rule()->classes(
Selector::AnyOf(
Selector::inNamespace('App\Controller'),
Selector::inNamespace('App\Controller\Admin')
)
) // classes that match ANY of the selectors = REUNION
PHPat::rule()->classes(
Selector::AllOf(
Selector::inNamespace('App\EventSubscriber'),
Selector::implements(EventSubscriberInterface::class)
)
) // classes that match ALL of the selectors = INTERSECTION
To conclude, PHPat offers an extensive interface for selecting classes, interfaces or traits by their placement in a certain namespace, by their name pattern, by their modifiers, or what classes or interfaces they have among ancestors. More on these here. Let’s see what PestPHP has to offer.
In PestPHP the selector is a method expect()
that you can call this way: arch()->expect(...)
. Since the interface of this tool aims at being fluid, we can understand why the name of the method seems to not describe what it does, more likely what follows after its call. Without further ado, here are some example selections:
arch()->expect('App\Controller')->... // all things defined in App\Controller namespace
arch()->expect('App\Controller')->classes()->... // all classes defined in App\Controller namespace
arch()->expect('App')->interfaces()->... // all interfaces in App namespace
arch()->expect('App\Controller\UserController')->... // the UserController class
arch()->expect(UserController::class)->... // the UserController class, given there is a use statement provided
arch()->expect('md5')->... // the md5 PHP function
Please note the classes()
and interfaces()
modifiers used in the examples above. They come in handy when you need to narrow down the selection by the type of definition. More on these here.
Applying assertions
Before going in more details about the actual assertions we have at our disposal in each of the two tools let’s talk about how negating an assertion is implemented in both tools. While in PHPat most of the assertions have a negated variant (i.e. for shouldExtend()
there is also a shouldNotExtend()
which checks for selected classes to not extend some base class), for PestPHP there is a not
property that can be added to the chain of calls to negate the expectation that immediately follows:
use Doctrine\ORM\Mapping\Entity;
arch('exclude entity inheritance mistake')
->expect('App\Entity')
->not->toExtend(Entity::class);
arch('forbid use of md5()')
->expect('md5')
->not->toBeUsed();
We have put together earlier a list of requirements for assertions of expectations that we would like our architecture testing tool to have. So let’s get back to that list and see next, in comparison, how we would do some of those checks within our architecture tests.
Control the dependency on other classes
Checking for bad coupling is probably the most important aspect that supports the idea of writing architecture tests. Adding coupling between classes where it shouldn’t be defeats the purpose of structuring the application as modules, as layers, using namespaces to separate unrelated classes but also to group similar ones or the ones used together.
For this purpose our tools provide us with the following sets of assertions:
- PHPat:
shouldNotDependOn()
,shouldNotConstruct()
,canOnlyDependOn()
- PestPHP:
toUse()
,toUseNothing()
toBeUsed()
,toBeUsedIn()
,toOnlyUse()
,toOnlyBeUsedIn()
PHPat example:
#[TestRule]
public function no_entities_dependence_on_controllers(): TargetExcludeOrBuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Entity'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Controller'));
}
PestPHP example:
arch('no entities dependence on controllers')
->expect('App\Entity')
->not->toUse('App\Controller');
Enforce class inheritance, interface implementation, or trait use
The second most important type of assertions you’ll want in your architecture tests is enforcing that some interfaces get implemented by services that share some behavior, or that some abstract base classes are extended for creating some plugin-able services, or even that a given trait is used whenever certain interface is implemented, which is a common practice at the moment.
For all these needs our tools provide us with the following sets of assertions:
- PHPat:
shouldExtend()
,shouldNotExtend()
,shouldImplement()
,shouldNotImplement()
,shouldInclude()
,shouldNotInclude()
- PestPHP:
toExtend()
,toExtendNothing()
,toImplement()
,toImplementNothing()
,toUseTrait()
,toUseTraits()
PHPat example:
#[TestRule]
public function controllers_extend_abstract_controller(): TargetExcludeOrBuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Controller'))
->shouldExtend()
->classes(Selector::classname(AbstractController::class));
}
PestPHP example:
arch('controllers extend abstract controller')
->expect('App\Controller')->classes()
->toExtend(AbstractController::class);
Check the single responsibility of classes
Single Responsibility Principle represents the first letter in the SOLID acronym, being a pillar of good class design. If you feel that in some parts of the application this principle needs to be enforced, then you may write an architecture test to check that:
- PHPat:
shouldHaveOnlyOnePublicMethod()
ignoring the constructor - PestPHP:
toHavePublicMethodsBesides()
, but there is an extensive set of similar assertions available:toHaveProtectedMethodsBesides()
,toHavePrivateMethodsBesides()
,toHavePublicMethods()
,toHaveProtectedMethods()
,toHavePrivateMethods()
,toHaveMethod()
,toHaveMethods()
that allow for fine-grained testing of the visibility methods in a class may have.
PHPat single responsibility test example:
public function test_single_responsibility(): TipOrBuildStep
{
return PHPat::rule()
->classes(Selector::classname(SlugGenerator::class))
->shouldHaveOnlyOnePublicMethod();
}
PestPHP single responsibility test example:
arch('single responsibility')
->expect(SlugGenerator::class)
->not->toHavePublicMethodsBesides('generateSlug');
Let’s go a bit further and think of a test for checking proper use of template method pattern:
arch('proper template method')
->expect(SimpleSlugGenerator::class)
->toExtend(AbstractSlugGenerator::class) // which declares protected abstract replaceWhitespace()
->not->toHavePublicMethods() // of its own
->toHaveMethod('replaceWhitespace')
->not->toHaveProtectedMethodsBesides('replaceWhitespace');
Note that PestPHP allows you to define in great detail rules about a class’ methods visibility. Our last example shown how an intended template method pattern can be enforced on the classes implementing the method. If you need this degree of control is up to you, but bear in mind that PestPHP gives you that control.
Check that a given namespace contains interfaces, abstract classes, traits or enums
Another set of assertions that will prove themselves useful when enforcing a certain discipline around “what goes where” are those that allow you to test that a selection of constructs, perhaps selected by their namespace, all represent only interfaces, abstract classes, traits or enums.
Here is what we have at hand for this matter:
- PHPat:
shouldBeInterface()
,shouldBeAbstract()
,shouldNotBeAbstract()
- PestPHP:
toBeInterfaces()
,toBeClasses()
,toBeAbstract()
,toBeTraits()
,toBeEnums()
,toBeIntBackedEnums()
,toBeStringBackedEnums()
Note that, yet again, PestPHP offers a more diverse set of assertions for this kind of tests. However, PHPat compensates through the specialized selectors that allow you to only select the interfaces, abstract classes, enums, traits and then apply assertions to them.
Here is how you test that Weekdays
is an enum in PestPHP:
arch('weekdays is enum')
->expect(Weekdays::class)
->toBeEnum();
And here is the rather complicated solution in PHPat, making use of proper selectors:
public function test_weekdays_is_enum(): TipOrBuildStep
{
return PHPat::rule()
->classes(
Selector::AllOf(
Selector::classname(Weekdays::class),
Selector::NOT(Selector::isEnum())
)
)->shouldNotExist();
}
To conclude, if you do extensive use of enums and traits in your applications and want to add rules about their usage, then maybe PestPHP is for you.
Check that specialized classes use consistent naming
This is the feature we’ve already exemplified when running our first arch test using the analysed tools.
In terms of assertions we have:
- PHPat:
shouldBeNamed()
- PestPHP:
toHavePrefix()
,toHaveSuffix()
,
Check that specialized classes have the requested attributes applied
- PHPat:
shouldApplyAttribute()
- PestPHP:
toHaveAttribute()
Check that some classes are/aren’t leaf classes
- PHPat:
shouldBeFinal()
,shouldNotBeFinal()
- PestPHP:
toBeFinal()
Check that some classes produce immutable objects
- PHPat:
shouldBeReadonly()
,shouldNotBeReadonly()
- PestPHP:
toBeReadonly()
Assertions that are unique to either of the tools
PHPat:
shouldNotExist()
useful if you, as a tech lead for example, want to prevent your team for going in a direction where some classes or interfaces or such are added in a certain place in your application.
PestPHP:
toBeInvokable()
useful if your team, or the framework, is making use of invokable classes for implementing the single responsibility, and you want to enforce that on a set of selected classes.toHaveMethodsDocumented()
andtoHavePropertiesDocumented()
useful if there is a policy of documenting code in your team, and it needs to be enforced.toHaveConstructor()
andtoHaveDestructor()
may be useful for enforcing a clear way resources are allocated and released in some scenarios involving IOtoUseStrictTypes()
,toUseStrictEquality()
,toHaveFileSystemPermissions()
, andtoHaveLineCountLessThan()
seem to not be really suited for architecture tests, but still useful.
Conclusion
The two tools we’ve analyzed are viable for real-world usage, both with their strengths and weaknesses:
- while PHPat is somewhat more diverse in the selectors area, PestPHP offers a broader set of expectations or assertions, plus it can select functions;
- while PestPHP architecture tests are close coupled to running the unit tests suite and the tool conflicts with PHPUnit, PHPat extends a static analysis tool (PHPStan), and it makes sense to be that way, since it tests your code without execution;
- while PHPat requires you to write tests as methods in test classes, PestPHP doesn’t stay in your way, and offers you that nice fluid interface to quickly write new rules.
Both tools would benefit from a better documentation around selectors and assertions. There is just a handful of examples in the docs and the assertions are simply listed, with no parameter descriptions. You’ll need to rely on your IDE’s autocompletion and experiment in order to learn how to use them. The fact that the documentation available for these tools is rather thin denotes a bit of lack of attention from their creators, thus architecture testing may not yet be regarded as necessary enough in real-world PHP projects, unlike other types of tests that development teams usually employ. Hence, this article explored architecture testing with the purpose of hopefully convincing you, the reader, that it is worth your attention.