This guide shows you how to enable the extension and what each of its checks does.
We use a small Profile aggregate as the running example throughout the documentation.
Install the extension as a dev dependency:
composer require --dev patchlevel/event-sourcing-phpstan-extensionIf you use phpstan/extension-installer, the extension is enabled automatically and you can skip this step. Otherwise include the shipped configuration in your phpstan.neon:
includes:
- vendor/patchlevel/event-sourcing-phpstan-extension/extension.neonThe extension registers all of its checks at once. They are enabled by default and single rules can be turned off, see configuration.
Here is a typical aggregate from the event-sourcing library. Its state lives in typed properties that are assigned inside apply methods, not in a constructor.
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;
public static function create(Uuid $id, string $name): self
{
$self = new self();
$self->recordThat(new ProfileCreated($id, $name));
return $self;
}
#[Apply]
protected function applyProfileCreated(ProfileCreated $event): void
{
$this->id = $event->id;
$this->name = $event->name;
}
public function name(): string
{
return $this->name;
}
}PHPStan with checkUninitializedProperties: true reports typed properties that are never assigned
in the constructor. For an aggregate that is a false positive, because the properties are filled when
the events are applied. The extension knows that Profile is an aggregate and marks $id and $name
as initialized, so the analysis passes.
This works for both aggregate roots and child aggregates: any class implementing AggregateRoot or ChildAggregate has its properties treated as initialized.
Because aggregate state only changes in apply methods, a property that no apply method writes can never receive a value. Such a property is dead weight: every read of it will fail at runtime. The extension reports it as unused:
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;
}
}Running PHPStan now produces:
Property "email" of aggregate "Profile" is never written in an #[Apply] method
and is therefore unused.
💡 Change the state in an #[Apply] method or remove the property.
The rule does not try to prove when a property becomes initialized. That depends on the lifecycle of your aggregate, which is domain specific knowledge static analysis cannot have. It only checks that some apply method can populate the property at all. Properties with a default value and static properties are skipped, they do not depend on an event to have one.
The mirror image of an unused property: state that apply methods populate but that nothing ever reads. An aggregate holds state for exactly one purpose, deciding whether a command is allowed, so a property that is written but never read is not part of any decision. The extension reports it:
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 $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;
}
}Running PHPStan now produces:
Property "lastName" of aggregate "Profile" is written in an #[Apply] method
but never read, so it is not used to check any invariants.
💡 Use the property to check invariants or remove it. State that only exists
for reading belongs in a projection.
Any read counts: an invariant check in a command method, a read inside an apply method, or a getter.
Only private properties are checked, and properties the library itself reads, #[Id] and
#[ChildAggregate], are skipped.
Apply methods are also called while an aggregate is rebuilt from its stored events. If you record a new event from inside an apply method, that event is recorded again on every replay. The extension flags this:
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;
#[Apply]
protected function applyProfileCreated(ProfileCreated $event): void
{
$this->id = $event->id;
$this->name = $event->name;
$this->recordThat(new ProfileCreated($event->id, $event->name)); // reported
}
}Running PHPStan now produces:
Method Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot::recordThat() records
an event and is called from apply method applyProfileCreated().
The check also follows calls into helper methods, so hiding recordThat() behind another method does not bypass the rule.
The state of an aggregate must only change inside apply methods. That is what makes the state reproducible: every change is the result of an applied event. A property that is written anywhere else, for example directly in a command method, is not backed by an event, so the change is silently lost the next time the aggregate is loaded from the store. The extension flags every such write:
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;
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
}
}Running PHPStan now produces:
Aggregate state property "name" should only be written in an #[Apply] method,
but is written in "Profile::create()".
💡 Record an event instead and change the state in an #[Apply] method.
It also covers every way a property can be mutated: plain assignments, compound assignments like .= and +=,
increments and decrements, array writes like $this->items[] = ..., list destructuring, unset() and static
properties.
This also applies to helper methods inside the aggregate: a private method that assigns a property is reported at the offending line, no matter where it is called from.
All rules are enabled by default. You can deactivate single rules in your phpstan.neon,
the same way PHPStan handles its own rules:
parameters:
patchlevelEventSourcing:
propertyInitialization: false
unusedProperty: false
writeOnlyProperty: false
noRecordThatWhenApplying: false
noStateWriteWhenNotApplying: falseWith the extension enabled, PHPStan understands your aggregates: it stops complaining about properties that are initialized through events, it reports properties that no apply method ever writes, it reports state that is written but never used for a decision, it fails the build when an apply method records an event, and it fails the build when aggregate state is written outside an apply method. You get accurate static analysis without writing a single annotation. You get accurate static analysis without writing a single annotation.