Skip to content

PHPArkitect: writing architecture rules as code

Published on

Architecture usually lives in a wiki: controllers render, services calculate, legacy code isn't touched anymore. Hardly anyone reads it. And once several people are generating code at the same time, everyone ends up following only what a machine checks.

PHPArkitect checks such rules statically. It reads the code without running it and fails the pipeline when a class is in the wrong place, has the wrong name, or uses something it shouldn't.

Everything here comes from a project where it runs: PHPArkitect 1.3.1 on PHP 8.4, a legacy codebase of just over 80 classes without a namespace and about 260 classes in clean PSR-4.

A PHPArkitect rule: classes named *Exception must live in the Exception namespace

How a rule is built

After a few rules you notice they all follow the same pattern: classes that are X should be Y.

that() selects the classes, should() states the requirement. andThat() and andShould() each add another condition, and because() supplies the reason for the error message:

PHP
Rule::allClasses()
    ->that(new MatchOneOfTheseNames(['*Exception']))
    ->should(new ResideInOneOfTheseNamespaces('App\*\Exception'))
    ->because('exceptions live together in the Exception folder of their module');

The rule says: every class whose name ends in Exception has to live in a namespace like App\Billing\Exception. If an InvoiceFailedException sits in App\Billing\Service instead, PHPArkitect reports a violation.

From rule to rule, really only the property changes. The bundled building blocks roughly fall into these groups:

What it's about Examples
Location ResideInOneOfTheseNamespaces, NotResideInTheseNamespaces
Name HaveNameMatching, MatchOneOfTheseNames
Dependencies NotDependsOnTheseNamespaces, NotHaveDependencyOutsideNamespace
Kind of class IsFinal, IsReadonly, IsEnum, IsInterface
Inheritance Extend, Implement, IsA

Almost everything is a combination of these. "Controllers may only live in Http\" is name plus location, "models must not know services" is location plus dependency.

The configuration

The rules live in phparkitect.php in the project root. The file returns a function that assigns its rules to each set of classes:

PHP
return static function (Config $config): void {
    $config->add(
        ClassSet::fromDir(__DIR__ . '/src'),
        $exceptionRule,
        $controllerRule,
    );
};

You run it with vendor/bin/phparkitect check. If it finds nothing, it exits with 0. Otherwise it lists every class with its violation and exits with 1, which any pipeline can work with.

From three rules on, a class per rule pays off. The configuration is then just a list, and in review you see right away whether someone changes an existing rule or adds a new one.

The baseline: freezing the legacy code

No rule fits a grown project without a hundred violations showing up at once. That's what the baseline is for:

Bash
vendor/bin/phparkitect generate-baseline   # writes phparkitect-baseline.json
vendor/bin/phparkitect check               # ignores everything listed there
vendor/bin/phparkitect prune-baseline      # removes what has been fixed since

After that, the pipeline only fails on new violations. Whoever cleans up an old class runs prune-baseline, and the file gets shorter.

There are two things to know before you rely on it.

Entries are matched by class name and message text. If you change the text of a rule, and even the because() counts, every old entry becomes invalid and you have to regenerate the baseline.

generate-baseline takes in everything without asking. One call, and every new violation ends up in the file. prune-baseline, on the other hand, only removes and never adds. Checking the line count in the pipeline doesn't help much, because one fixed and one new violation cancel each other out. Only an entry-by-entry comparison with the target branch's baseline is reliable.

The size of the baseline also tells you whether a rule is any good. If it produces 10,000 entries, it describes a wish more than the project, and in daily work it gets worked around more often than followed.

When the building blocks aren't enough: a custom expression

The legacy code in the project sits without a namespace in a folder legacy/: Session, Db, Mailer. New code shouldn't use any of it. NotDependsOnTheseNamespaces can't express that, because without a namespace there's no pattern that matches exactly these classes.

A custom expression is a class with two methods. describe() supplies the text, evaluate() reports the violations:

PHP
final readonly class NotDependOnLegacyClasses implements Expression
{
    public function __construct(private string $legacyDir) {}

    public function describe(ClassDescription $theClass, string $because): Description
    {
        return new Description('must not use classes from legacy/', $because);
    }

    public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void
    {
        $seen = [];
        foreach ($theClass->getDependencies() as $dependency) {
            $fqcn = $dependency->getFQCN()->toString();
            if (in_array($fqcn, $seen, true)
                || !is_file($this->legacyDir . '/' . str_replace('\\', '/', $fqcn) . '.php')
            ) {
                continue;
            }
            $seen[] = $fqcn;
            $violations->add(Violation::create(
                $theClass->getFQCN(),
                ViolationMessage::selfExplanatory(new Description("must not use {$fqcn}", $because)),
                $theClass->getFilePath(),
            ));
        }
    }
}

is_file() does the actual work. The expression doesn't look at the namespace at all. It takes the same route as the autoloader and checks whether there's a file for the class in legacy/.

getDependencies() returns every occurrence separately. Without $seen, a class that uses Db in ten places would show up ten times in the baseline.

The name of the class being used is in the message on purpose. The baseline then says "class A uses Db" rather than just "class A violates". If A later adds Mailer, that's a new entry, and the pipeline goes red.

What can't be replaced goes on a list

The first version forbade everything from legacy/. That gave 87 entries, and every new class that needed the session would have gone red. There was no replacement for the session, though, so nobody could follow the rule.

So the expression got a list of allowed classes. It only holds what has no replacement yet. The list may only get shorter, and every change to it shows up in review. The old database class stayed forbidden, because a query builder already existed next to it. The baseline shrank to 41 entries, and every one of them can actually be fixed.

Where PHPArkitect trips you up

IsA loads every class

The obvious selection "all classes that are Throwable" can only be answered by PHP itself. But PHPArkitect only knows the Composer autoloader. As soon as a checked class inherits from a legacy class, the whole check aborts:

❌ Class "LegacyView" not found

You don't get a violation, the check simply crashes. Selecting by name avoids it: MatchOneOfTheseNames(['*Exception']) only needs the source text.

* spans several namespace levels

The pattern is compared with fnmatch() without path mode. So App\*\Exception also matches App\Billing\Provider\Stripe\Exception\…. Most of the time that's fine, you just need to know it. Case does matter, though: App\*\exception doesn't match a folder called Exception.

It doesn't see what's only known at runtime

If you get the session via $_SESSION['current'], PHPArkitect sees no dependency on Session. The same goes for class names in strings (new $class). A @var Session in a docblock does count, as do @param, @return and @throws. Anything dynamic is PHPStan's job.

An expression doesn't see where the file is

getFilePath() returns the path relative to the directory of the ClassSet. If a rule should only apply to one folder, you select that folder via ClassSet::fromDir() and not in the expression.

Sanity check

You only find out whether a rule works at all once it goes red. So every new rule gets a throwaway class once that violates it:

PHP
namespace App\Billing\Service;

final class InvoiceFailedException extends \RuntimeException {}

The class sits next to the services instead of in the Exception folder. If the check goes red and the message is understandable, the rule is right. Then the file goes away again.

Product, company and trademark names mentioned here belong to their respective owners and are used descriptively only. No affiliation with them, and no endorsement by them, is implied.