Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
## Features

* [Property initialization](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#property-initialization) for aggregate roots and child aggregates, so PHPStan does not report false uninitialized property errors.
* [Write only properties](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#write-only-properties) are reported when apply methods store state that is never read, because state that is not used to check invariants belongs in a projection.
* [Recording in apply methods](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#recording-in-apply-methods) is reported as an error, because recording events while replaying them leads to duplicated events.
* [Writing state outside apply methods](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#writing-state-outside-apply-methods) is reported as an error, because state that is not derived from an event is lost when the aggregate is reloaded.

Expand Down
52 changes: 49 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,51 @@ 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.
:::

## Write only properties

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:

```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 $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.

## Recording in apply methods

Apply methods are also called while an aggregate is rebuilt from its stored events. If you record a
Expand Down Expand Up @@ -168,15 +213,16 @@ the same way PHPStan handles its own rules:
parameters:
patchlevelEventSourcing:
propertyInitialization: false
writeOnlyProperty: false
noRecordThatWhenApplying: false
noStateWriteWhenNotApplying: false
```
## Result

With the extension enabled, PHPStan understands your aggregates: it stops complaining about
properties that are initialized through events, 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.
properties that are initialized through events, 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.

## Learn more

Expand Down
1 change: 1 addition & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and it catches common mistakes before they ever reach runtime.
## Features

* [Property initialization](getting-started.md#property-initialization) for aggregate roots and child aggregates, so PHPStan does not report false uninitialized property errors.
* [Write only properties](getting-started.md#write-only-properties) are reported when apply methods store state that is never read, because state that is not used to check invariants belongs in a projection.
* [Recording in apply methods](getting-started.md#recording-in-apply-methods) is reported as an error, because recording events while replaying them leads to duplicated events.
* [Writing state outside apply methods](getting-started.md#writing-state-outside-apply-methods) is reported as an error, because state that is not derived from an event is lost when the aggregate is reloaded.

Expand Down
7 changes: 7 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
parameters:
patchlevelEventSourcing:
propertyInitialization: true
writeOnlyProperty: true
noRecordThatWhenApplying: true
noStateWriteWhenNotApplying: true

parametersSchema:
patchlevelEventSourcing: structure([
propertyInitialization: bool()
writeOnlyProperty: bool()
noRecordThatWhenApplying: bool()
noStateWriteWhenNotApplying: bool()
])

conditionalTags:
Patchlevel\EventSourcingPHPStanExtension\AggregateRootExtension:
phpstan.properties.readWriteExtension: %patchlevelEventSourcing.propertyInitialization%
Patchlevel\EventSourcingPHPStanExtension\WriteOnlyPropertyRule:
phpstan.rules.rule: %patchlevelEventSourcing.writeOnlyProperty%
Patchlevel\EventSourcingPHPStanExtension\DontRecordWhenApplyingExtension:
phpstan.restrictedMethodUsageExtension: %patchlevelEventSourcing.noRecordThatWhenApplying%
Patchlevel\EventSourcingPHPStanExtension\DontWriteStateWhenNotApplyingRule:
Expand All @@ -23,6 +27,9 @@ services:
-
class: Patchlevel\EventSourcingPHPStanExtension\AggregateRootExtension

-
class: Patchlevel\EventSourcingPHPStanExtension\WriteOnlyPropertyRule

-
class: Patchlevel\EventSourcingPHPStanExtension\DontRecordWhenApplyingExtension

Expand Down
6 changes: 6 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,9 @@ parameters:
identifier: patchlevel.noRecordThatWhenApplying
count: 1
path: tests/Invalid/Profile.php

-
rawMessage: 'Property "lastName" of aggregate "Patchlevel\EventSourcingPHPStanExtension\Tests\Invalid\Profile" is written in an #[Apply] method but never read, so it is not used to check any invariants.'
identifier: patchlevel.writeOnlyProperty
count: 1
path: tests/Invalid/Profile.php
7 changes: 6 additions & 1 deletion src/AggregateRootExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class AggregateRootExtension implements ReadWritePropertiesExtension
{
public function isAlwaysRead(PropertyReflection $property, string $propertyName): bool
{
return false;
return $this->isAggregate($property);
}

public function isAlwaysWritten(PropertyReflection $property, string $propertyName): bool
Expand All @@ -22,6 +22,11 @@ public function isAlwaysWritten(PropertyReflection $property, string $propertyNa
}

public function isInitialized(PropertyReflection $property, string $propertyName): bool
{
return $this->isAggregate($property);
}

private function isAggregate(PropertyReflection $property): bool
{
$interfaces = $property->getDeclaringClass()->getInterfaces();

Expand Down
Loading