Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ce6fb13
allow to refresh subscriptions
DavidBadura Feb 11, 2026
395bc1c
refresh cleanup tasks
DavidBadura Feb 15, 2026
e2d2979
rename interface into CanRefreshSubscriptions
DavidBadura Feb 15, 2026
905b6c5
Update dependency pymdown-extensions to v10.21
renovate[bot] Feb 15, 2026
3a37e45
add shared apply context
DavidBadura Feb 17, 2026
424f3c4
add tests for CanRefreshSubscriptions
DavidBadura Feb 17, 2026
bbc0a18
rename method into 'refresh'
DavidBadura Feb 17, 2026
2211cfa
add docs
DavidBadura Feb 17, 2026
45c94fd
Merge pull request #813 from patchlevel/refresh-subscription
DavidBadura Feb 17, 2026
dd49672
fix deptrac
DavidBadura Feb 17, 2026
e037b51
Merge pull request #817 from patchlevel/shared-apply-context
DavidBadura Feb 17, 2026
62ae8bf
Lock file maintenance
renovate[bot] Feb 17, 2026
78851fc
Merge pull request #808 from patchlevel/renovate/lock-file-maintenance
DavidBadura Feb 17, 2026
2400182
Update dependency mkdocs-material to v9.7.2
renovate[bot] Feb 18, 2026
4c37ff0
Lock file maintenance
renovate[bot] Feb 19, 2026
b05a448
allow to pass conection registry in dbal cleanup task handler
DavidBadura Feb 18, 2026
8f4ecdb
Merge pull request #820 from patchlevel/conection-registry-dbal-clean…
DavidBadura Feb 19, 2026
4c79ed5
allow multiple hander, union types and inheritance in command bus
DavidBadura Feb 19, 2026
8516bba
Merge pull request #821 from patchlevel/command-bus-improvements
DavidBadura Feb 20, 2026
1f7d8fb
add auto initializable aggregate feature
DavidBadura Feb 20, 2026
0796d5d
Lock file maintenance
renovate[bot] Feb 21, 2026
d17688b
Merge pull request #822 from patchlevel/auto-initializable-aggregate
DavidBadura Feb 23, 2026
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
386 changes: 205 additions & 181 deletions composer.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions deptrac-baseline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ deptrac:
- Patchlevel\EventSourcing\Subscription\RunMode
Patchlevel\EventSourcing\Attribute\Projector:
- Patchlevel\EventSourcing\Subscription\RunMode
Patchlevel\EventSourcing\Attribute\SharedApplyContext:
- Patchlevel\EventSourcing\Aggregate\AggregateRoot
Patchlevel\EventSourcing\Attribute\Stream:
- Patchlevel\EventSourcing\Aggregate\AggregateRoot
Patchlevel\EventSourcing\Attribute\Subscriber:
Expand Down
93 changes: 93 additions & 0 deletions docs/pages/aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ final class Profile extends BasicAggregateRoot
}
}
```
!!! tip

You don't necessarily need to define multiple `Apply` attributes with the event class
if you define the event types in the method using a union type.

## Suppress missing apply methods

Sometimes you have events that do not change the state of the aggregate itself,
Expand Down Expand Up @@ -358,6 +363,38 @@ final class Profile extends BasicAggregateRoot

When all events are suppressed, debugging becomes more difficult if you forget an apply method.

## Shared apply context

When working with [micro-aggregates](./aggregate.md#micro-aggregates),
it’s common that events are applied by different aggregates.
As a result, an aggregate may receive events it does not handle, which can lead to multiple “missing apply” warnings.

The `SharedApplyContext` attribute allows you to declare that several aggregates share the same apply context.
With this configuration, a missing apply is only reported if none of the shared aggregates handle the event.

```php
use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\SharedApplyContext;
use Patchlevel\EventSourcing\Attribute\Stream;

#[Aggregate('profile')]
#[SharedApplyContext([PersonalInformation::class])]
final class Profile extends BasicAggregateRoot
{
}

#[Aggregate('personal_information')]
#[Stream(Profile::class)]
#[SharedApplyContext([Profile::class])]
final class PersonalInformation extends BasicAggregateRoot
{
}
```
!!! warning

You need to define the `SharedApplyContext` attribute on all aggregates that share the apply context.

## Stream Name

!!! warning
Expand Down Expand Up @@ -675,8 +712,10 @@ use Patchlevel\EventSourcing\Aggregate\Uuid;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\Id;
use Patchlevel\EventSourcing\Attribute\SharedApplyContext;

#[Aggregate('order')]
#[SharedApplyContext([Shipping::class])]
final class Order extends BasicAggregateRoot
{
#[Id]
Expand Down Expand Up @@ -706,10 +745,12 @@ use Patchlevel\EventSourcing\Aggregate\Uuid;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\Id;
use Patchlevel\EventSourcing\Attribute\SharedApplyContext;
use Patchlevel\EventSourcing\Attribute\Stream;

#[Aggregate('shipping')]
#[Stream(Order::class)]
#[SharedApplyContext([Order::class])]
final class Shipping extends BasicAggregateRoot
{
#[Id]
Expand Down Expand Up @@ -740,6 +781,11 @@ final class Shipping extends BasicAggregateRoot
}
}
```
!!! tip

With the [SharedApplyContext](./aggregate.md#shared-apply-context) attribute,
you can suppress missing applies for events that are handled by other aggregates.

### Child Aggregates

??? example "Experimental"
Expand Down Expand Up @@ -830,6 +876,53 @@ final class Order extends BasicAggregateRoot
}
}
```

## Auto Initialize

??? example "Experimental"

This feature is still experimental and may change in the future.
Use it with caution.

Sometimes you want to be able to access an aggregate even if it has not yet been created in the system.
In this case, the aggregate should be automatically initialized if it cannot be found in the store.
To achieve this, the aggregate must mark the initialization method with the `AutoInitialize` attribute.
The method must be static, receives the aggregate ID as an argument and must return an instance of the aggregate.

```php
use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
use Patchlevel\EventSourcing\Aggregate\Uuid;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\AutoInitialize;
use Patchlevel\EventSourcing\Attribute\Id;

#[Aggregate('profile')]
final class Profile extends BasicAggregateRoot
{
#[Id]
private Uuid $id;

#[AutoInitialize]
public static function initialize(Uuid $id): static
{
$self = new static();
$self->recordThat(new ProfileCreated($id));

return $self;
}

#[Apply]
public function applyProfileCreated(ProfileCreated $event): void
{
$this->id = $event->id;
}
}
```
!!! note

Recording events in the `initialize` method is optional but recommended.

## Aggregate Root Registry

The library needs to know about all aggregates so that the correct aggregate class is used to load from the database.
Expand Down
58 changes: 58 additions & 0 deletions docs/pages/command_bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,59 @@ final class CreateProfileHandler
!!! tip

A class can have multiple handle methods.

### Multiple Handle Attributes

A method can also have multiple `#[Handle]` attributes.
This is useful if you want to handle different commands with the same method.

```php
use Patchlevel\EventSourcing\Attribute\Handle;

final class CreateProfileHandler
{
#[Handle(CreateProfile::class)]
#[Handle(UpdateProfile::class)]
public function __invoke(object $command): void
{
// handle both commands
}
}
```

### Union Types

You can also use union types to handle multiple commands and the library will automatically detect the commands.

```php
use Patchlevel\EventSourcing\Attribute\Handle;

final class CreateProfileHandler
{
#[Handle]
public function __invoke(CreateProfile|UpdateProfile $command): void
{
// handle both commands
}
}
```

### Inheritance

The handler will also be invoked if the command implements an interface or extends a class that the handler expects.

```php
use Patchlevel\EventSourcing\Attribute\Handle;

final class CreateProfileHandler
{
#[Handle]
public function __invoke(CommandInterface $command): void
{
// handle all commands that implement CommandInterface
}
}
```

### Aggregate Handler

Expand Down Expand Up @@ -138,6 +191,11 @@ final class Profile extends BasicAggregateRoot
// ... apply methods
}
```
!!! tip

If you want to automatically initialize an aggregate if it cannot be found in the store,
you can use the [Auto Initialize](aggregate.md#auto-initialize) feature.

#### Inject Service

You can inject services into aggregate handler methods.
Expand Down
9 changes: 7 additions & 2 deletions docs/pages/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,17 @@ $profile = $repository->load($id);
!!! warning

When the method is called, the aggregate is always reloaded and rebuilt from the database.

!!! note

You can only fetch one aggregate at a time and don't do any complex queries either.
Projections are used for this purpose.


!!! tip

If you want to automatically initialize an aggregate if it cannot be found in the store,
you can use the [Auto Initialize](aggregate.md#auto-initialize) feature.

### Has an aggregate

You can also check whether an `aggregate` with a certain id exists.
Expand Down
48 changes: 46 additions & 2 deletions docs/pages/subscription.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,11 @@ Default, we provide the following cleanup tasks for `doctrine/dbal`:
| `DropIndexTask` | Drops an index from a table. |
| `DropTableTask` | Drops a table. |

!!! note

If you are passing connection registry, you can use the connection name as parameter.
The `connectionName` parameter is optional and defaults to the default connection.

!!! tip

You can create your own cleanup tasks and handler.
Expand Down Expand Up @@ -1054,18 +1059,45 @@ Lastly, we have to add the new handler to `DefaultCleaner`,
which is responsible for cleaning up subscriptions.

```php
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupTaskHandler;
use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner;

$cleaner = new DefaultCleaner([
new DbalCleanupTaskHandler($projectionConnection),
new MongodbCleanupTaskHandler($mongodbDatabase),
]);
```
!!! warning

You need to pass the Cleaner to the Subscription Engine.

#### Dbal Cleanup Task Handler

We provide a Dbal cleanup task handler by default.
More information about the available tasks can be found in the [Dbal Cleanup Tasks](#dbal-cleanup-tasks) documentation.

```php
use Doctrine\Dbal\Connection;
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupTaskHandler;
use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner;

/** @var Connection $connection */
$cleaner = new DefaultCleaner([
new DbalCleanupTaskHandler($connection),
]);
```
If you have multiple database connections and want to use the `DbalCleanupTaskHandler` to clean up the respective databases,
you can also pass a `ConnectionRegistry` (from `doctrine/persistence`) to the `DbalCleanupTaskHandler`.
Then you can pass the connection name as parameter in the cleanup task and the handler will use the corresponding connection to execute the task.

```php
use Doctrine\Persistence\ConnectionRegistry;
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupTaskHandler;
use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner;

/** @var ConnectionRegistry $connectionRegistry */
$cleaner = new DefaultCleaner([
new DbalCleanupTaskHandler($connectionRegistry),
]);
```
### Subscriber Accessor

The subscriber accessor repository is responsible for providing the subscribers to the subscription engine.
Expand Down Expand Up @@ -1315,6 +1347,18 @@ foreach ($subscriptions as $subscription) {
echo $subscription->status()->value;
}
```
### Refresh

If you change the metadata of a subscriber in the code (e.g. `runMode`, `group` or `cleanupTasks`),
you can use the `refresh` method to update the existing subscriptions in the store.

```php
use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine;
use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria;

/** @var SubscriptionEngine $subscriptionEngine */
$subscriptionEngine->refresh(new SubscriptionEngineCriteria());
```
## Learn more

* [How to use CLI commands](./cli.md)
Expand Down
4 changes: 2 additions & 2 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
mkdocs==1.6.1
mike==2.1.3
markdown==3.10.2
mkdocs-material==9.7.1
mkdocs-material==9.7.2

# Markdown extensions
Pygments==2.19.2
pymdown-extensions==10.20.1
pymdown-extensions==10.21

# MkDocs plugins
mkdocs-material-extensions==1.3.1
8 changes: 7 additions & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
parameters:
ignoreErrors:
-
message: '#^Cannot unset offset ''url'' on array\{application_name\?\: string, charset\?\: string, dbname\?\: string, defaultTableOptions\?\: array\<string, mixed\>, driver\?\: ''ibm_db2''\|''mysqli''\|''oci8''\|''pdo_mysql''\|''pdo_oci''\|''pdo_pgsql''\|''pdo_sqlite''\|''pdo_sqlsrv''\|''pgsql''\|''sqlite3''\|''sqlsrv'', driverClass\?\: class\-string\<Doctrine\\DBAL\\Driver\>, driverOptions\?\: array\<mixed\>, host\?\: string, \.\.\.\}\.$#'
message: '#^Cannot unset offset ''url'' on array\{application_name\?\: string, charset\?\: string, defaultTableOptions\?\: array\<string, mixed\>, driver\?\: ''ibm_db2''\|''mysqli''\|''oci8''\|''pdo_mysql''\|''pdo_oci''\|''pdo_pgsql''\|''pdo_sqlite''\|''pdo_sqlsrv''\|''pgsql''\|''sqlite3''\|''sqlsrv'', driverClass\?\: class\-string\<Doctrine\\DBAL\\Driver\>, driverOptions\?\: array\<mixed\>, host\?\: string, keepReplica\?\: bool, \.\.\.\}\.$#'
identifier: unset.offset
count: 1
path: src/Console/DoctrineHelper.php
Expand Down Expand Up @@ -444,6 +444,12 @@ parameters:
count: 1
path: tests/Unit/Fixture/ProfileWithHandler.php

-
message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithSharedApplyContext\:\:\$id is unused\.$#'
identifier: property.unused
count: 1
path: tests/Unit/Fixture/ProfileWithSharedApplyContext.php

-
message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithSuppressAll\:\:\$id is unused\.$#'
identifier: property.unused
Expand Down
13 changes: 13 additions & 0 deletions src/Attribute/AutoInitialize.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Attribute;

use Attribute;

/** @experimental */
#[Attribute(Attribute::TARGET_METHOD)]
final class AutoInitialize
{
}
2 changes: 1 addition & 1 deletion src/Attribute/Handle.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Handle
{
/** @param class-string|null $commandClass */
Expand Down
18 changes: 18 additions & 0 deletions src/Attribute/SharedApplyContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Attribute;

use Attribute;
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class SharedApplyContext
{
/** @param list<class-string<AggregateRoot>> $aggregates */
public function __construct(
public array $aggregates,
) {
}
}
Loading
Loading