What is New in the Event Sourcing PHPStan Extension 1.3.0

Daniel Badura
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:

use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
use Patchlevel\EventSourcing\Aggregate\Uuid;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\Id;

final class Profile extends BasicAggregateRoot
{
    #[Id]
    private Uuid $id;
    private string $name;
    private string $email; // reported

    #[Apply]
    protected function applyProfileCreated(ProfileCreated $event): void
    {
        $this->id = $event->id;
        $this->name = $event->name;
    }
}

PHPStan now tells you exactly what is missing:

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:

parameters:
    patchlevelEventSourcing:
        propertyInitialization: false
        unusedProperty: false
        writeOnlyProperty: false
        noRecordThatWhenApplying: false
        noStateWriteWhenNotApplying: false

Conclusion

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.

Written by
Daniel Badura
Daniel Badura
Software Engineer

Other recent posts

RSS