In September 2017 Lukas Taake published the first part of a write-up of his master's thesis about static code analysis for Domain-Driven Design. It was built as a SonarQube plugin for Java, and its thesis is the most honest sentence in the genre: static analysis cannot show that DDD is done correctly; it can help keep some basic principles intact over time.
I wanted to know what that looks like in PHP today, nine years later. Two things have changed: the language now enforces a lot by itself (readonly, enums, asymmetric visibility, attributes), and we have mutation testing, which gives us a way to ask the question the 2017 post could not: if someone breaks a rule, would your checks notice?
Everything below was run, not described. The demo domain is small (13 files), the mutants are hand-written, and I list the limits at the end.
The eight rules, then and now
Taake's rules, as I read them in his post:
- Every entity type has an identity.
- Value objects are immutable.
- No anemic domain model (entities that are only getters and setters).
- Aggregates are trees and link to other aggregates only by identity.
- An aggregate has a maximum size.
- Domain services exist only where behaviour cannot sit in an entity.
- Repository implementations live outside the domain model.
- The domain model depends on nothing else in the application.
The 2017 approach discovers the "model parts" by class hierarchy, name patterns or annotations, and the author admits the weak spot: you must tell the tool what is an entity and what is a value object, and the analysis is only as good as that configuration. In PHP 8 an attribute is the natural answer, because it is part of the class and the compiler sees it:
namespace App\Sales\Domain; #[\Attribute(\Attribute::TARGET_CLASS)] final class AggregateRoot {} #[\Attribute(\Attribute::TARGET_CLASS)] final class Entity {} #[\Attribute(\Attribute::TARGET_CLASS)] final class ValueObject {} #[ValueObject] final readonly class Money { public function __construct(public int $amount, public string $currency) {} public function add(self $o): self { return new self($this->amount + $o->amount, $this->currency); } } #[AggregateRoot] final class Customer { public function __construct(private CustomerId $id, private string $name) {} public function id(): CustomerId { return $this->id; } public function rename(string $name): void { if ($name === '') { throw new \DomainException('empty name'); } $this->name = $name; } } The demo domain has two aggregate roots (Customer with Address value objects, Order with OrderLine entities), value objects for money, ids and the order number, an OrderRepository interface in the domain and an implementation in Infrastructure. Customer and order refer to each other by id (CustomerId, OrderId), as rule 4 asks.
Versions used: PHP 8.4.16, PHPStan 2.3.5, phpat 0.12.5, Deptrac 4.7.2, PHP Arkitect 1.3.1.
Rule 2 first, because PHP does half of it
final readonly class Money makes the compiler and the engine refuse most of what rule 2 was written for. Writing to a readonly property from outside, or from a setter, throws:
Error: Cannot modify readonly property Money::$amount Two caveats, both worth knowing:
- This is a runtime error. The code that does it is still valid PHP; the engine only complains when it runs. A static check is still useful (PHPStan reports an assignment to a readonly property outside the constructor on its own, with no custom rule).
- Asymmetric visibility (
public private(set) int $amount, PHP 8.4) is not immutability. The class itself may still change the value:Cannot modify private(set) propertyappears only for a write from outside.
And one that no keyword fixes: readonly is shallow. A readonly value object holding an ArrayObject is still mutable through it. More on that below.
Rules 1 and 4: where only a custom rule helps
Layer tools know about namespaces; they know nothing about aggregates or identity. For rule 4 (an aggregate root must not hold another aggregate root), a PHPStan rule is about twenty lines:
/** @implements Rule<InClassNode> */ final class AggregateReferencesByIdRule implements Rule { public function __construct(private ReflectionProvider $rp) {} public function getNodeType(): string { return InClassNode::class; } public function processNode(Node $node, Scope $scope): array { $class = $node->getClassReflection(); if (!Marker::has($class, 'AggregateRoot')) { return []; } $errors = []; foreach ($class->getNativeReflection()->getProperties() as $p) { $type = $class->getProperty($p->getName(), $scope)->getReadableType(); foreach ($type->getReferencedClasses() as $name) { if ($name !== $class->getName() && $this->rp->hasClass($name) && Marker::has($this->rp->getClass($name), 'AggregateRoot')) { $errors[] = RuleErrorBuilder::message(sprintf( 'Aggregate root %s holds another aggregate root %s in $%s; reference it by id.', $class->getName(), $name, $p->getName())) ->identifier('ddd.aggregateByObject')->build(); } } } return $errors; } } Marker::has() is a three-line helper that looks for the attribute on a class. Rule 1 (an entity has a public id() method) is the same shape: if a class is marked #[Entity] or #[AggregateRoot] and has no public id(), report it. The rules are registered in phpstan.neon like any other:
parameters: level: 8 paths: [src] services: - { class: Rules\AggregateReferencesByIdRule, tags: [phpstan.rules.rule] } - { class: Rules\EntityHasIdentityRule, tags: [phpstan.rules.rule] } - { class: Rules\ValueObjectIsImmutableRule, tags: [phpstan.rules.rule] } Rules 7 and 8: layers, with two tools that disagree
Deptrac states the dependency direction as data (use the deptrac/deptrac package; the old qossmic/deptrac is flagged as abandoned by Composer):
deptrac: paths: [./src] layers: - name: Domain collectors: [{ type: classLike, value: '^App\\Sales\\Domain\\.*' }] - name: Application collectors: [{ type: classLike, value: '^App\\Sales\\Application\\.*' }] - name: Infrastructure collectors: [{ type: classLike, value: '^App\\Sales\\Infrastructure\\.*' }] - name: Doctrine collectors: [{ type: classLike, value: '^Doctrine\\.*' }] ruleset: Domain: ~ Application: [Domain] Infrastructure: [Domain, Application, Doctrine] phpat expresses architecture rules as PHP, as a PHPStan extension:
public function testDomainDependsOnNothing(): PHPatRule { return PHPat::rule() ->classes(Selector::inNamespace('App\Sales\Domain')) ->canOnlyDependOn() ->classes( Selector::inNamespace('App\Sales\Domain'), Selector::classname('/^[^\\\\]+$/', true), // PHP built-ins in the global namespace ) ->because('the domain depends on nothing else'); } public function testRepositoryImplementationIsNotInDomain(): PHPatRule { return PHPat::rule() ->classes(Selector::inNamespace('App\Sales\Domain')) ->shouldNotImplement() ->classes(Selector::classname(OrderRepository::class)) ->because('repository implementations live outside the domain model'); } (The regular expression lets DomainException and other global classes through, otherwise phpat flags them as outside dependencies.) I ran PHP Arkitect on the dependency and immutability rules too; it caught the same mutants as the other two there.
Rules 3 and 5: a number you choose
I did not find a standard tool for "anemic entity" or "aggregate too big", so the demo has a thirty-line script. It counts, per #[Entity] or #[AggregateRoot] class, behaviour methods against getters and setters, and the members (entities and value objects) reachable inside an aggregate:
anemia App\Sales\Domain\Customer behaviour=3 accessors=3 share=0.50 ok anemia App\Sales\Domain\Order behaviour=2 accessors=3 share=0.40 ok size App\Sales\Domain\Order members=3 (max 5) ok [Money,OrderLine,OrderNumber] Both thresholds are arbitrary and that is the point of rules 3 and 5: they are project decisions, written down and enforced, not facts about DDD.
The question the rules cannot answer about themselves
All of the above is green on the clean domain. That says nothing about whether the rules would go red on a violation. This is exactly the question mutation testing answers for code: change the code a little, run the tests, and count the changes nobody noticed. The idea carries over to a ruleset. A mutant here is a violation of one rule, applied to a copy of the domain as a textual patch, and the check is whether any tool exits with an error.
The runner is short: copy the clean src/, apply one patch, run every tool, record "caught" when its exit code is not zero, restore, repeat. A mutant looks like this:
'M3 Customer holds an Order object' => function () use ($D) { edit($D . 'Customer.php', 'public function id(): CustomerId', 'private ?Order $lastOrder = null;' . "\n " . 'public function lastOrder(): ?Order { return $this->lastOrder; }' . "\n " . 'public function id(): CustomerId'); }, Twelve mutants, seven checks (php -l, a runtime probe that instantiates the value object and tries to change it, PHPStan with the three custom rules, Deptrac, phpat, Arkitect, the metrics script):
| Mutant | probe | PHPStan + rules | Deptrac | phpat | Arkitect | metrics |
|---|---|---|---|---|---|---|
| M1 setter on a (non-readonly) VO | caught | caught | caught | caught | ||
| M1b setter on a readonly VO | the language | caught | ||||
| M2 public mutable VO property | caught | caught | caught | caught | ||
| M3 Customer holds an Order object | caught | |||||
| M4 domain imports Doctrine | caught | caught | caught | |||
| M5 entity without identity | caught | |||||
| M6 repository implementation in the domain | caught | |||||
| M7 anemic entity | caught | |||||
| M8 aggregate over the size limit | caught | |||||
M9 an equivalent rewrite (+=) | ||||||
| M10 readonly VO holding a mutable object | ||||||
| M11 an invariant dropped |
php -l caught nothing, because every mutant is valid PHP.
What the table says
- Different tools see different mutants. M3 and M5 are caught only by my two custom PHPStan rules; the layer tools know nothing about aggregates or identity. M7 and M8 are caught only by the metrics script. M6, a repository implementation put into the domain namespace, is caught only by phpat: Deptrac sees a domain class implementing a domain interface, which is an allowed dependency. I did not write an Arkitect rule for it, and it can probably do the same. No single tool has the whole picture, which is an argument for a small set instead of a big one.
- The language is the cheapest rule. M1b is stopped by PHP at runtime and by PHPStan statically without any custom rule;
final readonly classplus an attribute is enough for most of rule 2. - Three mutants slip through everything, and each has a different reason:
- M9 is equivalent: a rewrite that changes nothing observable. Correctly caught by nothing. This is the same class as in ordinary mutation testing.
- M10 is a real blind spot:
readonlyis shallow, and a value object that holds a mutable object is still mutable. A rule could look at property types, but then you have to decide what counts as mutable. - M11 drops an invariant (a validation in the constructor). That is behaviour, and static rules cannot see it. Only unit tests, or mutation testing of the domain's own tests, will.
That last point is where the two kinds of mutation meet: architecture rules protect the shape of the model, and classic mutation testing (Infection in PHP) protects the behaviour inside it. A rule set with a high detection rate and a domain test suite with a high mutation score cover different failures. I did not run Infection on this toy domain; with 13 files it would prove nothing.
Limits, honestly
- 13 files and 12 hand-written mutants: this is a demonstration of a method, not a benchmark of the tools. Do not read the table as "phpat beats Deptrac".
- The thresholds (an anemia share, five members per aggregate) are arbitrary.
- The mutants are textual patches. Real violations are subtler, and generating them automatically, the way mutation tools do for code, is the interesting next step.
- Everything ran on one machine (Windows, PHP 8.4). The runner (
php bin/mutants.php, about three minutes) restores the sources afterwards. - The 2017 post's own limitation stands: no tool tells you that your aggregate boundaries are right. These checks keep a model from drifting; they do not design it.
References
- Lukas Taake, "Static code analysis for Domain-Driven Design, part 1", 2017: https://www.ltaake.com/blog/2017/09/static-code-analysis-for-domain-driven-design-part-1/
- Eric Evans, Domain-Driven Design (2003); Vaughn Vernon, Implementing Domain-Driven Design (2013).
- Yue Jia and Mark Harman, "An Analysis and Survey of the Development of Mutation Testing", IEEE Transactions on Software Engineering, 2011.
- Tools: PHPStan, Deptrac, phpat, PHP Arkitect, Infection.
I maintain a PostgreSQL search library, Fuzzphony, and the habit of asking "would a test notice if this were wrong?" came from there; I wrote about what a 95% mutation score still misses in the previous post. If you run something like this on a real codebase, I would like to hear which mutants your rules missed.