What is New in the Event Sourcing PHPStan Extension 1.3.0
Daniel Badura
· 5 min read
PHPEventSourcingReleasePHPStan
Version 1.3.0
of patchlevel/event-sourcing-phpstan-extension is here. The
extension teaches PHPStan how event sourced aggregates work, so static analysis stays
accurate on PHP code that fills its state from events instead of a constructor.
So far it did that with two rules: no false "uninitialized property" warnings on an
aggregate, and an error when you record an event from inside an apply
method. This release adds three new rules and lets you switch any of them off. All three new checks protect the
same contract, the one that makes an aggregate an aggregate: its state exists only to enforce invariants, and
that state may only come from events. Break that contract and the bug usually shows up much later, at load
time or on a replay. These rules catch them way before they will be a problem for production.
Properties that no event ever fills
Because aggregate state changes only in apply methods, a property that no apply method writes can never hold a
value. Every read of it is a runtime error waiting to happen, usually the day you finally exercise that code
path. The new unusedProperty rule reports such a property as dead weight:
Property "email" of aggregate "Profile" is never written in an #[Apply] method
and is therefore unused.
Most of the time this means you added a property and forgot to wire up the apply method that fills it. The rule
deliberately does not try to prove when a property becomes initialized, that depends on your aggregate's
lifecycle, which is domain knowledge static analysis cannot have. It only checks that some apply method could
populate it at all. Properties with a default value and static properties are skipped, since they do not need an
event to hold a value.
State that nothing ever reads
The mirror image is just as telling. If an apply method stores a value but nothing on the aggregate ever reads
it, that value is not part of any decision. And an aggregate keeps state for exactly one reason: to decide
whether the next command is allowed. State that is never read to check an invariant does not belong on the write
side at all, it belongs in a projection. The new writeOnlyProperty
rule points it out:
final class Profile extends BasicAggregateRoot{ #[Id] private Uuid $id; private string $name; private string $lastName; // reported #[Apply] protected function applyProfileCreated(ProfileCreated $event): void { $this->id = $event->id; $this->name = $event->name; $this->lastName = $event->name; } public function name(): string { return $this->name; }}
Property "lastName" of aggregate "Profile" is written in an #[Apply] method
but never read, so it is not used to check any invariants.
Any read counts here: an invariant check in a command method, a read inside another apply method, or a plain
getter. Only private properties are analysed, and the properties the library reads itself, #[Id] and
#[ChildAggregate], are left alone.
State that changes outside an apply method
The state of an aggregate must only change inside apply methods. That is what makes it reproducible: every
change is the replayable result of an event. Assign a property anywhere else, say directly in a command method,
and that change is not backed by an event, so it is silently lost the next time the aggregate is loaded from the
store. The new noStateWriteWhenNotApplying rule flags every such write:
final class Profile extends BasicAggregateRoot{ #[Id] private Uuid $id; private string $name; public static function create(Uuid $id, string $name): self { $self = new self(); $self->recordThat(new ProfileCreated($id, $name)); $self->name = $name; // reported return $self; } #[Apply] protected function applyProfileCreated(ProfileCreated $event): void { $this->id = $event->id; $this->name = $event->name; // allowed }}
Aggregate state property "name" should only be written in an #[Apply] method,
but is written in "Profile::create()".
This is the natural counterpart to the older "recording in apply methods" check. Together the two rules pin down
the whole flow: command methods record events, apply methods change state, and neither steps into the other's
lane. The rule covers every way a property can be mutated, plain and compound assignments, increments, array
writes like $this->items[] = ..., list destructuring, unset() and static properties, and it follows writes
into private helper methods so you cannot hide one behind another method.
Turn any rule off
Every rule is enabled the moment you include the extension, but they are no longer all-or-nothing. Each
rule now has its own switch under a patchlevelEventSourcing section in your phpstan.neon, the same way you
would toggle any other PHPStan rule:
The three new rules in 1.3.0 all guard the same idea from different angles: aggregate state comes from events,
and only state you actually read to protect an invariant belongs there. That is exactly the kind of rule that is
easy to state and easy to violate in a hurry, which makes it a good fit for static analysis. The
getting started guide
walks through each rule with the Profile aggregate as a running example.
Questions or ideas? Open an issue or start a
discussion on GitHub.