---
title: 'What is New in the Event Sourcing PHPStan Extension 1.3.0'
date: '2026-08-05'
author: 'daniel-badura'
tags: ['PHP', 'EventSourcing', 'Release', 'PHPStan']
contentPreview: 'Version 1.3.0 of the patchlevel/event-sourcing-phpstan-extension adds three new rules that guard the state of your aggregates: they report properties that no event ever fills, state that nothing reads, and writes that happen outside an apply method. Every rule can now be turned off individually.'
---

Version [1.3.0](https://github.com/patchlevel/event-sourcing-phpstan-extension/releases/tag/1.3.0)
of [patchlevel/event-sourcing-phpstan-extension](/docs/event-sourcing-phpstan-extension/latest) is here. The
extension teaches [PHPStan](https://phpstan.org/) 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](/docs/event-sourcing/latest/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:

```php
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:

```text
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](/docs/event-sourcing/latest/subscription). The new `writeOnlyProperty`
rule points it out:

```php
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;
    }
}
```

```text
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:

```php
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
    }
}
```

```text
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:

```neon
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](/docs/event-sourcing-phpstan-extension/latest/getting-started)
walks through each rule with the `Profile` aggregate as a running example.

Questions or ideas? Open an [issue](https://github.com/patchlevel/event-sourcing-phpstan-extension) or start a
[discussion](https://github.com/patchlevel/event-sourcing/discussions) on GitHub.
