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.
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:
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:
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:
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:
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:
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.
