# patchlevel - full documentation > Concatenated markdown of every current-API documentation page on https://patchlevel.dev, grouped by library. Each library is introduced by an H1 with its package name (`patchlevel/`) followed by a description blockquote; each doc page below uses an H1 with the page title and a `Source:` line pointing at the canonical `.md` URL. Generated for AI-assisted developer tooling per https://llmstxt.org. # patchlevel/event-sourcing > The core PHP event-sourcing library: aggregates, events, repositories, stores, subscriptions, and the message/command/event/query buses. # Upcasting Source: https://patchlevel.dev/docs/event-sourcing/latest/upcasting.md There are cases where we already have events in our stream but there is data missing or not in the right format for our new usecase. Normally you would need to create versioned events for this. This can lead to many versions of the same event which could lead to some chaos. To prevent this we offer `Upcaster`, which can operate on the payload before denormalizing to an event object. There you can change the event name and adjust the payload of the event. ## Adjust payload Let's assume we have an `ProfileCreated` event which holds an email. Now the business needs to have all emails to be in lower case. For that we could adjust the aggregate and the projections to take care of that. Or we can do this beforehand so we don't need to maintain two different places. ```php use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; final class ProfileCreatedEmailLowerCastUpcaster implements Upcaster { public function __invoke(Upcast $upcast): Upcast { // ignore if other event is processed if ($upcast->eventName !== 'profile.created') { return $upcast; } if (!array_key_exists('email', $upcast->payload) || !is_string($upcast->payload['email'])) { return $upcast; } return $upcast->replacePayloadByKey('email', strtolower($upcast->payload['email'])); } } ``` :::warning Keep in mind that all events are passed to the upcaster, so an early return for unrelated events is recommended. ::: ## Adjust event name Sometimes your event name was not the best choice and you want to change it. For this we can use the `Upcaster` to change the event name. ```php use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; final class EventNameRenameUpcaster implements Upcaster { /** @param array $eventNameMap */ public function __construct( private readonly array $eventNameMap, ) { } public function __invoke(Upcast $upcast): Upcast { if (array_key_exists($upcast->eventName, $this->eventNameMap)) { return $upcast->replaceEventName($this->eventNameMap[$upcast->eventName]); } return $upcast; } } ``` :::tip Events can also have [aliases](events.md#alias). This is usually sufficient. ::: ## Configure After we have defined the upcasting rules, we also have to pass the whole thing to the serializer. Since we have multiple upcasters, we use a chain here. ```php use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain; /** @var EventRegistry $eventRegistry */ $upcaster = new UpcasterChain([ new ProfileCreatedEmailLowerCastUpcaster(), new EventNameRenameUpcaster(['old_event_name' => 'new_event_name']), ]); $serializer = DefaultEventSerializer::createFromPaths( ['src/Domain'], $upcaster, ); ``` ## Learn more * [How to create messages](message.md) * [How to define events](events.md) * [How to configure store](store.md) --- # Testing Source: https://patchlevel.dev/docs/event-sourcing/latest/testing.md The library's design promotes easily testable code, and we offer several helpers to simplify the testing process even further. If you need additional support, we also provide a [PHPUnit testing library](https://github.com/patchlevel/event-sourcing-phpunit) to make testing even more convenient. ## Testing with patchlevel/event-sourcing-phpunit ### Aggregate Unit Tests There is a special `TestCase` for aggregate tests that you can extend. By extending `AggregateRootTestCase`, you can use the given/when/then notation, making the test's purpose clear. When extending this class, you must implement a method that provides the fully qualified class name (FQCN) of the aggregate you want to test. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testCreateProfile(): void { $this ->when(static fn () => Profile::createProfile(new CreateProfile(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de')))) ->then( new ProfileCreated(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de')), static function (Profile $profile): void { self::assertSame('1', $profile->id()->toString()); self::assertSame('hq@patchlevel.de', $profile->email()->toString()); self::assertSame(0, $profile->visited()); }, ); } } ``` In addition to expected events, you can pass `Closure`s to `then`. The closure receives the aggregate instance after `when`, so you can run PHPUnit assertions on aggregate state. This support is available since `patchlevel/event-sourcing-phpunit` `1.5`. You can also prepare the aggregate with events to set it to a specific state and then test whether it behaves as expected. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { // protected function aggregateClass(): string; public function testBehaviour(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ) ->when(static fn (Profile $profile) => $profile->visitProfile(ProfileId::fromString('2'))) ->then( new ProfileVisited(ProfileId::fromString('2')), static function (Profile $profile): void { self::assertSame('1', $profile->id()->toString()); self::assertSame('hq@patchlevel.de', $profile->email()->toString()); self::assertSame(1, $profile->visited()); }, ); } } ``` #### Using Commandbus like syntax When using the command bus and the `#[Handle]` attributes in your aggregate, you can directly provide the command to the `when` method. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { // protected function aggregateClass(): string; public function testBehaviour(): void { $this ->when(new CreateProfile(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'))) ->then( new ProfileCreated(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de')), static fn (Profile $profile) => self::assertSame('hq@patchlevel.de', $profile->email()->toString()), ); } } ``` If additional parameters are required beyond the command, they can be provided as extra arguments for `when`. In this example, a string is needed, which will be passed directly to the event. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { // protected function aggregateClass(): string; public function testBehaviour(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ) ->when( new VisitProfile(ProfileId::fromString('2')), 'Extra Parameter / Dependency', ) ->then( new ProfileVisited(ProfileId::fromString('2'), 'Extra Parameter / Dependency'), static fn (Profile $profile) => self::assertSame(1, $profile->visited()), ); } } ``` ### Subscriber Tests For testing a subscriber, there is a utility class available. Using `SubscriberUtilities` provides several DX features that simplify testing. First, you need to specify the subscriptions you want to test when initializing the utility class. Once set up, you can call three methods: - `executeSetup` - `executeRun` - `executeTeardown` These methods automatically invoke the appropriate functions defined via attributes. ```php use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; use PHPUnit\Framework\TestCase; final class ProfileSubscriberTest extends TestCase { public function testProfileCreated(): void { $subscriber = new ProfileSubscriber(/* inject deps or mock tests as needed */); $util = new SubscriberUtilities($subscriber); $util->executeSetup(); $util->executeRun( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ); $util->executeTeardown(); self::assertSame(3, $subscriber->count); } } ``` ## Tests with DateTime using a Clock You should not instantiate the `DateTimeImmutable` directly in the aggregate. Instead, you should pass a `Clock` to the aggregate and use this to get the current time. This allows you to test the aggregate with a fixed time. ```php use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { // protected function aggregateClass(): string; public function testCreateProfile(): void { $clock = new FrozenClock(new DateTimeImmutable('2021-01-01 00:00:00')); $profile = Profile::createProfile( ProfileId::generate(), Email::fromString('info@patchlevel.de'), $clock, ); $clock->sleep(10); $profile->changeEmail(Email::fromString('info@patchlevel.de')); $this ->given(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'))) ->when( new ChangeEmail(ProfileId::fromString('1'), Email::fromString('new-hq@patchlevel.de')), $clock, ) ->then( new EmailChanged( ProfileId::fromString('1'), Email::fromString('new-hq@patchlevel.de'), new DateTimeImmutable('2021-01-01 00:00:10'), ), static fn (Profile $profile) => self::assertSame('new-hq@patchlevel.de', $profile->email()->toString()), ); } } ``` :::note You can find out more about the [clock](clock.md). ::: :::tip You can use the `FrozenClock` in your integration tests to test the time-based behavior of your application. ::: ## Tests with UUID Uuids are randomly generated and can be a problem in tests. If you want deterministic tests, you can use the `IncrementalRamseyUuidFactory` from the library. ```php use Patchlevel\EventSourcing\Test\IncrementalRamseyUuidFactory; use PHPUnit\Framework\TestCase; use Ramsey\Uuid\Uuid; final class ProfileTest extends TestCase { public function setUp(): void { Uuid::setFactory(new IncrementalRamseyUuidFactory()); } public function testCreateProfile(): void { $id1 = ProfileId::generate(); // 10000000-7000-0000-0000-000000000001 $id2 = ProfileId::generate(); // 10000000-7000-0000-0000-000000000002 } } ``` :::warning The `IncrementalRamseyUuidFactory` is only for testing purposes and supports only uuid version 7, which is used by the library. ::: ## Learn more * [How to create an aggregate](aggregate.md) * [How to use the clock](clock.md) * [How to use subscriptions](subscription.md) * [How to use the command bus](command-bus.md) --- # Our supported versions Source: https://patchlevel.dev/docs/event-sourcing/latest/supported-versions.md Our top priority is to ensure that our library is as good as it can be. To be able to do this only the newest major version is considered to be in active development. This means new features only go in there and not in earlier major releases. The only exception would be, if this new feature would ease the upgrade to the next major version. If critical security issue would be discovered in our past major versions we will try our best to fix them there. They may be cases where this is not feasible to do this, and therefore an upgrade would be the task for you to do. ## PHP Versions We will drop the support for PHP versions as soon as they go EOL in our next minor release. If you are running this PHP version do not worry, you can still use our library. You only won't get any updates until you update your PHP version. This is also done to ensure that our development efforts are not consumed by supporting old PHP versions which should not be used anymore. ## Versioning For more information about our versioning you should read [our backward compatibility promise](/backward-compatibility-promise). --- # Subscriptions Source: https://patchlevel.dev/docs/event-sourcing/latest/subscription.md One core concept of event sourcing is the ability to react and process events in a different way. This is where subscriptions and the subscription engine come into play. There are different types of subscriptions. In most cases, we are talking about projectors and processors. But you can use it for anything like migration, report or something else. For this, we use the event store to get the events and process them. The event store remains untouched and everything can always be reproduced from the events. The subscription engine manages individual subscribers and keeps the subscriptions running. Internally, the subscription engine does this by tracking where each subscriber is in the event stream. ## Subscriber If you want to react to events, you have to create a subscriber. Each subscriber needs a unique ID and a run mode. ```php use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('do_stuff', RunMode::Once)] final class DoStuffSubscriber { } ``` :::note For each subscriber ID, the engine will create a subscription. If the subscriber ID changes, a new subscription will be created. In some cases like projections, you want to change the subscriber ID to rebuild the projection. ::: :::tip You can use specific attributes for specific subscribers like `Projector` or `Processor`. So you don't have to define the group and run mode every time. ::: ### Projector You can create projections and read models with a subscriber. We named this type of subscriber `projector`. But in the end it's the same. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('profile_1', RunMode::FromBeginning)] final class ProfileProjector { public function __construct( private readonly Connection $connection, ) { } } ``` Mostly you want to process the events from the beginning. For this reason, it is also possible to use the `Projector` attribute. It extends the `Subscriber` attribute with a default group and run mode. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Projector; #[Projector('profile_1')] final class ProfileProjector { public function __construct( private readonly Connection $connection, ) { } } ``` :::warning PostgreSQL, MySQL and MariaDB don't support transactions for DDL statements. So you must use a different database connection for your subscriptions. ::: :::tip Add a version as suffix to the subscriber id so you can increment it when the subscription changes. Like `profile_1` to `profile_2`. ::: ### Processor The other way to react to events is to take actions like sending an email, dispatch commands or change other aggregates. We named this type of subscriber `processor`. ```php use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('welcome_email', RunMode::FromNow)] final class WelcomeEmailProcessor { public function __construct( private readonly Mailer $mailer, ) { } } ``` Mostly you want to process the events from now, because you don't want to email users who already have an account for a long time. For this reason, it is also possible to use the `Processor` attribute. It extends the `Subscriber` attribute with a default group and run mode. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Processor; #[Processor('welcome_email')] final class WelcomeEmailProcessor { public function __construct( private readonly Connection $connection, ) { } } ``` ### Subscribe A subscriber (projector/processor) can subscribe any number of events. In order to say which method is responsible for which event, you need the `Subscribe` attribute. There you can pass the event class to which the reaction should then take place. The method itself must expect a `Message`, which then contains the event. The method name itself doesn't matter. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('do_stuff', RunMode::Once)] final class DoStuffSubscriber { #[Subscribe(ProfileCreated::class)] public function onProfileCreated(Message $message): void { $profileCreated = $message->event(); // do something } } ``` :::tip If you are using psalm then you can install the event sourcing [plugin](https://github.com/patchlevel/event-sourcing-psalm-plugin) to make the event method return the correct type. ::: ### Subscribe all events If you want to subscribe on all events, you can pass `*` or `Subscribe::ALL` instead of the event class. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('welcome_email', RunMode::FromNow)] final class WelcomeSubscriber { #[Subscribe('*')] public function onProfileCreated(Message $message): void { echo 'Welcome!'; } } ``` #### Argument Resolver The library analyses the method signature and tries to resolve the arguments. The order of the arguments doesn't matter, you can use multiple arguments and mix them. ##### Message Resolver The message resolver resolves the `Message` object. It looks for a parameter with the type `Message`. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('do_stuff', RunMode::Once)] final class DoStuffSubscriber { #[Subscribe(ProfileCreated::class)] public function onProfileCreated(Message $message): void { // do something } } ``` ##### Event Resolver The event resolver resolves the event object. It looks for a parameter with the type of the event. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('do_stuff', RunMode::Once)] final class DoStuffSubscriber { #[Subscribe(ProfileCreated::class)] public function onProfileCreated(ProfileCreated $profileCreated): void { // do something } } ``` ##### Lookup Resolver Sometimes you need to query previous events to build a projection. For this you can use the `Lookup` service. This service only has access to the messages before the current message. Here is an example how you can use it in a projector. ```php use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Reducer; use Patchlevel\EventSourcing\Subscription\Lookup\Lookup; #[Projector('public_profile')] final class PublicProfileProjection { // ... constructor #[Subscribe(Published::class)] public function onPublished(Lookup $lookup): void { $messages = $lookup ->currentAggregate() // or ->currentStream() for StreamStore ->events( ProfileCreated::class, ProfileNameChanged::class, )->fetchAll(); $state = (new Reducer()) ->initState([ 'id' => null, 'name' => null, ]) ->match([ ProfileCreated::class => static function (Message $message): array { return [ 'id' => $message->event()->id->toString(), 'name' => $message->event()->name, ]; }, ProfileNameChanged::class => static function (Message $message, array $prevState): array { return array_merge($prevState, [ 'name' => $message->event()->name, ]); }, ]) ->reduce($messages); $this->connection->insert('public_profile', $state); } // ... setup, teardown, ... } ``` :::note More information can be found in the [reducer](message.md#reducer) documentation. ::: ##### Recorded On Resolver The recorded on resolver resolves the recorded on date. It looks for a parameter with the type `DateTimeImmutable`. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('do_stuff', RunMode::Once)] final class DoStuffSubscriber { #[Subscribe(ProfileCreated::class)] public function onProfileCreated(DateTimeImmutable $recordedOn): void { // do something } } ``` ##### Custom Resolvers You can provide your own argument resolvers by implementing the `ArgumentResolver` interface. This can be useful for providing direct access to custom headers or other data. ### Setup Subscribers can have one `setup` method that is executed when the subscription is created. For this there is the attribute `Setup`. The method name itself doesn't matter. This is especially helpful for projectors, as they can create the necessary structures for the projection here. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; #[Projector(self::TABLE)] final class ProfileProjector { private const TABLE = 'profile_v1'; private Connection $connection; #[Setup] public function create(): void { $this->connection->executeStatement( sprintf('CREATE TABLE IF NOT EXISTS %s (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL);', self::TABLE), ); } } ``` :::danger PostgreSQL, MySQL and MariaDB don't support transactions for DDL statements. So you must use a different database connection in your projectors, otherwise you will get an error when the subscription tries to create the table. ::: :::warning If you change the subscriber id, you must also change the table/collection name. The subscription engine will create a new subscription with the new subscriber id. That means the setup method will be called again and the table/collection will conflict with the old existing projection. ::: :::note Most databases have a limit on the length of the table/collection name. The limit is usually 64 characters. ::: ### Teardown Subscribers can have one `teardown` method that is executed when the subscription is removed. For this there is the attribute `Teardown`. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Teardown; #[Projector(self::TABLE)] final class ProfileProjector { private const TABLE = 'profile_v1'; private Connection $connection; #[Teardown] public function drop(): void { $this->connection->executeStatement(sprintf('DROP TABLE IF EXISTS %s;', self::TABLE)); } } ``` :::danger PostgreSQL, MySQL and MariaDB don't support transactions for DDL statements. So you must use a different database connection in your projectors, otherwise you will get an error when the subscription tries to create the table. ::: :::warning A teardown can only be performed for a subscription if the code for the subscriber with that subscriber ID still exists. Another option is to use the `Cleanup` method. ::: :::note You can not mix the `cleanup` method with the `teardown` method. ::: ### Cleanup Alternatively, you can use a `cleanup` method for cleanup tasks. Unlike Teardown, this method is called when the subscription is created. The tasks are then saved in the Subscription Store. When removing the subscription, the subscriber is not necessary anymore, as the cleanup can be performed using the tasks in the store and an associated external handler. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Cleanup; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropIndexTask; #[Projector(self::TABLE)] final class ProfileProjector { private const TABLE = 'profile_v1'; private Connection $connection; #[Cleanup] public function drop(): array { return [new DropIndexTask(self::TABLE)]; } } ``` :::note You can not mix the `cleanup` method with the `teardown` method. ::: #### Dbal Cleanup Tasks By default, we provide the following cleanup tasks for `doctrine/dbal`: | Task | Description | |-----------------|------------------------------| | `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. For more information, see [Cleanup Handler](#cleanup-handler). ::: ### On Failed The subscription engine has a [retry strategy](#retry-strategy) to retry subscriptions that have an error. If this does not work, the subscription changes the status to failed and will be ignored in all future runs. You can react to this transition and prevent it, so the subscription can skip the message and continue with the next one. To do this, you can add a method with the `OnFailed` attribute. If the method throws an exception, the subscription will be set to failed, otherwise the subscription will continue with the next message. ```php use Patchlevel\EventSourcing\Attribute\OnFailed; use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; #[Processor('invoice')] final class InvoiceProcessor { #[Subscribe(OrderPlaced::class)] public function onOrderPlaced(OrderPlaced $orderPlaced): void { // an error occurs } #[OnFailed] public function onFailed(Message $message, Throwable $throwable): void { // do something (failed queue, logging, etc.), so the subscription can continue } } ``` :::warning Currently, the `OnFailed` method is only available for non-batchable subscribers. ::: :::note The `OnFailed` method is called after the retry strategy has decided that the subscription should be set to failed. ::: ### Versioning As soon as the structure of a projection changes, or you need other events from the past, you can change the subscriber ID to rebuild the projection. This will trigger the subscription engine to create a new subscription and boot the projection from the beginning. ```php use Patchlevel\EventSourcing\Attribute\Projector; #[Projector('profile_2')] final class ProfileSubscriber { // ... } ``` :::warning If you change the `subscriberID`, you must also change the table/collection name. Otherwise the table/collection will conflict with the old subscription. ::: :::tip Add a version as suffix to the subscriber id so you can increment it when the subscription changes. Like `profile_1` to `profile_2`. ::: ### Grouping You can also group subscribers together and filter them in the subscription engine. This is useful if you want to run subscribers in different processes or on different servers. ```php use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('profile_1', runMode: RunMode::Once, group: 'a')] final class ProfileSubscriber { // ... } ``` :::note The different attributes have different default groups. * `Subscriber` - `default` * `Projector` - `projector` * `Processor` - `processor` ::: ### Run Mode The run mode determines how the subscriber should behave. There are three different modes: #### From Beginning The subscriber will start from the beginning of the event stream and process all events. This is useful for subscribers that need to build up a projection from scratch. ```php use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('welcome_email', RunMode::FromBeginning)] final class WelcomeEmailSubscriber { // ... } ``` :::tip If you want to create projections and run from the beginning, you can use the `Projector` attribute. ::: #### From Now Certain subscribers operate exclusively on post-release events, disregarding historical data. This is useful for subscribers that are only interested in events that occur after a certain point in time. As example, a welcome email subscriber that only wants to send emails to new users. ```php use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('welcome_email', RunMode::FromNow)] final class WelcomeEmailSubscriber { // ... } ``` :::tip If you want to process events from now, you can use the `Processor` attribute. ::: #### Once This mode is useful for subscribers that only need to run once. This is useful for subscribers to create reports or to migrate data. ```php use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('migration', RunMode::Once)] final class MigrationSubscriber { // ... } ``` ### Select Retry Strategy You can select a retry strategy for your subscriber. We preconfigured two strategies for you: `default` and `no_retry`. * `default` - The default strategy retries the subscription 5 times. * `no_retry` - The no retry strategy does not retry the subscription. ```php use Patchlevel\EventSourcing\Attribute\RetryStrategy; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('welcome_email', RunMode::FromNow)] #[RetryStrategy('default')] final class WelcomeSubscriber { #[Subscribe('*')] public function onProfileCreated(Message $message): void { echo 'Welcome!'; } } ``` You can configure or add more strategies if you want. For more information, see the [retry strategy](#retry-strategy) documentation. ### Batching You can also optimize the performance of your subscribers by processing a number of events in a batch. This is particularly useful when projections need to be rebuilt. To achieve this, you can implement the `BatchableSubscriber` interface. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber; #[Projector('profile_1')] final class MigrationSubscriber implements BatchableSubscriber { public function __construct( private readonly Connection $connection, ) { } /** @var array */ private array $nameChanged = []; #[Subscribe(NameChanged::class)] public function handleNameChanged(NameChanged $event): void { $this->nameChanged[$event->userId] = $event->name; } public function beginBatch(): void { $this->nameChanged = []; $this->connection->beginTransaction(); } public function commitBatch(): void { foreach ($this->nameChanged as $userId => $name) { $this->connection->executeStatement( 'UPDATE user SET name = :name WHERE id = :id', ['name' => $name, 'id' => $userId], ); } $this->connection->commit(); $this->nameChanged = []; } public function rollbackBatch(): void { $this->connection->rollBack(); } public function forceCommit(): bool { return count($this->nameChanged) > 1000; } } ``` This interface provides you with all the options you need to process your data collectively. The `beginBatch` method is called as soon as a subscriber wants to process an event. If no suitable event is found in the stream, batching will not start, and this method will not be called. Here, you can make all necessary preparations, such as opening a transaction or preparing variables. The `commitBatch` method is called when batching was previously started, and one of the following conditions is met: Either the Subscription Engine reaches its limit, or the stream is finished. Alternatively, if the subscriber explicitly indicates using the `forceCommit` method that they want to process the data now. At this step, you must process all the data. The `rollbackBatch` method is called when an error occurs and the batching needs to be aborted. Here, you can respond to the error and potentially perform a database rollback. The method `forceCommit` is called after each handled event, and you can decide whether the batch commit process should start now. This helps to determine the batch size and thus avoid memory overflow. :::danger Make sure to fully process the data in `commitBatch` and close any open transactions. Otherwise, it may lead to inconsistent data. ::: :::note The position of the subscriber is only updated after a successful commit. In case of an error, the position remains at the state before the batch started. ::: :::tip Use `forceCommit` to prevent memory leaks. This allows you to decide when it's suitable to process the data and then release the memory. ::: ## Subscription Engine The subscription engine manages individual subscribers and keeps the subscriptions running. Internally, the subscription engine does this by tracking where each subscriber is in the event stream and keeping all subscriptions up to date. It also takes care that new subscribers are booted and old ones are removed again. If something breaks, the subscription engine marks the individual subscriptions as faulty and retries them. :::tip The Subscription Engine was inspired by the following two blog posts: * [Projection Building Blocks: What you'll need to build projections](https://barryosull.com/blog/projection-building-blocks-what-you-ll-need-to-build-projections/) * [Managing projectors is harder than you think](https://barryosull.com/blog/managing-projectors-is-harder-than-you-think/) ::: ## Subscription ID The subscription ID is taken from the associated subscriber and corresponds to the subscriber ID. Unlike the subscriber ID, the subscription ID can no longer change. If the Subscriber ID is changed, a new subscription will be created with this new subscriber ID. So there are two subscriptions, one with the old subscriber ID and one with the new subscriber ID. ## Subscription Position Furthermore, the position in the event stream is stored for each subscription. So that the subscription engine knows where the subscription stopped and must continue. ## Subscription Status There is a lifecycle for each subscription. This cycle is tracked by the subscription engine. ```mermaid stateDiagram-v2 direction LR [*] --> New New --> Booting New --> Active New --> Error Booting --> Active Booting --> Paused Booting --> Finished Booting --> Error Active --> Paused Active --> Finished Active --> Detached Active --> Error Paused --> Booting Paused --> Active Paused --> Detached Finished --> Active Finished --> Detached Error --> New Error --> Booting Error --> Active Error --> Paused Error --> Failed Failed --> New Failed --> Booting Failed --> Active Failed --> [*] Detached --> Active Detached --> [*] ``` ### New A subscription is created and "new" if a subscriber exists with an ID that is not yet tracked. This can happen when either a new subscriber has been added, the subscriber ID has changed or the subscription has been manually deleted from the subscription store. You can then set up the subscription so that it is booting or active. In this step, the subscription engine also tries to call the `setup` method if available. ### Booting Booting status is reached when the setup process is finished. In this step the subscription engine tries to catch up to the current event stream. When the process is finished, the subscription is set to active or finished. ### Active The active status describes the subscriptions currently being actively managed by the subscription engine. These subscriptions have a subscriber, follow the event stream and should be up-to-date. ### Paused A subscription can manually be paused. It will then no longer be updated by the subscription engine. This can be useful if you want to pause a subscription for a certain period of time. You can also reactivate the subscription if you want so that it continues. ### Finished A subscription is finished if the subscriber has the mode `RunMode::Once`. This means that the subscription is only run once and then set to finished if it reaches the end of the event stream. You can also reactivate the subscription if you want so that it continues. ### Detached If an active or finished subscription exists in the subscription store that does not have a subscriber in the source code with a corresponding subscriber ID, then this subscription is marked as detached. This happens when either the subscriber has been deleted or the subscriber ID of a subscriber has changed. In the last case there should be a new subscription with the new subscriber ID. A detached subscription does not automatically become active again when the subscriber exists again. This happens, for example, when an old version was deployed again during a rollback. There are two options to reactivate the subscription: * Reactivate the subscription, so that the subscription is active again. * Remove the subscription and rebuild it from scratch. ### Error If an error occurs in a subscriber, then the subscription is set to Error. This can happen in the create process, in the boot process or in the run process. This subscription will then no longer boot/run until the subscription is reactivated or retried. The subscription engine has a retry strategy to retry subscriptions that have an error. It tries to reactivate the subscription after a certain time and a certain number of attempts. If this does not work, the subscription changes the status to failed. ### Failed If the retry strategy says that the subscription should not be retried anymore, e.g. the maximum number of retry attempts has been reached, then the subscription is set to failed. The subscription will be now ignored by the subscription engine in all future runs. There are two options here: * Reactivate the subscription, so that the subscription is in the previous state again. * Remove the subscription and rebuild it from scratch. ## Setup In order for the subscription engine to be able to do its work, you have to assemble it beforehand. ### Message Loader The subscription engine needs a message loader to load the messages. We provide three implementations by default. Which one has a better performance depends on the use case. :::tip We recommend the `GapResolverStoreMessageLoader` as it handles gaps in the stream. ::: #### Store Message Loader The store message loader loads all the messages from the event store. ```php use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; /** @var Store $store */ $messageLoader = new StoreMessageLoader($store); ``` #### Event Filtered Store Message Loader The event filtered store message loader loads only the messages that are relevant for the subscribers. It looks before loading the messages which subscribers are interested in the events. Then it loads with a filter only the relevant messages. ```php use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; /** * @var Store $store * @var EventMetadataFactory $eventMetadataFactory * @var SubscriberAccessorRepository $subscriberRepository */ $messageLoader = new EventFilteredStoreMessageLoader( $store, $eventMetadataFactory, $subscriberRepository, ); ``` #### Gap Resolver Store Message Loader Relational databases can lead to so-called gaps in the stream. There's a [blog post](https://event-driven.io/en/ordering_in_postgres_outbox/) by Oskar Dudycz that describes the problem very well. By default, we use a write lock for our event store to ensure that only one process can write at the same time to maintain order and completeness. However, there is still a small chance that the gap will occur. To detect and prevent these, we've introduced the `GapResolverStoreMessageLoader`. ```php use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\GapResolverStoreMessageLoader; use Psr\Clock\ClockInterface; /** * @var Store $store * @var ClockInterface $clock */ $messageLoader = new GapResolverStoreMessageLoader( $store, $clock, [0, 5, 50, 500], // default: retries in milliseconds (0 means immediate) new DateInterval('PT5M'), // default: detection window when to retry (5 minutes) ); ``` ### Subscription Store The Subscription Engine uses a subscription store to store the status of each subscription. We provide a Doctrine implementation of this by default. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; /** @var Connection $connection */ $subscriptionStore = new DoctrineSubscriptionStore($connection); ``` So that the schema for the subscription store can also be created, we have to tell the `DoctrineSchemaDirector` our schema configuration. Using `ChainDoctrineSchemaConfigurator` we can add multiple schema configurators. In our case they need the `DoctrineSchemaDirector` from the event store and subscription store. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; /** * @var Connection $connection * @var Store $eventStore * @var DoctrineSubscriptionStore $subscriptionStore */ $schemaDirector = new DoctrineSchemaDirector( $connection, new ChainDoctrineSchemaConfigurator([ $eventStore, $subscriptionStore, ]), ); ``` :::note You can find more about the schema configurator in the [store](store.md) documentation. ::: ### Retry Strategy The subscription engine uses a retry strategy to retry subscriptions that have an error. If the retry strategy says that the subscription should not be retried anymore, e.g. the maximum number of retry attempts has been reached, then the subscription is set to failed. #### Clock Based Retry Strategy We provide a clock based retry strategy by default. You can configure the base delay, the delay factor and the maximum number of attempts. * `baseDelay` - The base delay in seconds. * `delayFactor` - The factor by which the delay is multiplied after each attempt. * `maxAttempts` - The maximum number of attempts. ```php use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; $retryStrategy = new ClockBasedRetryStrategy( baseDelay: 5, delayFactor: 2, maxAttempts: 5, ); ``` #### Non Retry Strategy If you don't want to retry subscriptions that have an error, you can use the non retry strategy. The subscription will be set to failed after the first error. ```php use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; $retryStrategy = new NoRetryStrategy(); ``` #### Retry Strategy Repository You can define multiple retry strategies and select them by name in the subscriber. This is useful if you have different retry strategies for different subscribers. Here is an example how you can configure the repository. ```php use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; $retryStrategyRepository = new RetryStrategyRepository([ 'default' => new ClockBasedRetryStrategy( baseDelay: 5, delayFactor: 2, maxAttempts: 5, ), 'no_retry' => new NoRetryStrategy(), ]); ``` :::note This is what our default configuration looks like if you do not define the retry strategy. ::: :::tip You can change the default retry strategy by defining the name in the constructor as second parameter. ::: ### Cleanup Handler You can also create your own cleanup tasks with associated handlers. First, create a task class that has all necessary information for the task. In our example, we create a task that deletes a collection from MongoDB. ```php final class DropCollection { public function __construct( public readonly string $collectionName, ) { } } ``` :::warning The task class must be serializable. It will be stored in the subscription store. ::: The next step is to create a handler for the task. The handler must implement the `CleanupHandler` interface. ```php use MongoDb\Database; use Patchlevel\EventSourcing\Subscription\Cleanup\CleanupTaskHandler; final class MongodbCleanupTaskHandler implements CleanupTaskHandler { public function __construct( private readonly Database $database, ) { } public function __invoke(object $task): void { if (!($task instanceof DropCollection)) { return; } $this->database->dropCollection($task->collectionName); } public function supports(object $task): bool { return $task instanceof DropCollection; } } ``` Lastly, we have to add the new handler to `DefaultCleaner`, which is responsible for cleaning up subscriptions. ```php use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner; $cleaner = new DefaultCleaner([ 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. We provide a metadata subscriber accessor repository by default. ```php use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; /** * @var object $subscriber1 * @var object $subscriber2 * @var object $subscriber3 */ $subscriberAccessorRepository = new MetadataSubscriberAccessorRepository([ $subscriber1, $subscriber2, $subscriber3, ]); ``` ### Subscription Engine Now we can create the subscription engine and plug together the necessary services. The message loader is needed to load the messages, the Subscription Store to store the subscription state and we need the subscriber accessor repository. Optionally, we can also pass a retry strategy. Finally, if we want to use the cleanup feature, we need to pass the cleanup handlers. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupTaskHandler; use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Psr\Log\LoggerInterface; /** * @var MessageLoader $messageLoader * @var DoctrineSubscriptionStore $subscriptionStore * @var MetadataSubscriberAccessorRepository $subscriberAccessorRepository * @var RetryStrategyRepository $retryStrategyRepository * @var LoggerInterface $logger * @var Connection $projectionConnection */ $subscriptionEngine = new DefaultSubscriptionEngine( $messageLoader, $subscriptionStore, $subscriberAccessorRepository, $retryStrategyRepository, // optional, if not set the default retry strategy is used $logger, // optional new DefaultCleaner([new DbalCleanupTaskHandler($projectionConnection)]), // optional but required if you want to use the cleanup feature ); ``` ### Catch up Subscription Engine If aggregates are used in the processors and new events are generated there, then they are not part of the current subscription engine run and will only be processed during the next run or boot. This is usually not a problem in dev or prod environment because a worker is used and these events will be processed at some point. But in testing it is not so easy. For this reason, we have the `CatchUpSubscriptionEngine` decorator. ```php use Patchlevel\EventSourcing\Subscription\Engine\CatchUpSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $catchupSubscriptionEngine = new CatchUpSubscriptionEngine($subscriptionEngine); ``` :::tip You can use the `CatchUpSubscriptionEngine` in your tests to process the events immediately. ::: :::note Learn more about the worker in the [subscription commands](cli.md#subscription-commands) documentation. ::: ### Throw on error Subscription Engine This is another decorator for the subscription engine. It throws an exception if a subscription is in error state. This is useful for testing or development to get directly feedback if something is wrong. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $throwOnErrorSubscriptionEngine = new ThrowOnErrorSubscriptionEngine($subscriptionEngine); ``` :::warning This is only for testing or development. Don't use it in production. The subscription engine has a built-in retry strategy to retry subscriptions that have failed. ::: ### Run Subscription Engine after save You can trigger the subscription engine after calling the `save` method on the repository. This means that a worker to run the subscriptions is not needed. ```php use Patchlevel\EventSourcing\Repository\RepositoryManager; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; /** * @var SubscriptionEngine $subscriptionEngine * @var RepositoryManager $defaultRepositoryManager */ $repositoryManager = new RunSubscriptionEngineRepositoryManager( $defaultRepositoryManager, $subscriptionEngine, ['id1', 'id2'], // filter subscribers by id ['group1', 'group2'], // filter subscribers by group 100, // limit the number of messages ); ``` :::danger By using this, you can't wrap the repository in a transaction. A rollback is not supported and can break the subscription engine. Internally, the events are saved in a transaction to ensure data consistency. ::: :::note More about the repository manager can be found in the [repository](repository.md) documentation. ::: :::tip You can perfectly use it in development or testing. Especially in combination with the `CatchUpSubscriptionEngine` and `ThrowOnErrorSubscriptionEngine` decorators. ::: ## Usage The Subscription Engine has a few methods needed to use it effectively. A `SubscriptionEngineCriteria` can be passed to all of these methods to filter the respective subscriptions. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; $criteria = new SubscriptionEngineCriteria( ids: ['profile_1', 'welcome_email'], groups: ['default'], ); ``` :::note An `OR` check is made for the respective criteria and all criteria are checked with an `AND`. ::: ### Setup New subscriptions need to be set up before they can be used. In this step, the subscription engine also tries to call the `setup` method if available. After the setup process, the subscription is set to booting or active. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->setup(new SubscriptionEngineCriteria()); ``` :::tip You can skip the booting step with the second boolean parameter named `skipBooting`. ::: ### Boot You can boot the subscriptions with the `boot` method. All booting subscriptions will catch up to the current event stream. After the boot process, the subscription is set to active or finished. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->boot(new SubscriptionEngineCriteria()); ``` ### Run All active subscriptions are continued and updated here. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->run(new SubscriptionEngineCriteria()); ``` ### Teardown If subscriptions are detached, they can be cleaned up here. The subscription engine also tries to call the `teardown` method if available. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->teardown(new SubscriptionEngineCriteria()); ``` ### Remove You can also directly remove a subscription regardless of its status. An attempt is made to call the `teardown` method if available. But the entry will still be removed if it doesn't work. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->remove(new SubscriptionEngineCriteria()); ``` ### Reactivate If a subscription had an error or is outdated, you can reactivate it. As a result, the subscription gets in the last status again. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->reactivate(new SubscriptionEngineCriteria()); ``` ### Pause Pausing a subscription is also possible. The subscription will then no longer be managed by the subscription engine. You can reactivate the subscription if you want so that it continues. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->pause(new SubscriptionEngineCriteria()); ``` ### Status To get the current status of all subscriptions, you can get them using the `subscriptions` method. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptions = $subscriptionEngine->subscriptions(new SubscriptionEngineCriteria()); 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()); ``` ## Basic workflow for the worker Use `event-sourcing:subscription:boot --setup` to first run the setup of any new subscriptions and immediately boot them. The `event-sourcing:subscription:run` command will continue to run and process new events until the process is killed. After adding a new subscriber and booting it, you should restart the `run` command. ## Learn more * [How to use CLI commands](cli.md) * [How to create Messages](message.md) * [How to Test](testing.md) --- # Store Source: https://patchlevel.dev/docs/event-sourcing/latest/store.md In the end, the messages have to be saved somewhere. Each message contains an event and the associated headers. :::note More information can be found in the [message](message.md) documentation. ::: The store is optimized to efficiently store and load events for aggregates. ## Configure Store We offer different stores to store the messages. Two stores based on [doctrine dbal](https://www.doctrine-project.org/projects/dbal.html) and one in-memory store for testing purposes. ### DoctrineDbalStore This is the current default store for event sourcing. You can create a store with the `DoctrineDbalStore` class. The store needs a dbal connection, an event serializer and has some optional parameters like options. ```php use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; $connection = DriverManager::getConnection( (new DsnParser())->parse('pdo-pgsql://user:secret@localhost/app'), ); $store = new DoctrineDbalStore( $connection, DefaultEventSerializer::createFromPaths(['src/Event']), ); ``` :::note You can find out more about [how to create a connection](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html) in the doctrine dbal documentation. ::: Following options are available in `DoctrineDbalStore`: | Option | Type | Default | Description | |-------------------|-----------------|------------|----------------------------------------------| | table_name | string | eventstore | The name of the table in the database | | aggregate_id_type | "uuid"/"string" | uuid | The type of the `aggregate_id` column | | locking | bool | true | If the store should use locking for writing | | lock_id | int | 133742 | The id of the lock | | lock_timeout | int | -1 | The timeout of the lock. -1 means no timeout | The table structure of the `DoctrineDbalStore` looks like this: | Column | Type | Description | |------------------|-------------|--------------------------------------------------| | id | bigint | The index of the whole stream (autoincrement) | | aggregate | string | The name of the aggregate | | aggregate_id | uuid/string | The id of the aggregate | | playhead | int | The current playhead of the aggregate | | event | string | The name of the event | | payload | json | The payload of the event | | recorded_on | datetime | The date when the event was recorded | | new_stream_start | bool | If the event is the first event of the aggregate | | archived | bool | If the event is archived | | custom_headers | json | Custom headers for the event | :::note The default type of the `aggregate_id` column is `uuid` if the database supports it and `string` if not. You can change the type with the `aggregate_id_type` option to `string` if you want to use a custom id. ::: ### StreamDoctrineDbalStore We offer a new store called `StreamDoctrineDbalStore`. This store is decoupled from the aggregate and can be used to store events from other sources. The difference to the `DoctrineDbalStore` is that the `StreamDoctrineDbalStore` merges the aggregate id and the aggregate name into one column named `stream`. Additionally, the column `playhead` is nullable. This store introduces two new methods `streams` and `remove`. The store needs a dbal connection, an event serializer and has some optional parameters like options. ```php use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; $connection = DriverManager::getConnection( (new DsnParser())->parse('pdo-pgsql://user:secret@localhost/app'), ); $store = new StreamDoctrineDbalStore( $connection, DefaultEventSerializer::createFromPaths(['src/Event']), ); ``` :::note You can find out more about [how to create a connection](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html) in the doctrine dbal documentation. ::: Following options are available in `StreamDoctrineDbalStore`: | Option | Type | Default | Description | |--------------|--------|-------------|-----------------------------------------------| | table_name | string | event_store | The name of the table in the database | | locking | bool | true | If the store should use locking for writing | | lock_id | int | 133742 | The id of the lock | | lock_timeout | int | -1 | The timeout of the lock. -1 means no timeout | | keep_index | bool | false | If enabled, the index header is kept on save | The table structure of the `StreamDoctrineDbalStore` looks like this: | Column | Type | Description | |------------------|----------|--------------------------------------------------| | id | bigint | The index of the whole stream (autoincrement) | | stream | string | The name of the stream | | playhead | ?int | The current playhead of the aggregate | | event_id | string | The id of the event | | event_name | string | The name of the event | | event_payload | json | The payload of the event | | recorded_on | datetime | The date when the event was recorded | | new_stream_start | bool | If the event is the first event of the aggregate | | archived | bool | If the event is archived | | custom_headers | json | Custom headers for the event | ### InMemoryStore We also offer an in-memory store for testing purposes. ```php use Patchlevel\EventSourcing\Store\InMemoryStore; $store = new InMemoryStore(); ``` :::tip You can pass messages to the constructor to initialize the store with some events. ::: ### ReadOnlyStore & StreamReadOnlyStore Last but not least, we offer two read-only stores. One for the `DoctrineDbalStore` and one for the `StreamDoctrineDbalStore`. It passes all methods to the underlying store, but throws a `StoreIsReadOnly` exception when trying to execute write operations. ```php use Patchlevel\EventSourcing\Store\ReadOnlyStore; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamReadOnlyStore; use Patchlevel\EventSourcing\Store\StreamStore; /** @var Store $store */ $readOnlyStore = new ReadOnlyStore($store); /** @var StreamStore $store */ $readOnlyStore = new StreamReadOnlyStore($store); ``` ## Schema With the help of the `SchemaDirector`, the database structure can be created, updated and deleted. :::tip You can also use doctrine migration to create and keep your schema in sync. ::: ### Doctrine Schema Director The `SchemaDirector` is responsible for creating, updating and deleting the database schema. The `DoctrineSchemaDirector` is a concrete implementation of the `SchemaDirector` for doctrine dbal. Additionally, it implements the `DryRunSchemaDirector` interface, to show the sql statements that would be executed. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; /** * @var Connection $connection * @var Store $store */ $schemaDirector = new DoctrineSchemaDirector( $connection, $store, ); ``` :::note How to setup [cli commands](cli.md) for the schema director can be found in the cli documentation. ::: #### Create schema You can create the table from scratch using the `create` method. ```php use Patchlevel\EventSourcing\Schema\SchemaDirector; /** @var SchemaDirector $schemaDirector */ $schemaDirector->create(); ``` Or can give you back which SQL statements would be necessary for this. Either for a dry run, or to define your own migrations. ```php use Patchlevel\EventSourcing\Schema\DryRunSchemaDirector; /** @var DryRunSchemaDirector $schemaDirector */ $sql = $schemaDirector->dryRunCreate(); ``` #### Update schema The update method compares the current state in the database and how the table should be structured. As a result, the diff is executed to bring the table to the desired state. ```php use Patchlevel\EventSourcing\Schema\SchemaDirector; /** @var SchemaDirector $schemaDirector */ $schemaDirector->update(); ``` Or can give you back which SQL statements would be necessary for this. ```php use Patchlevel\EventSourcing\Schema\DryRunSchemaDirector; /** @var DryRunSchemaDirector $schemaDirector */ $sql = $schemaDirector->dryRunUpdate(); ``` #### Drop schema You can also delete the table with the `drop` method. ```php use Patchlevel\EventSourcing\Schema\SchemaDirector; /** @var SchemaDirector $schemaDirector */ $schemaDirector->drop(); ``` Or can give you back which SQL statements would be necessary for this. ```php use Patchlevel\EventSourcing\Schema\DryRunSchemaDirector; /** @var DryRunSchemaDirector $schemaDirector */ $sql = $schemaDirector->dryRunDrop(); ``` ### Doctrine Migrations You can use [doctrine migration](https://www.doctrine-project.org/projects/migrations.html), which is known from [doctrine orm](https://www.doctrine-project.org/projects/orm.html), to create your schema and keep it in sync. We have added a `DoctrineMigrationSchemaProvider` for doctrine migrations so that you just have to plug the whole thing together. ```php use Doctrine\DBAL\Connection; use Doctrine\Migrations\Configuration\Connection\ExistingConnection; use Doctrine\Migrations\Configuration\Migration\ConfigurationLoader; use Doctrine\Migrations\DependencyFactory; use Doctrine\Migrations\Provider\SchemaProvider; use Patchlevel\EventSourcing\Schema\DoctrineMigrationSchemaProvider; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; // event sourcing schema director configuration /** * @var Connection $connection * @var Store $store */ $schemaDirector = new DoctrineSchemaDirector( $connection, $store, ); $schemaProvider = new DoctrineMigrationSchemaProvider($schemaDirector); // doctrine migration configuration /** @var ConfigurationLoader $configLoader */ $dependencyFactory = DependencyFactory::fromConnection( $configLoader, new ExistingConnection($connection), ); $dependencyFactory->setService( SchemaProvider::class, $schemaProvider, ); ``` :::note Here you can find more information on how to [configure doctrine migration](https://www.doctrine-project.org/projects/doctrine-migrations/en/3.3/reference/custom-configuration.html). ::: :::note How to setup [cli commands](cli.md) for doctrine migration can be found in the cli documentation. ::: ## Usage The store has a few methods to interact with the database. ### Load You can load all events from an aggregate with the `load` method. This method returns a `Stream` object, which is a collection of events. ```php use Patchlevel\EventSourcing\Store\Store; /** @var Store $store */ $stream = $store->load(); ``` The load method also has a few parameters to filter, limit and sort the events. ```php use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Store; /** @var Store $store */ $stream = $store->load( new Criteria(), // filter criteria 100, // limit 50, // offset true, // latest first ); ``` #### Criteria The `Criteria` object is used to filter the events. ```php use Patchlevel\EventSourcing\Store\Criteria\AggregateIdCriterion; use Patchlevel\EventSourcing\Store\Criteria\AggregateNameCriterion; use Patchlevel\EventSourcing\Store\Criteria\ArchivedCriterion; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; $criteria = new Criteria( new AggregateNameCriterion('profile'), new AggregateIdCriterion('e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), new FromPlayheadCriterion(2), new FromIndexCriterion(100), new ArchivedCriterion(true), new EventsCriterion(['profile.created', 'profile.name_changed']), ); ``` Or you can use the criteria builder to create the criteria. ```php use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; $criteria = (new CriteriaBuilder()) ->aggregateName('profile') ->aggregateId('e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e') ->fromPlayhead(2) ->fromIndex(100) ->archived(true) ->events(['profile.created', 'profile.name_changed']) ->build(); ``` #### Stream The load method returns a `Stream` object and is a generator. This means that the messages are only loaded when they are needed. ```php use Patchlevel\EventSourcing\Store\Stream; /** @var Stream $stream */ $stream->index(); // get the index of the stream $stream->position(); // get the current position of the stream $stream->current(); // get the current event $stream->next(); // move to the next event $stream->end(); // check if the stream is at the end foreach ($stream as $message) { $message->event(); // get the event } ``` :::note You can find more information about the [message](message.md) object in the message documentation. ::: :::warning The stream cannot rewind, so you can only iterate over it once. If you want to iterate over it again, you have to call the `load` method again. ::: ### Count You can count the number of events in the store with the `count` method. ```php use Patchlevel\EventSourcing\Store\Store; /** @var Store $store */ $count = $store->count(); ``` The count method also has the possibility to filter the events. ```php use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Store; /** @var Store $store */ $count = $store->count( new Criteria(), // filter criteria ); ``` ### Save You can save a message with the `save` method. ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\Store; /** * @var Store $store * @var Message $message * @var Message $message1 * @var Message $message2 * @var Message $message3 * @var list $messages */ $store->save($message); $store->save($message1, $message2, $message3); $store->save(...$messages); ``` :::note The saving happens in a transaction, so all messages are saved or none. The store locks the table for writing during each save by default. ::: :::tip Use the transactional method if you want to call multiple save methods in one transaction. ::: ### Update It is not possible to update events. In event sourcing, the events are immutable. ### Remove You can remove a stream with the `remove` method. ```php use Patchlevel\EventSourcing\Store\StreamStore; /** @var StreamStore $store */ $store->remove('profile-*'); ``` :::note The method is only available in the `StreamStore` like `StreamDoctrineDbalStore`. ::: ### List Streams You can list all streams with the `streams` method. ```php use Patchlevel\EventSourcing\Store\StreamStore; /** @var StreamStore $store */ $streams = $store->streams(); // ['profile-1', 'profile-2', 'profile-3'] ``` :::note The method is only available in the `StreamStore` like `StreamDoctrineDbalStore`. ::: ### Transaction There is also the possibility of executing a function in a transaction. The store takes care of starting a transaction, committing it and then possibly rollback it again. ```php use Patchlevel\EventSourcing\Store\Store; /** @var Store $store */ $store->transactional(static function () use ($command, $bankAccountRepository): void { $accountFrom = $bankAccountRepository->get($command->from()); $accountTo = $bankAccountRepository->get($command->to()); $accountFrom->transferMoney($command->to(), $command->amount()); $accountTo->receiveMoney($command->from(), $command->amount()); $bankAccountRepository->save($accountFrom); $bankAccountRepository->save($accountTo); }); ``` :::note The store locks the table for writing during the transaction by default. ::: :::tip If you only want to save one aggregate, you don't have to use the transactional method. The save method in store and repository is already transactional. ::: ## Learn more * [How to create events](events.md) * [How to use repositories](repository.md) * [How to create messages](message.md) * [How to create projections](subscription.md) * [How to upcast events](upcasting.md) * [How to configure cli commands](cli.md) --- # Split Stream Source: https://patchlevel.dev/docs/event-sourcing/latest/split-stream.md In some cases the business has rules which imply a restart of the event stream for an aggregate since the past events are not relevant for the current state. A bank is often used as an example. A bank account has hundreds of transactions, but every bank makes a balance report at the end of the year. In this step the current account balance is persisted. This event is perfect to split the stream and start aggregating from this point. Not only do some businesses require such an action, it also increases the performance for aggregates which would have a really long event stream. In the background the library will mark all past events as archived and will not load them anymore for building the aggregate. It will only load the events from the split event and onwards. But subscriptions will still receive all events. So you can create projections which are based on the full event stream. ## Configuration To use this feature you need to add the `SplitStreamDecorator` in the repository manager. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; use Patchlevel\EventSourcing\Store\Store; /** * @var AggregateRootRegistry $aggregateRootRegistry * @var Store $store * @var EventMetadataFactory $eventMetadataFactory */ $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, $store, null, null, new SplitStreamDecorator($eventMetadataFactory), ); ``` :::note You can find out more about the [message decorator](message-decorator.md). ::: :::tip You can use multiple decorators with the `ChainMessageDecorator`. ::: ## Usage To use this feature you need to mark the event which should split the stream. For that you can use the `#[SplitStream]` attribute. ```php use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Attribute\SplitStream; #[Event('bank_account.balance_reported')] #[SplitStream] final class BalanceReported { public function __construct( public BankAccountId $bankAccountId, public int $year, public int $balanceInCents, ) { } } ``` :::warning The event needs all data which is relevant for the aggregate, since all past events will not be loaded! Keep this in mind if you want to use this feature. ::: :::note This impacts only the aggregate loaded by the repository. Subscriptions will still receive all events. ::: :::tip You can combine this feature with the snapshot feature to increase the performance even more. ::: ## Learn more * [How to use message decorator](message-decorator.md) * [How to define events](events.md) * [How to define aggregates](aggregate.md) * [How to store and load aggregates](repository.md) * [How to use snapshots](snapshots.md) --- # Snapshots Source: https://patchlevel.dev/docs/event-sourcing/latest/snapshots.md Some aggregates can have a large number of events. This is not a problem if there are a few hundred. But if the number gets bigger at some point, then loading and rebuilding can become slow. The `snapshot` system can be used to control this. :::tip Use snapshots only if you have a performance problem, because it introduces additional complexity. In our benchmarks we can load 10 000 events for one aggregate in 50ms. Of course, this can vary from system to system. ::: Normally, the events are all applied again on the aggregate in order to rebuild the current state. With a `snapshot`, we can shorten the way in which we temporarily save the current state of the aggregate. When loading it is checked whether the snapshot exists. If a hit exists, the aggregate is created with the help of the snapshot. A check is then made to see whether further events have existed since the snapshot and these are then also applied on the aggregate. Here, however, only the last events are loaded from the database and not all. ## Configuration First of all you have to define a snapshot store. This store may have multiple adapters for different caches. These caches also need a name so that you can determine which aggregates should be stored in which cache. ```php use Patchlevel\EventSourcing\Snapshot\Adapter\Psr16SnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; $snapshotStore = new DefaultSnapshotStore([ 'default' => new Psr16SnapshotAdapter($defaultCache), 'other_cache' => new Psr16SnapshotAdapter($otherCache), ]); ``` After creating the snapshot store, you need to pass that store to the DefaultRepositoryManager. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Snapshot\SnapshotStore; use Patchlevel\EventSourcing\Store\Store; /** * @var AggregateRootRegistry $aggregateRootRegistry * @var Store $store * @var SnapshotStore $snapshotStore */ $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, $store, null, $snapshotStore, ); ``` :::note You can read more about the [repository](repository.md). ::: Next we need to tell the Aggregate to take a snapshot of it. We do this using the snapshot attribute. There we also specify where it should be saved. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Snapshot; #[Aggregate('profile')] #[Snapshot('default')] final class Profile extends BasicAggregateRoot { // ... } ``` When taking a snapshot, all properties are extracted and saved. When loading, this data is written back to the properties. In other words, in the end everything has to be serializable. To ensure this, the same system is used as for the events. You can define normalizers to bring the properties into the correct format. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\EventSourcing\Attribute\Snapshot; #[Aggregate('profile')] #[Snapshot('default')] final class Profile extends BasicAggregateRoot { #[Id] public Uuid $id; public string $name; public DateTimeImmutable $createdAt; // ... } ``` :::danger If anything changes in the properties of the aggregate, then the cache must be cleared. Or the snapshot version needs to be changed so that the previous snapshot is invalid. ::: :::warning In the end the complete aggregate must be serializable as json, including the aggregate id. ::: :::note The [hydrator](https://github.com/patchlevel/hydrator) is used internally and you can use all of its features. You can find more about this in the [normalizer](normalizer.md) documentation. ::: ### Snapshot batching Since the loading of events in itself is quite fast and only becomes noticeably slower with thousands of events, we do not need to create a snapshot after each event. That would also have a negative impact on performance. Instead, we can also create a snapshot after `n` events. The remaining events that are not in the snapshot are then loaded from the store. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Snapshot; #[Aggregate('profile')] #[Snapshot('default', batch: 1000)] final class Profile extends BasicAggregateRoot { // ... } ``` ### Snapshot versioning Whenever something changes on the aggregate, the previous snapshot must be discarded. You can do this by removing the entire snapshot cache when deploying. But that can be quickly forgotten. It is much easier to specify a snapshot version. This snapshot version is also saved in the snapshot cache. When loading, the versions are compared and if they do not match, the snapshot is discarded and the aggregate is rebuilt from scratch. The new snapshot is then created automatically. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Snapshot; #[Aggregate('profile')] #[Snapshot('default', version: '2')] final class Profile extends BasicAggregateRoot { // ... } ``` :::warning If the snapshots are discarded, a load peak can occur since the aggregates have to be rebuilt. You should update the snapshot version only when necessary. ::: :::tip If you have aggregates with a lot of events, you should consider using [split streams](split-stream.md) if it makes sense in your domain. Then the load peak is not so high anymore, because only the events from new stream start are loaded to rebuild the aggregate. ::: ## Adapter We offer a few `SnapshotAdapter` implementations that you can use. But not a direct implementation of a cache. There are many good libraries out there that address this problem, and before we reinvent the wheel, choose one of them. Since there is a psr-6 and psr-16 standard, there are plenty of libraries. Here are a few listed: * [symfony cache](https://symfony.com/doc/current/components/cache.html) * [laminas cache](https://docs.laminas.dev/laminas-cache/) * [scrapbook](https://www.scrapbook.cash/) ### psr-6 A `Psr6SnapshotAdapter`, based on the [PSR-6 caching standard](https://www.php-fig.org/psr/psr-6/). ```php use Patchlevel\EventSourcing\Snapshot\Adapter\Psr6SnapshotAdapter; use Psr\Cache\CacheItemPoolInterface; /** @var CacheItemPoolInterface $cache */ $adapter = new Psr6SnapshotAdapter($cache); ``` ### psr-16 A `Psr16SnapshotAdapter`, based on the [PSR-16 caching standard](https://www.php-fig.org/psr/psr-16/). ```php use Patchlevel\EventSourcing\Snapshot\Adapter\Psr16SnapshotAdapter; use Psr\SimpleCache\CacheInterface; /** @var CacheInterface $cache */ $adapter = new Psr16SnapshotAdapter($cache); ``` ### in memory An `InMemorySnapshotAdapter` that can be used for test purposes. ```php use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; $adapter = new InMemorySnapshotAdapter(); ``` ## Usage The snapshot store is automatically used by the repository and takes care of saving and loading. But you can also use the snapshot store yourself. ### Save This allows you to save the aggregate as a snapshot: ```php use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Snapshot\SnapshotStore; /** * @var SnapshotStore $snapshotStore * @var AggregateRoot $aggregate */ $snapshotStore->save($aggregate); ``` :::danger If the state of an aggregate is saved as a snapshot without being saved to the event store (database), it can lead to data loss or broken aggregates! ::: ### Load You can also load an aggregate from the snapshot store: ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Snapshot\SnapshotStore; $id = Uuid::fromString('229286ff-6f95-4df6-bc72-0a239fe7b284'); /** @var SnapshotStore $snapshotStore */ $aggregate = $snapshotStore->load(Profile::class, $id); ``` The method returns the Aggregate if it was loaded successfully. If the aggregate was not found, then a `SnapshotNotFound` is thrown. And if the version is no longer correct and the snapshot is therefore invalid, then a `SnapshotVersionInvalid` is thrown. :::warning The aggregate may be in an old state as the snapshot may lag behind. You still have to bring the aggregate up to date by loading the missing events from the event store. ::: ## Learn more * [How to define aggregates](aggregate.md) * [How to store and load aggregates](repository.md) * [How to split streams](split-stream.md) * [How to work with personal data](personal-data.md) --- # Repository Source: https://patchlevel.dev/docs/event-sourcing/latest/repository.md A `repository` takes care of storing and loading the `aggregates`. It is also responsible for building [messages](message.md) from the events and optionally dispatching them to the event bus. ## Create a repository The best way to create a repository is to use the `DefaultRepositoryManager`. This helps to build the repository correctly. The `DefaultRepositoryManager` needs some services to work. For one, it needs [AggregateRootRegistry](aggregate.md#aggregate-root-registry) so that it knows which aggregates exist. And the [store](store.md), which is then given to the repository so that it can save and load the events at the end. After plugging the `DefaultRepositoryManager` together, you can create the repository associated with the aggregate. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Store\Store; /** * @var AggregateRootRegistry $aggregateRootRegistry * @var Store $store */ $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, $store, ); $repository = $repositoryManager->get(Profile::class); ``` :::note The same repository instance is always returned for a specific aggregate. ::: ### Event Bus You can pass an event bus to the `DefaultRepositoryManager` to dispatch events synchronously. This will be done after the events are saved in the store outside the transaction. ```php use Patchlevel\EventSourcing\EventBus\DefaultEventBus; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Store\Store; $eventBus = DefaultEventBus::create([/* listeners */]); /** * @var AggregateRootRegistry $aggregateRootRegistry * @var Store $store */ $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, $store, $eventBus, ); $repository = $repositoryManager->get(Profile::class); ``` :::warning If you use the event bus, you should be aware that the events are dispatched synchronously. You may encounter [at least once](https://softwaremill.com/message-delivery-and-deduplication-strategies/) problems. ::: :::note You can find out more about the [event bus](event-bus.md). ::: :::tip In most cases it is better to react to events asynchronously, that's why we recommend the [subscription engine](subscription.md). ::: ### Snapshots Loading events for an aggregate is superfast. You can have thousands of events in the database that load in a few milliseconds and build the corresponding aggregate. But at some point you realize that it takes time. To counteract this there is a snapshot store. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Snapshot\Adapter\Psr16SnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\Store; $adapter = new Psr16SnapshotAdapter($cache); $snapshotStore = new DefaultSnapshotStore(['default' => $adapter]); /** * @var AggregateRootRegistry $aggregateRootRegistry * @var Store $store */ $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, $store, null, $snapshotStore, ); $repository = $repositoryManager->get(Profile::class); ``` :::note You can find out more about [snapshots](snapshots.md). ::: ### Decorator If you want to add more metadata to the message, like e.g. an application id, then you can use decorators. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Store\Store; $decorator = new ApplicationIdDecorator(); /** * @var AggregateRootRegistry $aggregateRootRegistry * @var Store $store */ $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, $store, null, null, $decorator, ); $repository = $repositoryManager->get(Profile::class); ``` :::note You can find out more about the [message decorator](message-decorator.md). ::: :::tip If you have multiple decorators, you can use the `ChainMessageDecorator` to chain them. ::: ## Use the repository Each `repository` has three methods that are responsible for loading an `aggregate`, saving it or checking whether it exists. ### Save an aggregate An `aggregate` can be `saved`. All new events that have not yet been written to the database are fetched from the aggregate. These events are then appended to the database. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Repository\Repository; $id = Uuid::generate(); $profile = Profile::create($id, 'david.badura@patchlevel.de'); /** @var Repository $repository */ $repository->save($profile); ``` :::warning All events are written to the database with one transaction in order to ensure data consistency. If an exception occurs during the save process, the transaction is rolled back and the aggregate is not valid anymore. You can not save the aggregate again and you need to load it again. ::: :::note Due to the nature of the aggregate having a playhead, we have a unique constraint that ensures that no race condition happens here. An `AggregateOutdated` exception is thrown if a conflict occurs. ::: :::tip If you use the Command Bus, you can use the [RetryOutdatedAggregateCommandBus](command-bus.md#retry-outdated-aggregate-command-bus) to retry the command when an `AggregateOutdated` exception occurs automatically. ::: ### Load an aggregate An `aggregate` can be loaded using the `load` method. All events for the aggregate are loaded from the database and the current state is rebuilt. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Repository\Repository; $id = Uuid::fromString('229286ff-6f95-4df6-bc72-0a239fe7b284'); /** @var Repository $repository */ $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. It is checked whether any event with this id exists in the database. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Repository\Repository; $id = Uuid::fromString('229286ff-6f95-4df6-bc72-0a239fe7b284'); /** @var Repository $repository */ if ($repository->has($id)) { // ... } ``` :::note The query is fast and does not load any event. This means that the state of the aggregate is not rebuilt either. ::: ## Custom Repository In clean code you want to have explicit type hints for the repositories so that you don't accidentally use the wrong repository. It would also help in frameworks with a dependency injection container, as this allows the services to be autowired. However, you cannot inherit from our repository implementations. Instead, you just have to wrap these repositories. This also gives you more type security. ```php use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Repository\RepositoryManager; class ProfileRepository { /** @var Repository */ private Repository $repository; public function __construct(RepositoryManager $repositoryManager) { $this->repository = $repositoryManager->get(Profile::class); } public function load(ProfileId $id): Profile { return $this->repository->load($id); } public function save(Profile $profile): void { $this->repository->save($profile); } public function has(ProfileId $id): bool { return $this->repository->has($id); } } ``` ## Learn more * [How to create an aggregate](aggregate.md) * [How to create an event](events.md) * [How to work with the store](store.md) * [How to use snapshots](snapshots.md) * [How to split streams](split-stream.md) * [How to use the event bus](event-bus.md) * [How to create messages](message.md) --- # Query Bus Source: https://patchlevel.dev/docs/event-sourcing/latest/query-bus.md The Query Bus is another optional component in the Event Sourcing library that coordinates the data flow in the system. Unlike the command bus, the query bus's intention is not to perform actions on the system but instead retrieve information from the system. It allows you to fully utilize the read write split and the usage of small, independent and tailored projections. ## Query First, you need to create a simple data transfer object which will be our query. It represents our intention to retrieve data from the system. ```php final class QueryProfile { public function __construct( public readonly ProfileId $id, ) { } } ``` ## Handler The next step is to create a handler which has a method which can handle the query. The method will be marked with the `#[Answer]` attribute. The method will be called when the query is dispatched in the bus and should return the desired data. ```php use Patchlevel\EventSourcing\Attribute\Answer; final class QueryProfileHandler { #[Answer] public function __invoke(QueryProfile $query): mixed { return 'result'; } } ``` :::warning A query can only be answered by one method. ::: :::note To use Service Handler you need to register the handler in the `ServiceHandlerProvider`. ::: :::tip A class can have multiple methods which answer different queries. ::: ### Projector Another way to handle queries is to answer them directly in the corresponding projectors. The configuration is the same as when using a dedicated class. The method which should handle the query will be marked with the `#[Answer]` attribute. ```php use Patchlevel\EventSourcing\Attribute\Answer; use Patchlevel\EventSourcing\Attribute\Projector; #[Projector('profiles')] final class ProfileProjector { #[Answer] public function answerQueryProfile(QueryProfile $query): mixed { return 'result'; } // projector related methods to maintain the state of profiles } ``` :::tip Using small dedicated projections for each usecase is best practice. Using them directly as query handlers is endorsed and can reduce fragmentation of the system. ::: ## Setup We provide a `SyncQueryBus` that you can use to dispatch queries. You need to pass a `HandlerProvider` to the constructor. ```php use Patchlevel\EventSourcing\QueryBus\HandlerProvider; use Patchlevel\EventSourcing\QueryBus\SyncQueryBus; /** @var HandlerProvider $handlerProvider */ $queryBus = new SyncQueryBus($handlerProvider); $result = $queryBus->dispatch(new QueryProfile($profileId)); ``` ## Provider We created an interface for `HandlerProvider` which allows you to implement different types of providers that you can use to register handlers. Right now we have two implementations: `ServiceHandlerProvider` and `ChainHandlerProvider`. ### Service Handler Provider The `ServiceHandlerProvider` is used to handle queries by invoking methods on services. ```php use Patchlevel\EventSourcing\QueryBus\ServiceHandlerProvider; $provider = new ServiceHandlerProvider([ new QueryProfileHandler(), new ProfileProjector( $dbalConnection, ), ]); ``` ### Chain Handler Provider The `ChainHandlerProvider` allows you to combine multiple handler providers. ```php use Patchlevel\EventSourcing\QueryBus\ChainHandlerProvider; $provider = new ChainHandlerProvider([ $serviceHandlerProvider1, $serviceHandlerProvider2, ]); ``` ## Learn more * [How to use aggregates](aggregate.md) * [How to use events](events.md) * [How to use subscriptions](subscription.md) * [How to use command bus](command-bus.md) --- # Personal Data (GDPR) Source: https://patchlevel.dev/docs/event-sourcing/latest/personal-data.md According to GDPR, personal data must be able to be deleted upon request. But here we have the problem that our events are immutable and we cannot easily manipulate the event store. The first solution is not to save the personal data in the Event Store at all and use something different for this, for example a separate table or an ORM. The other option the library offers is crypto shredding. In this process, the personal data is encrypted with a key that is assigned to a subject (like person). When saving and reading the events, this key is then used to convert the data. This key with the subject is saved in a database. As soon as a request for data deletion comes, you can simply delete the key and the personal data can no longer be decrypted. ## Configuration Encrypting and decrypting is handled by the library. You just have to configure the events accordingly. And if you use snapshots, you have to configure your aggregates too. ### DataSubjectId In order for the correct key to be used, a subject ID must be defined. Without Subject Id, no personal data can be encrypted or decrypted. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\Hydrator\Attribute\DataSubjectId; final class EmailChanged { public function __construct( #[DataSubjectId] public readonly Uuid $profileId, // ... ) { } } ``` :::tip You can use the `DataSubjectId` in aggregates for snapshots too. ::: ### PersonalData Next, you have to mark the properties that should be encrypted with the `#[PersonalData]` attribute. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\Hydrator\Attribute\DataSubjectId; use Patchlevel\Hydrator\Attribute\PersonalData; final class EmailChanged { public function __construct( #[DataSubjectId] public readonly Uuid $profileId, #[PersonalData] public readonly string|null $email, ) { } } ``` :::tip You can use the `PersonalData` in aggregates for snapshots too. ::: If the information could not be decrypted, then a fallback value will be used. The default fallback value is `null`. You can change this by setting the `fallback` parameter or using the `fallbackCallable` parameter. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\Hydrator\Attribute\DataSubjectId; use Patchlevel\Hydrator\Attribute\PersonalData; final class ProfileChanged { public function __construct( #[DataSubjectId] public readonly Uuid $profileId, #[PersonalData(fallback: 'unknown')] public readonly string $name, #[PersonalData(fallbackCallable: [self::class, 'createAnonymousEmail'])] public readonly string $email, ) { } public static function createAnonymousEmail(string $subjectId): string { return sprintf('%s@example.com', $subjectId); } } ``` :::danger You have to deal with this case in your business logic such as aggregates and subscriptions. ::: :::note The normalized data is encrypted. This means that this happens after the `extract` or before the `hydrate`. ::: ## Setup In order for the system to work, a few things have to be done. ### Cipher Key Store The keys must be stored somewhere. For this we provide a doctrine implementation. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; /** @var Connection $dbalConnection */ $cipherKeyStore = new DoctrineCipherKeyStore($dbalConnection); ``` To use the `DoctrineCipherKeyStore` you need to register this service in Doctrine Schema Director. Then the table will be added automatically. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; /** * @var Connection $dbalConnection * @var DoctrineCipherKeyStore $cipherKeyStore * @var Store $store */ $schemaDirector = new DoctrineSchemaDirector( $dbalConnection, new ChainDoctrineSchemaConfigurator([ $store, $cipherKeyStore, ]), ); ``` ### Personal Data Payload Cryptographer Now we have to put the whole thing together in a Personal Data Payload Cryptographer. ```php use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; /** @var CipherKeyStore $cipherKeyStore */ $cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore); ``` :::tip You can specify the cipher method with the second parameter. ::: ### Event Serializer Integration The last step is to integrate the cryptographer into the event store. ```php use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; /** @var PersonalDataPayloadCryptographer $cryptographer */ DefaultEventSerializer::createFromPaths( [__DIR__ . '/Events'], cryptographer: $cryptographer, ); ``` :::note More information can be found in the [events](events.md) documentation. ::: ### Snapshot Store Integration And for the snapshot store. ```php use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; /** @var PersonalDataPayloadCryptographer $cryptographer */ $snapshotStore = DefaultSnapshotStore::createDefault( [ /* adapters... */ ], $cryptographer, ); ``` :::note More information can be found in the [snapshots](snapshots.md) documentation. ::: :::success Now you can save and read events with personal data. ::: ## Remove personal data To remove personal data, you can either remove the key manually or do it with a processor. ```php use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; #[Processor('delete_personal_data')] final class DeletePersonalDataProcessor { public function __construct( private readonly CipherKeyStore $cipherKeyStore, ) { } #[Subscribe(UserHasRequestedDeletion::class)] public function handleUserHasRequestedDeletion(Message $message): void { $event = $message->event(); $this->cipherKeyStore->remove($event->personId); } } ``` ## Learn more * [How to use the hydrator](https://github.com/patchlevel/hydrator) * [How to define aggregates](aggregate.md) * [How to define events](events.md) * [How to normalize data](normalizer.md) --- # Normalizer Source: https://patchlevel.dev/docs/event-sourcing/latest/normalizer.md Sometimes you also want to add more complex data in events as payload or in aggregates for the snapshots. For example DateTime, enums or value objects. Here you can use the normalizer to define how the data should be saved and loaded. :::note The underlying system exists as a separate library. You can find out more details in the [hydrator](https://github.com/patchlevel/hydrator) documentation. ::: ## Usage You have a lot of options to use the normalizer. First of all and simplest, you can let the hydrator guess the normalizer from the type hint. ```php final class DTO { public DateTimeImmutable $date; } ``` Most built-in normalizers can be inferred from the type hint: * `DateTimeImmutable` => `DateTimeImmutableNormalizer` * `DateTime` => `DateTimeNormalizer` * `DateTimeZone` => `DateTimeZoneNormalizer` * `Enum` => `EnumNormalizer` * `AggregateRootId` => `IdNormalizer` :::note `ObjectNormalizer` will not be inferred. You have to specify it yourself. This should prevent you from accidentally serializing objects that you don't want to serialize. ::: The other way is to specify the normalizer to the properties directly. This example is equivalent to the previous one. ```php use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class DTO { #[DateTimeImmutableNormalizer] public DateTimeImmutable $date; } ``` And the whole thing also works with property promotion and readonly properties too. ```php use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class DTO { public function __construct( #[DateTimeImmutableNormalizer] public readonly DateTimeImmutable $date, ) { } } ``` If you have child entities or value objects, then you can also define the normalizer on class level. So you don't have to specify it for each property. ```php use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; #[ObjectNormalizer] final class Item { public function __construct( public readonly int $number, public readonly DateTimeImmutable $addedAt, ) { } } ``` :::note With the `ObjectNormalizer`, you can serialize and deserialize recursively. ::: ### Event For the event, the properties are normalized to a payload and saved in the DB at the end. The whole thing is then loaded again from the DB and denormalized in the properties. ```php use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; #[Event('hotel.created')] final class HotelCreated { public function __construct( public readonly string $name, #[DateTimeImmutableNormalizer] public readonly DateTimeImmutable $createdAt, ) { } } ``` :::note If you have personal data, you can use [crypto-shredding](personal-data.md). ::: ### Aggregate For the aggregates it is very similar to the events. However, the normalizer is only used for the snapshots. Here you can determine how the aggregate is saved in the snapshot store at the end. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Snapshot; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; #[Aggregate('hotel')] #[Snapshot('default')] final class Hotel extends BasicAggregateRoot { private string $name; #[DateTimeImmutableNormalizer] private DateTimeImmutable $createdAt; // ... } ``` :::note You can learn more about [snapshots](snapshots.md). ::: ## Built-in Normalizer For some standard cases we already offer built-in normalizers. ### Array If you have a list of objects that you want to normalize, then you must normalize each object individually. That's what the `ArrayNormalizer` does for you. In order to use the `ArrayNormalizer`, you still have to specify which normalizer should be applied to the individual objects. Internally, it basically does an `array_map` and then runs the specified normalizer on each element. ```php use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class DTO { #[ArrayNormalizer(new DateTimeImmutableNormalizer())] public array $dates; } ``` :::note The keys from the arrays are taken over here. ::: ### DateTimeImmutable With the `DateTimeImmutable` Normalizer, as the name suggests, you can convert DateTimeImmutable objects to a String and back again. ```php use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class DTO { #[DateTimeImmutableNormalizer] public DateTimeImmutable $date; } ``` :::tip You can let the hydrator guess the normalizer from the type hint. ::: You can also define the format. Either describe it yourself as a string or use one of the existing constants. The default is `DateTimeImmutable::ATOM`. ```php use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class DTO { #[DateTimeImmutableNormalizer(format: DateTimeImmutable::RFC3339_EXTENDED)] public DateTimeImmutable $date; } ``` :::note You can read about how the format is structured in the [php docs](https://www.php.net/manual/de/datetime.format.php). ::: ### DateTime The `DateTimeNormalizer` works exactly like the `DateTimeImmutableNormalizer`. Only for DateTime objects. ```php use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; final class DTO { #[DateTimeNormalizer] public DateTime $date; } ``` :::tip You can let the hydrator guess the normalizer from the type hint. ::: You can also specify the format here. The default is `DateTime::ATOM`. ```php use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; final class DTO { #[DateTimeNormalizer(format: DateTime::RFC3339_EXTENDED)] public DateTime $date; } ``` :::warning It is highly recommended to only ever use DateTimeImmutable objects and the DateTimeImmutableNormalizer. This prevents you from accidentally changing the state of the DateTime and thereby causing bugs. ::: :::note You can read about how the format is structured in the [php docs](https://www.php.net/manual/de/datetime.format.php). ::: ### DateTimeZone To normalize a `DateTimeZone` one can use the `DateTimeZoneNormalizer`. ```php use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer; final class DTO { #[DateTimeZoneNormalizer] public DateTimeZone $timeZone; } ``` :::tip You can let the hydrator guess the normalizer from the type hint. ::: ### Enum Backed enums can also be normalized. ```php use Patchlevel\Hydrator\Normalizer\EnumNormalizer; final class DTO { #[EnumNormalizer] public Status $status; } ``` :::tip You can let the hydrator guess the normalizer from the type hint. ::: You can also specify the enum class. ```php use Patchlevel\Hydrator\Normalizer\EnumNormalizer; final class DTO { #[EnumNormalizer(Status::class)] public Status $status; } ``` ### Id If you have your own AggregateRootId, you can use the `IdNormalizer`. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; final class DTO { #[IdNormalizer] public Uuid $id; } ``` :::tip You can let the hydrator guess the normalizer from the type hint. ::: Optionally you can also define the type of the id. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; final class DTO { #[IdNormalizer(Uuid::class)] public Uuid $id; } ``` ### Object If you have a complex object that you want to normalize, you can use the `ObjectNormalizer`. Internally, it uses the `Hydrator` to normalize and denormalize the object. ```php use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; final class DTO { #[ObjectNormalizer] public ComplexObject $object; } ``` Optionally you can also define the type of the object. ```php use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; final class DTO { #[ObjectNormalizer(ComplexObject::class)] public object $object; } ``` ## Custom Normalizer Since we only offer normalizers for PHP native things, you have to write your own normalizers for your own structures, such as value objects. In our example we have built a value object that should hold a name. ```php final class Name { public function __construct(private string $value) { if (strlen($value) < 3) { throw new NameIsTooShortException($value); } } public function toString(): string { return $this->value; } } ``` For this we now need a custom normalizer. This normalizer must implement the `Normalizer` interface. You also need to implement a `normalize` and `denormalize` method. Finally, you have to allow the normalizer to be used as an attribute. ```php use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\Normalizer; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] class NameNormalizer implements Normalizer { public function normalize(mixed $value): string { if (!$value instanceof Name) { throw InvalidArgument::withWrongType(Name::class, $value); } return $value->toString(); } public function denormalize(mixed $value): Name|null { if ($value === null) { return null; } if (!is_string($value)) { throw InvalidArgument::withWrongType('string', $value); } return new Name($value); } } ``` :::warning The important thing is that the result of Normalize is serializable! ::: Now we can also use the normalizer directly. ```php final class DTO { #[NameNormalizer] public Name $name; } ``` :::tip Every normalizer, including the custom normalizer, can be used both for the events and for the snapshots. ::: Or define it on class level, so you don't have to specify it for each property. ```php #[NameNormalizer] final class Name { /* name logic... */ } ``` ## Normalized Name By default, the property name is used to name the field in the normalized result. This can be customized with the `NormalizedName` attribute. ```php use Patchlevel\Hydrator\Attribute\NormalizedName; final class DTO { #[NormalizedName('profile_name')] public string $name; } ``` The whole thing looks like this ```json { "profile_name": "David" } ``` :::tip You can also rename properties in events without having a backwards compatibility break by keeping the serialized name. ::: :::note NormalizedName also works for snapshots. But since a snapshot is just a cache, you can also just invalidate it, if you have a backwards compatibility break in the property name. ::: ## Ignore You can also ignore properties with the `Ignore` attribute. ```php use Patchlevel\Hydrator\Attribute\Ignore; final class DTO { #[Ignore] public string $name; } ``` ## Learn more * [How to use the Hydrator](https://github.com/patchlevel/hydrator) * [How to define aggregates](aggregate.md) * [How to define events](events.md) * [How to snapshot aggregates](snapshots.md) * [How to work with personal data](personal-data.md) --- # Message Source: https://patchlevel.dev/docs/event-sourcing/latest/message.md A message is a construct that contains additional meta information for each event in the form of headers. The messages are created in the repository as soon as an aggregate is saved. These messages are then stored in the store and dispatched to the event bus. Here is a simple example without headers: ```php use Patchlevel\EventSourcing\Message\Message; $message = Message::create(new NameChanged('foo')); ``` :::note You don't have to create the message yourself, it is automatically created, saved and dispatched in the [repository](repository.md). ::: You can add a header using `withHeader`: ```php use Patchlevel\EventSourcing\Aggregate\AggregateHeader; use Patchlevel\EventSourcing\Clock\SystemClock; use Patchlevel\EventSourcing\Message\Message; $clock = new SystemClock(); $message = Message::create(new NameChanged('foo')) ->withHeader(new AggregateHeader( aggregateName: 'profile', aggregateId: 'bca7576c-536f-4428-b694-7b1f00c714b7', playhead: 2, recordedOn: $clock->now(), )); ``` :::note The message object is immutable. It creates a new instance with the new data. ::: You can also access the headers: ```php use Patchlevel\EventSourcing\Aggregate\AggregateHeader; use Patchlevel\EventSourcing\Message\Message; /** @var Message $message */ $message->header(AggregateHeader::class); // AggregateHeader object $message->hasHeader(AggregateHeader::class); // true $message->headers(); // [AggregateHeader object] ``` ## Built-in headers The message object has some built-in headers which are used internally. * `AggregateHeader` - Contains the aggregate name, aggregate id, playhead and recorded on. * `ArchivedHeader` - Flag if the message is archived. * `StreamStartHeader` - Flag if the message is the first message in a new stream. ## Custom headers You can also add custom headers to the message object. For example, you can add an application id. To do this, you need to create a Header class. ```php use Patchlevel\EventSourcing\Attribute\Header; #[Header('application')] class ApplicationHeader { public function __construct( private readonly string $id, ) { } } ``` Then you can add the header to the message object. ```php use Patchlevel\EventSourcing\Message\Message; $message = Message::create(new NameChanged('foo')) ->withHeader(new ApplicationHeader('app')); ``` :::warning The header needs to be serializable. The library uses the hydrator to serialize and deserialize the headers. So you can add normalize attributes to the properties if needed. ::: :::note You can read about how to pass additional headers to the message object in the [message decorator](message-decorator.md) docs. ::: You can also access your custom headers: ```php use Patchlevel\EventSourcing\Message\Message; /** @var Message $message */ $message->header(ApplicationHeader::class); ``` ## Missing headers When a message is deserialized, every header name is resolved to its registered header class. If a header name cannot be resolved, for example because the header class was removed or renamed, the `DefaultHeadersSerializer` throws a `HeaderNameNotRegistered` exception by default. In some cases you want to keep reading old messages that still contain such headers without losing their data. For this you can configure which header names should be handled gracefully. Those headers are collected into a single `MissingHeaders` object instead of crashing. The raw names and payloads are preserved, so you could still access them. ```php use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; $serializer = DefaultHeadersSerializer::createFromPaths( ['src/Header'], ['legacyApplication', 'legacyTenant'], ); ``` You can access the collected headers via the `MissingHeaders` object: ```php use Patchlevel\EventSourcing\Message\MissingHeaders; /** @var Message $message */ $missingHeaders = $message->header(MissingHeaders::class); $missingHeaders->headers; // ['legacyApplication' => [...], 'legacyTenant' => [...]] ``` :::warning Only the header names you list are handled gracefully. If a message contains an unregistered header whose name is **not** in the list, deserialization still throws `HeaderNameNotRegistered`. ::: If you want to handle every unregistered header gracefully, you can use the `*` wildcard: ```php use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; $serializer = DefaultHeadersSerializer::createFromPaths( ['src/Header'], ['*'], ); ``` ## Pipe The `Pipe` is a construct that allows you to chain multiple translators. This can be used to manipulate, filter or expand messages or events. This can be used for anti-corruption layers, data migration, or to fix errors in the event stream. ```php use Patchlevel\EventSourcing\Message\Pipe; use Patchlevel\EventSourcing\Message\Translator\ExcludeEventTranslator; use Patchlevel\EventSourcing\Message\Translator\RecalculatePlayheadTranslator; $messages = new Pipe( $messages, new ExcludeEventTranslator([ProfileCreated::class]), new RecalculatePlayheadTranslator(), ); foreach ($messages as $message) { // do something with the message } ``` ## Translator Translator can be used to manipulate, filter or expand messages or events. Translators can also be seen as middlewares. ### Exclude With this translator you can exclude certain events. ```php use Patchlevel\EventSourcing\Message\Translator\ExcludeEventTranslator; $translator = new ExcludeEventTranslator([EmailChanged::class]); ``` ### Include With this translator you can only allow certain events. ```php use Patchlevel\EventSourcing\Message\Translator\IncludeEventTranslator; $translator = new IncludeEventTranslator([ProfileCreated::class]); ``` ### Filter If the translator `ExcludeEventTranslator` and `IncludeEventTranslator` are not sufficient, you can also write your own filter. This translator expects a callback that returns either true to allow events or false to not allow them. ```php use Patchlevel\EventSourcing\Message\Translator\FilterEventTranslator; $translator = new FilterEventTranslator(static function (object $event) { if (!$event instanceof ProfileCreated) { return true; } return $event->allowNewsletter(); }); ``` ### Exclude Events with Header With this translator you can exclude events with a specific header. ```php use Patchlevel\EventSourcing\Message\Translator\ExcludeEventWithHeaderTranslator; use Patchlevel\EventSourcing\Store\ArchivedHeader; $translator = new ExcludeEventWithHeaderTranslator(ArchivedHeader::class); ``` ### Only Events with Header With this translator you can only allow events with a specific header. ```php use Patchlevel\EventSourcing\Message\Translator\IncludeEventWithHeaderTranslator; use Patchlevel\EventSourcing\Store\ArchivedHeader; $translator = new IncludeEventWithHeaderTranslator(ArchivedHeader::class); ``` ### Replace If you want to replace an event, you can use the `ReplaceEventTranslator`. The first parameter you have to define is the event class that you want to replace. And as a second parameter a callback, that the old event awaits and a new event returns. ```php use Patchlevel\EventSourcing\Message\Translator\ReplaceEventTranslator; $translator = new ReplaceEventTranslator(OldVisited::class, static function (OldVisited $oldVisited) { return new NewVisited($oldVisited->profileId()); }); ``` ### Until A use case could also be that you want to look at the projection from a previous point in time. You can use the `UntilEventTranslator` to only allow events that were `recorded` before this point in time. ```php use Patchlevel\EventSourcing\Message\Translator\UntilEventTranslator; $translator = new UntilEventTranslator(new DateTimeImmutable('2020-01-01 12:00:00')); ``` ### Recalculate playhead This translator can be used to recalculate the playhead. The playhead must always be in ascending order so that the data is valid. Some translators can break this order and the `RecalculatePlayheadTranslator` can fix this problem. ```php use Patchlevel\EventSourcing\Message\Translator\RecalculatePlayheadTranslator; $translator = new RecalculatePlayheadTranslator(); ``` :::warning The `RecalculatePlayheadTranslator` is stateful and needs to be. You can't reuse the translator for multiple streams. ::: :::tip If you migrate your event stream, you can use the `RecalculatePlayheadTranslator` to fix the playhead. ::: ### Chain If you want to group your translator, you can use one or more `ChainTranslator`. ```php use Patchlevel\EventSourcing\Message\Translator\ChainTranslator; use Patchlevel\EventSourcing\Message\Translator\ExcludeEventTranslator; use Patchlevel\EventSourcing\Message\Translator\RecalculatePlayheadTranslator; $translator = new ChainTranslator([ new ExcludeEventTranslator([EmailChanged::class]), new RecalculatePlayheadTranslator(), ]); ``` ### Custom Translator You can also write a custom translator. The translator gets a message and can return `n` messages. There are the following possibilities: * Return only the message in an array to leave it unchanged. * Put another message in the array to swap the message. * Return an empty array to remove the message. * Or return multiple messages to enrich the stream. In our case, the domain has changed a bit. In the beginning we had a `ProfileCreated` event that just created a profile. Now we have a `ProfileRegistered` and a `ProfileActivated` event, which should replace the `ProfileCreated` event. ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Translator\Translator; final class SplitProfileCreatedTranslator implements Translator { public function __invoke(Message $message): array { $event = $message->event(); if (!$event instanceof ProfileCreated) { return [$message]; } $profileRegisteredMessage = Message::createWithHeaders( new ProfileRegistered($event->id(), $event->name()), $message->headers(), ); $profileActivatedMessage = Message::createWithHeaders( new ProfileActivated($event->id()), $message->headers(), ); return [$profileRegisteredMessage, $profileActivatedMessage]; } } ``` :::warning Since we changed the number of messages, we have to recalculate the playhead. ::: :::tip You don't have to migrate the store directly for every change, but you can also use the [upcasting](upcasting.md) feature. ::: ## Reducer The `Reducer` is a construct that allows you to reduce messages to a state. This can be used to build temporal projections or to create a read model. ### Initial state The initial state is the state that is used at the beginning of the reduction. ```php use Patchlevel\EventSourcing\Message\Reducer; $state = (new Reducer()) ->initState(['count' => 0]) ->reduce($messages); // state is ['count' => 0] ``` ### When The `when` method is used to define a function that is called when a specific event occurs. It gets the message and the current state and returns the new state. ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Reducer; $state = (new Reducer()) ->initState([ 'names' => [], ]) ->when( ProfileCreated::class, static function (Message $message, array $state): array { $state['names'][] = $message->event()->name; return $state; }, ) ->reduce($messages); // state is ['names' => ['foo', 'bar']] ``` ### Match You can also use the `match` method to define multiple events at once. ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Reducer; $state = (new Reducer()) ->match([ ProfileCreated::class => static function (Message $message, array $state): array { return [...$state, $message]; }, ]) ->reduce($messages); ``` ### Any If you want to react to any event, you can use the `any` method. ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Reducer; $state = (new Reducer()) ->any( static function (Message $message, array $state): array { return [...$state, $message]; }, ) ->reduce($messages); ``` ### Finalize If you want to do something with the state after the reduction, you can use the `finalize` method. This method gets the state and returns the new state. ```php use Patchlevel\EventSourcing\Message\Reducer; $state = (new Reducer()) ->finalize( static function (array $state): array { return ['count' => count($state['messages'])]; }, ) ->reduce($messages); // state is ['count' => 2] ``` ## Learn more * [How to decorate messages](message-decorator.md) * [How to load aggregates](repository.md) * [How to store messages](store.md) * [How to use subscriptions](subscription.md) * [How to use the event bus](event-bus.md) --- # Message Decorator Source: https://patchlevel.dev/docs/event-sourcing/latest/message-decorator.md There are use-cases where you want to add some extra context to your events like metadata which is not directly relevant for your domain. With `MessageDecorator` we are providing a solution to add this metadata to your events. The metadata will also be persisted in the database and can be retrieved later on. ## Built-in decorator We offer a few decorators that you can use. ### SplitStreamDecorator In order to use the [split stream](split-stream.md) feature, the `SplitStreamDecorator` must be added. ```php use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; $eventMetadataFactory = new AttributeEventMetadataFactory(); $decorator = new SplitStreamDecorator($eventMetadataFactory); ``` ### ChainMessageDecorator To use multiple decorators at the same time, you can use the `ChainMessageDecorator`. ```php use Patchlevel\EventSourcing\Repository\MessageDecorator\ChainMessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator; /** * @var MessageDecorator $decorator1 * @var MessageDecorator $decorator2 */ $decorator = new ChainMessageDecorator([ $decorator1, $decorator2, ]); ``` ## Use decorator To use the message decorator, you have to pass it to the `DefaultRepositoryManager`, which will then pass it to all Repositories. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Repository\MessageDecorator\ChainMessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; use Patchlevel\EventSourcing\Store\Store; /** @var EventMetadataFactory $eventMetadataFactory */ $decorator = new ChainMessageDecorator([new SplitStreamDecorator($eventMetadataFactory)]); /** * @var AggregateRootRegistry $aggregateRootRegistry * @var Store $store */ $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, $store, null, null, $decorator, ); $repository = $repositoryManager->get(Profile::class); ``` :::note You can find out more about the [repository](repository.md). ::: ## Create own decorator You can also use this feature to add your own metadata to your events. For this, the `Message` has extra methods: `withHeader` to add data and `header` to read this data later on. ```php use Patchlevel\EventSourcing\Attribute\Header; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator; #[Header('system')] final class SystemHeader { public function __construct( public string $system, ) { } } final class OnSystemRecordedDecorator implements MessageDecorator { public function __invoke(Message $message): Message { return $message->withHeader(new SystemHeader('system')); } } ``` :::note The message is immutable, more information can be found in the [message](message.md) documentation. ::: :::tip You can also set multiple headers with `withHeaders` which expects a list of headers. ::: ## Learn more * [How to create messages](message.md) * [How to define events](events.md) * [How to configure repositories](repository.md) * [How to upcast events](upcasting.md) --- # Getting Started Source: https://patchlevel.dev/docs/event-sourcing/latest/getting-started.md In our little getting started example, we manage hotels. We keep the example small, so we can only create hotels and let guests check in and check out. ## Define some events First we define the events that happen in our system. A hotel can be created with a `name` and an `id`: ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.created')] final class HotelCreated { public function __construct( public readonly Uuid $hotelId, public readonly string $hotelName, ) { } } ``` A guest can check in by `name`: ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.guest_checked_in')] final class GuestIsCheckedIn { public function __construct( public readonly Uuid $hotelId, public readonly string $guestName, ) { } } ``` And also check out again: ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.guest_checked_out')] final class GuestIsCheckedOut { public function __construct( public readonly Uuid $hotelId, public readonly string $guestName, ) { } } ``` :::note You can find out more about [events](events.md). ::: ## Define aggregates Next we need to define the hotel aggregate. How you can interact with it, which events happen and what the business rules are. For this we create the methods `create`, `checkIn` and `checkOut`. In these methods the business checks are made and the events are recorded. Last but not least, we need the associated apply methods to change the state. ```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\Id; #[Aggregate('hotel')] final class Hotel extends BasicAggregateRoot { #[Id] private Uuid $id; private string $name; /** @var list */ private array $guests; public function name(): string { return $this->name; } /** @return list */ public function guests(): array { return $this->guests; } public static function create(Uuid $id, string $hotelName): static { $self = new static(); $self->recordThat(new HotelCreated($id, $hotelName)); return $self; } public function checkIn(string $guestName): void { if (in_array($guestName, $this->guests, true)) { throw new GuestHasAlreadyCheckedIn($guestName); } $this->recordThat(new GuestIsCheckedIn($this->id, $guestName)); } public function checkOut(string $guestName): void { if (!in_array($guestName, $this->guests, true)) { throw new IsNotAGuest($guestName); } $this->recordThat(new GuestIsCheckedOut($this->id, $guestName)); } #[Apply] protected function applyHotelCreated(HotelCreated $event): void { $this->id = $event->hotelId; $this->name = $event->hotelName; $this->guests = []; } #[Apply] protected function applyGuestIsCheckedIn(GuestIsCheckedIn $event): void { $this->guests[] = $event->guestName; } #[Apply] protected function applyGuestIsCheckedOut(GuestIsCheckedOut $event): void { $this->guests = array_values( array_filter( $this->guests, static fn ($name) => $name !== $event->guestName, ), ); } } ``` :::note You can find out more about [aggregates](aggregate.md). ::: ## Define projections So that we can see all the hotels on our website and also see how many guests are currently visiting the hotels, we need a projection for it. To create a projection we need a projector. Each projector is then responsible for a specific projection. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Teardown; #[Projector(self::TABLE)] final class HotelProjector { // use a const for easier access in the projector & to keep projector id and table name in sync private const TABLE = 'hotel'; public function __construct( private readonly Connection $db, ) { } /** @return list */ public function getHotels(): array { return $this->db->fetchAllAssociative(sprintf('SELECT id, name, guests FROM %s;', self::TABLE)); } #[Subscribe(HotelCreated::class)] public function handleHotelCreated(HotelCreated $event): void { $this->db->insert( self::TABLE, [ 'id' => $event->hotelId->toString(), 'name' => $event->hotelName, 'guests' => 0, ], ); } #[Subscribe(GuestIsCheckedIn::class)] public function handleGuestIsCheckedIn(GuestIsCheckedIn $event): void { $this->db->executeStatement( sprintf('UPDATE %s SET guests = guests + 1 WHERE id = ?;', self::TABLE), [$event->hotelId->toString()], ); } #[Subscribe(GuestIsCheckedOut::class)] public function handleGuestIsCheckedOut(GuestIsCheckedOut $event): void { $this->db->executeStatement( sprintf('UPDATE %s SET guests = guests - 1 WHERE id = ?;', self::TABLE), [$event->hotelId->toString()], ); } #[Setup] public function create(): void { $this->db->executeStatement(sprintf('CREATE TABLE IF NOT EXISTS %s (id VARCHAR PRIMARY KEY, name VARCHAR, guests INTEGER);', self::TABLE)); } #[Teardown] public function drop(): void { $this->db->executeStatement(sprintf('DROP TABLE IF EXISTS %s;', self::TABLE)); } } ``` :::note You can find out more about [projectors](subscription.md). ::: ## Processor In our example we also want to email the head office as soon as a guest is checked in. ```php use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; #[Processor('admin_emails')] final class SendCheckInEmailProcessor { public function __construct( private readonly Mailer $mailer, ) { } #[Subscribe(GuestIsCheckedIn::class)] public function onGuestIsCheckedIn(GuestIsCheckedIn $event): void { $this->mailer->send( 'hq@patchlevel.de', 'Guest is checked in', sprintf('A new guest named "%s" is checked in', $event->guestName), ); } } ``` :::note You can find out more about [processors](subscription.md). ::: ## Configuration After we have defined everything, we still have to plug the whole thing together: :::tip If you use symfony, you can use our [symfony bundle](/docs/event-sourcing-bundle/latest/installation) to skip this step. ::: ```php use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegistryFactory; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; $connection = DriverManager::getConnection( (new DsnParser())->parse('pdo-pgsql://user:secret@localhost/app'), ); $projectionConnection = DriverManager::getConnection( (new DsnParser())->parse('pdo-pgsql://user:secret@localhost/projection'), ); /* your own mailer */ $mailer; $serializer = DefaultEventSerializer::createFromPaths(['src/Domain/Hotel/Event']); $aggregateRegistry = (new AttributeAggregateRootRegistryFactory())->create(['src/Domain/Hotel']); $eventStore = new DoctrineDbalStore( $connection, $serializer, ); $hotelProjector = new HotelProjector($projectionConnection); $subscriberRepository = new MetadataSubscriberAccessorRepository([ $hotelProjector, new SendCheckInEmailProcessor($mailer), ]); $subscriptionStore = new DoctrineSubscriptionStore($connection); $engine = new DefaultSubscriptionEngine( new StoreMessageLoader($eventStore), $subscriptionStore, $subscriberRepository, ); $repositoryManager = new RunSubscriptionEngineRepositoryManager( new DefaultRepositoryManager( $aggregateRegistry, $eventStore, ), $engine, ); $hotelRepository = $repositoryManager->get(Hotel::class); ``` :::note You can find out more about [stores](store.md). ::: :::note The `RunSubscriptionEngineRepositoryManager` is a decorator that triggers the Subscription Engine when an Aggregate is saved. Normally, you'd use the `DefaultRepositoryManager` and a worker to run the Subscription Engine. Learn more about the [subscription engine](subscription.md). ::: ## Database setup So that we can actually write the data to a database, we need the associated schema and databases. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; /** * @var Connection $connection * @var Store $eventStore * @var SubscriptionStore $subscriptionStore */ $schemaDirector = new DoctrineSchemaDirector( $connection, new ChainDoctrineSchemaConfigurator([ $eventStore, $subscriptionStore, ]), ); $schemaDirector->create(); /** @var SubscriptionEngine $engine */ $engine->setup(skipBooting: true); ``` :::note You can use the predefined [cli commands](cli.md) for this. ::: ## Usage We are now ready to use the Event Sourcing System. We can load, change and save aggregates. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Repository\Repository; $hotel1 = Hotel::create(Uuid::generate(), 'HOTEL'); $hotel1->checkIn('David'); $hotel1->checkIn('Daniel'); $hotel1->checkOut('David'); /** @var Repository $hotelRepository */ $hotelRepository->save($hotel1); $hotel2 = $hotelRepository->load(Uuid::fromString('d0d0d0d0-d0d0-d0d0-d0d0-d0d0d0d0d0d0')); $hotel2->checkIn('David'); $hotelRepository->save($hotel2); $hotels = $hotelProjector->getHotels(); ``` :::note You can also use other forms of IDs such as uuid version 6 or a custom format. You can find more about this in the [aggregate id](aggregate-id.md) documentation. ::: ## Result :::success We have successfully implemented and used event sourcing. Feel free to browse further in the documentation for more detailed information. If there are still open questions, create a ticket on Github and we will try to help you. ::: ## Learn more * [How to create an aggregate](aggregate.md) * [How to create an event](events.md) * [How to store aggregates](repository.md) * [How to create a projection and processors](subscription.md) * [How to setup the database](store.md) --- # Events Source: https://patchlevel.dev/docs/event-sourcing/latest/events.md Events are used to describe things that happened in the application. Since the events already happened, they are also immutable. In event sourcing, these are used to save and rebuild the current state. You can also listen on events to react and perform different actions. An event has a name and additional information called payload. Such an event can be represented as any class. It is important that the payload can be serialized as JSON at the end. Later it will be explained how to ensure it for all values. To register an event you have to set the `Event` attribute over the class, otherwise it will not be recognized as an event. There you also have to give the event a name. ```php use Patchlevel\EventSourcing\Attribute\Event; #[Event(name: 'profile.created')] final class ProfileCreated { public function __construct( public readonly string $profileId, public readonly string $name, ) { } } ``` :::warning The payload must be serializable and unserializable as json. ::: :::tip An event should be named in the past because it has already happened. Best practice is to prefix the event names with the aggregate name, lowercase everything, and replace spaces with underscores. Here are some examples: * `profile.created` * `profile.name_changed` * `hotel.guest_checked_out` ::: ## Alias You also have the option to set aliases for the events. This can be useful when you want to rename events but still need to process the old ones. ```php use Patchlevel\EventSourcing\Attribute\Event; #[Event(name: 'profile.registered', aliases: ['profile.created'])] final class ProfileRegistered { } ``` When saving, the name will always be used. However, when loading, aliases will also be taken into account. :::note In the database, the name of the event is always stored, allowing the class to be renamed without encountering any issues. ::: :::tip If you want to make significant changes to an event, you can take a look at the [Upcaster](upcasting.md). ::: ## Serializer So that the events can be saved in the database, they must be serialized and deserialized. That's what the serializer is for. The library comes with a `DefaultEventSerializer` that can be given further instructions using attributes. ```php use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; $serializer = DefaultEventSerializer::createFromPaths(['src/Domain']); ``` The serializer needs the path information where the event classes are located so that it can instantiate the correct classes. Internally, an EventRegistry is used, which will be described later. ## Normalizer Sometimes you also want to add more complex data as a payload. For example DateTime or value objects. You can do that too. However, you must define a normalizer for this so that the library knows how to write this data to the database and load it again. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; #[Event('profile.created')] final class ProfileCreated { public function __construct( #[IdNormalizer] public readonly Uuid $id, #[NameNormalizer] public readonly Name $name, #[DateTimeImmutableNormalizer] public readonly DateTimeImmutable $createdAt, ) { } } ``` :::tip Built-in normalizers like `IdNormalizer` and `DateTimeImmutableNormalizer` can be inferred from the type hint and so you don't have to specify them. If you want to configure the Normalizer, you still have to do it. ::: :::note You can find out more about [normalizer](normalizer.md). ::: ## Event Registry The library needs to know about all events so that the correct event class is used for the serialization and deserialization of an event. There is an EventRegistry for this purpose. The registry is a simple hashmap between event name and event class. ```php use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; $eventRegistry = new EventRegistry([ 'profile.created' => ProfileCreated::class, ]); ``` So that you don't have to create it by hand, you can use a factory. By default, the `AttributeEventRegistryFactory` is used. There, with the help of paths, all classes with the attribute `Event` are searched for and the `EventRegistry` is built up. ```php use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; $eventRegistry = (new AttributeEventRegistryFactory())->create([/* paths... */]); ``` ## Learn more * [How to normalize events](normalizer.md) * [How to subscribe on events](subscription.md) * [How to store events](store.md) * [How to upcast events](upcasting.md) * [How to use messages](message.md) --- # Event Bus Source: https://patchlevel.dev/docs/event-sourcing/latest/event-bus.md Optionally you can use an event bus to dispatch events to listeners. For all events that are persisted (when the `save` method has been executed on the [repository](repository.md)), the event wrapped in a message will be dispatched to the `event bus`. All listeners are then called for each message. :::tip It is recommended to use the [subscription engine](subscription.md) to process the messages. It is more powerful and flexible than the event bus. ::: ## Event Bus The library delivers a light-weight event bus for which you can register listeners and dispatch events. ```php use Patchlevel\EventSourcing\EventBus\DefaultEventBus; $eventBus = DefaultEventBus::create([$mailListener]); ``` :::note The order in which the listeners are executed is determined by the order in which they are passed to the factory. ::: Internally, the event bus uses the `Consumer` to consume the messages and call the listeners. ## Consumer The consumer is responsible for consuming the messages and calling the listeners. ```php use Patchlevel\EventSourcing\EventBus\DefaultConsumer; $consumer = DefaultConsumer::create([$mailListener]); $consumer->consume($message); ``` Internally, the consumer uses the `ListenerProvider` to find the listeners for the message. ## Listener provider The listener provider is responsible for finding all listeners for a specific event. The default listener provider uses attributes to find the listeners. ```php use Patchlevel\EventSourcing\EventBus\AttributeListenerProvider; use Patchlevel\EventSourcing\EventBus\DefaultConsumer; use Patchlevel\EventSourcing\EventBus\DefaultEventBus; $listenerProvider = new AttributeListenerProvider([$mailListener]); $eventBus = new DefaultEventBus( new DefaultConsumer($listenerProvider), ); ``` :::tip The `DefaultEventBus::create` method uses the `DefaultConsumer` and `AttributeListenerProvider` by default. ::: ### Custom listener provider You can also use your own listener provider. ```php use Patchlevel\EventSourcing\EventBus\ListenerDescriptor; use Patchlevel\EventSourcing\EventBus\ListenerProvider; $listenerProvider = new class implements ListenerProvider { public function listenersForEvent(string $eventClass): iterable { return [ new ListenerDescriptor( (new WelcomeSubscriber())->onProfileCreated(...), ), ]; } }; ``` :::tip You can use `$listenerDescriptor->name()` to get the name of the listener. ::: ## Listener You can listen for specific events with the attribute `Subscribe`. This listener is then called for all saved events / messages. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; final class WelcomeSubscriber { #[Subscribe(ProfileCreated::class)] public function onProfileCreated(Message $message): void { echo 'Welcome!'; } } ``` :::tip If you use psalm, you can use the [event sourcing plugin](https://github.com/patchlevel/event-sourcing-psalm-plugin) for better type support. ::: ### Listen on all events If you want to listen on all events, you can pass `*` or `Subscribe::ALL` instead of the event class. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; final class WelcomeSubscriber { #[Subscribe('*')] public function onProfileCreated(Message $message): void { echo 'Welcome!'; } } ``` ## Psr-14 Event Bus You can also use a [psr-14](https://www.php-fig.org/psr/psr-14/) compatible event bus. In this case, you can't use the `Subscribe` attribute. You need to use the system of the psr-14 event bus. ```php use Patchlevel\EventSourcing\EventBus\Psr14EventBus; $eventBus = new Psr14EventBus($psr14EventDispatcher); ``` :::warning You can't use the `Subscribe` attribute with the psr-14 event bus. ::: ## Learn more * [How to use messages](message.md) * [How to use events](events.md) * [How to use the subscription engine](subscription.md) * [How to use repositories](repository.md) * [How to decorate messages](message-decorator.md) --- # Command Bus Source: https://patchlevel.dev/docs/event-sourcing/latest/command-bus.md The Command Bus is an optional component in the Event Sourcing library that coordinates the execution of commands. It allows commands to be forwarded to the appropriate aggregates and their handlers to be invoked. This promotes a clear separation of responsibilities and simplifies the management of business logic. ## Command First of all, you need to create a command class. A command is a simple data transfer object that represents an intention to perform an action. ```php final class CreateProfile { public function __construct( public readonly ProfileId $id, public readonly string $name, ) { } } ``` ## Handler Then you need to create a handler class. A handler is a class that contains the business logic for a command. It will be invoked when a command is dispatched. You need to mark the method that handles the command with the `#[Handle]` attribute. ```php use Patchlevel\EventSourcing\Attribute\Handle; final class CreateProfileHandler { #[Handle] public function __invoke(CreateProfile $command): void { // handle command } } ``` :::note To use Service Handler you need to register the handler in the `ServiceHandlerProvider`. ::: :::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 Another way to handle commands is to use the aggregates themselves. To do this, you need to mark the method that handles the command with the `#[Handle]` attribute. :::note The aggregates themselves are of course not a service. The AggregateHandlerProvider uses the aggregates to create the handlers for you. You can find out more about this in the [providers](command-bus.md#provider) section. ::: #### Create Aggregate If you want to create a new aggregate, you need to create a static method that returns a new instance of the aggregate. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private ProfileId $id; private string $name; #[Handle] public static function create(CreateProfile $command): self { $self = new self(); $self->recordThat(new ProfileCreated($command->id, $command->name)); return $self; } // ... apply methods } ``` :::tip You can find more information about [aggregates](aggregate.md). ::: #### Update Aggregate If you want to update an existing aggregate, first you need to mark the `aggregate id` with the `#[Id]` attribute in the command class. Otherwise, the handler does not know which aggregates should be loaded. ```php use Patchlevel\EventSourcing\Attribute\Id; final class ChangeProfileName { public function __construct( #[Id] public readonly ProfileId $id, public readonly string $name, ) { } } ``` Then you need to create a method that changes the aggregate state. Here too, you need to mark the method with the `#[Handle]` attribute. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private ProfileId $id; private string $name; #[Handle] public function changeName(ChangeProfileName $command): void { $this->recordThat(new NameChanged($command->name)); } // ... 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. Starting with the second parameter, it automatically tries to inject the service using a service locator. By default, it uses the fully qualified class name from the parameter type hint to find the service. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Handle; use Psr\Clock\ClockInterface; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Handle] public static function create( CreateProfile $command, ClockInterface $clock, ): self { $self = new self(); $self->recordThat(new ProfileCreated( $command->id, $command->name, $clock->now(), )); return $self; } // ... apply methods } ``` :::note The service must be registered in the service locator. ::: :::tip You can inject multiple services into the handler method. ::: Or you can inject the service manually using the `#[Inject]` attribute. There you can specify the service name that should be injected. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\Attribute\Inject; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Handle] public static function create( CreateProfile $command, #[Inject('name_validator')] NameValidator $nameValidator, ): self { $self = new self(); if (!$nameValidator($command->name)) { throw new InvalidArgument(); } $self->recordThat(new ProfileCreated($command->id, $command->name)); return $self; } // ... apply methods } ``` :::note Injection in handler methods is only possible with the `AggregateHandlerProvider`. ::: ## Setup We provide a `SyncCommandBus` that you can use to dispatch commands. You need to pass a `HandlerProvider` to the constructor. ```php use Patchlevel\EventSourcing\CommandBus\HandlerProvider; use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; /** @var HandlerProvider $handlerProvider */ $commandBus = new SyncCommandBus($handlerProvider); $commandBus->dispatch(new CreateProfile($profileId, 'name')); $commandBus->dispatch(new ChangeProfileName($profileId, 'new name')); ``` ### Instant Retry If you want to retry the command when defined exceptions occur, you can use the `InstantRetryCommandBus` command bus decorator. ```php use Patchlevel\EventSourcing\CommandBus\CommandBus; use Patchlevel\EventSourcing\CommandBus\InstantRetryCommandBus; use Patchlevel\EventSourcing\Repository\AggregateOutdated; /** @var CommandBus $commandBus */ $commandBus = new InstantRetryCommandBus( $commandBus, 3, // maximum number of retries, default is 3 [AggregateOutdated::class], // exceptions to retry, default is [AggregateOutdated::class] ); ``` After that, you need to mark the command class with the `#[InstantRetry]` attribute, to indicate that the command should be retried when the condition is met. ```php use Patchlevel\EventSourcing\Attribute\InstantRetry; #[InstantRetry] final class CreateProfile { public function __construct( public readonly ProfileId $id, public readonly string $name, ) { } } ``` :::tip You can override the default values for the maximum number of retries and the conditions by passing them to the `InstantRetry` attribute. ```php use Patchlevel\EventSourcing\Attribute\InstantRetry; #[InstantRetry(3, [AggregateOutdated::class])] final class CreateProfile { } ``` ::: ## Provider There are different types of providers that you can use to register handlers. ### Service Handler Provider The classic way to handle commands is to use services. The `ServiceHandlerProvider` is used to handle commands by invoking methods on services. ```php use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; $provider = new ServiceHandlerProvider([ new CreateProfileHandler(), new ChangeProfileNameHandler( new NameValidator(), ), ]); ``` ### Aggregate Handler Provider The `AggregateHandlerProvider` is used to handle commands by invoking methods on aggregates. The special thing about it is that the aggregates themselves are not services, but the handler provider automatically creates suitable handler services for the aggregates. ```php use Patchlevel\EventSourcing\CommandBus\AggregateHandlerProvider; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\RepositoryManager; /** * @var AggregateRootRegistry $aggregateRootRegistry * @var RepositoryManager $repositoryManager */ $provider = new AggregateHandlerProvider( $aggregateRootRegistry, $repositoryManager, ); ``` #### Service Locator If you want service injection in aggregate handler methods, you need to pass a service locator to the `AggregateHandlerProvider`. You can use any psr-11 compatible container, or you can use our implementation `ServiceLocator`. ```php use Patchlevel\EventSourcing\CommandBus\AggregateHandlerProvider; use Patchlevel\EventSourcing\CommandBus\ServiceLocator; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Repository\RepositoryManager; /** * @var AggregateRootRegistry $aggregateRootRegistry * @var RepositoryManager $repositoryManager */ $provider = new AggregateHandlerProvider( $aggregateRootRegistry, $repositoryManager, new ServiceLocator([ 'name_validator' => new NameValidator(), ]), // or other psr-11 compatible container ); ``` :::tip You can find suitable implementations of psr-11 containers on [packagist](https://packagist.org/search/?tags=PSR-11). ::: ### Chain Handler Provider The `ChainHandlerProvider` allows you to combine multiple handler providers. ```php use Patchlevel\EventSourcing\CommandBus\ChainHandlerProvider; $provider = new ChainHandlerProvider([ $serviceHandlerProvider, $aggregateHandlerProvider, ]); ``` ## Learn more * [How to use aggregates](aggregate.md) * [How to use events](events.md) * [How to use clock](clock.md) * [How to use aggregate id](aggregate-id.md) * [How to use query bus](query-bus.md) --- # Clock Source: https://patchlevel.dev/docs/event-sourcing/latest/clock.md We are using the clock to get the current datetime. This is needed to create the `recorded_on` datetime for the event stream. We have two implementations of the clock, one for the production and one for the tests. But you can also create your own implementation that is compatible with the [PSR-20 clock specification](https://www.php-fig.org/psr/psr-20/). ## SystemClock This uses the native system clock to return the `DateTimeImmutable` instance. ```php use Patchlevel\EventSourcing\Clock\SystemClock; $clock = new SystemClock(); $date = $clock->now(); // get the actual datetime $date2 = $clock->now(); // $date == $date2 => false // $date === $date2 => false ``` ## FrozenClock This implementation should only be used for the tests. This enables you to freeze the time and with that to have deterministic tests. ```php use Patchlevel\EventSourcing\Clock\FrozenClock; $date = new DateTimeImmutable(); $clock = new FrozenClock($date); $frozenDate = $clock->now(); // gets the date provided before // $date == $frozenDate => true // $date === $frozenDate => false ``` The `FrozenClock` can also be updated with a new date, so you can test a jump in time. ```php use Patchlevel\EventSourcing\Clock\FrozenClock; $firstDate = new DateTimeImmutable(); $clock = new FrozenClock($firstDate); $secondDate = new DateTimeImmutable(); $clock->update($secondDate); $frozenDate = $clock->now(); // $firstDate == $frozenDate => false // $secondDate == $frozenDate => true ``` Or you can use the `sleep` method to simulate a time jump. ```php use Patchlevel\EventSourcing\Clock\FrozenClock; $firstDate = new DateTimeImmutable(); $clock = new FrozenClock($firstDate); $clock->sleep(10); // sleep 10 seconds ``` :::note The instance of the frozen datetime will be cloned internally, so it's not the same instance but equal. ::: ## Learn more * [How to test with datetime](testing.md) * [How to normalize datetime](normalizer.md) * [How to use messages](message.md) --- # CLI Source: https://patchlevel.dev/docs/event-sourcing/latest/cli.md This library provides a `cli` to manage the `event-sourcing` functionalities. You can: * Create and delete `databases` * Create, update and delete `schemas` * Manage `subscriptions` ## Database commands There are two commands for creating and deleting a database. * DatabaseCreateCommand: `event-sourcing:database:create` * DatabaseDropCommand: `event-sourcing:database:drop` ## Schema commands The database schema can also be created, updated and dropped. * SchemaCreateCommand: `event-sourcing:schema:create` * SchemaUpdateCommand: `event-sourcing:schema:update` * SchemaDropCommand: `event-sourcing:schema:drop` :::note You can also register doctrine migration commands. ::: ## Subscription commands To manage your subscriptions there are the following cli commands. * SubscriptionBootCommand: `event-sourcing:subscription:boot` * SubscriptionPauseCommand: `event-sourcing:subscription:pause` * SubscriptionReactivateCommand: `event-sourcing:subscription:reactivate` * SubscriptionRefreshCommand: `event-sourcing:subscription:refresh` * SubscriptionRemoveCommand: `event-sourcing:subscription:remove` * SubscriptionRunCommand: `event-sourcing:subscription:run` * SubscriptionSetupCommand: `event-sourcing:subscription:setup` * SubscriptionStatusCommand: `event-sourcing:subscription:status` * SubscriptionTeardownCommand: `event-sourcing:subscription:teardown` :::note You can find out more about [subscriptions](subscription.md). ::: ## Inspector commands The inspector is a tool to inspect the event streams. * ShowCommand: `event-sourcing:show` * ShowAggregateCommand: `event-sourcing:show-aggregate` * WatchCommand: `event-sourcing:watch` ## CLI example A cli php file can look like this: ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Console\Command; use Patchlevel\EventSourcing\Console\DoctrineHelper; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Symfony\Component\Console\Application; $cli = new Application('Event-Sourcing CLI'); $cli->setCatchExceptions(true); $doctrineHelper = new DoctrineHelper(); /** * @var Connection $connection * @var Store $store */ $schemaDirector = new DoctrineSchemaDirector($connection, $store); /** @var SubscriptionEngine $subscriptionEngine */ $cli->addCommands([ new Command\DatabaseCreateCommand($connection, $doctrineHelper), new Command\DatabaseDropCommand($connection, $doctrineHelper), new Command\SubscriptionBootCommand($subscriptionEngine), new Command\SubscriptionPauseCommand($subscriptionEngine), new Command\SubscriptionRunCommand($subscriptionEngine, $store), new Command\SubscriptionTeardownCommand($subscriptionEngine), new Command\SubscriptionRemoveCommand($subscriptionEngine), new Command\SubscriptionReactivateCommand($subscriptionEngine), new Command\SubscriptionSetupCommand($subscriptionEngine), new Command\SubscriptionStatusCommand($subscriptionEngine), new Command\SchemaCreateCommand($schemaDirector), new Command\SchemaDropCommand($schemaDirector), new Command\SchemaUpdateCommand($schemaDirector), ]); $cli->run(); ``` ### Doctrine Migrations If you want to use doctrine migrations, you can register the commands like this: ```php use Doctrine\DBAL\Connection; use Doctrine\Migrations\Configuration\Connection\ExistingConnection; use Doctrine\Migrations\Configuration\Migration\ConfigurationLoader; use Doctrine\Migrations\DependencyFactory; use Doctrine\Migrations\Provider\SchemaProvider; use Doctrine\Migrations\Tools\Console\Command; use Patchlevel\EventSourcing\Schema\DoctrineMigrationSchemaProvider; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; use Symfony\Component\Console\Application; /** * @var Connection $connection * @var Store $store */ $schemaDirector = new DoctrineSchemaDirector($connection, $store); /** @var ConfigurationLoader $migrationConfig */ $dependencyFactory = DependencyFactory::fromConnection( $migrationConfig, new ExistingConnection($connection), ); $dependencyFactory->setService( SchemaProvider::class, new DoctrineMigrationSchemaProvider($schemaDirector), ); /** @var Application $cli */ $cli->addCommands([ new Command\ExecuteCommand($dependencyFactory, 'event-sourcing:migrations:execute'), new Command\GenerateCommand($dependencyFactory, 'event-sourcing:migrations:generate'), new Command\LatestCommand($dependencyFactory, 'event-sourcing:migrations:latest'), new Command\ListCommand($dependencyFactory, 'event-sourcing:migrations:list'), new Command\MigrateCommand($dependencyFactory, 'event-sourcing:migrations:migrate'), new Command\DiffCommand($dependencyFactory, 'event-sourcing:migrations:diff'), new Command\StatusCommand($dependencyFactory, 'event-sourcing:migrations:status'), new Command\VersionCommand($dependencyFactory, 'event-sourcing:migrations:version'), ]); ``` :::note Here you can find more information on how to [configure doctrine migration](https://www.doctrine-project.org/projects/doctrine-migrations/en/3.3/reference/custom-configuration.html). ::: ## Learn more * [How to configure store](store.md) * [How to configure subscription engine](subscription.md) --- # Aggregate Source: https://patchlevel.dev/docs/event-sourcing/latest/aggregate.md The linchpin of event-sourcing is the aggregate. These aggregates can be imagined like entities in ORM. One main difference is that we don't save the current state, but only the individual events that led to the state. This means it is always possible to build the current state again from the events. :::note The term aggregate itself comes from DDD and has nothing to do with event sourcing and can be used independently as a pattern. You can find out more about aggregates in [Martin Fowler's article about the DDD Aggregate pattern](https://martinfowler.com/bliki/DDD_Aggregate.html). ::: An aggregate must fulfill a few points so that we can use it in event-sourcing: * It must implement the `AggregateRoot` interface. * It needs a unique identifier. * It needs to provide the current playhead. * It must make changes to its state available as events. * And rebuild/catchup its state from the events. We can implement this ourselves, or use the `BasicAggregateRoot` implementation that already brings everything with it. This basic implementation uses attributes to configure the aggregate and to specify how it should handle events. We are building a minimal aggregate class here which only has an ID and mark this with the `Id` attribute. To make it easy to register with a name, we also add the `Aggregate` attribute. This is what it looks like: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private Uuid $id; public static function register(Uuid $id): self { $self = new self(); $self->id = $id; // we need to set the id temporary here for the basic example and will be replaced later. return $self; } } ``` :::warning The aggregate is not yet finished and has only been built to the point that you can instantiate the object. ::: :::tip Find out more about [aggregate IDs](aggregate-id.md). ::: We use a so-called named constructor here to create an object of the AggregateRoot. The constructor itself is protected and cannot be called from outside. But it is possible to define different named constructors for different use-cases like `import`. After the basic structure for an aggregate is in place, it could theoretically be saved: ```php use Patchlevel\EventSourcing\Repository\Repository; final class CreateProfileHandler { public function __construct( private readonly Repository $profileRepository, ) { } public function __invoke(CreateProfile $command): void { $profile = Profile::register($command->id()); $this->profileRepository->save($profile); } } ``` :::warning If you look in the database now, you would see that nothing has been saved. This is because only events are stored in the database and as long as no events exist, nothing happens. ::: :::tip A **command bus** system is not necessary, only recommended. The interaction can also easily take place in a controller or service. ::: ## Create a new aggregate In order that an aggregate is actually saved, at least one event must exist in the DB. For our aggregate we create the Event `ProfileRegistered` with an ID and a name. We also give the event a unique name using the `Event` attribute. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('profile.registered')] final class ProfileRegistered { public function __construct( public readonly Uuid $profileId, public readonly string $name, ) { } } ``` :::note You can find out more about [events](events.md). ::: After we have defined the event, we have to adapt the profile 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\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private Uuid $id; private string $name; public function name(): string { return $this->name; } public static function register(Uuid $id, string $name): self { $self = new self(); $self->recordThat(new ProfileRegistered($id, $name)); return $self; } #[Apply] protected function applyProfileRegistered(ProfileRegistered $event): void { $this->id = $event->profileId; $this->name = $event->name; } } ``` :::tip Prefixing the apply methods with "apply" improves readability. ::: In our named constructor `register` we have now created the event and recorded it with the method `recordThat`. The aggregate remembers all new recorded events in order to save them later. At the same time, a defined `apply` method is executed directly so that we can change our state. So that the AggregateRoot also knows which method it should call, we have to mark it with the `Apply` attribute. We did that in the `applyProfileRegistered` method. In there we then change the state of the aggregate by filling the properties with the values from the event. :::success The aggregate is now ready to be saved! ::: ### Modify an aggregate In order to change the state of the aggregates afterwards, only further events have to be defined. As example we can add a `NameChanged` event: ```php use Patchlevel\EventSourcing\Attribute\Event; #[Event('profile.name_changed')] final class NameChanged { public function __construct( public readonly string $name, ) { } } ``` :::note Events should best be written in the past, as they describe a state that has happened. ::: After we have defined the event, we can define a new public method called `changeName` to change the profile name. This method then creates the event `NameChanged` and records it: ```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\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private Uuid $id; private string $name; public function name(): string { return $this->name; } public static function register(Uuid $id, string $name): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name)); return $self; } public function changeName(string $name): void { $this->recordThat(new NameChanged($name)); } #[Apply] protected function applyProfileRegistered(ProfileRegistered $event): void { $this->id = $event->profileId; $this->name = $event->name; } #[Apply] protected function applyNameChanged(NameChanged $event): void { $this->name = $event->name; } } ``` We have also defined a new `apply` method named `applyNameChanged` where we change the name depending on the value in the event. When using it, it can look like this: ```php use Patchlevel\EventSourcing\Repository\Repository; final class ChangeNameHandler { public function __construct(private Repository $profileRepository) { } public function __invoke(ChangeName $command): void { $profile = $this->profileRepository->load($command->id()); $profile->changeName($command->name()); $this->profileRepository->save($profile); } } ``` :::success Our aggregate can now be changed and saved. ::: :::note You can read more about the [repository](repository.md). ::: Here the aggregate is loaded from the `repository` by fetching all events from the database. These events are then executed again with the `apply` methods in order to rebuild the current state. All of this happens automatically in the `load` method. The method `changeName` is then executed on the aggregate to change the name. In this method the event `NameChanged` is generated and recorded. The `applyNameChanged` method was also called again internally to adjust the state. When the `save` method is called on the repository, all newly recorded events are then fetched and written to the database. In this specific case only the `NameChanged` changed event. ## Multiple apply attributes on the same method You can also define several apply attributes with different events using the same method. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { // ... #[Apply(ProfileCreated::class)] #[Apply(NameChanged::class)] protected function applyProfileCreated(ProfileCreated|NameChanged $event): void { if ($event instanceof ProfileCreated) { $this->id = $event->profileId; } $this->name = $event->name; } } ``` :::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, but are still recorded for the future or to subscribe for processor and projection. So that you are not forced to write an apply method for it, you can suppress the missing apply exceptions for these events with the `SuppressMissingApply` attribute. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; #[Aggregate('profile')] #[SuppressMissingApply([NameChanged::class])] final class Profile extends BasicAggregateRoot { // ... #[Apply] protected function applyProfileCreated(ProfileCreated $event): void { $this->id = $event->profileId; $this->name = $event->name; } } ``` ## Suppress missing apply for all methods You can also completely deactivate the exceptions for missing apply methods. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; #[Aggregate('profile')] #[SuppressMissingApply(SuppressMissingApply::ALL)] final class Profile extends BasicAggregateRoot { // ... #[Apply] protected function applyProfileCreated(ProfileCreated $event): void { $this->id = $event->profileId; $this->name = $event->name; } } ``` :::warning 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 The `stream name` works only with the [StreamDoctrineDbalStore](store.md#streamdoctrinedbalstore). ::: The stream name is the name of the stream in the event store. By default, the stream name has the format `aggregateName-aggregateId`. But you can also define your own stream name with the `Stream` attribute. You can use the placeholder `{id}` to insert the aggregate id into the stream name. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Stream; #[Aggregate('profile')] #[Stream('profile-{id}')] final class Profile extends BasicAggregateRoot { // ... } ``` You can use also an aggregate class for the stream name. In this case you use the stream name from another aggregate. This is useful if you want to store multiple aggregates in the same stream, for example if you want to use the micro aggregate pattern. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; #[Aggregate('guest_list')] #[Stream(Meeting::class)] final class GuestList extends BasicAggregateRoot { // ... } ``` :::tip You can find more about [splitting aggregates](aggregate.md#splitting-aggregates). ::: ## Business rules Usually, aggregates have business rules that must be observed. Like there may not be more than 10 people in a group. These rules must be checked before an event is recorded. As soon as an event was recorded, the described thing happened and cannot be undone. A further check in the apply method is also not possible because these events have already happened and were then also saved in the database. In the next example we want to make sure that **the name is at least 3 characters long**: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { // ... public function changeName(string $name): void { if (strlen($name) < 3) { throw new NameIsTooShortException($name); } $this->recordThat(new NameChanged($name)); } #[Apply] protected function applyNameChanged(NameChanged $event): void { $this->name = $event->name; } } ``` :::danger Validations during "apply" should not happen, they will break the rebuilding of the aggregate! Instead validate the data *before* the event will be recorded. ::: We have now ensured that this rule takes effect when a name is changed with the method `changeName`. But when we create a new profile this rule does not currently apply. In order for this to work, we either have to duplicate the rule or outsource it. Here we show how we can do it all with a value object: ```php final class Name { public function __construct(private string $value) { if (strlen($value) < 3) { throw new NameIsTooShortException($value); } } public function toString(): string { return $this->value; } } ``` We can now use the value object `Name` in our 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\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private Uuid $id; private Name $name; public static function register(Uuid $id, Name $name): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name)); return $self; } // ... public function name(): Name { return $this->name; } public function changeName(Name $name): void { $this->recordThat(new NameChanged($name)); } #[Apply] protected function applyNameChanged(NameChanged $event): void { $this->name = $event->name; } } ``` In order for the whole thing to work, we still have to adapt our `NameChanged` event, since we only expected a string before but now passed a `Name` value object. ```php use Patchlevel\EventSourcing\Attribute\Event; #[Event('profile.name_changed')] final class NameChanged { public function __construct( #[NameNormalizer] public readonly Name $name, ) { } } ``` :::warning You need to create a normalizer for the `Name` value object. So the payload must be serializable and unserializable as json. ::: :::note You can find out more about [normalizer](normalizer.md). ::: There are also cases where business rules have to be defined depending on the aggregate state. Sometimes also from states, which were changed in the same method. This is not a problem, as the `apply` methods are always executed immediately. In the next case we throw an exception if the hotel is already overbooked. Besides that, we record another event `FullyBooked`, if the hotel is fully booked with the last booking. With this event we could [notify](subscription.md) external systems or fill a [projection](subscription.md) with fully booked hotels. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; #[Aggregate('hotel')] #[SuppressMissingApply([FullyBooked::class])] final class Hotel extends BasicAggregateRoot { private const SIZE = 5; private int $people; // ... public function book(string $name): void { if ($this->people === self::SIZE) { throw new NoPlaceException($name); } $this->recordThat(new RoomBooked($name)); if ($this->people !== self::SIZE) { return; } $this->recordThat(new FullyBooked()); } #[Apply] protected function applyRoomBooked(RoomBooked $event): void { $this->people++; } } ``` ## Working with dates An aggregate should always be deterministic. In other words, whenever I execute methods on the aggregate, I always get the same result. This also makes testing much easier. But that often doesn't seem to be possible, e.g. if you want to save a createdAt date. But you can pass this information by yourself. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private Uuid $id; private Name $name; private DateTimeImmutable $registeredAt; public static function register(Uuid $id, string $name, DateTimeImmutable $registeredAt): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name, $registeredAt)); return $self; } // ... } ``` But if you still want to make sure that the time is "now" and not in the past or future, you can pass a clock. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Id; use Psr\Clock\ClockInterface; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private Uuid $id; private Name $name; private DateTimeImmutable $registeredAt; public static function register(Uuid $id, string $name, ClockInterface $clock): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name, $clock->now())); return $self; } // ... } ``` Now you can pass the `SystemClock` to determine the current time. Or for test purposes the `FrozenClock`, which always returns the same time. :::note You can find out more about the [clock](clock.md). ::: ## Splitting Aggregates In some cases, it makes sense to split an aggregate into several smaller aggregates. This can be the case if the aggregate becomes too large or if the aggregate is used in different contexts. We currently support two patterns for this: Micro Aggregates and Child Aggregates (experimental). ### Micro Aggregates :::warning This feature works only with the [StreamDoctrineDbalStore](store.md#streamdoctrinedbalstore). ::: Micro Aggregates are a pattern to split an aggregate into several smaller aggregates. Each of these aggregates is saved in the same stream. This gives the Micro Aggregates the ability to independently manage their state and trigger their events, but still allows the associated Micro Aggregates to listen to the events in order to enforce their own business rules. In the following example, we have an `Order` micro aggregate and a `Shipping` micro aggregate. The order handle the order itself and the shipping handle the shipping of the order. ```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\Id; use Patchlevel\EventSourcing\Attribute\SharedApplyContext; #[Aggregate('order')] #[SharedApplyContext([Shipping::class])] final class Order extends BasicAggregateRoot { #[Id] private Uuid $id; public static function create(Uuid $id): static { $self = new static(); $self->recordThat(new OrderCreated($id)); return $self; } #[Apply] public function applyOrderCreated(OrderCreated $event): void { $this->id = $event->id; } } ``` With this pattern, the Shipping aggregate can listen to the events of the Order aggregate. In this case, the `Shipping` aggregate listens to the `OrderCreated` event to initialize itself. ```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\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] private Uuid $id; private bool $arrived = false; public function arrive(): void { $this->recordThat(new Arrived()); } #[Apply] public function applyOrderCreated(OrderCreated $event): void { $this->id = $event->id; } #[Apply] public function applyArrived(Arrived $event): void { $this->arrived = true; } public function isArrived(): bool { return $this->arrived; } } ``` :::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 :::experimental This feature is still experimental and may change in the future. Use it with caution. ::: Another way to split an aggregate is to use child aggregates. The difference to Micro Aggregates, child aggregates can only be accessed by the root aggregate and are not separate aggregates. In the following example, we have an `Order` aggregate that has a `Shipping` child aggregate. ```php use Patchlevel\EventSourcing\Aggregate\BasicChildAggregate; use Patchlevel\EventSourcing\Attribute\Apply; final class Shipping extends BasicChildAggregate { private bool $arrived = false; public function __construct( private string $trackingId, ) { } public function arrive(): void { $this->recordThat(new Arrived()); } #[Apply] public function applyArrived(Arrived $event): void { $this->arrived = true; } public function isArrived(): bool { return $this->arrived; } } ``` :::warning The apply method must be public, otherwise the root aggregate cannot call it. ::: :::note Suppressing missing apply methods needs to be defined in the root aggregate. ::: And the `Order` aggregate root looks like this: ```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\ChildAggregate; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('order')] final class Order extends BasicAggregateRoot { #[Id] private Uuid $id; #[ChildAggregate] private Shipping $shipping; public static function create(Uuid $id, string $trackingId): static { $self = new static(); $self->recordThat(new OrderCreated($id, $trackingId)); return $self; } #[Apply] public function applyOrderCreated(OrderCreated $event): void { $this->shipping = new Shipping($event->trackingId); } public function arrive(): void { $this->shipping->arrive(); } } ``` ## Auto Initialize :::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. There is an `AggregateRootRegistry` for this purpose. The registry is a simple hashmap between aggregate name and aggregate class. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; $aggregateRegistry = new AggregateRootRegistry([ 'profile' => Profile::class, ]); ``` So that you don't have to create it by hand, you can use a factory. By default, the `AttributeAggregateRootRegistryFactory` is used. There, with the help of paths, all classes with the attribute `Aggregate` are searched for and the `AggregateRootRegistry` is built up. ```php use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegistryFactory; $aggregateRegistry = (new AttributeAggregateRootRegistryFactory())->create([/* paths... */]); ``` ## Learn more * [How to create own aggregate id](aggregate-id.md) * [How to store and load aggregates](repository.md) * [How to snapshot aggregates](snapshots.md) * [How to create Projections](subscription.md) * [How to split streams](split-stream.md) --- # Aggregate ID Source: https://patchlevel.dev/docs/event-sourcing/latest/aggregate-id.md The `aggregate id` is a unique identifier for an aggregate. It is used to identify the aggregate in the event store. The `aggregate` does not care how the id is generated, since only an aggregate-wide unique string is expected in the store. This library provides you with a few options for generating the id. :::warning For performance reasons, the default configuration of the store requires an uuid string for the `aggregate id`. But technically, for the library, it can be any string. If you want to use a custom id, you have to change the `aggregate_id_type` in the [store](store.md) configuration. ::: ## Uuid The easiest way is to use an `uuid` as an aggregate ID. For this, we have the `Uuid` class, which is a simple wrapper for the [ramsey/uuid](https://github.com/ramsey/uuid) library. You can use it like this: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private Uuid $id; } ``` You have multiple options for generating an uuid: ```php use Patchlevel\EventSourcing\Aggregate\Uuid; $uuid = Uuid::generate(); $uuid = Uuid::fromString('d6e8d7a0-4b0b-4e6a-8a9a-3a0b2d9d0e4e'); ``` :::note We implemented the version 7 of the uuid, because it is most suitable for event sourcing. More information about [uuid versions](https://uuid.ramsey.dev/en/stable/rfc4122.html) can be found in the ramsey/uuid documentation. ::: ## Custom ID If you don't want to use an uuid, you can also use the custom ID implementation. This is a value object that holds any string. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\CustomId; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private CustomId $id; } ``` :::warning If you want to use a custom id that is not an uuid, you need to change the `aggregate_id_type` to `string` in the [store](store.md) configuration. ::: So you can use any string as an id: ```php use Patchlevel\EventSourcing\Aggregate\CustomId; $id = CustomId::fromString('my-id'); ``` ## Implement own ID Or even better, you create your own aggregate-specific ID class. This allows you to ensure that the correct id is always used. The whole thing looks like this: ```php use Patchlevel\EventSourcing\Aggregate\AggregateRootId; class ProfileId implements AggregateRootId { private function __construct( private readonly string $id, ) { } public function toString(): string { return $this->id; } public static function fromString(string $id): self { return new self($id); } } ``` So you can use it like this: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private ProfileId $id; } ``` We also offer you some traits, so that you don't have to implement the `AggregateRootId` interface yourself. Here for the uuid: ```php use Patchlevel\EventSourcing\Aggregate\AggregateRootId; use Patchlevel\EventSourcing\Aggregate\RamseyUuidV7Behaviour; class ProfileId implements AggregateRootId { use RamseyUuidV7Behaviour; } ``` Or for the custom id: ```php use Patchlevel\EventSourcing\Aggregate\AggregateRootId; use Patchlevel\EventSourcing\Aggregate\CustomIdBehaviour; class ProfileId implements AggregateRootId { use CustomIdBehaviour; } ``` ## Learn more * [How to create an aggregate](aggregate.md) * [How to create an event](events.md) * [How to test an aggregate](testing.md) ===== # patchlevel/event-sourcing-bundle > Symfony integration for patchlevel/event-sourcing. Autowiring, console commands, Messenger integration, and Flex recipe. # Usage Source: https://patchlevel.dev/docs/event-sourcing-bundle/latest/usage.md Here you will find some examples of how to use the bundle. But we provide only examples for specific symfony features. :::note You can find out more about event sourcing in the library [documentation](/docs/event-sourcing/latest). This documentation is limited to bundle integration and configuration. ::: ## Repository You can access the specific repositories using the `RepositoryManager::get`. Or inject directly the right repository via argument name injection. For our aggregate `Hotel` it would be `$hotelRepository`. ```php namespace App\Hotel\Infrastructure\Controller; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Repository\Repository; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; #[AsController] final class HotelController { public function __construct( /** @var Repository */ private readonly Repository $hotelRepository, ) { } public function doStuffAction(Uuid $hotelId): Response { $hotel = $this->hotelRepository->load($hotelId); $hotel->doStuff(); $hotelRepository->save($hotel); return new Response(); } } ``` ## Aggregate Id Value Resolver The bundle registers a controller argument value resolver for aggregate ids. If you type-hint a controller argument with a class that implements `Patchlevel\EventSourcing\Aggregate\AggregateRootId`, the resolver builds it from the matching request attribute (e.g. a route parameter with the same name) using `fromString()`. ```php namespace App\Hotel\Infrastructure\Controller; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Repository\Repository; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Attribute\Route; #[AsController] final class HotelController { public function __construct( /** @var Repository */ private readonly Repository $hotelRepository, ) { } #[Route('/hotel/{hotelId}')] public function doStuffAction(Uuid $hotelId): Response { $hotel = $this->hotelRepository->load($hotelId); // ... return new Response(); } } ``` :::note The name of the argument (`$hotelId`) must match the name of the request attribute (the `{hotelId}` route parameter). If the attribute is missing or not a string, the resolver is skipped and Symfony continues with the other value resolvers. ::: :::tip This works with any of your own aggregate id classes, as long as they implement `AggregateRootId`. The library's `Patchlevel\EventSourcing\Aggregate\Uuid` already does. ::: ## Subscriber A subscriber can be used to send an email when a guest is checked in: ```php namespace App\Hotel\Application\Subscriber; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('send_check_in_email', RunMode::FromNow)] class SendCheckInEmailSubscriber { // ... } ``` If you have the symfony default service setting with `autowire`and `autoconfiger` enabled, the subscriber is automatically recognized and registered at the `Subscriber` attribute. Otherwise you have to define the subscriber in the symfony service file: ```yaml services: App\Hotel\Application\Subscriber\SendCheckInEmailSubscriber: tags: - event_sourcing.subscriber ``` ## Event Bus Listener A process can be for example used to send an email when a guest is checked in: ```php namespace App\Hotel\Application\Listener; use App\Hotel\Domain\Event\GuestIsCheckedIn; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcingBundle\Attribute\AsListener; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use function sprintf; #[AsListener] final class SendCheckInEmailListener { private function __construct(private MailerInterface $mailer) { } #[Subscribe(GuestIsCheckedIn::class)] public function __invoke(Message $message): void { $event = $message->event(); $email = (new Email()) ->from('noreply@patchlevel.de') ->to('hq@patchlevel.de') ->subject('Guest is checked in') ->text(sprintf('A new guest named "%s" is checked in', $event->guestName())); $this->mailer->send($email); } } ``` If you have the symfony default service setting with `autowire`and `autoconfiger` enabled, the listener is automatically recognized and registered at the `AsListener` attribute. Otherwise you have to define the listener in the symfony service file: ```yaml services: App\Hotel\Application\Listener\SendCheckInEmailListener: tags: - event_sourcing.listener ``` ### Priority You can also determine the `priority` in which the listeners are executed. The higher the priority, the earlier the listener is executed. You have to add the tag manually and specify the priority. ```php namespace App\Hotel\Application\Listener; #[AsListener(priority: 16)] final class SendCheckInEmailListener { // ... } ``` ```yaml services: App\Hotel\Application\Listener\SendCheckInEmailListener: autoconfigure: false tags: - name: event_sourcing.listener priority: 16 ``` :::warning You have to deactivate the `autoconfigure` for this service, otherwise the service will be added twice. ::: ## Normalizer This bundle adds more Symfony specific normalizers in addition to the existing built-in normalizers. :::note You can find the other build-in normalizers [here](/docs/event-sourcing/latest/normalizer/#built-in-normalizer) ::: :::tip The Hydrator can automatically determine the appropriate normalizer based on the data type and annotations. You don't have to specify the normalizer manually like in the example below. ::: ### Uid With the `Uid` Normalizer, as the name suggests, you can convert Symfony Uuid and Ulid objects to a string and back again. ```php use Patchlevel\EventSourcingBundle\Normalizer\UidNormalizer; use Symfony\Component\Uid\Uuid; final class DTO { #[UidNormalizer] public Uuid $id; } ``` :::warning The symfony uuid don't implement the `AggregateId` interface, so it can not be used as an aggregate id directly. Use instead the `Patchlevel\EventSourcing\Aggregate\Uuid` class. ::: :::tip Use the `Uuid` implementation and `IdNormalizer` from the library to use it as an aggregate id. ::: ### DatePoint With the `DatePoint` Normalizer, you can convert a `DatePoint` object to a string and back again. ```php use Patchlevel\EventSourcingBundle\Normalizer\DatePointNormalizer; use Symfony\Component\Clock\DatePoint; final class DTO { #[DatePointNormalizer] public DatePoint $createdAt; } ``` ## Upcasting ```php use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; final class ProfileCreatedEmailLowerCastUpcaster implements Upcaster { public function __invoke(Upcast $upcast): Upcast { // ignore if other event is processed if ($upcast->eventName !== 'profile_created') { return $upcast; } return $upcast->replacePayloadByKey('email', strtolower($upcast->payload['email'])); } } ``` If you have the symfony default service setting with `autowire`and `autoconfigure` enabled, the upcaster is automatically recognized and registered at the `Upcaster` interface. Otherwise you have to define the upcaster in the symfony service file: ```yaml services: App\Upcaster\ProfileCreatedEmailLowerCastUpcaster: tags: - event_sourcing.upcaster ``` ## Message Decorator We want to add the header information which user was logged in when this event was generated. ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; final class LoggedUserDecorator implements MessageDecorator { public function __construct( private readonly TokenStorageInterface $tokenStorage, ) { } public function __invoke(Message $message): Message { $token = $this->tokenStorage->getToken(); if (!$token) { return $message; } return $message->withHeader(new UserHeader($token->getUsername())); } } ``` If you have the symfony default service setting with `autowire`and `autoconfigure` enabled, the message decorator is automatically recognized and registered at the `MessageDecorator` interface. Otherwise you have to define the message decorator in the symfony service file: ```yaml services: App\Message\Decorator\LoggedUserDecorator: tags: - event_sourcing.message_decorator ``` ## Profiler When the kernel is in debug mode (e.g. in the `dev` environment), the bundle registers a [Symfony Web Profiler](https://symfony.com/doc/current/profiler.html) panel for event sourcing. It collects the messages that were dispatched during a request as well as the registered aggregates and events, and shows them in the profiler toolbar and panel. :::note This is enabled automatically and needs no configuration. It is only active when `kernel.debug` is `true`, so it has no effect in production. ::: --- # Installation Source: https://patchlevel.dev/docs/event-sourcing-bundle/latest/installation.md If you are not using a symfony [flex](https://github.com/symfony/flex) or the [recipes](https://flex.symfony.com/) for it, then you have to carry out a few installation steps by hand. ## Require package The first thing to do is to install packet if it has not already been done. ```bash composer require patchlevel/event-sourcing-bundle ``` :::note how to install [composer](https://getcomposer.org/doc/00-intro.md) ::: ## Enable bundle Then we have to activate the bundle in the `config/bundles.php`: ```php use Patchlevel\EventSourcingBundle\PatchlevelEventSourcingBundle; return [ PatchlevelEventSourcingBundle::class => ['all' => true], ]; ``` ## Configuration file Now you have to add following recommended configuration file here `config/packages/patchlevel_event_sourcing.yaml`. ```yaml patchlevel_event_sourcing: aggregates: '%kernel.project_dir%/src' events: '%kernel.project_dir%/src' connection: url: '%env(EVENTSTORE_URL)%' provide_dedicated_connection: true store: type: dbal_stream # if you are using doctrine bundle you should enable this #merge_orm_schema: true command_bus: service: messenger.default_bus query_bus: service: messenger.default_bus subscription: gap_detection: ~ # enable this if you want to use sensitive data encryption #cryptography: ~ # use_encrypted_field_name: true when@dev: patchlevel_event_sourcing: subscription: catch_up: true throw_on_error: true run_after_aggregate_save: true rebuild_after_file_change: true auto_setup: true when@test: patchlevel_event_sourcing: subscription: store: type: 'static_in_memory' catch_up: true throw_on_error: true run_after_aggregate_save: true ``` ## Dotenv Finally, we have to fill the ENV variable with a connection url. ```dotenv EVENTSTORE_URL="pdo-pgsql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" ``` :::note You can find out more about what a connection url looks like [here](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url). ::: ## Database with Docker If you are using docker, you can use the following `compose.yaml` file to start a postgres database. ```yaml services: eventstore: image: postgres:${POSTGRES_VERSION:-16}-alpine environment: POSTGRES_DB: ${POSTGRES_DB:-app} # You should definitely change the password in production POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} POSTGRES_USER: ${POSTGRES_USER:-app} volumes: - eventstore_data:/var/lib/postgresql/data:rw # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! # - ./docker/db/data:/var/lib/postgresql/data:rw volumes: eventstore_data: ``` And for development, you can add a `compose.override.yaml` file to expose the port. ```yaml services: eventstore: ports: - "5432" ``` ## Symfony CLI If you are using the [symfony cli](https://symfony.com/download), you can configure that the database is started automatically if you start the server. For this you have to add the following configuration to the `.symfony.local.yaml` file. ```yaml workers: docker_compose: ~ ``` :::success You have successfully installed the bundle. Now you can start with the [quickstart](getting-started.md) to get a feeling for the bundle. ::: --- # Event-Sourcing-Bundle Source: https://patchlevel.dev/docs/event-sourcing-bundle/latest/index.md An event sourcing bundle, complete with all the essential features, powered by the reliable Doctrine ecosystem and focused on developer experience. This bundle is a [symfony](https://symfony.com/) integration for [event-sourcing](https://github.com/patchlevel/event-sourcing) library. ## Features * Everything is included in the package for event sourcing * Based on [doctrine dbal](https://github.com/doctrine/dbal) and their ecosystem * Developer experience oriented and fully typed * Automatic [snapshot](/docs/event-sourcing/latest/snapshots)-system to boost your performance * [Split](/docs/event-sourcing/latest/split-stream) big aggregates into multiple streams * Versioned and managed lifecycle of [subscriptions](/docs/event-sourcing/latest/subscription) like projections and processors * Safe usage of [Personal Data](/docs/event-sourcing/latest/personal-data) with crypto-shredding * Smooth [upcasting](/docs/event-sourcing/latest/upcasting) of old events * Simple setup with [scheme management](/docs/event-sourcing/latest/store) and [doctrine migration](/docs/event-sourcing/latest/store) * Built in [cli commands](/docs/event-sourcing/latest/cli) with [symfony](https://symfony.com/) * and much more... ## Installation ```bash composer require patchlevel/event-sourcing-bundle ``` :::note If you don't use the symfony flex recipe for this bundle, you need to follow this [installation documentation](installation.md). ::: :::tip Start with the [quickstart](getting-started.md) to get a feeling for the bundle. ::: ## Integration * [Psalm](https://github.com/patchlevel/event-sourcing-psalm-plugin) * [Admin Bundle](https://github.com/patchlevel/event-sourcing-admin-bundle) --- # Getting Started Source: https://patchlevel.dev/docs/event-sourcing-bundle/latest/getting-started.md In our little getting started example, we manage hotels. We keep the example small, so we can only create hotels and let guests check in and check out. For this example we use [symfony/mailer](https://symfony.com/doc/current/mailer.html). :::note First of all, the bundle has to be installed and configured. If you haven't already done so, see the [installation introduction](installation.md). ::: ## Define some events First we define the events that happen in our system. A hotel can be created with a `name` and an `id`: ```php namespace App\Hotel\Domain\Event; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.created')] final class HotelCreated { public function __construct( public readonly Uuid $hotelId, public readonly string $hotelName, ) { } } ``` A guest can check in by `guestName`: ```php namespace App\Hotel\Domain\Event; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.guest_is_checked_in')] final class GuestIsCheckedIn { public function __construct( public readonly Uuid $hotelId, public readonly string $guestName, ) { } } ``` And also check out again: ```php namespace App\Hotel\Domain\Event; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.guest_is_checked_out')] final class GuestIsCheckedOut { public function __construct( public readonly Uuid $hotelId, public readonly string $guestName, ) { } } ``` :::note You can find out more about events in the [library](/docs/event-sourcing/latest/events). ::: ## Define aggregates Next we need to define the hotel aggregate. How you can interact with it, which events happen and what the business rules are. For this we create the methods `create`, `checkIn` and `checkOut`. In these methods the business checks are made and the events are recorded. Last but not least, we need the associated apply methods to change the state. ```php namespace App\Hotel\Domain; use App\Hotel\Domain\Event\GuestIsCheckedIn; use App\Hotel\Domain\Event\GuestIsCheckedOut; use App\Hotel\Domain\Event\HotelCreated; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; use function array_filter; use function array_values; use function in_array; #[Aggregate(name: 'hotel')] final class Hotel extends BasicAggregateRoot { #[Id] private Uuid $id; private string $name; /** @var list */ private array $guests; public function name(): string { return $this->name; } public function guests(): array { return $this->guests; } public static function create(Uuid $id, string $hotelName): self { $self = new self(); $self->recordThat(new HotelCreated($id, $hotelName)); return $self; } public function checkIn(string $guestName): void { if (in_array($guestName, $this->guests, true)) { throw new GuestHasAlreadyCheckedIn($guestName); } $this->recordThat(new GuestIsCheckedIn($this->id, $guestName)); } public function checkOut(string $guestName): void { if (!in_array($guestName, $this->guests, true)) { throw new IsNotAGuest($guestName); } $this->recordThat(new GuestIsCheckedOut($this->id, $guestName)); } #[Apply] protected function applyHotelCreated(HotelCreated $event): void { $this->id = $event->id; $this->name = $event->hotelName; $this->guests = []; } #[Apply] protected function applyGuestIsCheckedIn(GuestIsCheckedIn $event): void { $this->guests[] = $event->guestName; } #[Apply] protected function applyGuestIsCheckedOut(GuestIsCheckedOut $event): void { $this->guests = array_values( array_filter( $this->guests, static fn ($name) => $name !== $event->guestName, ), ); } } ``` :::note You can find out more about aggregates in the [library](/docs/event-sourcing/latest/aggregate). ::: ## Define projections Now we want to see which guests are currently checked in at a hotel or when a guest checked in and out. For this we need a projection and to create a projection we need a projector. Each projector is then responsible for a specific projection. ```php namespace App\Hotel\Infrastructure\Projection; use App\Hotel\Domain\Event\GuestIsCheckedIn; use App\Hotel\Domain\Event\GuestIsCheckedOut; use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Teardown; use function sprintf; /** * @psalm-type GuestData = array{ * guest_name: string, * hotel_id: string, * check_in_date: string, * check_out_date: string|null * } */ #[Projector(self::SUBSCRIBER_ID)] final class GuestProjection { private const SUBSCRIBER_ID = 'guests'; public function __construct( private Connection $db, ) { } /** @return list */ public function findGuestsByHotelId(Uuid $hotelId): array { return $this->db->createQueryBuilder() ->select('*') ->from(self::SUBSCRIBER_ID) ->where('hotel_id = :hotel_id') ->setParameter('hotel_id', $hotelId->toString()) ->fetchAllAssociative(); } #[Subscribe(GuestIsCheckedIn::class)] public function onGuestIsCheckedIn( GuestIsCheckedIn $event, DateTimeImmutable $recordedOn, ): void { $this->db->insert( self::SUBSCRIBER_ID, [ 'hotel_id' => $event->hotelId->toString(), 'guest_name' => $event->guestName, 'check_in_date' => $recordedOn->format('Y-m-d H:i:s'), 'check_out_date' => null, ], ); } #[Subscribe(GuestIsCheckedOut::class)] public function onGuestIsCheckedOut( GuestIsCheckedOut $event, DateTimeImmutable $recordedOn, ): void { $this->db->update( self::SUBSCRIBER_ID, [ 'check_out_date' => $recordedOn->format('Y-m-d H:i:s'), ], [ 'hotel_id' => $event->hotelId->toString(), 'guest_name' => $event->guestName, 'check_out_date' => null, ], ); } #[Setup] public function create(): void { $this->db->executeStatement(sprintf( 'CREATE TABLE %s ( hotel_id VARCHAR(36) NOT NULL, guest_name VARCHAR(255) NOT NULL, check_in_date TIMESTAMP NOT NULL, check_out_date TIMESTAMP NULL );', self::SUBSCRIBER_ID, )); } #[Teardown] public function drop(): void { $this->db->executeStatement(sprintf('DROP TABLE IF EXISTS %s;', self::SUBSCRIBER_ID)); } } ``` :::warning autoconfigure need to be enabled, otherwise you need add the `event_sourcing.subscriber` tag. ::: :::note You can find out more about projections in the [library](/docs/event-sourcing/latest/subscription). ::: ## Processor In our example we also want to send an email to the head office as soon as a guest is checked in. ```php namespace App\Hotel\Application\Processor; use App\Hotel\Domain\Event\GuestIsCheckedIn; use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use function sprintf; #[Processor('admin_emails')] final class SendCheckInEmailProcessor { public function __construct( private readonly MailerInterface $mailer, ) { } #[Subscribe(GuestIsCheckedIn::class)] public function onGuestIsCheckedIn(GuestIsCheckedIn $event): void { $email = (new Email()) ->from('noreply@patchlevel.de') ->to('hq@patchlevel.de') ->subject('Guest is checked in') ->text(sprintf('A new guest named "%s" is checked in', $event->guestName)); $this->mailer->send($email); } } ``` :::warning autoconfigure need to be enabled, otherwise you need add the `event_sourcing.subscriber` tag. ::: :::note You can find out more about processor in the [library](/docs/event-sourcing/latest/subscription) ::: ## Database setup So that we can actually write the data to a database, we need the associated schema and databases. ```bash bin/console event-sourcing:database:create bin/console event-sourcing:schema:create ``` or you can use doctrine migrations: ```bash bin/console event-sourcing:migrations:diff bin/console event-sourcing:migrations:migrate ``` :::note You can find out more about the cli in the [library](/docs/event-sourcing/latest/cli). ::: ## Usage We are now ready to use the Event Sourcing System. We can load, change and save aggregates. ```php namespace App\Hotel\Infrastructure\Controller; use App\Hotel\Domain\Hotel; use App\Hotel\Infrastructure\Projection\GuestProjection; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Repository\Repository; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Annotation\Route; #[AsController] final class HotelController { /** @param Repository $hotelRepository */ public function __construct( private readonly Repository $hotelRepository, private readonly GuestProjection $guestProjection, ) { } #[Route('/{hotelId}/guests', methods:['GET'])] public function hotelGuestsAction(Uuid $hotelId): JsonResponse { return new JsonResponse( $this->guestProjection->findGuestsByHotelId($hotelId), ); } #[Route('/create', methods:['POST'])] public function createAction(Request $request): JsonResponse { $hotelName = $request->getPayload()->get('name'); // need validation! $id = Uuid::generate(); $hotel = Hotel::create($id, $hotelName); $this->hotelRepository->save($hotel); return new JsonResponse(['id' => $id->toString()]); } #[Route('/{hotelId}/check-in', methods:['POST'])] public function checkInAction(Uuid $hotelId, Request $request): JsonResponse { $guestName = $request->getPayload()->get('name'); // need validation! $hotel = $this->hotelRepository->load($hotelId); $hotel->checkIn($guestName); $this->hotelRepository->save($hotel); return new JsonResponse(); } #[Route('/{hotelId}/check-out', methods:['POST'])] public function checkOutAction(Uuid $hotelId, Request $request): JsonResponse { $guestName = $request->getPayload()->get('name'); // need validation! $hotel = $this->hotelRepository->load($hotelId); $hotel->checkOut($guestName); $this->hotelRepository->save($hotel); return new JsonResponse(); } } ``` ## Result :::success We have successfully implemented and used event sourcing. Feel free to browse further in the documentation for more detailed information. If there are still open questions, create a ticket on Github and we will try to help you. ::: :::note This documentation is limited to the bundle integration. You should also read the [library documentation](/docs/event-sourcing/latest). ::: --- # Configuration Source: https://patchlevel.dev/docs/event-sourcing-bundle/latest/configuration.md :::note You can find out more about event sourcing in the library [documentation](/docs/event-sourcing/latest). This documentation is limited to bundle integration and configuration. ::: :::tip We provide a [default configuration](installation.md#configuration-file) that should work for most projects. ::: ## Aggregate A path must be specified for Event Sourcing to know where to look for your aggregates. If you want you can use glob patterns to specify multiple paths. ```yaml patchlevel_event_sourcing: aggregates: '%kernel.project_dir%/src/*/Domain' ``` Or use an array to specify multiple paths. ```yaml patchlevel_event_sourcing: aggregates: - '%kernel.project_dir%/src/Hotel/Domain' - '%kernel.project_dir%/src/Room/Domain' ``` :::note The library will automatically register all classes marked with the `#[Aggregate]` attribute in the specified paths. ::: :::tip If you want to learn more about aggregates, read the [library documentation](/docs/event-sourcing/latest/aggregate). ::: ## Events A path must be specified for Event Sourcing to know where to look for your events. If you want you can use glob patterns to specify multiple paths. ```yaml patchlevel_event_sourcing: events: '%kernel.project_dir%/src/*/Domain/Event' ``` Or use an array to specify multiple paths. ```yaml patchlevel_event_sourcing: events: - '%kernel.project_dir%/src/Hotel/Domain/Event' - '%kernel.project_dir%/src/Room/Domain/Event' ``` :::tip If you want to learn more about events, read the [library documentation](/docs/event-sourcing/latest/events). ::: ## Custom Headers If you want to implement custom headers for your application, you must specify the paths to look for those headers. If you want you can use glob patterns to specify multiple paths. ```yaml patchlevel_event_sourcing: headers: '%kernel.project_dir%/src/*/Domain/Header' ``` Or use an array to specify multiple paths. ```yaml patchlevel_event_sourcing: headers: - '%kernel.project_dir%/src/Hotel/Domain/Header' - '%kernel.project_dir%/src/Room/Domain/Header' ``` :::tip If you want to learn more about custom headers, read the [library documentation](/docs/event-sourcing/latest/message/#custom-headers). ::: ## Connection You have to specify the connection url to the event store. ```yaml patchlevel_event_sourcing: connection: url: '%env(EVENTSTORE_URL)%' ``` :::note You can find out more about how to create a connection [here](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html) ::: ### Connection for Projections Per default, our event sourcing connection is not available to use in your application. But you can create a dedicated connection that you can use for your projections. ```yaml patchlevel_event_sourcing: connection: url: '%env(EVENTSTORE_URL)%' provide_dedicated_connection: true ``` :::warning If you use doctrine migrations, you should exclude you projection tables from the schema generation. The schema is managed by the subscription engine and should not be managed by doctrine. ::: :::tip You can autowire the connection in your services like this: ```php use Doctrine\DBAL\Connection; class YourService { public function __construct( private readonly Connection $projectionConnection, ) { } } ``` ::: ### Doctrine Bundle If you have installed the [doctrine bundle](https://github.com/doctrine/DoctrineBundle), you can also define the connection via doctrine and then use it in the store. ```yaml doctrine: dbal: connections: eventstore: url: '%env(EVENTSTORE_URL)%' patchlevel_event_sourcing: connection: service: doctrine.dbal.eventstore_connection ``` :::danger Do not use the same connection for event sourcing and your projections, otherwise you may run into transaction problems. ::: :::warning If you want to use the same connection as doctrine orm, then you have to set the flag `merge_orm_schema`. Otherwise you should avoid using the same connection as other tools. ::: :::note You can find out more about the dbal configuration [here](https://symfony.com/bundles/DoctrineBundle/current/configuration.html). ::: If you are using Doctrine for your projections too, you need to create a dedicated connection for this. You can do this by defining a new connection named `projection` in the `doctrine.yaml` file and use the same connection url as for the event store. ```yaml doctrine: dbal: connections: eventstore: url: '%env(EVENTSTORE_URL)%' projection: url: '%env(EVENTSTORE_URL)%' patchlevel_event_sourcing: connection: service: doctrine.dbal.eventstore_connection ``` :::warning You should exclude your projection tables from the schema generation. ```yaml doctrine: dbal: schema_filter: ~^(projection_)~ ``` ::: Then you can use this connection in your projections. If you are using autowiring you can inject the right connection `Connection $projectionConnection` parameter name. The prefix `projection` is used to identify the connection. ```php namespace App\Projection; use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Projector; #[Projector('my_projection')] class MyProjection { public function __construct( private readonly Connection $projectionConnection, ) { } } ``` ## Store The store and schema is configurable. ### Change Store type You can change the store type of the event store. ```yaml patchlevel_event_sourcing: store: type: 'in_memory' ``` Following store types are available: - `dbal_aggregate` *default (deprecated)* - `dbal_stream` *recommended* - `in_memory` - `custom` :::note If you use `custom` store type, you need to set the service id under `patchlevel_event_sourcing.store.service`. ::: ### Change table Name You can change the table name of the event store. ```yaml patchlevel_event_sourcing: store: options: table_name: 'my_event_store' ``` ### Read Only Mode For `dbal_aggregate` and `dbal_stream` store types you can activate the read only mode. Readings are possible, but if you try to write, an exception `StoreIsReadOnly` is thrown. ```yaml patchlevel_event_sourcing: store: read_only: true ``` :::tip This is useful if you have maintenance work on the event store and you want to avoid side effects. ::: ### Merge ORM Schema You can also merge the schema with doctrine orm. You have to set the following flag for this: ```yaml patchlevel_event_sourcing: store: merge_orm_schema: true ``` :::warning If you want to merge the schema, then the same doctrine connection must be used as with the doctrine orm. Otherwise errors may occur! ::: :::note All schema relevant commands are removed if you activate this option. You should use the doctrine commands then. ::: :::tip If you want to learn more about store, read the [library documentation](/docs/event-sourcing/latest/store). ::: ### Kernel Reset Only available in `in_memory` store. If you want to reset the store after each kernel request, you can activate this option. So you can avoid side effects between the tests. ```yaml patchlevel_event_sourcing: store: kernel_reset: true ``` ### Data Migration If you want to migrate from your current store to a new store, you can use the following configuration. This register a new store and a new cli command `event-sourcing:store:migrate`. You can define translators to translate the old events to the new store. Here is an example for a migration from `dbal_aggregate` to `dbal_stream`. ```yaml patchlevel_event_sourcing: store: migrate_to_new_store: type: 'dbal_stream' options: table_name: 'my_stream_store' translators: - Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator ``` :::danger Make sure that you use different table names for the old and new store. Otherwise your event store will be destroyed. ::: :::tip Set the `read_only` flag to `true` for the old store to avoid side effects and missing events during the migration. ::: ## Migration You can use [doctrine migrations](https://www.doctrine-project.org/projects/migrations.html) to manage the schema. ```yaml patchlevel_event_sourcing: migration: namespace: EventSourcingMigrations path: "%kernel.project_dir%/migrations" ``` ## Subscription :::tip You can find out more about subscriptions in the library [documentation](/docs/event-sourcing/latest/subscription). ::: ### Store You can change where the subscription engine stores its necessary information about the subscription. Default is `dbal`, which means it stores it in the same DB that is used by the dbal event store. Otherwise you can choose between the following stores: - `dbal` *default* - `in_memory` - `static_in_memory` - `custom` ```yaml patchlevel_event_sourcing: subscription: store: type: 'custom' # default is 'dbal' service: 'my_subscription_store' options: table_name: 'my_subscription_store' ``` :::tip If you are using the [doctrine-test-bundle](https://github.com/dmaicher/doctrine-test-bundle), you can use the `static_in_memory` store for testing. ::: ### Retry Strategies If a subscriber throws an error, the subscription engine can retry it later instead of leaving it in an error state. You can define one or more named retry strategies and choose which one is used by default. ```yaml patchlevel_event_sourcing: subscription: retry_strategies: default: type: clock_based options: base_delay: 5 delay_factor: 2 max_attempts: 5 no_retry: type: no_retry default_retry_strategy: default ``` The following strategy types are available: - `clock_based`: retries with an increasing delay based on the clock. Configurable via `base_delay` (seconds), `delay_factor` and `max_attempts`. - `no_retry`: never retries. - `custom`: use your own strategy. You need to set the `service` id to a service implementing the `Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy` interface. ```yaml patchlevel_event_sourcing: subscription: retry_strategies: my_strategy: type: custom service: my_retry_strategy_service default_retry_strategy: my_strategy ``` :::note If you don't configure anything, a `default` (`clock_based`) and a `no_retry` strategy are registered and `default` is used. ::: :::tip You can select the retry strategy per subscriber. If you want to learn more about retry strategies, read the [library documentation](/docs/event-sourcing/latest/subscription/#retry-strategy). ::: ### Catch Up If aggregates are used in the processors and new events are generated there, then they are not part of the current subscription engine `run` and will only be processed during the next run or boot. This is usually not a problem in dev or prod environment because a worker is used and these events will be processed at some point. But in testing it is not so easy. For this reason, you can activate the `catch_up` option. ```yaml patchlevel_event_sourcing: subscription: catch_up: true ``` You can also limit how many messages are processed per catch up run with the `limit` option. ```yaml patchlevel_event_sourcing: subscription: catch_up: limit: 100 ``` ### Throw on Error You can activate the `throw_on_error` option to throw an exception if a subscription engine run has an error. This is useful for testing or development to get directly feedback if something is wrong. ```yaml patchlevel_event_sourcing: subscription: throw_on_error: true ``` :::warning This option should not be used in production. The normal behavior is to log the error and continue. ::: ### Run After Aggregate Save If you want to run the subscription engine after an aggregate is saved, you can activate this option. This is useful for testing or development, so you don't have run a worker to process the events. ```yaml patchlevel_event_sourcing: subscription: run_after_aggregate_save: true ``` You can also restrict which subscribers are run and limit how many messages are processed. Use `ids` and `groups` to only run specific subscribers and `limit` to cap the number of processed messages. ```yaml patchlevel_event_sourcing: subscription: run_after_aggregate_save: ids: - 'profile_projection' groups: - 'default' limit: 100 ``` :::note If `ids` and `groups` are empty, all subscribers are run. ::: ### Auto Setup If you want to automatically setup the subscription engine, you can activate this option. This is useful for development, so you don't have to setup the subscription engine manually. ```yaml patchlevel_event_sourcing: subscription: auto_setup: true ``` :::note This works only before each http requests and not if you use the console commands. ::: You can restrict the setup to specific subscribers with `ids` and `groups`. With `exclude_url` you can define a regex for urls that should not trigger the auto setup. By default the symfony internal routes (`^/_(wdt|profiler|error)`) are excluded. ```yaml patchlevel_event_sourcing: subscription: auto_setup: ids: - 'profile_projection' groups: - 'default' exclude_url: '^/_(wdt|profiler|error)' ``` ### Rebuild After File Change If you want to rebuild the subscription engine after a file change, you can activate this option. This is also useful for development, so you don't have to rebuild the projections manually. ```yaml patchlevel_event_sourcing: subscription: rebuild_after_file_change: true ``` :::note This works only before each http requests and not if you use the console commands. ::: :::tip This is using the cache system to store the latest file change time. You can change the cache pool with the `cache_pool` option. ::: With `exclude_url` you can define a regex for urls that should not trigger the rebuild. By default the symfony internal routes (`^/_(wdt|profiler|error)`) are excluded. ```yaml patchlevel_event_sourcing: subscription: rebuild_after_file_change: cache_pool: cache.app exclude_url: '^/_(wdt|profiler|error)' ``` ### Gap Detection Depending on the database you are using for the eventstore it may be happening that your subscriptions are skipping some events. This is due to how auto-increments work in these databases in combination with e.g. longer open transactions. Even when not working with longer open transactions, this may occur if load is high on the database. We already have a locking mechanism in place to prevent this behaviour which throttles write speed. Gap Detection operates different, it checks if a gap between the last message handled and the current message is present. If so it waits a reasonable amount of time and re-fetches the message. This results into slower updates for the subscriptions but creates more resilience. ```yaml patchlevel_event_sourcing: subscription: gap_detection: ~ ``` :::note For more context you can read more about this in [this issue](https://github.com/patchlevel/event-sourcing/issues/727#issuecomment-2757297536). ::: :::tip You can use both techniques locking and gap detecion to mitigate gaps happening in the subscriptions. ::: You can also define how often the gap detection should re-check the gap and how long it should wait, in this example we instantly retry the first time, then we wait 500ms and after that we check a last time after 1 second. ```yaml patchlevel_event_sourcing: subscription: gap_detection: retries_in_ms: [0, 500, 1000] ``` Another config option is to define the detection window. The option defines the timeframe from now if we should check for a gap. It's defined as an [DateInterval](https://www.php.net/manual/en/class.dateinterval.php) so you need to provide a valid `string` for it. ```yaml patchlevel_event_sourcing: subscription: gap_detection: detection_window: 'PT5M' ``` ## Command Bus You can enable the command bus integration to use your aggregates as command handlers. For this bundle we provide only a symfony messenger integration, so you have to define the bus in the messenger configuration. ```yaml framework: messenger: default_bus: command.bus buses: command.bus: ~ ``` After this, you need to define the command bus in the event sourcing configuration. This will automatically register the aggregate handlers for the symfony messenger, so you can handle commands with your aggregates. ```yaml patchlevel_event_sourcing: command_bus: service: command.bus ``` :::note You can find out more about the command bus and the aggregate handlers [here](/docs/event-sourcing/latest/command-bus). ::: ### Register Aggregate Handlers By default the aggregate command handlers are automatically registered for the configured messenger bus. If you want to register them yourself, you can disable this behaviour. ```yaml patchlevel_event_sourcing: command_bus: service: command.bus register_aggregate_handlers: false ``` ### Instant Retry You can define the default instant retry configuration for the command bus. This will be used if you don't define a retry configuration for a specific command. ```yaml patchlevel_event_sourcing: command_bus: instant_retry: default_max_retries: 3 default_exceptions: - Patchlevel\EventSourcing\Repository\AggregateOutdated ``` :::note You can find out more about instant retry [here](/docs/event-sourcing/latest/command-bus/#instant-retry). ::: ## Query Bus You can enable the query bus integration to use queries to retrieve data from your system. For this bundle we provide only a symfony messenger integration, so you have to define the bus in the messenger configuration. ```yaml framework: messenger: buses: query.bus: ~ ``` After this, you need to define the query bus in the event sourcing configuration. This will automatically register the handlers for the symfony messenger, so you can handle queries in your services. ```yaml patchlevel_event_sourcing: query_bus: service: query.bus ``` :::note You can find out more about the query bus [here](/docs/event-sourcing/latest/query-bus). ::: ## Event Bus You can enable the event bus to listen for events and messages synchronously. But you should consider using the subscription engine for this. ```yaml patchlevel_event_sourcing: event_bus: ~ ``` :::note Default is the patchlevel [event bus](/docs/event-sourcing/latest/event-bus). ::: ### Patchlevel (Default) Event Bus First of all we have our own default event bus. This works best with the library, as the `#[Subscribe]` attribute is used there, among other things. ```yaml patchlevel_event_sourcing: event_bus: type: default ``` :::note You don't have to specify this as it is the default value. ::: ### Symfony Event Bus But you can also use [Symfony Messenger](https://symfony.com/doc/current/messenger.html). To do this, you first have to define a suitable message bus. This must be "allow_no_handlers" so that this messenger can be an event bus according to the definition. ```yaml # messenger.yaml framework: messenger: buses: event.bus: default_middleware: allow_no_handlers ``` We can then use this messenger or event bus in event sourcing: ```yaml patchlevel_event_sourcing: event_bus: type: symfony service: event.bus ``` Since the event bus was replaced, event sourcing own attributes no longer work. You use the Symfony attributes instead. ```php use Patchlevel\EventSourcing\EventBus\Message; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler('event.bus')] class SmsNotificationHandler { public function __invoke(Message $message): void { if (!$message instanceof GuestIsCheckedIn) { return; } // ... do some work - like sending an SMS message! } } ``` ### PSR-14 Event Bus You can also use any other event bus that implements the [PSR-14](https://www.php-fig.org/psr/psr-14/) standard. ```yaml patchlevel_event_sourcing: event_bus: type: psr14 service: my.event.bus.service ``` :::note Like the Symfony event bus, the event sourcing attributes no longer work here. You have to use the system that comes with the respective psr14 implementation. ::: ### Custom Event Bus You can also use your own event bus that implements the `Patchlevel\EventSourcing\EventBus\EventBus` interface. ```yaml patchlevel_event_sourcing: event_bus: type: custom service: my.event.bus.service ``` :::note Like the Symfony event bus, the event sourcing attributes no longer work here. You have to use the system that comes with the respective custom implementation. ::: ## Snapshot You can use symfony cache to define the target of the snapshot store. ```yaml framework: cache: default_redis_provider: 'redis://localhost' pools: event_sourcing.cache: adapter: cache.adapter.redis ``` After this, you need define the snapshot store. Symfony cache implement the psr6 interface, so we need choose this type and enter the id from the cache service. ```yaml patchlevel_event_sourcing: snapshot_stores: default: service: event_sourcing.cache ``` You can also choose the store type. The following types are available: - `psr6` *default* - `psr16` - `custom` ```yaml patchlevel_event_sourcing: snapshot_stores: default: type: psr16 service: event_sourcing.cache ``` :::note If you use the `custom` type, the `service` has to implement the `Patchlevel\EventSourcing\Snapshot\SnapshotStore` interface. ::: Finally, you have to tell the aggregate that it should use this snapshot store. ```php namespace App\Profile\Domain; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Snapshot; #[Aggregate(name: 'profile')] #[Snapshot('default')] final class Profile extends BasicAggregateRoot { // ... } ``` :::note You can find out more about snapshots [here](/docs/event-sourcing/latest/snapshots). ::: ## Cryptography You can use the library to encrypt and decrypt personal data. For this you need to enable the crypto shredding. ```yaml patchlevel_event_sourcing: cryptography: use_encrypted_field_name: true ``` :::tip You should activate `use_encrypted_field_name` to mark the fields that are encrypted. That allows you later to migrate not encrypted fields to encrypted fields. If you have already encrypted fields, you can activate `fallback_to_field_name` to use the old field name as fallback. ::: If you want to use another algorithm, you can specify this here: ```yaml patchlevel_event_sourcing: cryptography: algorithm: 'aes-256-gcm' ``` :::note You can find out more about personal data [here](/docs/event-sourcing/latest/personal-data). ::: ## Hydrator You can enable the extension based hydrator, which replaces the legacy metadata hydrator. ```yaml patchlevel_event_sourcing: hydrator: ~ ``` ### Default Lazy You can enable lazy hydration by default. This means that values are only hydrated when they are accessed. ```yaml patchlevel_event_sourcing: hydrator: default_lazy: true ``` ### Cryptography The hydrator brings its own cryptography extension to encrypt and decrypt personal data. You can enable it and optionally choose the algorithm. ```yaml patchlevel_event_sourcing: hydrator: cryptography: enabled: true algorithm: 'aes-256-gcm' ``` ### Lifecycle You can enable the lifecycle extension to run lifecycle hooks during hydration. ```yaml patchlevel_event_sourcing: hydrator: lifecycle: enabled: true ``` ## Clock The clock is used to return the current time as DateTimeImmutable. ### Freeze Clock You can freeze the clock for testing purposes: ```yaml when@test: patchlevel_event_sourcing: clock: freeze: '2020-01-01 22:00:00' ``` :::note If freeze is not set, then the system clock is used. ::: ### Symfony Clock Since symfony 6.2 there is a [clock](https://symfony.com/doc/current/components/clock.html) implementation based on psr-20 that you can use. ```bash composer require symfony/clock ``` ```yaml patchlevel_event_sourcing: clock: service: 'clock' ``` ### PSR-20 You can also use your own implementation of your choice. They only have to implement the interface of the [psr-20](https://www.php-fig.org/psr/psr-20/). You can then specify this service here: ```yaml patchlevel_event_sourcing: clock: service: 'my_own_clock_service' ``` ===== # patchlevel/laravel-event-sourcing > Laravel integration for patchlevel/event-sourcing. Service-provider auto-discovery, Artisan commands and config publishing to wire event sourcing into a Laravel app. # Installation Source: https://patchlevel.dev/docs/laravel-event-sourcing/latest/installation.md This guide will help you to install the package in your laravel project. ## Require package The first thing to do is to install packet if it has not already been done. ```bash composer require patchlevel/laravel-event-sourcing=1.0.0-beta3 ``` :::note how to install [composer](https://getcomposer.org/doc/00-intro.md) ::: ## Configuration Next you need to publish the event sourcing config file. It will be published to `config/event-sourcing.php` ```bash php artisan vendor:publish --tag patchlevel-config ``` ## Migrations You can publish the migrations with the following command: ```bash php artisan vendor:publish --tag patchlevel-migrations ``` And then run the migrations: ```bash php artisan migrate ``` ## Middlewares Some features need a middleware to work properly. You should add the middleware to your `bootstrap/app.php` file. ```php use Patchlevel\LaravelEventSourcing\Middleware\EventSourcingMiddleware; $app->withMiddleware(static function (Middleware $middleware): void { $middleware->append(EventSourcingMiddleware::class); }); ``` :::success You have successfully installed the package! You can now start using the event sourcing library in your laravel project. Start with the [quickstart](getting-started.md) to get a feeling for the package. ::: :::note This documentation is limited to the package integration. You should also read the [library documentation](/docs/event-sourcing/latest). ::: --- # Laravel Event-Sourcing Source: https://patchlevel.dev/docs/laravel-event-sourcing/latest/index.md An event sourcing laravel package, complete with all the essential features, powered by the reliable Doctrine ecosystem and focused on developer experience. This package is a [laravel](https://laravel.com/) integration for [event-sourcing](https://github.com/patchlevel/event-sourcing) library. ## Features * Everything is included in the package for event sourcing * Facades for easy access to event sourcing services and aggregates * Developer experience oriented and fully typed * Automatic [snapshot](/docs/event-sourcing/latest/snapshots)-system to boost your performance * [Split](/docs/event-sourcing/latest/split-stream) big aggregates into multiple streams * Versioned and managed lifecycle of [subscriptions](/docs/event-sourcing/latest/subscription) like projections and processors * Safe usage of [Sensitive Data](/docs/event-sourcing/latest/personal-data) with crypto-shredding * Smooth [upcasting](/docs/event-sourcing/latest/upcasting) of old events * Simple setup with [scheme management](/docs/event-sourcing/latest/store) and [doctrine migration](/docs/event-sourcing/latest/store) * Built in [cli commands](/docs/event-sourcing/latest/cli) * and much more... ## Installation ```bash composer require patchlevel/laravel-event-sourcing ``` :::note More about installation can be found in the [installation documentation](installation.md). ::: :::tip Start with the [quickstart](getting-started.md) to get a feeling for the package. ::: --- # Getting Started Source: https://patchlevel.dev/docs/laravel-event-sourcing/latest/getting-started.md In our little getting started example, we manage hotels. We keep the example small, so we can only create hotels and let guests check in and check out. :::note First of all, the package has to be installed and configured. If you haven't already done so, see the [installation introduction](installation.md). ::: ## Define some events First we define the events that happen in our system. A hotel can be created with a `name` and an `id`: ```php namespace App\Events; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.created')] final class HotelCreated { public function __construct( public readonly Uuid $id, public readonly string $hotelName, ) { } } ``` A guest can check in by `name`: ```php namespace App\Events; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.guest_is_checked_in')] final class GuestIsCheckedIn { public function __construct( public readonly string $guestName, ) { } } ``` And also check out again: ```php namespace App\Events; use Patchlevel\EventSourcing\Attribute\Event; #[Event('hotel.guest_is_checked_out')] final class GuestIsCheckedOut { public function __construct( public readonly string $guestName, ) { } } ``` :::note You can find out more about events in the [library](/docs/event-sourcing/latest/events). ::: ## Define aggregates Next we need to define the hotel aggregate. How you can interact with it, which events happen and what the business rules are. For this we create the methods `create`, `checkIn` and `checkOut`. In these methods the business checks are made and the events are recorded. Last but not least, we need the associated apply methods to change the state. ```php namespace App\Models; use App\Events\GuestIsCheckedIn; use App\Events\GuestIsCheckedOut; use App\Events\HotelCreated; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\LaravelEventSourcing\AggregateRoot; use function array_filter; use function array_values; use function in_array; use function sprintf; #[Aggregate(name: 'hotel')] final class Hotel extends AggregateRoot { #[Id] private Uuid $id; private string $name; /** @var list */ private array $guests; public function name(): string { return $this->name; } public function guests(): array { return $this->guests; } public static function create(Uuid $id, string $hotelName): self { $self = new self(); $self->recordThat(new HotelCreated($id, $hotelName)); return $self; } public function checkIn(string $guestName): void { if (in_array($guestName, $this->guests, true)) { throw new RuntimeException(sprintf('Guest %s is already checked in', $guestName)); } $this->recordThat(new GuestIsCheckedIn($guestName)); } public function checkOut(string $guestName): void { if (!in_array($guestName, $this->guests, true)) { throw new RuntimeException(sprintf('Guest %s is not checked in', $guestName)); } $this->recordThat(new GuestIsCheckedOut($guestName)); } #[Apply] protected function applyHotelCreated(HotelCreated $event): void { $this->id = $event->id; $this->name = $event->hotelName; $this->guests = []; } #[Apply] protected function applyGuestIsCheckedIn(GuestIsCheckedIn $event): void { $this->guests[] = $event->guestName; } #[Apply] protected function applyGuestIsCheckedOut(GuestIsCheckedOut $event): void { $this->guests = array_values( array_filter( $this->guests, static fn ($name) => $name !== $event->guestName, ), ); } } ``` :::note You can find out more about aggregates in the [library](/docs/event-sourcing/latest/aggregate). ::: ## Define projections So that we can see all the hotels on our website and also see how many guests are currently visiting the hotels, we need a projection for it. To create a projection we need a projector. Each projector is then responsible for a specific projection. ```php namespace App\Subscribers; use App\Events\GuestIsCheckedIn; use App\Events\GuestIsCheckedOut; use App\Events\HotelCreated; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; #[Projector('hotel')] final class HotelProjection { use SubscriberUtil; /** @return Collection */ public function getHotels(): Collection { return DB::table($this->table())->get(); } #[Subscribe(HotelCreated::class)] public function handleHotelCreated(HotelCreated $event): void { DB::table($this->table())->insert([ 'id' => $event->id->toString(), 'name' => $event->hotelName, 'guests' => 0, ]); } #[Subscribe(GuestIsCheckedIn::class)] public function handleGuestIsCheckedIn(Uuid $hotelId): void { DB::table($this->table()) ->where('id', $hotelId->toString()) ->increment('guests'); } #[Subscribe(GuestIsCheckedOut::class)] public function handleGuestIsCheckedOut(Uuid $hotelId): void { DB::table($this->table()) ->where('id', $hotelId->toString()) ->decrement('guests'); } #[Setup] public function create(): void { Schema::create($this->table(), static function (Blueprint $table): void { $table->uuid('id')->primary(); $table->string('name'); $table->integer('guests'); }); } #[Teardown] public function drop(): void { Schema::dropIfExists('hotels'); } private function table(): string { return 'projection_' . $this->subscriberId(); } } ``` You need to register the projector in the `event-sourcing.php` configuration file. ```php use App\Subscribers\HotelProjection; return [ 'subscribers' => [ HotelProjection::class, ], ]; ``` :::note You can find out more about projections in the [library](/docs/event-sourcing/latest/subscription). ::: ## Processor In our example we also want to send an email to the head office as soon as a guest is checked in. ```php namespace App\Subscribers; use App\Events\GuestIsCheckedIn; use Illuminate\Mail\Message; use Illuminate\Support\Facades\Mail; use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; use function sprintf; #[Processor('admin_emails')] final class SendCheckInEmailProcessor { #[Subscribe(GuestIsCheckedIn::class)] public function onGuestIsCheckedIn(GuestIsCheckedIn $event): void { Mail::raw('Event Sourcing is amazing!', static function (Message $message) use ($event): void { $message ->subject(sprintf('Guest %s checked in', $event->guestName)) ->to('info@patchlevel.de'); }); } } ``` You need to register the processor in the `event-sourcing.php` configuration file. ```php use App\Subscribers\SendCheckInEmailProcessor; return [ 'subscribers' => [ SendCheckInEmailProcessor::class, ], ]; ``` :::note You can find out more about processor in the [library](/docs/event-sourcing/latest/subscription) ::: ## Usage We are now ready to use the Event Sourcing System. To demonstrate this, we create a controller that allows us to create hotels and check in and out guests. ```php namespace App\Http\Controllers; use App\Models\Hotel; use App\Subscribers\HotelProjection; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Patchlevel\EventSourcing\Aggregate\Uuid; use function response; final class HotelController { public function __construct( private readonly HotelProjection $hotelProjection, ) { } public function list(): JsonResponse { return response()->json( $this->hotelProjection->getHotels(), ); } public function create(Request $request): JsonResponse { $hotelName = $request->json('name'); // need validation! $id = Uuid::generate(); $hotel = Hotel::create($id, $hotelName); $hotel->save(); return response()->json(['id' => $id->toString()]); } public function checkIn(string $id, Request $request): JsonResponse { $guestName = $request->request->get('name'); // need validation! $hotel = Hotel::load(Uuid::fromString($id)); $hotel->checkIn($guestName); $hotel->save(); return response()->json(); } public function checkOut(string $id, Request $request): JsonResponse { $guestName = $request->request->get('name'); // need validation! $hotel = Hotel::load(Uuid::fromString($id)); $hotel->checkOut($guestName); $hotel->save(); return response()->json(); } } ``` The last step is to define the routes in the `routes/api.php` file. ```php use App\Http\Controllers\HotelController; use Illuminate\Support\Facades\Route; Route::get('/hotel', [HotelController::class, 'list']); Route::post('/hotel/create', [HotelController::class, 'create']); Route::post('/hotel/{id}/check-in', [HotelController::class, 'checkIn']); Route::post('/hotel/{id}/check-out', [HotelController::class, 'checkOut']); ``` :::warning Don't forget to define the path to the [api routes](https://laravel.com/docs/11.x/routing#api-routes) in the `bootstrap/app.php` configuration file. ::: ## Result :::success We have successfully implemented and used event sourcing. Feel free to browse further in the documentation for more detailed information. If there are still open questions, create a ticket on Github and we will try to help you. ::: :::note This documentation is limited to the package integration. You should also read the [library documentation](/docs/event-sourcing/latest). ::: --- # Facades Source: https://patchlevel.dev/docs/laravel-event-sourcing/latest/facades.md We offer facades for easy access to event sourcing services. You can use the facades to access the repositories, the store or manage your aggregates. This feature is optional, you can still use the services directly via dependency injection. ## Aggregate If your aggregates extend the laravel package provided `AggregateRoot` class, you can use the helper methods to load and save your aggregates. ```php use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\LaravelEventSourcing\AggregateRoot; #[Aggregate(name: 'hotel')] final class Hotel extends AggregateRoot { // ... } ``` With the static `load` method of your specific aggregate class you can load your aggregates. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; $hotel = Hotel::load(Uuid::fromString('123')); ``` And save them by using the `save` method on the aggregate instance. ```php $hotel->save(); ``` ## Repository You can access the specific repositories using the `get` method of the `Repository` facade. ```php use Patchlevel\LaravelEventSourcing\Facade\Repository; $repository = Repository::get(Hotel::class); ``` ## Store You can access the store using the `Store` facade. There you can save multiple messages at once: ```php use Patchlevel\LaravelEventSourcing\Facade\Store; Store::save(/* messages... */); ``` or load messages by criteria: ```php use Patchlevel\EventSourcing\Store\Criteria\AggregateIdCriterion; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\LaravelEventSourcing\Facade\Store; $messages = Store::load( new Criteria( new AggregateIdCriterion('123'), ), ); ``` :::note This documentation is limited to the package integration. You should also read the [library documentation](/docs/event-sourcing/latest). ::: ## Projection Connection You can access the projection connection using the `ProjectionConnection` facade. This facade provides you the `DBAL\Connection` used to connect to the projection database. :::note This documentation is limited to the package integration. You should also read the [dbal documentation](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/data-retrieval-and-manipulation.html#api). ::: ## CommandBus You can access the command bus using the `CommandBus` facade. With this facade you can dispatch commands. ```php CommandBus::dispatch(new BookHotel()); ``` Then, the command will be handled by the corresponding command handler specified via `#[Handle]` attribute. ## QueryBus You can access the query bus using the `QueryBus` facade. With this facade you can dispatch queries. ```php $result = QueryBus::dispatch(new HotelCountQuery()); ``` Then, the query will be handled by the corresponding query handler specified via `#[Answer]` attribute. --- # Configuration Source: https://patchlevel.dev/docs/laravel-event-sourcing/latest/configuration.md :::note You can find out more about event sourcing in the library [documentation](/docs/event-sourcing/latest). This documentation is limited to the laravel integration and configuration. ::: :::tip We provide a [default configuration](installation.md#configuration-file) that should work for most projects. ::: ## Aggregate A path must be specified for Event Sourcing to know where to look for your aggregates. If you want you can use glob patterns to specify multiple paths. ```php return [ 'aggregates' => [app_path()], ]; ``` Or use an array to specify multiple paths. ```php return [ 'aggregates' => [ app_path() . 'src/Hotel/Domain', app_path() . 'src/Room/Domain', ], ]; ``` :::note The library will automatically register all classes marked with the `#[Aggregate]` attribute in the specified paths. ::: :::tip If you want to learn more about aggregates, read the [library documentation](/docs/event-sourcing/latest/aggregate). ::: ## Events A path must be specified for Event Sourcing to know where to look for your events. If you want you can use glob patterns to specify multiple paths. ```php return [ 'events' => [app_path()], ]; ``` Or use an array to specify multiple paths. ```php return [ 'events' => [ app_path() . 'src/Hotel/Domain/Event', app_path() . 'src/Room/Domain/Event', ], ]; ``` :::tip If you want to learn more about events, read the [library documentation](/docs/event-sourcing/latest/events). ::: ## Custom Headers If you want to implement custom headers for your application, you must specify the paths to look for those headers. If you want you can use glob patterns to specify multiple paths. ```php return [ 'headers' => [app_path()], ]; ``` Or use an array to specify multiple paths. ```php return [ 'headers' => [ app_path() . 'src/Hotel/Domain/Header', app_path() . 'src/Room/Domain/Header', ], ]; ``` :::tip If you want to learn more about custom headers, read the [library documentation](/docs/event-sourcing/latest/message#custom-headers). ::: ## Connection You have to specify the connection url to the event store. ```php return [ 'connection' => [ 'url' => env('EVENT_SOURCING_DB_URL'), ], ]; ``` :::note You can find out more about how to create a connection [here](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html) ::: ### Connection for Projections Per default, our event sourcing connection is not available to use in your application. But you can create a dedicated connection that you can use for your projections. ```php return [ 'connection' => [ 'url' => env('EVENT_SOURCING_DB_URL'), 'provide_dedicated_connection' => true, ], ]; ``` :::warning If you use doctrine migrations, you should exclude you projection tables from the schema generation. The schema is managed by the subscription engine and should not be managed by doctrine. ::: :::tip You can autowire the connection in your services like this: ```php use Doctrine\DBAL\Connection; use Patchlevel\LaravelEventSourcing\Attribute\ProjectionConnection; final class MyService { public function __construct( #[ProjectionConnection] private readonly Connection $connection, ) { } } ``` ::: ## Store The store and schema is configurable. ### Change Store type You can change the store type of the event store. ```php return [ 'store' => ['type' => 'dbal_stream'], ]; ``` Following store types are available: - `dbal_aggregate` *default (deprecated)* - `dbal_stream` *recommended* - `in_memory` - `custom` :::note If you use `custom` store type, you need to set the service id under `store.service`. ::: ### Change table Name You can change the table name of the event store. ```php return [ 'store' => [ 'type' => 'dbal_stream', 'options' => ['table_name' => 'my_event_store'], ], ]; ``` ### Read Only Mode For `dbal_aggregate` and `dbal_stream` store types you can activate the read only mode. Readings are possible, but if you try to write, an exception `StoreIsReadOnly` is thrown. ```php return [ 'store' => [ 'type' => 'dbal_stream', 'readonly' => true, ], ]; ``` :::tip This is useful if you have maintenance work on the event store and you want to avoid side effects. ::: ### Data Migration If you want to migrate from your current store to a new store, you can use the following configuration. This registers a new store and a new cli command `event-sourcing:store:migrate`. You can define translators to translate the old events to the new store. Here is an example for a migration from `dbal_aggregate` to `dbal_stream`. ```php use Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator; return [ 'store' => [ 'type' => 'dbal_aggregate', 'readonly' => true, 'options' => ['table_name' => 'old_store'], 'migrate_to_new_store' => [ 'enabled' => true, 'type' => 'dbal_stream', 'options' => ['table_name' => 'my_stream_store'], 'translators' => [ AggregateToStreamHeaderTranslator::class, ], ], ], ]; ``` :::danger Make sure that you use different table names for the old and new store. Otherwise your event store will be destroyed. ::: :::tip Set the `read_only` flag to `true` for the old store to avoid side effects and missing events during the migration. ::: ## Subscription :::tip You can find out more about subscriptions in the library [documentation](/docs/event-sourcing/latest/subscription). ::: ### Store You can change where the subscription engine stores its necessary information about the subscription. Default is `dbal`, which means it stores it in the same DB that is used by the dbal event store. Otherwise you can choose between the following stores: - `dbal` *default* - `in_memory` - `static_in_memory` - `custom` ```php return [ 'subscription' => [ 'store' => [ 'type' => 'custom', // default is 'dbal' 'service' => 'my_subscription_store', 'options' => ['table_name' => 'my_subscription_store'], ], ], ]; ``` :::tip You can use the `static_in_memory` store for testing, if you are using transactions to rollback changes. ::: ### Catch Up If aggregates are used in the processors and new events are generated there, then they are not part of the current subscription engine `run` and will only be processed during the next run or boot. This is usually not a problem in prod environment because a worker is used and these events will be processed at some point. But in testing it is not so easy. For this reason, you can activate the `catch_up` option. For local dev this is also very handy. ```php return [ 'subscription' => [ 'catch_up' => [ 'enabled' => true, 'limit' => null, // define a limit to catch up only a limited number of events ], ], ]; ``` ### Throw on Error You can activate the `throw_on_error` option to throw an exception if a subscription engine run has an error. This is useful for testing and development to get direct feedback if something is wrong. ```php return [ 'subscription' => ['throw_on_error' => true], ]; ``` :::warning This option should not be used in production. The normal behavior is to log the error and continue. ::: ### Run After Aggregate Save If you want to run the subscription engine after an aggregate is saved, you can activate this option. This is useful for testing and development, so you don't have to run a worker to process the events. ```php return [ 'subscription' => [ 'run_after_aggregate_save' => [ 'enabled' => true, 'ids' => null, // limit to specific subscriptions ids 'groups' => null, // limit to specific subscriptions groups 'limit' => null, // limit how many events should be processed ], ], ]; ``` ### Auto Setup If you want to automatically setup the subscription engine, you can activate this option. This is useful for development, so you don't have to setup the subscription engine manually. ```php return [ 'subscription' => [ 'auto_setup' => [ 'enabled' => true, 'ids' => null, // limit to specific subscriptions ids 'groups' => null, // limit to specific subscriptions groups ], ], ]; ``` :::note This works only before each http requests and not if you use the console commands. ::: ### Rebuild After File Change If you want to rebuild the subscription engine after a file change, you can activate this option. This is also useful for development, so you don't have to rebuild the projections manually. ```php return [ 'subscription' => [ 'rebuild_after_file_change' => ['enabled' => true], ], ]; ``` :::note This works only before each http requests and not if you use the console commands. ::: :::tip This is using the cache system to store the latest file change time. You can change the cache pool with the `cache_pool` option. ::: ### Gap Detection Depending on the database you are using for the eventstore it may be happening that your subscriptions are skipping some events. This is due to how auto-increments work in these databases in combination with e.g. longer open transactions. Even when not working with longer open transactions, this may occur if load is high on the database. We already have a locking mechanism in place to prevent this behavior which throttles write speed. Gap Detection operates differently, it checks if a gap between the last message handled and the current message is present. If so it waits a reasonable amount of time and re-fetches the message. This results in slower updates for the subscriptions but creates more resilience. ```php return [ 'subscription' => [ 'gap_detection' => ['enabled' => true], ], ]; ``` :::note For more context you can read more about this in [this issue](https://github.com/patchlevel/event-sourcing/issues/727#issuecomment-2757297536). ::: :::tip You can use both techniques locking and gap detecion to mitigate gaps happening in the subscriptions. ::: You can also define how often the gap detection should re-check the gap and how long it should wait, in this example we instantly retry the first time, then we wait 500ms and after that we check a last time after 1 second. ```php return [ 'subscription' => [ 'gap_detection' => [ 'enabled' => true, 'retries_in_ms' => [0, 5, 50, 500], ], ], ]; ``` Another config option is to define the detection window. The option defines the timeframe from now if we should check for a gap. It's defined as an [DateInterval](https://www.php.net/manual/en/class.dateinterval.php) so you need to provide a valid `string` for it. ```php return [ 'subscription' => [ 'gap_detection' => [ 'enabled' => true, 'detection_window' => 'PT5M', ], ], ]; ``` ## Command Bus You can enable the command bus integration to use your aggregates as command handlers. ```php return [ 'subscription' => [ 'command_bus' => ['enabled' => true], ], ]; ``` For now, we *do not* provide a laravel/queue integration, but we are open for suggestions. :::note You can find out more about the command bus and the aggregate handlers [here](/docs/event-sourcing/latest/command-bus). ::: ### Instant Retry You can define the default instant retry configuration for the command bus. This will be used if you don't define a retry configuration for a specific command. ```php use Patchlevel\EventSourcing\Repository\AggregateOutdated; return [ 'subscription' => [ 'command_bus' => [ 'enabled' => true, 'instant_retry' => [ 'default_max_retries' => 3, 'default_exceptions' => [ AggregateOutdated::class, ], ], ], ], ]; ``` :::note You can find out more about instant retry [here](/docs/event-sourcing/latest/command-bus#instant-retry). ::: ## Query Bus You can enable the query bus integration to use queries to retrieve data from your system. ```php return [ 'subscription' => [ 'query_bus' => ['enabled' => true], ], ]; ``` For now, we *do not* provide a laravel/queue integration, but we are open for suggestions. :::note You can find out more about the query bus [here](/docs/event-sourcing/latest/query-bus). ::: ## Event Bus You can enable the event bus to listen for events and messages synchronously. The subscription engine is highly recommended to use instead of the event bus. ```php return [ 'subscription' => [ 'event_bus' => ['enabled' => true], ], ]; ``` :::note Default is the patchlevel [event bus](/docs/event-sourcing/latest/event-bus). ::: ## Snapshot You only need to tell the aggregate that it should use this snapshot store. ```php namespace App\Profile\Domain; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Snapshot; #[Aggregate(name: 'profile')] #[Snapshot('default')] final class Profile extends BasicAggregateRoot { // ... } ``` :::note You can find out more about snapshots [here](/docs/event-sourcing/latest/snapshots). ::: ## Cryptography You can use the library to encrypt and decrypt personal data. For this you need to enable the crypto shredding. ```php return [ 'cryptography' => [ 'enabled' => true, 'use_encrypted_field_name' => true, 'fallback_to_field_name' => false, ], ]; ``` :::tip You should activate `use_encrypted_field_name` to mark the fields that are encrypted. That allows you later to migrate not encrypted fields to encrypted fields. If you have already encrypted fields, you can activate `fallback_to_field_name` to use the old field name as fallback. ::: If you want to use another algorithm, you can specify this here: ```php return [ 'cryptography' => [ 'enabled' => true, 'algorithm' => 'aes256', ], ]; ``` :::note You can find out more about sensitive data [here](/docs/event-sourcing/latest/personal-data). ::: ## Clock The clock is used to return the current time as `DateTimeImmutable`. ### Freeze Clock You can freeze the clock for testing purposes: ```php return [ 'clock' => ['freeze' => '2020-01-01 22:00:00'], ]; ``` :::note If freeze is not set, then the system clock is used. ::: ### PSR-20 You can also use your own implementation of your choice. They only have to implement the interface of the [psr-20](https://www.php-fig.org/psr/psr-20/). You can then specify this service here: ```php return [ 'clock' => ['service' => 'my_own_clock_service_id'], ]; ``` ===== # patchlevel/hydrator > The hydration library used in event-sourcing. # Upcasting Source: https://patchlevel.dev/docs/hydrator/latest/upcasting.md Over time the shape of your stored data drifts away from your classes: fields get renamed, split or merged. Upcasting reshapes the stored array on the fly while it is hydrated, so old payloads keep loading into your current classes without a migration of the underlying storage. ## Setup Register the `UpcastExtension` on the builder and pass it a list of upcasters. Each upcaster receives the raw data array and returns a reshaped array. ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new UpcastExtension( beforeTransform: [ CallbackUpcaster::forClass( ProfileCreated::class, static function (array $data): array { $data['name'] = $data['firstName'] . ' ' . $data['lastName']; unset($data['firstName'], $data['lastName']); return $data; }, ), ], )) ->build(); ``` :::note Upcasting only runs during [hydration](hydrator.md). Extraction always writes the current shape, so once an object has been re-extracted its stored payload is up to date. ::: ## Writing an upcaster An upcaster implements the `Upcaster` interface. It receives the [class metadata](hydrator.md), the data array and the context, and returns the reshaped data. Because every registered upcaster runs for every class, check the metadata and leave data you do not care about untouched. ```php use Patchlevel\Hydrator\Extension\Upcast\Upcaster; use Patchlevel\Hydrator\Metadata\ClassMetadata; final class RenameEmailUpcaster implements Upcaster { public function upcast(ClassMetadata $metadata, array $data, array $context): array { if ($metadata->className !== ProfileCreated::class) { return $data; } $data['email'] = $data['mail']; unset($data['mail']); return $data; } } ``` For the common case of a single class and a closure, use the `CallbackUpcaster`. It compares the class name for you and only invokes the callback for a match. The callback receives the data and the context: ```php use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; $upcaster = CallbackUpcaster::forClass( ProfileCreated::class, static function (array $data, array $context): array { $data['email'] = $data['mail']; unset($data['mail']); return $data; }, ); ``` ## When upcasters run The hydrator decodes the stored payload in stages: first it is read as raw values, then [normalizers](normalizer.md) decode each field, and finally the object is built. The `UpcastExtension` can hook into two of these stages, and you pass your upcasters to the matching argument. | Argument | Runs | Works on | | --- | --- | --- | | `beforeEncoding` | before the values are decoded | the raw stored values (strings, ints, ...) | | `beforeTransform` | after decoding, right before the object is built | the decoded values (enums, dates, value objects, ...) | Use `beforeEncoding` when you rename or restructure fields whose raw form is enough, and `beforeTransform` when you need the already decoded values. ```php use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; $extension = new UpcastExtension( beforeEncoding: [$renameFieldUpcaster], beforeTransform: [$mergeNameUpcaster], ); ``` :::warning The [cryptography](cryptography.md) extension decrypts values during the decoding stage. A `beforeEncoding` upcaster therefore still sees the encrypted values, while a `beforeTransform` upcaster sees the decrypted ones. Pick the stage that matches the data you need. ::: ## Learn more * [How to write your own extension](extensions.md) * [How to decode values with normalizers](normalizer.md) * [How to encrypt sensitive data](cryptography.md) --- # Normalizer Source: https://patchlevel.dev/docs/hydrator/latest/normalizer.md For complex structures, i.e. non-scalar data types, the hydrator uses normalizers. A normalizer converts a value into a serializable representation (`normalize`) and back into the original type (`denormalize`). The library ships normalizers for all PHP native structures such as enums, date types, collections and objects, and determines on its own which one to use. ## How normalizers are resolved For every property, the normalizer is determined in this order: 1. Does the property have a normalizer as an attribute? Use this. 2. Otherwise, the type of the property is determined: 1. If it is an array shape, the `ArrayShapeNormalizer` is used (recursive). 2. If it is a collection, the `ArrayNormalizer` is used (recursive). 3. If it is an object, a normalizer attribute is searched on the class, its parents and interfaces. 4. If none is found, the [guessers](guesser.md) are asked. The built-in guesser handles enums and date types and falls back to the `ObjectNormalizer`. The normalizer is only determined once per class because it is cached in the [metadata](caching.md). ## Array If you have a collection (array, iterable, list) the element type is read from the docblock and the matching normalizer is applied to every element automatically. ```php final readonly class ProfileCreated { /** @param list $skills */ public function __construct( public array $skills, ) { } } ``` You can also set the `ArrayNormalizer` explicitly and pass it the normalizer for the elements: ```php use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class Profile { /** @var list */ #[ArrayNormalizer(new DateTimeImmutableNormalizer())] public array $loginDates; } ``` :::note The keys of the array are kept. ::: ## ArrayShape If you have an array with a specific shape, the `ArrayShapeNormalizer` is used. It is inferred automatically from an `array{...}` docblock, or you can configure it explicitly with a map of field name to normalizer. ```php use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class Profile { /** * @var array{ * createdAt: DateTimeImmutable, * source: string * } */ public array $meta; #[ArrayShapeNormalizer(['createdAt' => new DateTimeImmutableNormalizer()])] public array $explicitMeta; } ``` ## DateTimeImmutable With the `DateTimeImmutableNormalizer` you can convert `DateTimeImmutable` objects to a string and back again. It is applied automatically to `DateTimeImmutable` properties. ```php use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class Profile { #[DateTimeImmutableNormalizer] public DateTimeImmutable $createdAt; } ``` You can also define the format. Either describe it yourself as a string or use one of the existing constants. The default is `DateTimeImmutable::ATOM`. ```php use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class Profile { #[DateTimeImmutableNormalizer(format: DateTimeImmutable::RFC3339_EXTENDED)] public DateTimeImmutable $createdAt; } ``` :::note You can read about how the format is structured in the [php docs](https://www.php.net/manual/en/datetime.format.php). ::: ## DateTime The `DateTimeNormalizer` works exactly like the `DateTimeImmutableNormalizer`, only for `DateTime` objects. The default format is `DateTime::ATOM`. ```php use DateTime; use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; final class Profile { #[DateTimeNormalizer(format: DateTime::RFC3339_EXTENDED)] public DateTime $lastSeen; } ``` ## DateTimeZone To normalize a `DateTimeZone`, the `DateTimeZoneNormalizer` is used. ```php use DateTimeZone; use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer; final class Profile { #[DateTimeZoneNormalizer] public DateTimeZone $timeZone; } ``` ## DateInterval A `DateInterval` is converted to its ISO 8601 duration string with the `DateIntervalNormalizer`. The format can be customized. ```php use DateInterval; use Patchlevel\Hydrator\Normalizer\DateIntervalNormalizer; final class Subscription { #[DateIntervalNormalizer] public DateInterval $renewEvery; } ``` ## Enum Backed enums are converted to their backing value. The enum class is inferred from the property type, but can also be passed explicitly. ```php use Patchlevel\Hydrator\Normalizer\EnumNormalizer; final class Profile { #[EnumNormalizer] public Role $role; #[EnumNormalizer(Role::class)] public mixed $explicitRole; } ``` ## Object If you have a complex object that you want to normalize, the `ObjectNormalizer` is used. It runs the [hydrator](hydrator.md) recursively on the object. It is the automatic fallback for object properties, so you only need the attribute when the class cannot be inferred from the type. ```php use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; final class Profile { #[ObjectNormalizer] public Address $address; #[ObjectNormalizer(Address::class)] public object $untypedAddress; } ``` :::warning Circular references are not supported and result in a `CircularReference` exception. ::: ## ObjectMap Use the `ObjectMapNormalizer` if you have either inheritance or a union type, where the concrete class can not be derived from the property type alone. The map assigns a stable type name to every class, which is stored in the data under the `_type` field (configurable via `typeFieldName`). ```php use Patchlevel\Hydrator\Normalizer\ObjectMapNormalizer; #[ObjectMapNormalizer([ ContentBlock::class => 'content', CodeBlock::class => 'code', ])] interface Block { } final class Page { #[ObjectMapNormalizer( [TextSection::class => 'text', ImageSection::class => 'image'], typeFieldName: 'kind', )] public TextSection|ImageSection $section; } ``` :::note Auto detection of the concrete type is not possible here. You have to specify the map yourself. ::: ## Inline The `InlineNormalizer` allows you to define normalization and denormalization logic directly via closures. This is useful for simple value objects when you don't want to create a separate normalizer class. ```php use Patchlevel\Hydrator\Normalizer\InlineNormalizer; #[InlineNormalizer( normalize: static fn (self $email): string => $email->toString(), denormalize: static fn (string $value): self => new self($value), )] final class Email { public function __construct( private string $value, ) { } public function toString(): string { return $this->value; } } ``` :::note Closures in attributes are only possible since PHP 8.5, therefore this normalizer can only be used as an attribute with PHP 8.5. ::: :::tip If you want to handle `null` values within your closures, you can set the `passNull` option to `true`. By default, `null` values are not passed to the closures and are returned as `null` directly. ::: ## Custom Normalizer The library only offers normalizers for PHP native things, so for your own structures, such as value objects, you write a custom normalizer. It must implement the `Normalizer` interface. To use it as an attribute, allow it for properties as well as classes. In this example we have a value object that holds a validated name: ```php final class Name { private string $value; public function __construct(string $value) { if (strlen($value) < 3) { throw new NameIsTooShort($value); } $this->value = $value; } public function toString(): string { return $this->value; } } ``` The matching normalizer converts it to a string and back: ```php use Attribute; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\Normalizer; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] final class NameNormalizer implements Normalizer { public function normalize(mixed $value, array $context): string|null { if ($value === null) { return null; } if (!$value instanceof Name) { throw InvalidArgument::withWrongType(Name::class, $value); } return $value->toString(); } public function denormalize(mixed $value, array $context): Name|null { if ($value === null) { return null; } if (!is_string($value)) { throw InvalidArgument::withWrongType('string', $value); } return new Name($value); } } ``` Now you can use the normalizer directly on a property: ```php final class Profile { #[NameNormalizer] public Name $name; } ``` ## Define a normalizer on class level Instead of specifying the normalizer on each property, you can also set the normalizer on the class or on an interface. Every property typed with that class then uses it automatically. ```php #[NameNormalizer] final class Name { // ... same as before } ``` :::tip If you can't put an attribute on the class, for example for third-party classes, write a [guesser](guesser.md) instead. ::: ## Learn more * [How to guess normalizers for third-party classes](guesser.md) * [How to rename or ignore fields](hydrator.md) * [How to use the hydrator](hydrator.md) --- # Lifecycle Hooks Source: https://patchlevel.dev/docs/hydrator/latest/lifecycle-hooks.md Sometimes you need to do something before or after the extract and hydrate process, for example migrate old data structures, compute derived state or clean up the result. For this, the `LifecycleExtension` provides four method attributes: `PreHydrate`, `PostHydrate`, `PreExtract` and `PostExtract`. ## Setup Register the `LifecycleExtension` on the builder: ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new LifecycleExtension()) ->build(); ``` ## Hooks The hooks are **static** methods on the class being hydrated. The data hooks (`PreHydrate`, `PostExtract`) receive the data array and must return the (modified) array; the object hooks (`PostHydrate`, `PreExtract`) receive the object instance. ```php use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostExtract; use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostHydrate; use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreExtract; use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreHydrate; final class Profile { public function __construct( public string $name, ) { } /** * @param array $data * @param array $context * * @return array */ #[PreHydrate] public static function migrateOldData(array $data, array $context): array { // rename a legacy field before the object is hydrated if (isset($data['profile_name'])) { $data['name'] = $data['profile_name']; unset($data['profile_name']); } return $data; } /** @param array $context */ #[PostHydrate] public static function afterHydrate(object $object, array $context): void { // do something with the freshly hydrated object } /** @param array $context */ #[PreExtract] public static function beforeExtract(object $object, array $context): void { // do something with the object before it is extracted } /** * @param array $data * @param array $context * * @return array */ #[PostExtract] public static function cleanupData(array $data, array $context): array { // adjust the extracted array before it is returned return $data; } } ``` :::warning The hook methods must be `static`, otherwise a `LogicException` is thrown when the metadata is created. The object hooks receive the instance as their first parameter instead of using `$this`. ::: :::tip `PreHydrate` is a good place for schema migrations: old stored data can be upgraded to the current class structure without touching the persisted payload. ::: ## Learn more * [How extensions and middlewares work](extensions.md) * [How to use the hydrator](hydrator.md) * [How to rename fields without hooks](hydrator.md) --- # Lazy Objects Source: https://patchlevel.dev/docs/hydrator/latest/lazy.md Since PHP 8.4, it is possible to hydrate objects lazily. The hydrator then returns a lazy proxy and the actual hydration happens only when the object is accessed for the first time. This saves work when you hydrate many objects but only touch a few of them. ## Enable lazy hydration per class You can define for each class whether you want it to be lazy by using the `Lazy` attribute. ```php use Patchlevel\Hydrator\Attribute\Lazy; #[Lazy] final readonly class ProfileCreated { public function __construct( public string $id, public string $name, ) { } } ``` :::note If you are using a PHP version older than 8.4, the attribute is ignored and the object is hydrated eagerly. ::: ## Enable lazy hydration by default Instead of marking every class, you can make lazy hydration the default when building the hydrator. ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->enableDefaultLazy() ->build(); ``` Single classes can then opt out again with the attribute: ```php use Patchlevel\Hydrator\Attribute\Lazy; #[Lazy(false)] final readonly class ProfileCreated { public function __construct( public string $id, public string $name, ) { } } ``` :::tip [Cryptography](cryptography.md) is very expensive in terms of performance. You can combine it with lazy objects so the data is only decrypted when you actually access the object. ::: ## Learn more * [How to use the hydrator](hydrator.md) * [How to configure the builder](extensions.md) * [How to encrypt sensitive data](cryptography.md) --- # Hydrator Source: https://patchlevel.dev/docs/hydrator/latest/introduction.md This library enables seamless hydration of objects to arrays - and back again. It is optimized for both developer experience and performance and works with `final`, `readonly` classes, constructor property promotion and deeply nested structures. Hydration is handled through [normalizers](normalizer.md), especially for complex data types. The library automatically determines the appropriate normalizer based on the property type and attributes, so in most cases no manual configuration is needed. And if customization is required, it can be done easily using attributes. ## Features * Extract objects to arrays and [hydrate](hydrator.md) them back, without calling the constructor. * Automatic [normalizer](normalizer.md) resolution for enums, date types, collections, array shapes and nested objects. * Rename or exclude fields with [attributes](hydrator.md). * [Lazy hydration](lazy.md) of objects with PHP 8.4 lazy proxies. * Pluggable [guessers](guesser.md) to pick normalizers for your own value objects. * [Extensions](extensions.md) with middlewares and metadata enrichers to hook into the process. * [Lifecycle hooks](lifecycle-hooks.md) before extracting and after hydrating. * Encrypt and decrypt sensitive data with the [cryptography](cryptography.md) extension (crypto-shredding). * [Upcast](upcasting.md) outdated stored data while it is hydrated. * [Cache](caching.md) the metadata with any PSR-6 or PSR-16 cache. ## Installation ```bash composer require patchlevel/hydrator ``` ## Integration * [Event Sourcing](https://github.com/patchlevel/event-sourcing) - the hydrator powers the storage and retrieval of thousands of events and aggregates. * [ODM](https://github.com/patchlevel/odm) - a lightweight object document mapper for MongoDB and PostgreSQL that builds on the hydrator for fast object mapping and full extension support. :::tip New here? Start with the [getting started guide](getting-started.md) and build your first hydrator in a few minutes. ::: --- # Hydrator Source: https://patchlevel.dev/docs/hydrator/latest/hydrator.md The hydrator converts objects into plain arrays (`extract`) and arrays back into objects (`hydrate`). The default implementation is the `StackHydrator`, which runs both operations through a stack of [middlewares](extensions.md) and resolves [normalizers](normalizer.md) from metadata. ## Create the hydrator The recommended way is the `StackHydratorBuilder` with the `CoreExtension`. The `CoreExtension` registers the `TransformMiddleware`, which does the actual property mapping, and the `BuiltInGuesser`, which picks normalizers for enums, date types and nested objects. ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->build(); ``` If you don't need any extensions, you can also instantiate the `StackHydrator` directly, it defaults to the same middleware and guesser: ```php use Patchlevel\Hydrator\StackHydrator; $hydrator = new StackHydrator(); ``` :::tip Use the builder as soon as you want [extensions](extensions.md), custom [guessers](guesser.md), [lazy objects by default](lazy.md) or a [metadata cache](caching.md). ::: ## Extract data To convert objects into serializable arrays, use the `extract` method. ```php use DateTimeImmutable; $event = new ProfileCreated( 1, 'patchlevel', Role::Admin, [new Skill('php', 10), new Skill('event-sourcing', 10)], new DateTimeImmutable('2023-10-01 12:00:00'), ); $data = $hydrator->extract($event); ``` The result is an array of scalars and nested arrays that can be passed straight to `json_encode`: ```php [ 'id' => 1, 'name' => 'patchlevel', 'role' => 'admin', 'skills' => [ ['name' => 'php', 'level' => 10], ['name' => 'event-sourcing', 'level' => 10], ], 'createdAt' => '2023-10-01T12:00:00+00:00', ] ``` ## Hydrate objects The reverse direction is the `hydrate` method. You specify the class that should be created and the data that should be written into it. ```php $event = $hydrator->hydrate( ProfileCreated::class, [ 'id' => 1, 'name' => 'patchlevel', 'role' => 'admin', 'skills' => [ ['name' => 'php', 'level' => 10], ['name' => 'event-sourcing', 'level' => 10], ], 'createdAt' => '2023-10-01T12:00:00+00:00', ], ); ``` :::warning The constructor is **not** called! The object is created without invoking the constructor and the properties are written directly. Validation logic in the constructor does not run during hydration. ::: If a field is missing in the data and the property is a promoted constructor parameter with a default value, the default value is used. ## Object to populate If you want to hydrate an object that already exists, you can pass the object to populate via the context. This is useful if you want to update an existing object. ```php use Patchlevel\Hydrator\Hydrator; $profile = new Profile(); $profile = $hydrator->hydrate( Profile::class, ['name' => 'patchlevel'], [Hydrator::OBJECT_TO_POPULATE => $profile], ); ``` ## Rename fields By default, the property name is used to name the field in the extracted result. This can be customized with the `NormalizedName` attribute. ```php use Patchlevel\Hydrator\Attribute\NormalizedName; final class Profile { #[NormalizedName('profile_name')] public string $name; } ``` The extracted result then looks like this: ```php [ 'profile_name' => 'patchlevel', ] ``` :::tip You can rename a property without a backwards compatibility break in your stored data by keeping the old serialized name with `NormalizedName`. ::: ## Ignore properties Sometimes it is necessary to exclude properties. You can do that with the `Ignore` attribute. The property is ignored both when extracting and when hydrating. ```php use Patchlevel\Hydrator\Attribute\Ignore; final readonly class ProfileCreated { public function __construct( public string $id, public string $name, #[Ignore] public string $internalState, ) { } } ``` :::warning An ignored property is never written during hydration. Make sure it has a default value or is set by a [lifecycle hook](lifecycle-hooks.md), otherwise it stays uninitialized. ::: ## Error handling Everything the library throws implements the `HydratorException` interface, so a single catch block is enough at the boundary. ```php use Patchlevel\Hydrator\HydratorException; try { $event = $hydrator->hydrate(ProfileCreated::class, $data); } catch (HydratorException $e) { // invalid data, unsupported class, type mismatch, ... } ``` :::note The most common exceptions are `ClassNotSupported` if the class does not exist, `DenormalizationFailure` if a normalizer rejects a value and `TypeMismatch` if a value does not fit the property type. ::: ## Learn more * [How normalizers convert complex types](normalizer.md) * [How to hydrate objects lazily](lazy.md) * [How to hook into the hydration process](extensions.md) --- # Guesser Source: https://patchlevel.dev/docs/hydrator/latest/guesser.md When a property is an object and no [normalizer](normalizer.md) attribute is found on the property or class, the hydrator asks its guessers which normalizer to use. Guessers are the right tool when you can't put an attribute on the class itself, for example for third-party classes. ## Built-in guesser The `BuiltInGuesser` is registered by the `CoreExtension`. It resolves backed enums to the `EnumNormalizer`, the date types (`DateTimeImmutable`, `DateTime`, `DateTimeZone`, `DateInterval`) to their normalizers and falls back to the `ObjectNormalizer` for everything else. ## Custom guesser A guesser implements the `Guesser` interface. It receives the resolved object type and returns a normalizer or `null` if it is not responsible. ```php use Patchlevel\Hydrator\Guesser\Guesser; use Patchlevel\Hydrator\Normalizer\Normalizer; use Symfony\Component\TypeInfo\Type\ObjectType; final class NameGuesser implements Guesser { public function guess(ObjectType $type): Normalizer|null { return match ($type->getClassName()) { Name::class => new NameNormalizer(), default => null, }; } } ``` To use the guesser, add it to the builder: ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->addGuesser(new NameGuesser()) ->build(); ``` :::note The guessers are queried in order of their priority, and the first match wins. The built-in guesser is registered with priority `-64`, so your own guessers run before the fallback to the `ObjectNormalizer`. ::: ## Mapped guesser For the common case of a simple class-to-normalizer mapping, you don't need to write your own guesser class, use the `MappedGuesser`: ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Guesser\MappedGuesser; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->addGuesser(new MappedGuesser([ Name::class => NameNormalizer::class, Email::class => EmailNormalizer::class, ])) ->build(); ``` :::note The `MappedGuesser` instantiates the normalizer class without arguments, so the normalizer must have a constructor without required parameters. ::: ## Learn more * [How normalizers are resolved](normalizer.md) * [How to configure the builder](extensions.md) * [How to use the hydrator](hydrator.md) --- # Getting Started Source: https://patchlevel.dev/docs/hydrator/latest/getting-started.md In this guide you build a small profile domain and use the hydrator to convert its objects into plain arrays and back. Everything you see here works without any configuration, the hydrator figures out the normalizers on its own. ## Define the classes We start with a backed enum for the role, a `Skill` value object and a `ProfileCreated` event that combines them. All classes are `final`, `readonly` and use constructor property promotion, the hydrator supports all of it. ```php enum Role: string { case Admin = 'admin'; case Member = 'member'; } final readonly class Skill { public function __construct( public string $name, public int $level, ) { } } final readonly class ProfileCreated { /** @param list $skills */ public function __construct( public int $id, public string $name, public Role $role, public array $skills, public DateTimeImmutable $createdAt, ) { } } ``` :::note The `@param list` docblock is what tells the hydrator the element type of the collection. How types are resolved into normalizers is explained on the [normalizer](normalizer.md) page. ::: ## Create the hydrator The recommended way to create a hydrator is the `StackHydratorBuilder` together with the `CoreExtension`, which registers the default middleware and the built-in normalizer guesser. ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->build(); ``` :::note The builder is also the place to add [extensions](extensions.md), custom [guessers](guesser.md) and a [metadata cache](caching.md). ::: ## Extract data To convert an object into a serializable array, use the `extract` method. ```php $event = new ProfileCreated( 1, 'patchlevel', Role::Admin, [new Skill('php', 10), new Skill('event-sourcing', 10)], new DateTimeImmutable('2023-10-01 12:00:00'), ); $data = $hydrator->extract($event); ``` The result looks like this: ```php [ 'id' => 1, 'name' => 'patchlevel', 'role' => 'admin', 'skills' => [ ['name' => 'php', 'level' => 10], ['name' => 'event-sourcing', 'level' => 10], ], 'createdAt' => '2023-10-01T12:00:00+00:00', ] ``` You can now turn this array into JSON with `json_encode` and store it anywhere. ## Hydrate the object back The process can be reversed with the `hydrate` method. You pass the class that should be created and the data that should be written into it. ```php $event = $hydrator->hydrate( ProfileCreated::class, [ 'id' => 1, 'name' => 'patchlevel', 'role' => 'admin', 'skills' => [ ['name' => 'php', 'level' => 10], ['name' => 'event-sourcing', 'level' => 10], ], 'createdAt' => '2023-10-01T12:00:00+00:00', ], ); ``` :::warning The constructor is **not** called during hydration. The properties are written directly, so constructor validation does not run. You can find more details on the [hydrator](hydrator.md) page. ::: ## Result You can now round-trip arbitrarily nested objects: enums, date types, collections and nested value objects are handled automatically. When the automatic resolution is not enough, you attach a [normalizer](normalizer.md) to the property or class, and that is usually all the configuration you ever need. ## Learn more * [How to use the hydrator in depth](hydrator.md) * [How normalizers are resolved and which ones exist](normalizer.md) * [How to rename or ignore fields](hydrator.md) * [How to hydrate objects lazily](lazy.md) --- # Extensions Source: https://patchlevel.dev/docs/hydrator/latest/extensions.md The `StackHydrator` is assembled from small building blocks: middlewares that wrap the hydration process, [guessers](guesser.md) that resolve normalizers and metadata enrichers that add information to the class metadata. An extension bundles such building blocks so they can be registered with a single call. ## Using extensions Extensions are registered on the `StackHydratorBuilder` with `useExtension`. The `CoreExtension` provides the default behaviour and should (almost) always be there. ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension; use Patchlevel\Hydrator\StackHydratorBuilder; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new LifecycleExtension()) ->build(); ``` ## Built-in extensions The library ships with four extensions out of the box: | Extension | Purpose | | --- | --- | | `CoreExtension` | The default behaviour, the `TransformMiddleware` and the `BuiltInGuesser`. | | `LifecycleExtension` | [Lifecycle hooks](lifecycle-hooks.md), run code before and after the extract and hydrate process. | | `CryptographyExtension` | [Cryptography](cryptography.md), encrypt and decrypt sensitive data with crypto-shredding. | | `UpcastExtension` | [Upcasting](upcasting.md), reshape outdated stored data while it is hydrated. | ## Middleware A middleware wraps the hydration and extraction process, similar to HTTP middlewares. It can modify the incoming data, the outgoing array or the object itself, and then delegates to the next middleware on the stack. The innermost middleware is the `TransformMiddleware`, which does the actual property mapping. ```php use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; use Patchlevel\Hydrator\Middleware\Stack; final class RemoveNullValuesMiddleware implements Middleware { public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object { return $stack->next()->hydrate($metadata, $data, $context, $stack); } public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array { $data = $stack->next()->extract($metadata, $object, $context, $stack); return array_filter($data, static fn (mixed $value) => $value !== null); } } ``` Middlewares are added with a priority, higher priorities run first (outermost). The `TransformMiddleware` from the `CoreExtension` has priority `-64`, so it always runs last. ```php $builder->addMiddleware(new RemoveNullValuesMiddleware(), 0); ``` ## Metadata enricher A metadata enricher runs once per class when the metadata is created. It can inspect the class and attach extra information to `ClassMetadata::$extras`, which a middleware can later read. This keeps expensive reflection out of the hot path. ```php use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\MetadataEnricher; final class AuditMetadataEnricher implements MetadataEnricher { public function enrich(ClassMetadata $classMetadata): void { $attributes = $classMetadata->reflection->getAttributes(Audited::class); if ($attributes === []) { return; } $classMetadata->extras[Audited::class] = true; } } ``` ```php $builder->addMetadataEnricher(new AuditMetadataEnricher()); ``` :::note Metadata enrichers also accept a priority. Since the metadata (including the extras) can be [cached](caching.md), everything you store in `extras` must be serializable. ::: ## Writing your own extension An extension implements the `Extension` interface and configures the builder. This is the way to package a middleware together with its metadata enricher. ```php use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\StackHydratorBuilder; final class AuditExtension implements Extension { public function configure(StackHydratorBuilder $builder): void { $builder->addMetadataEnricher(new AuditMetadataEnricher()); $builder->addMiddleware(new AuditMiddleware()); } } ``` ## Learn more * [How to run code before extract and after hydrate](lifecycle-hooks.md) * [How to encrypt sensitive data](cryptography.md) * [How to reshape outdated stored data](upcasting.md) * [How to cache the metadata](caching.md) --- # Cryptography Source: https://patchlevel.dev/docs/hydrator/latest/cryptography.md The cryptography extension can encrypt and decrypt sensitive data, e.g. personal data of customers. For each subject (e.g. a person) a separate cipher key is created and used to encrypt the marked fields. If the key is deleted, the data becomes unreadable. This pattern is known as crypto-shredding and makes "forgetting" a person possible even in immutable storage. :::experimental The cryptography extension is experimental and may change in a minor release. ::: ## Setup Register the `CryptographyExtension` on the builder and pass it a `Cryptographer`. The `BaseCryptographer` with the openssl cipher is the default choice; it needs a [cipher key store](#cipher-key-store) to keep the keys. ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; use Patchlevel\Hydrator\StackHydratorBuilder; $cipherKeyStore = new InMemoryCipherKeyStore(); $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) ->build(); ``` ## DataSubjectId First you need to define which field identifies the subject the data belongs to. The cipher key is created and looked up per subject id. ```php use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; final class EmailChanged { public function __construct( #[DataSubjectId] public readonly string $profileId, #[SensitiveData] public readonly string|null $email, ) { } } ``` :::warning The `DataSubjectId` must be a string, you can use a [normalizer](normalizer.md) to convert a value object to a string. The subject id itself cannot be sensitive data. ::: You can also use multiple subject ids in one class by naming them and referencing the name from the sensitive fields. The default name is `default`. ```php use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; final class ProfilesMerged { public function __construct( #[DataSubjectId(name: 'source')] public readonly string $sourceProfileId, #[SensitiveData(subjectIdName: 'source')] public readonly string|null $sourceEmail, #[DataSubjectId(name: 'target')] public readonly string $targetProfileId, #[SensitiveData(subjectIdName: 'target')] public readonly string|null $targetEmail, ) { } } ``` ## Fallback values If the data could not be decrypted, because the key has been removed, a fallback value is inserted. The default fallback is `null`. You can change this with the `fallback` parameter: ```php use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; final class ProfileCreated { public function __construct( #[DataSubjectId] public readonly string $profileId, #[SensitiveData(fallback: 'unknown')] public readonly string $name, ) { } } ``` You can also use a callable as a fallback. It receives the subject id: ```php use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; final class ProfileCreated { public function __construct( #[DataSubjectId] public readonly string $profileId, #[SensitiveData(fallback: 'deleted profile')] public readonly string $name, #[SensitiveData(fallbackCallable: [self::class, 'anonymizedEmail'])] public readonly string $email, ) { } public static function anonymizedEmail(string $subjectId): string { return sprintf('%s@anonymized.example', $subjectId); } } ``` :::note `fallback` and `fallbackCallable` are mutually exclusive, setting both throws an exception. ::: ## Cipher Key Store The cipher keys must be stored somewhere. For testing purposes there is an in-memory implementation: ```php use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; $cipherKeyStore = new InMemoryCipherKeyStore(); ``` For production you have to implement the `CipherKeyStore` interface yourself, backed by a database or a key management service, because only you know where the keys should live: ```php namespace Patchlevel\Hydrator\Extension\Cryptography\Store; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; interface CipherKeyStore { /** @throws CipherKeyNotExists */ public function currentKeyFor(string $subjectId): CipherKey; /** @throws CipherKeyNotExists */ public function get(string $id): CipherKey; public function store(CipherKey $key): void; public function remove(string $id): void; public function removeWithSubjectId(string $subjectId): void; } ``` To avoid hitting your key storage for every operation, you can wrap the store in one of the cache decorators: ```php use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator; use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr16CacheStoreDecorator; $cipherKeyStore = new Psr6CacheStoreDecorator($myDatabaseStore, $psr6CachePool); // or $cipherKeyStore = new Psr16CacheStoreDecorator($myDatabaseStore, $psr16Cache); ``` ## Remove personal data To remove personal data, you only need to remove the keys for the subject from the store. All encrypted fields of that subject then resolve to their [fallback values](#fallback-values). ```php $cipherKeyStore->removeWithSubjectId('profile-1'); ``` :::danger Removing a cipher key is irreversible. The encrypted data can never be decrypted again, that is the point of crypto-shredding, but make sure it is what you want. ::: :::tip Cryptography is very expensive in terms of performance. You can combine it with [lazy objects](lazy.md) so the data is only decrypted when the object is actually accessed. ::: ## Learn more * [How to hydrate objects lazily](lazy.md) * [How to reshape outdated stored data](upcasting.md) * [How extensions work](extensions.md) * [How to use the hydrator](hydrator.md) --- # Caching Source: https://patchlevel.dev/docs/hydrator/latest/caching.md Before the hydrator can process a class, it builds metadata for it: the properties, their field names and the resolved [normalizers](normalizer.md). This happens once per class and process and is cheap, but with many classes (or in short-lived processes) you can cache the metadata with any PSR-6 or PSR-16 cache to skip the reflection entirely. ## Configure the cache Pass the cache to the builder with `setCache`. Both PSR-6 (`Psr\Cache\CacheItemPoolInterface`) and PSR-16 (`Psr\SimpleCache\CacheInterface`) implementations are accepted. ```php use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\StackHydratorBuilder; use Symfony\Component\Cache\Adapter\FilesystemAdapter; $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->setCache(new FilesystemAdapter()) ->build(); ``` :::note Internally the builder wraps the metadata factory in a `Psr6MetadataFactory` or `Psr16MetadataFactory` from the `Patchlevel\Hydrator\Metadata` namespace. You can also use these decorators directly if you construct the `StackHydrator` by hand. ::: :::warning The cached metadata contains the resolved normalizer instances and everything metadata enrichers stored in `extras`, so all of it must be serializable. Clear the cache when you change attributes, property types or normalizers, stale metadata leads to confusing results. ::: ## Learn more * [How metadata enrichers add data to the metadata](extensions.md) * [How normalizers are resolved](normalizer.md) * [How to use the hydrator](hydrator.md) ===== # patchlevel/worker > The worker library used in event-sourcing. # Worker Source: https://patchlevel.dev/docs/worker/latest/introduction.md A small library to build stable, long-running workers that terminate gracefully when limits are exceeded or a SIGTERM signal is received. Perfect for daemonized console commands running under Docker, Kubernetes, supervisor or systemd, where the process manager restarts the worker after it exits. It was extracted from the [event-sourcing](https://github.com/patchlevel/event-sourcing) library into a separate package. ## Features * Configurable run, memory and time [limits](getting-started.md#limits) * [Graceful shutdown](getting-started.md#graceful-shutdown-on-sigterm) on SIGTERM * Extensible via [events and custom listeners](events.md) * [PSR-3 logging](getting-started.md#logging) of the worker lifecycle * Plays well with [Symfony and Laravel console commands](integration.md) ## Installation ```bash composer require patchlevel/worker ``` :::tip Start with the [getting started](getting-started.md) guide to get a feeling for the library. ::: --- # Integration Source: https://patchlevel.dev/docs/worker/latest/integration.md A typical setup is a console command that exposes the [limits](getting-started.md#limits) as options, so they can be configured per environment. ## Symfony ```php use Patchlevel\Worker\DefaultWorker; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Logger\ConsoleLogger; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand('app:worker', 'do stuff')] final class WorkerCommand { public function __invoke( OutputInterface $output, #[Option(description: 'The maximum number of runs this command should execute')] int|null $runLimit = null, #[Option(description: 'How much memory consumption should the worker be terminated (500MB, 1GB, etc.)')] string|null $memoryLimit = null, #[Option(description: 'What is the maximum time the worker can run in seconds')] int|null $timeLimit = null, #[Option(description: 'How much time should elapse before the next job is executed in milliseconds')] int $sleep = 1000, ): int { $logger = new ConsoleLogger($output); $worker = DefaultWorker::create( static function (callable $stop): void { // do something if (some_condition()) { $stop(); } }, [ 'runLimit' => $runLimit, 'memoryLimit' => $memoryLimit, 'timeLimit' => $timeLimit, ], $logger, ); $worker->run($sleep); return Command::SUCCESS; } } ``` Run it with the limits suited to your deployment: ```bash bin/console app:worker --time-limit=3600 --memory-limit=512MB -v ``` ## Laravel ```php use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; use Patchlevel\Worker\DefaultWorker; use Psr\Log\LoggerInterface; #[Signature('app:worker {--run-limit= : The maximum number of runs this command should execute} {--memory-limit= : How much memory consumption should the worker be terminated (500MB, 1GB, etc.)} {--time-limit= : What is the maximum time the worker can run in seconds} {--sleep=1000 : How much time should elapse before the next job is executed in milliseconds}')] #[Description('do stuff')] final class WorkerCommand extends Command { public function handle(LoggerInterface $logger): int { $runLimit = $this->option('run-limit'); $timeLimit = $this->option('time-limit'); $worker = DefaultWorker::create( static function (callable $stop): void { // do something if (some_condition()) { $stop(); } }, [ 'runLimit' => $runLimit !== null ? (int)$runLimit : null, 'memoryLimit' => $this->option('memory-limit'), 'timeLimit' => $timeLimit !== null ? (int)$timeLimit : null, ], $logger, ); $worker->run((int)$this->option('sleep')); return self::SUCCESS; } } ``` Run it with the limits suited to your deployment: ```bash php artisan app:worker --time-limit=3600 --memory-limit=512MB ``` :::note The injected `LoggerInterface` writes to Laravel's default log channel. Use a dedicated channel if you want the worker output separated from the rest of your application logs. ::: --- # Getting Started Source: https://patchlevel.dev/docs/worker/latest/getting-started.md The easiest way to create a worker is the `DefaultWorker::create` factory. It takes the job to execute, an array of limit options and an optional PSR-3 logger: ```php use Patchlevel\Worker\DefaultWorker; $worker = DefaultWorker::create( static function (callable $stop): void { // do a unit of work if (nothing_left_todo()) { $stop(); } }, [ 'runLimit' => 100, 'memoryLimit' => '512MB', 'timeLimit' => 3600, ], $logger, // optional, any PSR-3 logger ); $worker->run(); ``` The job is executed in a loop until the worker is stopped. The job receives a `$stop` callback: calling it tells the worker to exit the loop after the current iteration has finished. The worker never aborts a running job — stopping always happens *between* iterations, so your job is never interrupted halfway. ## Limits All options are optional. Without limits the worker runs until it is stopped via `$stop()`, `$worker->stop()` or a SIGTERM signal. | Option | Type | Description | |---------------|----------|---------------------------------------------------------------------------------------------------------------------------------| | `runLimit` | `int` | Stop after this number of iterations. | | `memoryLimit` | `string` | Stop when memory usage exceeds this value, e.g. `128MB`. Supported units: `B`, `KB`, `MB`, `GB` (case-insensitive, 1024-based). | | `timeLimit` | `int` | Stop after this number of seconds. | Limits are checked after each iteration. When a limit is exceeded, the worker logs the reason and stops gracefully. :::warning An invalid `memoryLimit` string throws a `Patchlevel\Worker\InvalidFormat` exception. ::: :::note Internally every limit is implemented as an event listener. You can add your own stop conditions the same way, see [events & listeners](events.md). ::: ## Graceful shutdown on SIGTERM If the `pcntl` extension is available, the worker automatically registers a SIGTERM handler. When the process receives SIGTERM (e.g. from `docker stop`, a Kubernetes pod shutdown or supervisor), the worker finishes the current iteration and then exits cleanly. This makes the worker a good fit for process managers that send SIGTERM and restart the process, e.g. to roll out a new version or to keep long-running processes fresh. :::warning Without `ext-pcntl` this feature is not available. ::: ## Sleep `run()` takes a sleep timer in milliseconds (default: `1000`): ```php $worker->run(500); // aim for one iteration every 500ms ``` The job's own run time is subtracted from the sleep: if the job took 300ms and the sleep timer is 500ms, the worker only sleeps 200ms. If the job took longer than the sleep timer, the next iteration starts immediately. Pass `0` to disable sleeping entirely. ## Logging The worker logs its lifecycle (start, iteration timings, sleep, stop reason) to the given PSR-3 logger. Iteration details use the `debug` level; stop reasons (limit exceeded, SIGTERM received) use `info`. With the `ConsoleLogger` from the [Symfony command example](integration.md#symfony), run the command with `-v` to see stop reasons or `-vvv` to see everything. --- # Events & Listeners Source: https://patchlevel.dev/docs/worker/latest/events.md Internally the worker is built on the symfony event dispatcher. It dispatches three events, each carrying the worker instance: | Event | Dispatched | |----------------------|-------------------------------------| | `WorkerStartedEvent` | once, before the first iteration | | `WorkerRunningEvent` | after every iteration | | `WorkerStoppedEvent` | once, after the worker has stopped | All [limits](getting-started.md#limits) are implemented as event subscribers (`StopWorkerOnIterationLimitListener`, `StopWorkerOnMemoryLimitListener`, `StopWorkerOnTimeLimitListener`, `StopWorkerOnSigtermSignalListener`), so you can add your own stop conditions the same way: ```php use Patchlevel\Worker\Event\WorkerRunningEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; final class StopWorkerOnNewDeploymentListener implements EventSubscriberInterface { public function onWorkerRunning(WorkerRunningEvent $event): void { if (new_version_deployed()) { $event->worker->stop(); } } public static function getSubscribedEvents(): array { return [WorkerRunningEvent::class => 'onWorkerRunning']; } } ``` Pass your own event dispatcher to `create` to register additional listeners: ```php use Patchlevel\Worker\DefaultWorker; use Symfony\Component\EventDispatcher\EventDispatcher; $eventDispatcher = new EventDispatcher(); $eventDispatcher->addSubscriber(new StopWorkerOnNewDeploymentListener()); $worker = DefaultWorker::create( $job, ['timeLimit' => 3600], $logger, $eventDispatcher, ); ``` ===== # patchlevel/event-sourcing-phpstan-extension > PHPStan extension for the event-sourcing library. # event-sourcing-phpstan-extension Source: https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/introduction.md A [PHPStan](https://phpstan.org/) extension for the [patchlevel/event-sourcing](https://github.com/patchlevel/event-sourcing) library. It teaches PHPStan how aggregates work so that static analysis stays accurate on event sourced code, 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. * [Unused properties](getting-started.md#unused-properties) are reported when no apply method writes them, because such a property can never receive state from an event. * [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. ## Installation ```bash composer require --dev patchlevel/event-sourcing-phpstan-extension ``` If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer), the extension is registered automatically and you are done. Otherwise register it in your `phpstan.neon`: ```neon includes: - vendor/patchlevel/event-sourcing-phpstan-extension/extension.neon ``` That is all the configuration the extension needs. All checks are active as soon as the extension is registered, and single rules can be deactivated through the [configuration parameters](getting-started.md#configuration). ## Integration * [patchlevel/event-sourcing](https://github.com/patchlevel/event-sourcing) is the library this extension analyses. :::tip New to the extension? The [getting started](getting-started.md) guide walks you through enabling it and seeing both rules in action. ::: --- # Getting Started Source: https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started.md 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. ## Installation Install the extension as a dev dependency: ```bash composer require --dev patchlevel/event-sourcing-phpstan-extension ``` ## Enable the extension If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer), the extension is enabled automatically and you can skip this step. Otherwise include the shipped configuration in your `phpstan.neon`: ```neon includes: - vendor/patchlevel/event-sourcing-phpstan-extension/extension.neon ``` :::note The extension registers all of its checks at once. They are enabled by default and single rules can be turned off, see [configuration](#configuration). ::: ## The example aggregate 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. ```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; 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; } } ``` ## Property initialization 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. :::note This works for both aggregate roots and child aggregates: any class implementing `AggregateRoot` or `ChildAggregate` has its properties treated as initialized. ::: ## Unused properties 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: ```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; } } ``` 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. ## 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 new event from inside an apply method, that event is recorded again on every replay. The extension flags this: ```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; #[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(). ``` :::note The check also follows calls into helper methods, so hiding `recordThat()` behind another method does not bypass the rule. ::: ## Writing state outside apply methods 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: ```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; 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. :::info 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. ::: ## Configuration All rules are enabled by default. You can deactivate single rules in your `phpstan.neon`, the same way PHPStan handles its own rules: ```neon parameters: patchlevelEventSourcing: propertyInitialization: false unusedProperty: 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 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. ## Learn more * [What the extension offers](introduction.md) * [How to build aggregates with patchlevel/event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) ===== # patchlevel/event-sourcing-phpunit > PHPUnit extension for the event-sourcing library. # Testing Subscribers Source: https://patchlevel.dev/docs/event-sourcing-phpunit/latest/testing-subscribers.md Subscribers react to events to build projections, send notifications or run other side effects. `SubscriberUtilities` lets you drive a subscriber through its lifecycle without a running subscription engine, so you can unit test the reaction to a single event in isolation. ## The utility Create a `SubscriberUtilities` instance with the subscriber you want to test. It reads the `#[Setup]`, `#[Subscribe]` and `#[Teardown]` attributes and exposes one method per lifecycle step: `executeSetup()`, `executeRun()` and `executeTeardown()`. Each method returns the utility, so you can chain the calls. ```php use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('profile_counter', RunMode::FromBeginning)] final class ProfileCounter { public int $count = 0; #[Setup] public function setup(): void { $this->count = 0; } #[Subscribe(ProfileCreated::class)] public function onProfileCreated(): void { $this->count++; } #[Teardown] public function teardown(): void { $this->count = 0; } } ``` :::note The subscriber attributes come from the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library. This package only invokes the methods they mark. ::: ## Running the lifecycle Pass the subscriber to the utility and call the steps you want to verify. `executeRun()` accepts one or more events and forwards each to the matching `#[Subscribe]` methods. ```php use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; use PHPUnit\Framework\TestCase; final class ProfileCounterTest extends TestCase { public function testProfileCreated(): void { $subscriber = new ProfileCounter(); $util = new SubscriberUtilities($subscriber); $util->executeSetup(); $util->executeRun( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ); $util->executeTeardown(); self::assertSame(0, $subscriber->count); } } ``` :::note Each step is optional. If a subscriber has no setup or teardown method, `executeSetup()` and `executeTeardown()` simply do nothing, so you can call only the steps your test cares about. ::: ## Passing messages with headers A subscribe method can ask for header values such as the recording time. To provide them, wrap the event in a `Message` and add the headers you need, then pass the message to `executeRun()`. Plain events are wrapped in a message automatically. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; #[Subscriber('profile_log', RunMode::FromBeginning)] final class ProfileLog { public DateTimeImmutable|null $lastSeen = null; #[Subscribe(ProfileCreated::class)] public function onProfileCreated(ProfileCreated $event, DateTimeImmutable $recordedOn): void { $this->lastSeen = $recordedOn; } } ``` Build the message with the matching header in your test: ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use PHPUnit\Framework\TestCase; final class ProfileLogTest extends TestCase { public function testRecordsTimestamp(): void { $recordedOn = new DateTimeImmutable('2026-06-14 12:00:00'); $subscriber = new ProfileLog(); $util = new SubscriberUtilities($subscriber); $util->executeRun( Message::createWithHeaders( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), [new RecordedOnHeader($recordedOn)], ), ); self::assertEquals($recordedOn, $subscriber->lastSeen); } } ``` ## Testing multiple subscribers Pass an array of subscribers to test them together against the same events. Every lifecycle call is dispatched to all of them, which is useful when several projections react to one event. ```php $util = new SubscriberUtilities([ new ProfileCounter(), new ProfileLog(), ]); $util->executeRun( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ); ``` :::tip For subscribers that need extra constructor dependencies, build them yourself before handing them to the utility, just like any other object under test. ```php $subscriber = new ProfileNotifier($mailerMock); $util = new SubscriberUtilities($subscriber); ``` ::: ## Custom argument resolvers If your subscribe methods rely on custom arguments, pass your own argument resolvers as the third constructor argument. The second argument is the metadata factory, which defaults to the attribute based factory. ```php use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; $util = new SubscriberUtilities( new ProfileCounter(), new AttributeSubscriberMetadataFactory(), [new MyCustomArgumentResolver()], ); ``` :::note Metadata factories and argument resolvers are defined by the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library. You only need them when your subscribers go beyond the default event and header arguments. ::: ## Learn more * [How to test aggregates](testing-aggregates.md) * [How to get started](getting-started.md) --- # Testing Aggregates Source: https://patchlevel.dev/docs/event-sourcing-phpunit/latest/testing-aggregates.md Aggregates hold your domain behaviour, so they deserve focused tests. The `AggregateRootTestCase` gives you a given / when / then notation that makes each test read like a small specification: the history that happened, the action you trigger and the events you expect in return. ## The test case Extend `AggregateRootTestCase` and implement `aggregateClass()` to return the fully qualified class name of the aggregate under test. The test case uses it to rebuild the aggregate from your given events and to find command handlers. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } } ``` ## Given, when, then `given()` takes the events that already happened, `when()` triggers behaviour on the rebuilt aggregate and `then()` asserts the events that were recorded. The closure passed to `when()` receives the aggregate instance. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testVisit(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ) ->when(static fn (Profile $profile) => $profile->visit(ProfileId::fromString('2'))) ->then(new ProfileVisited(ProfileId::fromString('2'))); } } ``` :::note The expected events are compared with `assertEquals`, so value objects inside your events are matched by value, not by identity. ::: ## Multiple events You can pass several events to both `given()` and `then()`. The order of the expected events must match the order in which they were recorded. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testMultipleVisits(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), new ProfileVisited(ProfileId::fromString('2')), ) ->when(static function (Profile $profile): void { $profile->visit(ProfileId::fromString('3')); $profile->visit(ProfileId::fromString('4')); }) ->then( new ProfileVisited(ProfileId::fromString('3')), new ProfileVisited(ProfileId::fromString('4')), ); } } ``` ## Testing creation When the aggregate does not exist yet, omit `given()` and return the freshly created aggregate from the `when()` closure. The test case collects the events from the returned aggregate. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testCreate(): void { $this ->when(static fn () => Profile::create( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), )) ->then(new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), )); } } ``` :::warning Return the aggregate only when there are no given events. Combining `given()` with a closure that also returns an aggregate raises an `AggregateAlreadySet` error, because the test case would not know which instance to use. ::: ## Expecting exceptions Use `expectsException()` and `expectsExceptionMessage()` to assert that an action fails. You can use either one on its own or both together. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testThrowsOnInvalidAction(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ) ->when(static fn (Profile $profile) => $profile->throwException()) ->expectsException(ProfileError::class) ->expectsExceptionMessage('throwing so that you can catch it!'); } } ``` :::note `expectsExceptionMessage()` matches if the thrown message is or contains the given string, so you can assert on a meaningful fragment instead of the full text. ::: ## Asserting aggregate state Sometimes you want to check the aggregate's state, not only its events. Pass a closure to `then()` and it receives the aggregate after all events have been applied. You can mix expected events and closures freely, the event order is preserved regardless of where the closures sit. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testStateAfterVisit(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ) ->when(static fn (Profile $profile) => $profile->visit(ProfileId::fromString('2'))) ->then( new ProfileVisited(ProfileId::fromString('2')), static fn (Profile $profile) => self::assertSame('1', $profile->id()->toString()), ); } } ``` :::warning When `then()` receives only closures and no event objects, it asserts that zero events were recorded. Always list the events you expect alongside your state assertions. ::: ## Command bus syntax If your aggregate handles commands with the `#[Handle]` attribute, you can pass the command object straight to `when()`. The test case finds the matching handler and invokes it, whether it is a static factory or an instance method. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testCreateViaCommand(): void { $this ->when(new CreateProfile( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), )) ->then(new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), )); } } ``` When the handler needs more than the command, pass the extra arguments after the command. They are forwarded to the handler method in order, which is handy for injecting dependencies or values. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testVisitViaCommandWithToken(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ) ->when(new VisitProfile(ProfileId::fromString('2')), 'a-token') ->then(new ProfileVisited(ProfileId::fromString('2'), 'a-token')); } } ``` :::tip The command bus and the `#[Handle]` attribute are part of the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library. More about the [command bus](https://patchlevel.dev/docs/event-sourcing/latest) lives in its documentation. ::: ## Common errors The test case guards against incomplete tests with dedicated errors, all extending `AggregateTestError`: * `NoWhenProvided` is raised when a test forgets to call `when()`, because nothing would actually be exercised. * `NoAggregateCreated` is raised when neither given events nor a returned aggregate produced an instance to assert on. * `AggregateAlreadySet` is raised when given events and a returned aggregate are combined. ## Learn more * [How to test subscribers](testing-subscribers.md) * [How to get started](getting-started.md) --- # Event Sourcing PHPUnit Source: https://patchlevel.dev/docs/event-sourcing-phpunit/latest/introduction.md Testing utilities that make it easy to test your [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) code with PHPUnit. It ships a given / when / then test case for aggregates and a helper that drives subscribers through their lifecycle, so your tests describe behaviour instead of wiring. ## Features * A [given / when / then test case](testing-aggregates.md) for aggregate behaviour * A `when` that also [dispatches commands](testing-aggregates.md) through your `#[Handle]` methods * [Aggregate state assertions](testing-aggregates.md) with closures * A [subscriber utility](testing-subscribers.md) for setup, run and teardown * and much more... ## Installation ```bash composer require --dev patchlevel/event-sourcing-phpunit ``` ## Integration * [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) :::tip New here? Follow the [getting started](getting-started.md) guide to write your first test. ::: --- # Getting Started Source: https://patchlevel.dev/docs/event-sourcing-phpunit/latest/getting-started.md This guide walks you through testing a small profile domain end to end. You start with an aggregate test in the given / when / then style, then drive a subscriber through its lifecycle. The example assumes you already have an [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) aggregate in place. ## Installation Install the package as a development dependency: ```bash composer require --dev patchlevel/event-sourcing-phpunit ``` ## The example domain The whole guide uses a `Profile` aggregate that can be created and visited. It records a `ProfileCreated` event on creation and a `ProfileVisited` event whenever someone visits it. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Id] private ProfileId $id; private Email $email; private int $visits = 0; public function id(): ProfileId { return $this->id; } public static function create(ProfileId $id, Email $email): self { $self = new self(); $self->recordThat(new ProfileCreated($id, $email)); return $self; } public function visit(ProfileId $visitorId): void { $this->recordThat(new ProfileVisited($visitorId)); } #[Apply(ProfileCreated::class)] #[Apply(ProfileVisited::class)] protected function applyEvent(ProfileCreated|ProfileVisited $event): void { if ($event instanceof ProfileCreated) { $this->id = $event->profileId; $this->email = $event->email; return; } $this->visits++; } } ``` :::note Aggregates, events and the `#[Apply]` attribute come from the [event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) library, not from this package. ::: ## Write your first aggregate test Extend [`AggregateRootTestCase`](testing-aggregates.md) and tell it which aggregate you are testing by implementing `aggregateClass()`. From there you describe the past with `given()`, trigger behaviour with `when()` and assert the recorded events with `then()`. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testVisit(): void { $this ->given( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ) ->when(static fn (Profile $profile) => $profile->visit(ProfileId::fromString('2'))) ->then(new ProfileVisited(ProfileId::fromString('2'))); } } ``` ## Test the creation When there is no history yet, skip `given()` and let `when()` create the aggregate. Return the new aggregate from the closure so the test case can collect its events. ```php use Patchlevel\EventSourcing\PhpUnit\Test\AggregateRootTestCase; final class ProfileTest extends AggregateRootTestCase { protected function aggregateClass(): string { return Profile::class; } public function testCreate(): void { $this ->when(static fn () => Profile::create( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), )) ->then(new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), )); } } ``` :::tip There are more ways to drive an aggregate, including a command bus aware `when()`. See [testing aggregates](testing-aggregates.md) for the full picture. ::: ## Test a subscriber To test a subscriber, hand it to [`SubscriberUtilities`](testing-subscribers.md) and call the lifecycle methods. The utility resolves the `#[Setup]`, `#[Subscribe]` and `#[Teardown]` methods from the attributes for you. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\PhpUnit\Test\SubscriberUtilities; use Patchlevel\EventSourcing\Subscription\RunMode; use PHPUnit\Framework\TestCase; #[Subscriber('profile_counter', RunMode::FromBeginning)] final class ProfileCounter { public int $count = 0; #[Subscribe(ProfileCreated::class)] public function onProfileCreated(): void { $this->count++; } } final class ProfileCounterTest extends TestCase { public function testProfileCreated(): void { $subscriber = new ProfileCounter(); $util = new SubscriberUtilities($subscriber); $util->executeRun( new ProfileCreated( ProfileId::fromString('1'), Email::fromString('hq@patchlevel.de'), ), ); self::assertSame(1, $subscriber->count); } } ``` ## Result You now have a fast unit test suite for your aggregates and subscribers, written in a notation that reads like the behaviour it verifies. No database, no message bus and no container are involved. ## Learn more * [How to test aggregates](testing-aggregates.md) * [How to test subscribers](testing-subscribers.md) ===== # patchlevel/event-sourcing-analyser > Analyze your event-sourcing domain: See interactions of aggregates, events and subscriptions # Output Source: https://patchlevel.dev/docs/event-sourcing-analyser/latest/output.md Once the analyser has read your domain, it renders the model through a PHPStan error formatter. Two formatters ship with the package: `eventSourcingGraphviz` draws a diagram inspired by [Event Storming](how-it-works.md), and `eventSourcingJson` exports the same model as data for your own tooling. You pick one with the `--error-format` option of `phpstan analyse`. ## Graphviz The Graphviz formatter prints the model in the [DOT](https://graphviz.org/doc/info/lang.html) language, which you turn into an image with the `dot` binary: ```bash vendor/bin/phpstan analyse --error-format=eventSourcingGraphviz ./src | dot -Tpng > graph.png ``` `dot` is part of the Graphviz toolkit. Install it with your package manager, for example `brew install graphviz` on macOS or `apt-get install graphviz` on Debian based systems. Swap `-Tpng` for `-Tsvg` to get a scalable vector image instead. Every [bounded context](how-it-works.md) becomes a dotted cluster. Inside it, each aggregate is its own subgraph that groups the commands and events belonging to it, while subscribers and controllers sit next to the aggregates in the same context. The edges follow the flow of your domain: * a command points to the events it records * an event points to the subscribers that react to it * a processor points to the commands it dispatches * a controller points to the commands it dispatches * a subscriber points to the controller that reads from it The node colors match the [Event Storming notation](how-it-works.md), so commands are blue, events orange, aggregates yellow, and so on. :::tip The DOT output is plain text. Pipe it to a file and inspect it, or feed it into any Graphviz compatible viewer rather than the `dot` command line tool. ::: ## JSON The JSON formatter exposes the analysed model as data, so you can build your own renderer, documentation page or pipeline check on top of it. It prints the whole project as pretty printed JSON: ```bash vendor/bin/phpstan analyse --error-format=eventSourcingJson ./src > event-sourcing.json ``` The top level object mirrors the analysed project. Every collection is keyed by the fully qualified class name of its element: ```json { "boundedContexts": { "Profile": { "name": "Profile", "aggregates": ["App\\Profile\\Domain\\Profile"], "events": ["App\\Profile\\Domain\\ProfileCreated"], "commands": ["App\\Profile\\Domain\\CreateProfile"], "subscribers": ["App\\Profile\\Domain\\ProfileProjector"], "userInterfaces": [] } }, "aggregates": { "App\\Profile\\Domain\\Profile": { "name": "profile", "class": "App\\Profile\\Domain\\Profile", "events": ["App\\Profile\\Domain\\ProfileCreated"], "commands": ["App\\Profile\\Domain\\CreateProfile"] } }, "events": { "App\\Profile\\Domain\\ProfileCreated": { "name": "profile.created", "class": "App\\Profile\\Domain\\ProfileCreated" } }, "commands": { "App\\Profile\\Domain\\CreateProfile": { "name": "CreateProfile", "class": "App\\Profile\\Domain\\CreateProfile", "events": ["App\\Profile\\Domain\\ProfileCreated"] } }, "subscribers": { "App\\Profile\\Domain\\ProfileProjector": { "name": "profile", "class": "App\\Profile\\Domain\\ProfileProjector", "type": "projector", "events": ["App\\Profile\\Domain\\ProfileCreated"], "commands": [] } }, "userInterfaces": [] } ``` The `boundedContexts` entries hold only class names and reference the full elements in the other collections. The `type` of a subscriber is one of `subscriber`, `processor` or `projector`, matching the three [subscriber flavours](how-it-works.md). Names come straight from your attributes: an aggregate or event uses the name you passed to `#[Aggregate]` or `#[Event]`, while a command or controller falls back to its short class name. ## Learn more * [How each element and its name is detected](how-it-works.md) * [How to analyse a domain from scratch](getting-started.md) --- # Event Sourcing Analyser Source: https://patchlevel.dev/docs/event-sourcing-analyser/latest/introduction.md The event sourcing analyser turns a [patchlevel/event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) codebase into a picture. It is a [PHPStan](https://phpstan.org/) extension that statically reads your aggregates, events, commands and subscribers and renders them as a diagram inspired by [Event Storming](how-it-works.md) or as a JSON model, without ever running your code. Because the analysis is static, you get an always up to date overview of your domain straight from the source: every command that is handled, every event that is recorded and every subscriber that reacts to it. :::warning This package is still work in progress. It is usable today, but no API is stable yet: class names, attributes and the output format may change in any release until a stable version is tagged. ::: ## Features * Detects [aggregates, events, commands and subscribers](how-it-works.md) from your attributes * Groups everything into [bounded contexts](how-it-works.md#bounded-contexts) based on your namespaces * Picks up [Symfony controllers](how-it-works.md#symfony-controllers) that dispatch commands or read from projections * Renders a diagram inspired by Event Storming with [Graphviz](output.md#graphviz) * Exports the whole model as [JSON](output.md#json) for your own tooling ## Installation ```bash composer require --dev patchlevel/event-sourcing-analyser ``` :::tip New here? The [getting started guide](getting-started.md) walks you through analysing a small profile domain from installation to a rendered diagram. ::: --- # How It Works Source: https://patchlevel.dev/docs/event-sourcing-analyser/latest/how-it-works.md The analyser reads the attributes and method calls in your code and turns them into a model of your domain: which commands are handled, which events they record, and which subscribers react to them. This page explains how each element is detected, how they are grouped into bounded contexts and how Symfony controllers join the picture. The result is what you see in the [Graphviz and JSON output](output.md). :::note The diagram is inspired by [Event Storming](https://agiledojo.de/2023-04-14-event-storming-notation-explained/), a workshop format that describes a domain as a flow of commands, events and reactions. It is not a strict Event Storming diagram, but each building block below borrows the color of its Event Storming element, so the picture stays familiar. ::: ## Aggregates An aggregate is the consistency boundary that records events. The analyser finds every class carrying the `#[Aggregate]` attribute and uses its name as the label. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { } ``` The events and commands that belong to the aggregate are collected from its method bodies, so an aggregate node groups its own events and commands in the diagram. ## Events Events describe what happened. They are detected by the `#[Event]` attribute, and the event name becomes the node label. ```php use Patchlevel\EventSourcing\Attribute\Event; #[Event('profile.created')] final class ProfileCreated { } ``` An event is linked to an aggregate when a method of that aggregate records it through `recordThat`: ```php $this->recordThat(new ProfileCreated($name)); ``` :::note The analyser follows private helper methods as well. If a `#[Handle]` method calls another method that calls `recordThat`, the recorded event is still attributed to the command. ::: ## Commands A command expresses an intent to change an aggregate. Commands do not need an attribute of their own. Instead, the analyser looks at the aggregate methods marked with `#[Handle]` and reads the command type from there. You can give the command type implicitly through the first parameter or explicitly as an argument: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Handle; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Handle] public static function create(CreateProfile $command): self { // command resolved from the CreateProfile parameter type } #[Handle(RenameProfile::class)] public function rename(string $name): void { // command resolved from the explicit argument } } ``` Each command is connected to the events that its handler records, which is what draws the command to event edges in the diagram. ## Subscribers Subscribers react to events. The analyser recognises three flavours, each marked with its own attribute and rendered in its own color: * `#[Projector]` builds a read model from events * `#[Processor]` runs side effects, and may dispatch follow up commands * `#[Subscriber]` is the generic form The events a subscriber listens to come from its `#[Subscribe]` handlers: ```php use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; #[Projector('profile')] final class ProfileProjector { #[Subscribe(ProfileCreated::class)] public function onCreated(ProfileCreated $event): void { } } ``` A processor that dispatches a command through the command bus adds an edge back to that command: ```php use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\CommandBus\CommandBus; #[Processor('welcome-mail')] final class WelcomeProcessor { public function __construct( private readonly CommandBus $commandBus, ) { } #[Subscribe(ProfileCreated::class)] public function onCreated(ProfileCreated $event): void { $this->commandBus->dispatch(new SendWelcomeMail($event->name)); } } ``` :::tip `#[Subscribe('*')]` subscribes to every event. The analyser keeps the `*` wildcard so you can see catch all subscribers in the model. ::: ## Symfony controllers Your domain rarely lives on its own: something from the outside triggers it. The analyser models that outside world by reading your Symfony controllers and drawing them as user interface nodes, which closes the loop from the request that dispatches a command to the projection that a controller reads back. A controller is recognised by Symfony's `#[AsController]` attribute. When a controller dispatches a command through the command bus, the analyser draws an edge from the controller to that command: ```php use Patchlevel\EventSourcing\CommandBus\CommandBus; use Symfony\Component\HttpKernel\Attribute\AsController; #[AsController] final class CreateProfileController { public function __construct( private readonly CommandBus $commandBus, ) { } public function __invoke(): void { $this->commandBus->dispatch(new CreateProfile('patchlevel')); } } ``` When a controller instead calls a method that belongs to a projector, processor or subscriber, the analyser draws an edge from that subscriber to the controller, showing which read models a user interface depends on. :::note The read access is detected through the called method's declaring class. If that class carries a `#[Projector]`, `#[Processor]` or `#[Subscriber]` attribute, the controller is linked to it. ::: ## Bounded contexts A bounded context is a self contained part of your domain with its own language. The analyser groups aggregates, events, commands, subscribers and controllers into bounded contexts so the diagram stays readable even for large applications. It does not need an attribute to find a context. It reads the namespace of each class and looks for the layer segment that a typical layered application uses: `Domain`, `Infrastructure` or `Application`. The segment right before that layer becomes the context name. ```php namespace App\Profile\Domain; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { } ``` Here the namespace `App\Profile\Domain` matches the pattern, so the `Profile` aggregate is placed in the `Profile` context, while a class in `App\Billing\Domain` lands in `Billing`. A class whose namespace contains none of the layer segments stays in the model but is not grouped into a context. :::tip Adopt a consistent `App\\\...` namespace layout across your application and every element lands in the right cluster automatically. ::: ## Notation The diagram uses the colors below. They follow the usual Event Storming palette so the picture stays familiar. | Element | Color | Detected from | | --- | --- | --- | | Aggregate | yellow | `#[Aggregate]` | | Event | orange | `#[Event]` recorded via `recordThat` | | Command | blue | `#[Handle]` | | Subscriber | red | `#[Subscriber]` | | Processor | purple | `#[Processor]` | | Projector | green | `#[Projector]` | | User interface | gray | `#[AsController]` | ## Learn more * [How to render the model as a diagram or JSON](output.md) * [How to analyse a domain from scratch](getting-started.md) --- # Getting Started Source: https://patchlevel.dev/docs/event-sourcing-analyser/latest/getting-started.md In this guide you analyse a small profile domain and render it as a diagram inspired by Event Storming. You start from an empty PHPStan setup, add the analyser, write a handful of event sourcing classes and end up with a PNG of your domain. ## Installation The analyser is a PHPStan extension, so you install it as a dev dependency next to PHPStan and your event sourcing code: ```bash composer require --dev patchlevel/event-sourcing-analyser ``` ## Register the extension The package ships an `extension.neon` that registers the [collectors](how-it-works.md) and the output formatters. Include it from your `phpstan.neon`: ```neon includes: - vendor/patchlevel/event-sourcing-analyser/extension.neon parameters: level: max paths: - src ``` :::note If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer) the file is included automatically and you can drop the `includes` block. ::: ## Define some events Events describe what happened in your domain. The analyser finds them by their `#[Event]` attribute and uses the event name as the label in the diagram. ```php use Patchlevel\EventSourcing\Attribute\Event; #[Event('profile.created')] final class ProfileCreated { public function __construct( public readonly string $name, ) { } } #[Event('profile.renamed')] final class ProfileRenamed { public function __construct( public readonly string $name, ) { } } ``` ## Define the aggregate The aggregate is the heart of your domain. The `#[Aggregate]` attribute marks the class, and every `$this->recordThat(...)` call tells the analyser which event a method records. A method annotated with `#[Handle]` links the recorded events back to the command that triggered them. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Handle; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { #[Handle] public static function create(CreateProfile $command): self { $self = new self(); $self->recordThat(new ProfileCreated($command->name)); return $self; } #[Handle(RenameProfile::class)] public function rename(string $name): void { $this->recordThat(new ProfileRenamed($name)); } } ``` :::note The analyser reads the command from the typed parameter of `#[Handle]` or from its explicit `RenameProfile::class` argument. Both styles are described on the [how it works](how-it-works.md) page. ::: ## React with a subscriber A projector, processor or subscriber reacts to events. Mark the class with `#[Projector]` (or `#[Subscriber]` / `#[Processor]`) and each handler with `#[Subscribe]`. A processor that dispatches a command via the command bus adds another edge to the diagram. ```php use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; #[Projector('profile')] final class ProfileProjector { #[Subscribe(ProfileCreated::class)] public function onCreated(ProfileCreated $event): void { // write to your read model } #[Subscribe(ProfileRenamed::class)] public function onRenamed(ProfileRenamed $event): void { // update your read model } } ``` ## Render the diagram Run PHPStan with the Graphviz formatter and pipe the result into the `dot` binary to produce an image: ```bash vendor/bin/phpstan analyse --error-format=eventSourcingGraphviz ./src | dot -Tpng > profile.png ``` You now have a `profile.png` showing the `CreateProfile` and `RenameProfile` commands flowing into the `Profile` aggregate, the events it records and the projector that reacts to them. ## Export as JSON If you would rather feed the model into your own tooling, switch the formatter to JSON: ```bash vendor/bin/phpstan analyse --error-format=eventSourcingJson ./src > profile.json ``` ## Result With a single static analysis run you turned your attributes and method calls into a living diagram of your domain. As your code changes, the diagram changes with it. ## Learn more * [How the analyser maps your code to Event Storming notation](how-it-works.md) * [How bounded contexts are derived from namespaces](how-it-works.md#bounded-contexts) * [How to render the Graphviz output](output.md#graphviz) * [How to consume the JSON output](output.md#json) ===== # patchlevel/rango > MongoDB API layer for PostgreSQL. # Update Operators Source: https://patchlevel.dev/docs/rango/latest/update-operators.md Update operators describe how to change matching documents without replacing them. You pass them to [updateOne, updateMany, findOneAndUpdate](crud-operations.md), and to update operations inside a [bulk write](crud-operations.md#bulk-writes). Each operator is keyed by its name and takes a map of fields to values. All operators support dot-notation, so you can reach into nested documents. ## Field operators Field operators set, remove, and adjust individual values: ```php $collection->updateOne(['_id' => '1'], [ '$set' => ['name' => 'John Doe', 'profile.stats.score' => 100], '$unset' => ['temporary' => ''], '$rename' => ['username' => 'name'], ]); ``` | Operator | Effect | |---|---| | `$set` | sets the field to the value | | `$setOnInsert` | sets the field only when an upsert inserts a new document | | `$unset` | removes the field | | `$rename` | renames the field | | `$inc` | increments the field by the value | | `$mul` | multiplies the field by the value | | `$min` | sets the field only if the value is smaller | | `$max` | sets the field only if the value is larger | | `$currentDate` | sets the field to the current date | ```php $collection->updateOne(['_id' => '1'], [ '$inc' => ['profile.stats.score' => 5], '$mul' => ['profile.stats.multiplier' => 2], '$min' => ['lowest' => 10], '$max' => ['highest' => 90], '$currentDate' => ['updatedAt' => true], ]); ``` ## Array operators Array operators modify list fields in place. `$push` appends, `$pull` removes by match, `$addToSet` appends only if absent, and `$pop` removes from an end: ```php $collection->updateOne(['_id' => '1'], [ '$push' => ['tags' => 'mongodb'], '$addToSet' => ['roles' => 'admin'], ]); $collection->updateOne(['_id' => '1'], [ '$pull' => ['tags' => 'deprecated'], '$pop' => ['history' => 1], // 1 removes the last, -1 the first ]); ``` Use `$each` with `$push` to append several values at once: ```php $collection->updateOne(['_id' => '1'], [ '$push' => ['tags' => ['$each' => ['php', 'postgres']]], ]); ``` ## Bitwise operator `$bit` applies a bitwise `and`, `or`, or `xor` to an integer field: ```php $collection->updateOne(['_id' => '1'], [ '$bit' => ['flags' => ['or' => 4]], ]); ``` ## Upsert and setOnInsert When you combine the `upsert` option with `$setOnInsert`, the extra fields are only written if a new document is created: ```php $collection->updateOne( ['_id' => 'user-42'], [ '$set' => ['name' => 'New User'], '$setOnInsert' => ['createdAt' => '2026-01-01'], ], ['upsert' => true], ); ``` :::warning Upsert needs `_id` in the filter so Rango can build the primary key for the inserted document. ::: ## Learn more * [How to select the documents to update with query operators](querying.md) * [How to apply many updates in one transaction with bulk write](crud-operations.md#bulk-writes) * [How to replace or atomically modify documents](crud-operations.md) --- # Querying Source: https://patchlevel.dev/docs/rango/latest/querying.md Reading documents has two parts: a filter that selects which documents match, and options that shape and order the results. This page covers both. Filters are also accepted by the update and delete methods and by the `$match` stage of an [aggregation](aggregation.md). A filter is an array where each key is a field and each value is either a literal to match or an operator expression. Rango translates these into PostgreSQL `JSONB` conditions. ## Matching fields A plain value matches documents where the field equals that value. Nested fields use dot-notation, and matching an array field against a scalar checks whether the array contains that value: ```php $collection->find(['name' => 'John Doe']); $collection->find(['profile.stats.score' => 42]); $collection->find(['tags' => 'php']); // documents whose tags array contains "php" ``` ## Comparison operators Comparison operators wrap the value in an array keyed by the operator: ```php $collection->find(['age' => ['$gt' => 20]]); $collection->find(['age' => ['$gte' => 20]]); $collection->find(['age' => ['$lt' => 30]]); $collection->find(['age' => ['$lte' => 30]]); $collection->find(['age' => ['$ne' => 30]]); $collection->find(['age' => ['$in' => [20, 40]]]); $collection->find(['age' => ['$nin' => [20, 40]]]); ``` | Operator | Matches when the field | |---|---| | `$eq` | equals the value | | `$ne` | does not equal the value | | `$gt` / `$gte` | is greater than / greater than or equal | | `$lt` / `$lte` | is less than / less than or equal | | `$in` | is one of the listed values | | `$nin` | is none of the listed values | ## Logical operators Logical operators combine sub-filters. `$and`, `$or`, and `$nor` take a list of filters, while `$not` negates a single operator expression: ```php $collection->find([ '$or' => [ ['age' => ['$lt' => 18]], ['age' => ['$gte' => 65]], ], ]); $collection->find([ '$and' => [ ['tags' => 'php'], ['age' => ['$gte' => 21]], ], ]); $collection->find(['age' => ['$not' => ['$gt' => 30]]]); ``` ## Element operators Element operators test the presence or type of a field: ```php $collection->find(['name' => ['$exists' => true]]); $collection->find(['deletedAt' => ['$exists' => false]]); $collection->find(['age' => ['$type' => 'number']]); ``` ## Evaluation operators `$regex` matches a string field against a pattern, and `$mod` matches numbers by their remainder: ```php $collection->find(['email' => ['$regex' => '@example\\.com$']]); $collection->find(['age' => ['$mod' => [2, 0]]]); // even ages ``` ## Array operators Array operators inspect array fields. `$all` requires every listed value, `$size` matches by length, and `$elemMatch` matches array elements against a sub-filter: ```php $collection->find(['tags' => ['$all' => ['php', 'postgres']]]); $collection->find(['tags' => ['$size' => 2]]); $collection->find([ 'orders' => [ '$elemMatch' => ['total' => ['$gt' => 100], 'status' => 'paid'], ], ]); ``` :::tip Operators combine freely. A single field can carry several operators, and several fields act as an implicit `$and`, so `['age' => ['$gte' => 18, '$lt' => 65], 'tags' => 'php']` reads naturally. ::: ## Projection Read operations accept an options array as their last argument. A projection selects which fields come back. Use `1` to include a field and `0` to exclude it, with dot-notation for nested fields: ```php // only return name (and _id, which is included by default) $user = $collection->findOne(['_id' => '1'], ['projection' => ['name' => 1]]); // return everything except age $user = $collection->findOne(['_id' => '1'], ['projection' => ['age' => 0]]); // include name but drop the _id $user = $collection->findOne(['_id' => '1'], ['projection' => ['name' => 1, '_id' => 0]]); // exclude a deeply nested field $user = $collection->findOne(['_id' => '1'], ['projection' => ['profile.stats.score' => 0]]); ``` :::note As in MongoDB, `_id` is included unless you explicitly exclude it with `'_id' => 0`. ::: ## Sorting The `sort` option orders results by one or more fields. Use `1` for ascending and `-1` for descending, with dot-notation for nested fields: ```php $cursor = $collection->find([], ['sort' => ['name' => 1]]); $cursor = $collection->find([], ['sort' => ['age' => -1, 'name' => 1]]); $cursor = $collection->find([], ['sort' => ['profile.stats.score' => -1]]); ``` ## Limit and skip `limit` caps the number of returned documents and `skip` offsets the start. Together with `sort` they implement pagination: ```php // second page of 20 results, newest first $cursor = $collection->find([], [ 'sort' => ['createdAt' => -1], 'limit' => 20, 'skip' => 20, ]); ``` :::tip Rango maps `sort`, `limit`, and `skip` to SQL `ORDER BY`, `LIMIT`, and `OFFSET`, so paging stays efficient. ::: ## Learn more * [How to change the matched documents with update operators](update-operators.md) * [How to run multi-stage queries with aggregation](aggregation.md) * [How to speed up filtered and sorted reads with indexes](indexes.md) --- # Rango Source: https://patchlevel.dev/docs/rango/latest/introduction.md Rango is a high-performance PHP library that reimplements the MongoDB PHP API on top of PostgreSQL using the power of `JSONB`. It provides a drop-in compatible API, so you can use familiar MongoDB-style operations while storing your data in a reliable PostgreSQL database. This is ideal for applications that want PostgreSQL's ACID compliance and ecosystem without giving up the flexible document-based development experience of MongoDB. ## Features * [Drop-in MongoDB API](getting-started.md) with `Client`, `Database`, and `Collection` * [CRUD operations](crud-operations.md) like `insertOne`, `find`, `updateMany`, and `deleteOne` * [Rich query operators](querying.md) such as `$gt`, `$in`, `$or`, and `$elemMatch` * [Update operators](update-operators.md) like `$set`, `$inc`, `$push`, and `$rename` * [Projection and sorting](querying.md#projection) with dot-notation support * [Aggregation pipelines](aggregation.md) with `$match`, `$group`, `$unwind`, and `$lookup` * [Bulk writes](crud-operations.md#bulk-writes) wrapped in a single transaction * [Index management](indexes.md) backed by native PostgreSQL indexes ## Installation ```bash composer require patchlevel/rango ``` Rango needs the PDO extension and a PostgreSQL connection. The MongoDB extension is only required for the test suite, not at runtime. ## Integration * [odm](https://github.com/patchlevel/odm) * [event-sourcing](https://github.com/patchlevel/event-sourcing) * [hydrator](https://github.com/patchlevel/hydrator) :::tip New to Rango? Start with the [getting started](getting-started.md) tutorial, which builds a small application step by step. ::: --- # Indexes Source: https://patchlevel.dev/docs/rango/latest/indexes.md Indexes speed up [queries](querying.md) and [sorts](querying.md#sorting) by letting PostgreSQL find documents without scanning the whole table. Rango creates them as native PostgreSQL indexes on the `JSONB` `data` column, so you keep MongoDB-style ergonomics with PostgreSQL performance. ## Creating an index Pass a key map to `createIndex`, where each field maps to `1` for ascending or `-1` for descending order: ```php $collection->createIndex(['email' => 1]); $collection->createIndex(['age' => -1, 'name' => 1]); ``` Use the `unique` option to enforce uniqueness, and `name` to choose the index name. Without a name, Rango derives one from the database, collection, and fields: ```php $collection->createIndex(['email' => 1], ['unique' => true, 'name' => 'users_email_unique']); ``` :::tip Index the fields you filter and sort on most often, such as the keys you pass to `find` and the `sort` option. ::: ## Listing indexes `listIndexes` returns an iterator of `IndexInfo` objects describing the indexes on the collection: ```php foreach ($collection->listIndexes() as $index) { echo $index->getName(); $index->getKey(); // ['email' => 1] $index->isUnique(); // true or false } ``` ## Dropping an index Drop an index by name: ```php $collection->dropIndex('users_email_unique'); ``` :::note Geospatial, text, sparse, and TTL indexes are not supported. The matching `IndexInfo` checks always report `false`, as listed under [limitations](how-it-works.md#limitations). ::: ## Learn more * [How to write the queries an index accelerates](querying.md) * [How sorting benefits from matching indexes](querying.md#sorting) * [How Rango maps documents and indexes onto PostgreSQL](how-it-works.md) --- # How it Works Source: https://patchlevel.dev/docs/rango/latest/how-it-works.md Rango speaks the MongoDB PHP API but stores everything in PostgreSQL. Understanding the mapping helps you reason about performance, write SQL against the same tables, and know what to expect from each operation. ## The mapping Rango translates MongoDB concepts into native PostgreSQL structures: | MongoDB concept | PostgreSQL structure | |---|---| | Database | Schema | | Collection | Table with `_id` and `data` columns | | Document | Row, stored as `JSONB` in `data` | | Index | B-tree index on a `JSONB` expression | | Query operators | `JSONB` operators and conditions | Every collection is a table with two columns: a `TEXT` `_id` primary key and a `JSONB` `data` column holding the full document. The `_id` is also kept inside the document so reads return it like any other field. ## Lazy schema creation You never run migrations by hand. The first write to a [collection](crud-operations.md) creates the schema and table if they do not exist: ```sql CREATE SCHEMA IF NOT EXISTS "app"; CREATE TABLE IF NOT EXISTS "app"."users" (_id TEXT PRIMARY KEY, data JSONB NOT NULL); ``` :::success Selecting a database or collection never touches PostgreSQL. The structure is created on demand the first time you insert or update. ::: ## Generated ids When you insert a document without an `_id`, Rango generates a random 24-character hex string and uses it as the primary key. Provide your own `_id` whenever you need a stable, meaningful key. ## From queries to SQL [Query operators](querying.md) compile to PostgreSQL `JSONB` conditions, dot-notation paths become `->` and `->>` accessors, [update operators](update-operators.md) become `jsonb_set`-style expressions, and [aggregation](aggregation.md) pipelines become nested `SELECT` statements. Because the result is ordinary SQL against ordinary tables, you can inspect, back up, and query the data with any PostgreSQL tool. ## Limitations Rango covers the most common MongoDB use cases, but it does not reimplement the entire MongoDB feature set. The following features are currently out of scope: * **Geospatial queries** such as `$near` and `$geoWithin` * **Capped collections** * **Text search** with MongoDB-specific syntax and text indexes * **Complex aggregation expressions**, beyond the basic accumulators in [aggregation](aggregation.md) * **Special index types**: only ascending and descending [indexes](indexes.md) are supported, so geospatial (`2dsphere`), text, sparse, and TTL indexes are not, and the matching `IndexInfo` checks always report `false` [Upserts](update-operators.md) also need `_id` to be present in the filter, because Rango builds the primary key of the inserted document from it. An upsert without `_id` in the filter raises an exception. :::note This list reflects the current state of Rango. Features may be added over time, so check the changelog and the [aggregation](aggregation.md) and [indexes](indexes.md) pages for what is available in your version. ::: ## Learn more * [How to connect a client to PostgreSQL](connection.md) * [How to create indexes on the JSONB column](indexes.md) * [How to run aggregation pipelines](aggregation.md) --- # Getting Started Source: https://patchlevel.dev/docs/rango/latest/getting-started.md This tutorial walks you through Rango by building a small example: an `app` database with a `users` collection. You connect to PostgreSQL, insert documents, query them with MongoDB-style filters, and update them atomically. By the end you will know how to open a connection, run the core [CRUD operations](crud-operations.md), and where to go next for advanced features. ## Installation Install Rango with Composer: ```bash composer require patchlevel/rango ``` You need a running PostgreSQL instance. The fastest way to get one locally is Docker: ```bash docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16-alpine ``` ## Connect to PostgreSQL A [Client](connection.md) is the entry point. It takes a standard PDO DSN and manages the underlying connection: ```php use Patchlevel\Rango\Client; $client = new Client('pgsql:host=localhost;port=5432;dbname=app;user=postgres;password=postgres'); ``` :::note You can also pass an existing `PDO` instance instead of a DSN string. See the [connection](connection.md) page for details. ::: ## Select a collection In Rango, a database maps to a PostgreSQL schema and a collection maps to a table. You select them by name, and they are created automatically on the first write: ```php $collection = $client->selectDatabase('app')->selectCollection('users'); ``` :::success You never run migrations by hand. Rango creates the schema and table the first time you write to a collection. ::: ## Insert some documents Documents are plain PHP arrays. If you omit the `_id`, Rango generates one for you: ```php $collection->insertOne([ 'name' => 'John Doe', 'email' => 'john@example.com', 'age' => 27, 'tags' => ['php', 'postgres'], 'profile' => ['stats' => ['score' => 42]], ]); $result = $collection->insertMany([ ['name' => 'Jane Roe', 'email' => 'jane@example.com', 'age' => 31, 'tags' => ['php']], ['name' => 'Max Mustermann', 'email' => 'max@example.com', 'age' => 19, 'tags' => ['mongodb']], ]); echo $result->getInsertedCount(); // 2 ``` ## Find documents Pass a MongoDB-style filter to `find`. Here you combine an array match with a [comparison operator](querying.md#comparison-operators): ```php $users = $collection->find([ 'tags' => 'php', 'age' => ['$gte' => 25], ]); foreach ($users as $user) { echo $user['name'] . "\n"; } ``` :::note `find` returns a [Cursor](crud-operations.md), which you can iterate directly or turn into an array with `toArray()`. ::: To fetch a single document, use `findOne`: ```php $user = $collection->findOne(['email' => 'john@example.com']); ``` ## Update documents Updates use [update operators](update-operators.md). This increments a nested counter and adds a tag atomically: ```php $collection->updateOne( ['email' => 'john@example.com'], [ '$inc' => ['profile.stats.score' => 1], '$push' => ['tags' => 'mongodb'], ], ); ``` ## Delete documents Deleting works the same way, with a filter: ```php $collection->deleteOne(['email' => 'max@example.com']); ``` ## Result You now have a working Rango setup: a client connected to PostgreSQL, a collection that stores documents as `JSONB`, and the full CRUD cycle running through the MongoDB-style API. Everything you wrote lives in a regular PostgreSQL table you can back up, replicate, and query with SQL. ## Learn more * [How to connect and configure the client](connection.md) * [How to query with operators](querying.md) * [How to build aggregation pipelines](aggregation.md) * [How Rango maps documents to PostgreSQL](how-it-works.md) --- # CRUD Operations Source: https://patchlevel.dev/docs/rango/latest/crud-operations.md A `Collection` exposes the create, read, update, and delete methods you know from MongoDB. Documents are plain PHP arrays, and every write returns a result object that tells you what happened. This page covers the core methods. The filter and update syntax they accept is documented under [query operators](querying.md) and [update operators](update-operators.md). ## Create Use `insertOne` for a single document and `insertMany` for a batch. If a document has no `_id`, Rango generates one and returns it on the result: ```php $result = $collection->insertOne([ 'name' => 'John Doe', 'email' => 'john@example.com', 'age' => 27, ]); echo $result->getInsertedId(); echo $result->getInsertedCount(); // 1 ``` ```php $result = $collection->insertMany([ ['name' => 'Jane Roe', 'email' => 'jane@example.com'], ['name' => 'Max Mustermann', 'email' => 'max@example.com'], ]); $result->getInsertedIds(); // [0 => '...', 1 => '...'] ``` :::note Generated ids are random 24-character hex strings. Provide your own `_id` whenever you need a stable, meaningful key. ::: ## Read `find` returns a [Cursor](#working-with-the-cursor) over every matching document, while `findOne` returns the first match or `null`: ```php $cursor = $collection->find(['age' => ['$gte' => 25]]); $user = $collection->findOne(['email' => 'john@example.com']); if ($user === null) { // not found } ``` Use `countDocuments` to count matches without loading them, and `distinct` to collect the unique values of a field: ```php $total = $collection->countDocuments(['age' => ['$gte' => 18]]); $emails = $collection->distinct('email', ['age' => ['$gte' => 18]]); ``` ### Working with the cursor `find` returns a `Cursor`, which is iterable and countable. Iterate it directly, or materialize it with `toArray`: ```php $cursor = $collection->find(['tags' => 'php']); foreach ($cursor as $user) { echo $user['name']; } $users = $cursor->toArray(); $count = $cursor->count(); ``` :::warning A cursor backed by a database statement streams its rows. Iterate it once, and call `toArray()` if you need to read the result more than once. ::: ## Update `updateOne` and `updateMany` apply [update operators](update-operators.md) to matching documents, while `replaceOne` swaps the whole document. Each returns an `UpdateResult`: ```php $result = $collection->updateOne( ['email' => 'john@example.com'], ['$set' => ['age' => 28]], ); echo $result->getMatchedCount(); echo $result->getModifiedCount(); ``` ```php $collection->replaceOne( ['_id' => '1'], ['name' => 'John Doe', 'email' => 'john@new.example.com'], ); ``` Pass the `upsert` option to insert the document when no match exists: ```php $collection->updateOne( ['_id' => 'user-42'], ['$set' => ['name' => 'New User']], ['upsert' => true], ); ``` :::warning Upsert currently requires `_id` to be present in the filter, because the new document needs a primary key. ::: ## Delete `deleteOne` removes the first match and `deleteMany` removes all matches. Both return a `DeleteResult`: ```php $result = $collection->deleteOne(['email' => 'max@example.com']); echo $result->getDeletedCount(); $collection->deleteMany(['age' => ['$lt' => 18]]); ``` ## Find and modify The atomic helpers return the matched document and change it in one step. `findOneAndUpdate` and `findOneAndReplace` apply a change, while `findOneAndDelete` removes the match: ```php $old = $collection->findOneAndUpdate( ['_id' => '1'], ['$inc' => ['profile.stats.score' => 1]], ); $removed = $collection->findOneAndDelete(['_id' => '2']); ``` ## Bulk writes `bulkWrite` runs many write operations against a collection in a single database transaction. If any operation fails, the whole batch is rolled back, so the collection never ends up in a half-written state. Each entry in the list is a single-key array naming the operation, with its arguments as a positional list. The arguments mirror the matching standalone method: ```php $result = $collection->bulkWrite([ [ 'insertOne' => [ ['name' => 'John Doe', 'email' => 'john@example.com'], ], ], [ 'updateOne' => [ ['email' => 'jane@example.com'], ['$set' => ['age' => 31]], ], ], [ 'deleteOne' => [ ['email' => 'max@example.com'], ], ], ]); ``` The supported operations and their arguments are: | Operation | Arguments | |---|---| | `insertOne` | `[$document]` | | `updateOne` | `[$filter, $update, $options?]` | | `updateMany` | `[$filter, $update, $options?]` | | `replaceOne` | `[$filter, $replacement, $options?]` | | `deleteOne` | `[$filter]` | | `deleteMany` | `[$filter]` | `bulkWrite` returns a `BulkWriteResult` that aggregates the counts across every operation in the batch: ```php echo $result->getInsertedCount(); echo $result->getMatchedCount(); echo $result->getModifiedCount(); echo $result->getDeletedCount(); $result->getInsertedIds(); // ids of inserted documents ``` :::warning All operations run in one transaction. A failure in any of them rolls back every change in the batch, including ones that already succeeded. ::: ## Learn more * [How to filter and shape results when querying](querying.md) * [How to modify documents with update operators](update-operators.md) * [How to reshape data with aggregation](aggregation.md) --- # Connection Source: https://patchlevel.dev/docs/rango/latest/connection.md The `Client` is the entry point to Rango. It owns the PostgreSQL connection and hands out `Database` and [Collection](crud-operations.md) objects that you use for everything else. The same client also lets you inspect and manage the databases and collections themselves. ## Creating a client The simplest way to connect is with a PDO DSN string. Rango opens the connection and configures it to throw exceptions on errors: ```php use Patchlevel\Rango\Client; $client = new Client('pgsql:host=localhost;port=5432;dbname=app;user=postgres;password=postgres'); ``` ## Reusing an existing PDO If your application already manages a `PDO` instance, for example through a dependency injection container, you can pass it directly. Rango then uses your connection instead of opening its own: ```php use Patchlevel\Rango\Client; $pdo = new PDO('pgsql:host=localhost;dbname=app', 'postgres', 'postgres', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); $client = new Client($pdo); ``` :::tip Sharing a single `PDO` instance lets Rango operations take part in the same transaction as the rest of your application. ::: ## Selecting databases and collections A `Client` gives you a `Database`, and a `Database` gives you a `Collection`. The `getXxx` and `selectXxx` methods are equivalent, so use whichever reads better: ```php $database = $client->selectDatabase('app'); $collection = $database->selectCollection('users'); // or in one step $collection = $client->selectCollection('app', 'users'); ``` Nothing is queried while selecting. The schema and table are created lazily the first time you write to the collection, as explained in [how it works](how-it-works.md). ## Listing databases `listDatabases` returns an iterator of `DatabaseInfo` objects, one per PostgreSQL schema: ```php foreach ($client->listDatabases() as $database) { echo $database->getName(); } ``` ## Listing collections `listCollections` returns the collections in a database as `CollectionInfo` objects. Call it on the client with a database name, or on a `Database`: ```php foreach ($client->listCollections('app') as $collection) { echo $collection->getName(); } $database = $client->selectDatabase('app'); foreach ($database->listCollections() as $collection) { echo $collection->getName(); } ``` ## Renaming a collection `renameCollection` changes a collection's name within its database: ```php $database = $client->selectDatabase('app'); $database->renameCollection('users', 'members'); ``` ## Dropping collections and databases Drop a single collection from its database, or drop the whole database with all of its collections: ```php $database = $client->selectDatabase('app'); $database->dropCollection('members'); // or drop the collection through its own handle $client->selectCollection('app', 'members')->drop(); // remove the entire database (schema) $database->drop(); ``` :::danger Dropping a collection or database is irreversible and removes all of its documents. The database drop cascades to every collection it contains. ::: ## Learn more * [How to run CRUD operations on a collection](crud-operations.md) * [How to query and shape results](querying.md) * [How Rango maps a connection to PostgreSQL schemas](how-it-works.md) --- # Aggregation Source: https://patchlevel.dev/docs/rango/latest/aggregation.md Aggregation runs documents through a pipeline of stages, where each stage transforms the stream produced by the one before it. It is the right tool when a single [query](querying.md) is not enough, for example to group, reshape, or join documents. You pass a pipeline as a list of stages to `aggregate`, which returns a [Cursor](crud-operations.md): ```php $cursor = $collection->aggregate([ ['$match' => ['status' => 'paid']], ['$group' => ['_id' => '$userId', 'total' => ['$sum' => '$amount']]], ['$sort' => ['total' => -1]], ]); foreach ($cursor as $row) { echo $row['_id'] . ': ' . $row['total'] . "\n"; } ``` ## Filtering and ordering stages `$match` filters documents using the same syntax as [query operators](querying.md). `$sort`, `$limit`, and `$skip` order and page the stream just like the [read options](querying.md#sorting): ```php $collection->aggregate([ ['$match' => ['age' => ['$gte' => 18]]], ['$sort' => ['age' => -1]], ['$skip' => 10], ['$limit' => 5], ]); ``` ## Reshaping stages `$project` selects and renames fields, and `$unwind` expands an array field into one document per element: ```php $collection->aggregate([ ['$project' => ['name' => 1, '_id' => 0]], ]); $collection->aggregate([ ['$unwind' => '$tags'], ]); ``` ## Grouping `$group` buckets documents by an `_id` expression and computes accumulators per bucket. A field reference is written with a leading `$`: ```php $collection->aggregate([ [ '$group' => [ '_id' => '$status', 'orders' => ['$sum' => 1], 'revenue' => ['$sum' => '$total'], 'average' => ['$avg' => '$total'], 'highest' => ['$max' => '$total'], 'lowest' => ['$min' => '$total'], ], ], ]); ``` The supported accumulators are `$sum`, `$avg`, `$min`, `$max`, `$first`, and `$last`. Use `['$sum' => 1]` to count documents in each group. ## Joining collections `$lookup` performs a left outer join against another collection in the same database. It matches `localField` against `foreignField` and stores the matches in the array named by `as`: ```php $client->selectCollection('app', 'users')->aggregate([ [ '$lookup' => [ 'from' => 'orders', 'localField' => '_id', 'foreignField' => 'userId', 'as' => 'orders', ], ], ]); ``` Each `users` document gains an `orders` array holding the matching `orders` documents, or an empty array when there are none. :::note Only the stages and accumulators listed here are implemented. Complex aggregation expressions are out of scope, as noted under [limitations](how-it-works.md#limitations). ::: ## Learn more * [How to filter with the same operators used by `$match`](querying.md) * [How to read and iterate the resulting cursor](crud-operations.md) * [How Rango compiles a pipeline into SQL](how-it-works.md) ===== # patchlevel/event-sourcing-admin-bundle > Admin dashboard bundle for Symfony. # Subscriptions Source: https://patchlevel.dev/docs/event-sourcing-admin-bundle/latest/subscriptions.md The subscriptions view lists every subscription known to the subscription engine, shows its current state, and lets you control it. This is where you boot new projectors, rerun them, pause noisy processors, or rebuild a projection from scratch. ![The subscriptions view](screenshot3.png) ## What you see Each subscription shows its id, group, run mode and status, alongside the total number of messages in the store so you can gauge how much work a rebuild involves. The status and run mode values come straight from the engine, so they always match what your application reports. ## Filtering You can narrow the list with these filters: * **search**: match part of a subscription id * **group**: only subscriptions in a given group * **run mode**: only subscriptions with a given run mode * **status**: only subscriptions with a given status The available groups, run modes and statuses are derived from the registered subscriptions, so the filter options reflect your actual setup. ## Controls Each subscription exposes the operations of the subscription engine as individual actions: * **Setup**: create the subscription so it is ready to receive events. * **Boot**: catch a newly set up subscription up with the existing event stream. * **Run**: process the events that have accumulated since the last run. * **Pause**: stop a subscription from processing further events. * **Reactivate**: bring a paused or errored subscription back to active. * **Rebuild**: remove the subscription and boot it again from scratch. * **Remove**: delete the subscription entirely. :::danger Rebuild and remove are destructive. Rebuild drops the subscription's current state and replays every event again, and remove deletes it outright. On a large store a rebuild can take a long time and put load on your database. ::: :::warning These actions run synchronously inside the web request. For a subscription that is far behind, run and rebuild can exceed your PHP execution time limit. For large catch-ups prefer the console commands of the event-sourcing-bundle and use the dashboard for inspection and lighter operations. ::: ## Learn more * [How to see which events a subscriber consumes](events.md) * [How to browse the event store a subscription processes](store.md) * [How to run the dashboard safely in production](production-usage.md) --- # Store Source: https://patchlevel.dev/docs/event-sourcing-admin-bundle/latest/store.md The store view is the landing page of the dashboard. It lists the messages in your event store in reverse order, newest first, so you can see what your application recorded most recently. The index route of the bundle redirects here. ![The store view](screenshot1.png) ## Browsing events Each row shows one recorded event together with its aggregate, id and playhead. The list is paged, with 50 events per page by default, and you can page through the full history. ## Filtering You can narrow the list down with several filters, which map directly to the store criteria: * **aggregate**: only events of a given aggregate name, for example `hotel` * **aggregate id**: only events for a single aggregate id * **stream name**: only events of a specific stream * **event**: only a single event type, for example `guest_is_checked_in` The filters combine, so you can look at every `guest_is_checked_in` event of one hotel at once. The available aggregate names and event names come from the aggregate and event registries, so the filter dropdowns always reflect what is actually registered in your application. :::tip From the store you can jump straight into the [inspection](inspection.md) view of any aggregate to see its full history and current state. ::: ## Learn more * [How to inspect a single aggregate](inspection.md) * [How to see which subscribers react to an event and customize its display](events.md) * [How to manage subscriptions](subscriptions.md) --- # Production Usage Source: https://patchlevel.dev/docs/event-sourcing-admin-bundle/latest/production-usage.md The dashboard was built as a development tool, but it can run in production as well. Because it exposes internal data and lets visitors control your subscriptions, you must put it behind authentication before enabling it outside of `dev`. :::danger The dashboard has no built in access control. Anyone who can reach it can read your events, see internal information about your system and your users, and pause, rebuild or remove subscriptions. Never expose it to the public without a security layer in front of it. ::: ## Install as a regular dependency For production the bundle must be a normal dependency rather than a dev only one: ```bash composer require patchlevel/event-sourcing-admin-bundle ``` Make sure the bundle is registered for the environments you want it in, for example by removing the `['dev' => true]` restriction in `config/bundles.php`. ## Enable it for the right environments Move the configuration out of the `when@dev` block so it applies in production too: ```yaml # config/packages/patchlevel_event_sourcing_admin.yaml patchlevel_event_sourcing_admin: enabled: true ``` Do the same for the routes import: ```yaml # config/routes/patchlevel_event_sourcing_admin.yaml event_sourcing: resource: '@PatchlevelEventSourcingAdminBundle/config/routes.yaml' prefix: /es-admin ``` ## Secure the routes Protect the prefix you mounted the dashboard under with the Symfony security component, for example with an access control rule that requires an admin role: ```yaml # config/packages/security.yaml security: access_control: - { path: ^/es-admin, roles: ROLE_ADMIN } ``` :::warning The subscription controls run synchronously in the web request. In production prefer the console commands of the event-sourcing-bundle for heavy operations like full rebuilds, and keep the dashboard for inspection and lighter actions. ::: --- # Introduction Source: https://patchlevel.dev/docs/event-sourcing-admin-bundle/latest/introduction.md The Event-Sourcing Admin Bundle provides a web dashboard for applications built on [patchlevel/event-sourcing](https://github.com/patchlevel/event-sourcing) and the [event-sourcing-bundle](https://github.com/patchlevel/event-sourcing-bundle). It lets you browse the event store, inspect aggregates over time, see how events connect to your subscribers, and manage the subscription engine, all from the browser. It was designed as a developer experience tool for local development, but it can also run in production once it is placed behind proper authentication. ## Features * Browse the raw event [store](store.md) and filter by aggregate, id, stream or event * [Inspect](inspection.md) a single aggregate: its events, serialized state, snapshot and a full state dump * Time travel through an aggregate to see its state at any [playhead](inspection.md) * List all registered [events](events.md) together with their listeners and subscribers * View and control [subscriptions](subscriptions.md): boot, run, pause, reactivate, rebuild or remove * [Customize](events.md) how events are rendered with the `#[Inspect]` attribute ## Installation ```bash composer require --dev patchlevel/event-sourcing-admin-bundle ``` :::tip Follow the [getting started](getting-started.md) guide to enable the bundle, register its routes and open the dashboard for the first time. ::: --- # Inspection Source: https://patchlevel.dev/docs/event-sourcing-admin-bundle/latest/inspection.md The inspection view focuses on a single aggregate. It rebuilds the aggregate from its events and shows you everything about it: the recorded events, the current state in several representations, and the snapshot if one exists. You can also time travel to see the aggregate as it looked at an earlier point in its history. ![The inspection view](screenshot2.png) ## Opening an aggregate The inspection index lets you pick an aggregate name and enter an id, then takes you to the detail page for that aggregate. You can also reach an aggregate from the [store](store.md), where every row links straight into its inspection page. :::note If you only know the stream name, the bundle resolves it back to the matching aggregate. When a stream maps to exactly one aggregate, you land on its detail page directly. When it is ambiguous, you get a small selection screen first. ::: ## Tabs The detail page shows the list of recorded events next to a set of tabs that each render the current state differently: * **Details**: metadata about the aggregate, including its class, stream name and snapshot configuration. * **Serialized**: the aggregate as it is persisted, produced with the hydrator. If the aggregate cannot be serialized, the error is shown instead. * **Dump**: a full `symfony/var-dumper` dump of the live aggregate object, including private state. * **Snapshot**: the current snapshot loaded from the snapshot store, if snapshots are configured for this aggregate. :::note The serialized and snapshot tabs depend on your configuration. The serialized state needs an aggregate the hydrator can extract, and the snapshot tab needs a snapshot store that holds a snapshot for this aggregate. ::: ## Time travel Every aggregate is rebuilt by replaying its events up to a given playhead. You can stop the replay at any event and see the state the aggregate had at that moment. In the UI you step forward and backward through the history, and every event in the list links to the state right after it was applied. This is useful to understand how a specific event changed the aggregate, or to debug an unexpected current state. :::tip Combine time travel with the **Dump** tab to see the full internal state of the aggregate at each step, not just the serialized representation. ::: ## Learn more * [How to browse and filter the event store](store.md) * [How to see which subscribers react to an event and customize its display](events.md) * [How to manage the subscriptions that build projections](subscriptions.md) --- # Getting Started Source: https://patchlevel.dev/docs/event-sourcing-admin-bundle/latest/getting-started.md This guide enables the admin bundle in an existing Symfony application that already uses the [event-sourcing-bundle](https://github.com/patchlevel/event-sourcing-bundle), and walks you through opening the dashboard for the first time. The examples use a small hotel domain, with a `Hotel` aggregate that records events like `GuestIsCheckedIn` and `GuestIsCheckedOut`. :::note The admin bundle reads its data from the services configured by the event-sourcing-bundle (the store, the aggregate and event registries, and the subscription engine). If your application is not set up yet, start with the event-sourcing [getting started](https://patchlevel.dev/docs/event-sourcing/latest) guide first. ::: ## Install the bundle Require the package as a development dependency: ```bash composer require --dev patchlevel/event-sourcing-admin-bundle ``` If you use Symfony Flex without auto-registration, register the bundle for the `dev` environment in `config/bundles.php`: ```php use Patchlevel\EventSourcingAdminBundle\PatchlevelEventSourcingAdminBundle; return [ // ... PatchlevelEventSourcingAdminBundle::class => ['dev' => true], ]; ``` ## Enable the bundle The bundle does nothing until you set `enabled` to `true`. Enable it only for the `dev` environment: ```yaml # config/packages/patchlevel_event_sourcing_admin.yaml when@dev: patchlevel_event_sourcing_admin: enabled: true ``` :::warning When `enabled` is `false`, none of the controllers, routes or services are registered. This is the default, so the dashboard stays off until you opt in. ::: ## Register the routes The dashboard ships its own routing file. Import it under a prefix of your choice, again scoped to `dev`: ```yaml # config/routes/patchlevel_event_sourcing_admin.yaml when@dev: event_sourcing: resource: '@PatchlevelEventSourcingAdminBundle/config/routes.yaml' prefix: /es-admin ``` ## Build the assets The dashboard ships compiled CSS and JavaScript as bundle assets. Install them into your public directory: ```bash bin/console assets:install ``` :::note If you use the [asset mapper](https://symfony.com/doc/current/frontend/asset_mapper.html), the bundle assets are picked up automatically. The bundle depends on `symfony/asset` and `symfony/asset-mapper` for this. ::: ## Open the dashboard Start your application and visit the prefix you chose: ``` https://localhost/es-admin/ ``` The index route redirects to the [store](store.md), where you can see the latest recorded events. From there you can jump into the [inspection](inspection.md) view of a single hotel, browse all registered [events](events.md), or manage your [subscriptions](subscriptions.md). ## Result You now have a working dashboard scoped to your `dev` environment. You can browse the event store, inspect aggregates and time travel through their history, and control the subscription engine without leaving the browser. ## Learn more * [How to browse the event store](store.md) * [How to inspect an aggregate and time travel](inspection.md) * [How to manage subscriptions](subscriptions.md) * [How to run the dashboard in production](production-usage.md) --- # Events Source: https://patchlevel.dev/docs/event-sourcing-admin-bundle/latest/events.md The events view lists every event that is registered in your application through the event registry. For each event it also shows which parts of your system react to it, so you can see the flow from an event to its consumers at a glance. With the `#[Inspect]` attribute you can also control how each event is rendered across the dashboard. ![The events view](screenshot4.png) ## What you see Each entry shows: * the registered event name, for example `guest_is_checked_in` * the fully qualified event class * the **subscribers** whose `#[Subscribe]` methods handle this event Subscribers that listen to every event with `#[Subscribe(Subscribe::ALL)]` appear on each event, so you can tell that a catch-all subscriber such as an audit log will process a given event too. :::note Listeners are only listed if an event bus with a listener provider is configured. When no listener provider is available, the listeners column is omitted rather than shown as empty. ::: ## Why it is useful When you record a new event it is easy to lose track of everything that reacts to it. This view answers that question directly: pick an event and you immediately see every subscriber and listener that will run, which helps when adding new projections or debugging why a side effect did or did not happen. :::tip Use the [store](store.md) to find a concrete occurrence of an event, then come back here to see everything that consumes it. ::: ## Customizing how events are displayed By default events are rendered with their registered name. With the `#[Inspect]` attribute you can give an event a human readable description, an icon and a color, so the [store](store.md) and [inspection](inspection.md) views become much easier to scan. Add the attribute to an event class, where every argument is optional: ```php use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcingAdminBundle\Attribute\Inspect; use Patchlevel\EventSourcingAdminBundle\Color; #[Event('guest_is_checked_in')] #[Inspect( description: 'Guest checked into room', icon: 'user-plus', color: Color::Green, )] final class GuestIsCheckedIn { public function __construct( public readonly string $name, public readonly int $room, ) { } } ``` The arguments are: * `description`: the text shown for the event, with support for expressions and light markdown (see below). * `icon`: the name of a [Heroicon](https://heroicons.com), for example `user-plus` or `bolt`. * `color`: a color for the icon, either a `Color` enum case or a hex string like `#22c55e`. * `size`: an optional size hint for the icon. ### Dynamic descriptions The description is a template. Anything inside `{{ ... }}` is evaluated with the Symfony [expression language](https://symfony.com/doc/current/components/expression_language.html), with the event available as `event`. This lets you build a description from the event's own data: ```php use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcingAdminBundle\Attribute\Inspect; #[Event('guest_is_checked_in')] #[Inspect(description: 'Guest **{{ event.name }}** checked into room {{ event.room }}')] final class GuestIsCheckedIn { public function __construct( public readonly string $name, public readonly int $room, ) { } } ``` You can also apply light markdown: `**text**` becomes bold and `*text*` becomes italic. :::note The expression is evaluated against the deserialized event object, so you can call methods and read properties on it just like in PHP. ::: ### Colors The `Color` enum offers the Tailwind CSS color palette as named cases, such as `Color::Green`, `Color::Sky` or `Color::Rose`. Use a case for consistency with the dashboard's styling, or pass a raw hex string when you need an exact value: ```php use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcingAdminBundle\Attribute\Inspect; use Patchlevel\EventSourcingAdminBundle\Color; #[Event('guest_is_checked_in')] #[Inspect(icon: 'arrow-right-on-rectangle', color: Color::Rose)] final class GuestIsCheckedIn { public function __construct() { } } ``` ## Learn more * [How to browse the event store](store.md) * [How to manage the subscribers that consume events](subscriptions.md) * [How to inspect an aggregate built from these events](inspection.md) ===== # patchlevel/odm > MongoDB ODM, can be used with Rango but also with MongoDB. # Repository Source: https://patchlevel.dev/docs/odm/latest/repository.md A repository stores and loads documents of a single type. You obtain one from a [repository manager](databases.md) by passing the document class. The manager creates the repository on first use and caches it, so calling `get()` repeatedly returns the same instance. The manager you pick depends on your backend, but the repository it returns exposes the same methods on both MongoDB and PostgreSQL: ```php use Patchlevel\ODM\Repository\MongoDBRepositoryManager; use Patchlevel\ODM\Repository\RangoRepositoryManager; // PostgreSQL via Rango $manager = RangoRepositoryManager::create($rangoClient); // MongoDB $manager = MongoDBRepositoryManager::create($mongoClient); $repository = $manager->get(Profile::class); ``` :::note See the [databases](databases.md) page for how to create the client and manager for each backend. ::: ## No Unit of Work Patchlevel ODM has no Unit of Work. The repository never tracks your documents and never persists changes on its own. A document is written only when you explicitly call `insert()` or `update()`. This keeps memory usage flat in long-running workers and prevents changes from leaking into the database from unrelated parts of the code. ## Inserting `insert()` accepts one or many documents. A single document is written with one operation, multiple documents are written in a batch. ```php $repository->insert(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')])); $repository->insert( new Profile('r-2', 'Beans', Status::ACTIVE, [new Skill('js')]), new Profile('r-3', 'Elsa', Status::INACTIVE, [new Skill('go')]), ); ``` :::warning Inserting a document whose id already exists fails with an `InsertionFailed` exception. The same exception is raised when a [unique index](documents.md#indexes) is violated. ::: ## Updating `update()` replaces the stored fields of existing documents. Because there is no change tracking, you pass the full document you want to persist. ```php $profile = $repository->get('r-1'); $profile->name = 'Rango Updated'; $repository->update($profile); ``` ## Loading by id `find()` returns the document or `null`. `get()` returns the document or throws `DocumentNotFound` when it does not exist, which is convenient when the document is required. ```php $profile = $repository->find('r-1'); // Profile|null $profile = $repository->get('r-1'); // Profile, throws DocumentNotFound when missing ``` ## Existence and counting ```php $repository->has('r-1'); // bool $repository->count(); // int, number of documents in the collection ``` ## Iterating all documents `findAll()` streams every document in the collection as an iterable. ```php foreach ($repository->findAll() as $profile) { echo $profile->name; } ``` :::tip To filter, sort or paginate instead of loading everything, use `findBy()` and `findOneBy()` from the [querying](#querying) section below. ::: ## Querying Besides loading documents by id, the repository can query a collection with filters, sorting and pagination. Queries use the document property names, even when a property is stored under a different field name, so you never deal with the raw storage layout. ### Filtering `findBy()` takes a filter array and returns an iterable of documents. Each key is a property name and each value is the value to match. ```php $active = iterator_to_array( $repository->findBy(['status' => 'active']), false, ); ``` :::note `findBy()` returns a generator, so wrap it in `iterator_to_array()` when you need an array. Pass `false` as the second argument to reindex the result. ::: ### Operators Filter values support the MongoDB-style query operators. Operator keys start with `$` and are passed through untouched, while the property names around them are still mapped to their stored field names. ```php $selected = iterator_to_array( $repository->findBy(['id' => ['$in' => ['r-1', 'r-3']]]), false, ); $result = iterator_to_array( $repository->findBy([ '$or' => [ ['status' => 'active'], ['name' => 'Rango'], ], ]), false, ); ``` :::tip The same operators work on both backends, because MongoDB and [Rango](https://github.com/patchlevel/rango/) share the same query API. ::: ### Sorting Pass an `orderBy` array of property names mapped to `asc` or `desc`. ```php $sorted = iterator_to_array( $repository->findBy([], orderBy: ['name' => 'asc']), false, ); ``` ### Pagination Use `limit` and `offset` to page through a result set. ```php $page = iterator_to_array( $repository->findBy( filter: ['status' => 'active'], orderBy: ['name' => 'asc'], limit: 10, offset: 20, ), false, ); ``` ### Fetching a single document `findOneBy()` returns the first matching document or `null`. It accepts the same filter and an optional `orderBy`. ```php $profile = $repository->findOneBy(['name' => 'Beans']); $newest = $repository->findOneBy([], orderBy: ['name' => 'desc']); ``` ### Filtering on nested properties You can filter and sort on nested properties using dot notation. The path is resolved through the [field mapping](field-mapping.md), so renamed fields are handled automatically. ```php $result = iterator_to_array( $repository->findBy(['personalData.name' => 'Rango']), false, ); ``` :::warning Every property in a filter or sort must exist on the document. An unknown path raises `UnknownPropertyPath`, which lists the properties that are available at that level. ::: ## Removing `remove()` deletes documents by id and accepts one or many ids. ```php $repository->remove('r-1'); $repository->remove('r-2', 'r-3'); ``` ## Managing the collection The repository can create and drop its own collection. `createCollection()` also creates the [indexes](documents.md#indexes) declared on the document. ```php $repository->createCollection(); $repository->dropCollection(); ``` :::warning `dropCollection()` permanently deletes the collection and all of its documents. ::: ## Learn more * [How to define indexes and unique constraints](documents.md#indexes) * [How field names and nested objects are mapped](field-mapping.md) * [How to wire up MongoDB or PostgreSQL](databases.md) --- # Patchlevel ODM Source: https://patchlevel.dev/docs/odm/latest/introduction.md Patchlevel ODM is a lightweight Object Document Mapper for PHP. It maps plain PHP objects to document storage and runs on both MongoDB and PostgreSQL (through [patchlevel/rango](https://github.com/patchlevel/rango/)), exposing the same API for both. It is built on top of [patchlevel/hydrator](https://github.com/patchlevel/hydrator/), which gives you fast attribute-based mapping and enterprise features like encryption out of the box. Unlike Doctrine ODM, Patchlevel ODM has no Unit of Work. Repositories control persistence explicitly, so every write is deliberate and easy to reason about, which makes the library a good fit for long-running worker processes. ## Features * [MongoDB and PostgreSQL support](databases.md) with a single, consistent API * [Attribute-based document mapping](documents.md) with `#[Document]` and `#[Id]` * [Repositories without a Unit of Work](repository.md) for predictable writes * [Querying](repository.md#querying) with filters, sorting and pagination * [Indexes](documents.md#indexes) defined with `#[Index]`, including unique constraints * [Field mapping and normalization](field-mapping.md) for nested objects and custom field names * [Encryption and crypto shredding](encryption.md) for sensitive data ## Installation Install the library with Composer. Depending on your database, you also need the matching driver package. For PostgreSQL via [Rango](https://github.com/patchlevel/rango/): ```bash composer require patchlevel/odm patchlevel/rango ``` For MongoDB: ```bash composer require patchlevel/odm mongodb/mongodb ``` ## Integration * [patchlevel/hydrator](https://github.com/patchlevel/hydrator/) powers the object mapping and normalization * [patchlevel/rango](https://github.com/patchlevel/rango/) is the PostgreSQL document layer :::tip New to the library? The [getting started](getting-started.md) guide builds a complete example from defining a document to querying it. ::: --- # Getting Started Source: https://patchlevel.dev/docs/odm/latest/getting-started.md This guide walks you through a complete example: you define a `Profile` document, set up a repository manager, and then insert, load, query, update and remove documents. Only the initial setup differs between MongoDB and PostgreSQL; every step after it is identical on both. ## Installation Install the library together with the driver for your backend. For PostgreSQL via [Rango](databases.md): ```bash composer require patchlevel/odm patchlevel/rango ``` For MongoDB: ```bash composer require patchlevel/odm mongodb/mongodb ``` ## Define a document A document is a plain PHP class marked with the `#[Document]` attribute. The collection name is the first argument. One property carries the `#[Id]` attribute and becomes the document identifier. ```php use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; use Patchlevel\ODM\Attribute\Index; #[Document('profiles')] #[Index('by_status', ['status' => 'asc'])] final class Profile { /** @param list $skills */ public function __construct( #[Id] public readonly string $id, public string $name, public Status $status, public array $skills, ) { } } ``` The document references two small value types and an enum: ```php enum Status: string { case ACTIVE = 'active'; case INACTIVE = 'inactive'; } #[SkillNormalizer] final readonly class Skill { public function __construct( public string $value, ) { } } ``` :::note The enum and the `Skill` value object are turned into scalars by the hydrator. The `#[SkillNormalizer]` attribute is a custom normalizer. Both are explained on the [field mapping](field-mapping.md) page. ::: ## Set up the repository manager The repository manager creates and caches one repository per document class. Build it with the static `create()` factory and pass your database client. Pick the manager for your backend; the repository you get back behaves the same either way. For PostgreSQL via Rango: ```php use Patchlevel\ODM\Repository\RangoRepositoryManager; use Patchlevel\Rango\Client; $client = new Client($_ENV['POSTGRES_URI']); $manager = RangoRepositoryManager::create($client); ``` For MongoDB: ```php use MongoDB\Client; use Patchlevel\ODM\Repository\MongoDBRepositoryManager; $client = new Client($_ENV['MONGODB_URI']); $manager = MongoDBRepositoryManager::create($client); ``` From here on the code is the same for both backends: ```php $repository = $manager->get(Profile::class); ``` ## Create the collection Before storing documents, create the collection and its [indexes](documents.md#indexes): ```php $repository->createCollection(); ``` ## Insert documents `insert()` accepts one or many documents and writes them in a single operation. ```php $repository->insert( new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]), new Profile('r-2', 'Beans', Status::ACTIVE, [new Skill('node'), new Skill('js')]), new Profile('r-3', 'Elsa', Status::INACTIVE, [new Skill('mongodb')]), ); ``` ## Load documents Use `find()` to load a document by id, or `get()` if a missing document should raise an exception. ```php $profile = $repository->find('r-1'); // Profile|null $profile = $repository->get('r-1'); // Profile, throws DocumentNotFound when missing ``` ## Query documents `findBy()` filters documents and returns an iterable. You can sort, limit and offset the result. ```php $profiles = iterator_to_array( $repository->findBy( filter: ['status' => Status::ACTIVE->value], orderBy: ['name' => 'asc'], limit: 10, ), false, ); ``` :::note Filters and sorting accept the document property names, even when the stored field is renamed. The [querying](repository.md#querying) section covers operators like `$in` and `$or`. ::: ## Update and remove There is no Unit of Work, so changes are persisted only when you call `update()`. Remove documents by id with `remove()`. ```php $profile = $repository->get('r-2'); $profile->name = 'New Beans'; $repository->update($profile); $repository->remove('r-3'); ``` ## Result You now have a working document store: a `Profile` document is mapped through attributes, persisted through a repository, and queried with filters and sorting, all without a Unit of Work. ## Learn more * [How to define documents](documents.md) * [How to use the repository](repository.md) * [How to query documents](repository.md#querying) * [How to encrypt sensitive data](encryption.md) --- # Field Mapping Source: https://patchlevel.dev/docs/odm/latest/field-mapping.md Patchlevel ODM maps document properties to storage through [patchlevel/hydrator](https://github.com/patchlevel/hydrator/). Scalars, enums, nested objects and value objects are normalized into a storable shape on write and reconstructed on read. This page shows how the stored field names are chosen and how to customize them. ## Default mapping By default a property is stored under its own name. The only exception is the `#[Id]` property, which is always stored under the reserved `_id` field no matter what the property is called. ```php use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; #[Document('profiles')] final class Profile { public function __construct( #[Id] public readonly string $id, // stored as _id public string $name, // stored as name public Status $status, // stored as status ) { } } ``` ## Renaming fields Use the hydrator's `#[NormalizedName]` attribute to store a property under a different field name. This is useful for keeping a stable storage schema while renaming properties in code. ```php use Patchlevel\Hydrator\Attribute\NormalizedName; use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; #[Document('profiles')] final class Profile { public function __construct( #[Id] public readonly string $id, #[NormalizedName('_name')] public string $name, ) { } } ``` :::note You still filter and sort by the property name (`name`), not by the stored field (`_name`). The ODM translates property paths to field paths for you, as described in [querying](repository.md#querying). ::: ## Nested objects Nested objects are normalized recursively. Renamed fields on nested objects are respected, and you can filter on them with dot notation. ```php use Patchlevel\Hydrator\Attribute\NormalizedName; final readonly class PersonalData { public function __construct( #[NormalizedName('_name')] public string $name, #[NormalizedName('_age')] public int $age, ) { } } #[Document('profiles')] final class Profile { public function __construct( #[Id] public readonly string $id, #[NormalizedName('_personal_data')] public PersonalData $personalData, ) { } } ``` A filter on the nested property uses the property path, which is mapped to the stored field path: ```php $result = iterator_to_array( $repository->findBy(['personalData.name' => 'Rango']), false, ); ``` ## Custom normalizers For value objects with their own representation, write a normalizer and attach it as an attribute. The normalizer converts the object to a storable value and back. ```php use Patchlevel\Hydrator\Normalizer\InvalidType; use Patchlevel\Hydrator\Normalizer\NormalizerWithContext; #[Attribute(Attribute::TARGET_CLASS)] final class SkillNormalizer implements NormalizerWithContext { /** @param array $context */ public function normalize(mixed $value, array $context = []): mixed { if ($value === null) { return null; } if (!$value instanceof Skill) { throw new InvalidType(); } return $value->value; } /** @param array $context */ public function denormalize(mixed $value, array $context = []): mixed { if ($value === null) { return null; } if (!is_string($value)) { throw new InvalidType(); } return new Skill($value); } } ``` Attach the normalizer to the value object, then use it like any other property: ```php #[SkillNormalizer] final readonly class Skill { public function __construct( public string $value, ) { } } ``` :::tip The hydrator ships normalizers for enums, dates and arrays out of the box. See the [hydrator documentation](https://patchlevel.dev/docs/hydrator/latest) for the full list. ::: ## Learn more * [How to query renamed and nested properties](repository.md#querying) * [How to define documents](documents.md) * [How to encrypt sensitive fields](encryption.md) --- # Encryption Source: https://patchlevel.dev/docs/odm/latest/encryption.md Patchlevel ODM can transparently encrypt sensitive document fields using the cryptography extension of [patchlevel/hydrator](https://github.com/patchlevel/hydrator/). Each data subject gets its own encryption key, stored separately from the documents. Deleting a subject's key makes their encrypted data unrecoverable, a technique known as crypto shredding that helps with data deletion requests. ## How it works You mark one property as the data subject id and the sensitive properties as encrypted. On write, the sensitive values are encrypted with the subject's key. On read, they are decrypted again. The keys live in a separate collection, managed by a key store that ships with the ODM for each backend. ## Marking sensitive data Use the `#[DataSubjectId]` attribute to identify the subject and `#[SensitiveData]` on the properties that should be encrypted. You can provide a fallback value that is returned when the key is gone. ```php use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; #[Document('profiles')] final class Profile { public function __construct( #[Id] #[DataSubjectId] public readonly string $id, #[SensitiveData] public string $name, #[SensitiveData(fallback: 'unknown')] public string $email, ) { } } ``` :::note The fallback is used when the subject's key has been deleted, so the document still hydrates after the encrypted data became unreadable. ::: ## Setting up the hydrator Encryption is configured on the hydrator, which you then pass to the repository manager's `create()` factory. Build the hydrator with the `CryptographyExtension` and the key store for your backend. Each backend ships its own key store; the rest of the setup is the same. For PostgreSQL via Rango: ```php use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\ODM\Hydrator\RangoCipherKeyStore; use Patchlevel\ODM\Repository\RangoRepositoryManager; use Patchlevel\Rango\Client; $client = new Client($_ENV['POSTGRES_URI']); $keyStore = new RangoCipherKeyStore($client->selectDatabase('public')); $cryptographer = BaseCryptographer::createWithOpenssl($keyStore); $hydrator = (new StackHydratorBuilder()) ->useExtension(new CryptographyExtension($cryptographer)) ->build(); $manager = RangoRepositoryManager::create($client, $hydrator); ``` For MongoDB: ```php use MongoDB\Client; use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\ODM\Hydrator\MongoDBCipherKeyStore; use Patchlevel\ODM\Repository\MongoDBRepositoryManager; $client = new Client($_ENV['MONGODB_URI']); $keyStore = new MongoDBCipherKeyStore($client->selectDatabase('default')); $cryptographer = BaseCryptographer::createWithOpenssl($keyStore); $hydrator = (new StackHydratorBuilder()) ->useExtension(new CryptographyExtension($cryptographer)) ->build(); $manager = MongoDBRepositoryManager::create($client, $hydrator); ``` :::note The cipher key store is the only backend-specific part. The `#[DataSubjectId]` and `#[SensitiveData]` attributes and everything else work the same on both. ::: ## Storing and loading Once configured, encryption is transparent. You store and load documents exactly as before, and the sensitive fields are encrypted at rest. ```php $repository = $manager->get(Profile::class); $repository->insert(new Profile('r-1', 'Rango', 'rango@example.com')); $profile = $repository->get('r-1'); // name and email are decrypted ``` ## Crypto shredding To erase a subject's data, delete their key from the key store. The encrypted fields can no longer be decrypted, and the fallback value is returned instead. ```php $keyStore->removeWithSubjectId('r-1'); ``` :::danger Removing a key is irreversible. The encrypted data stays in the document but can never be decrypted again. ::: ## Learn more * [How fields are mapped and normalized](field-mapping.md) * [How to define documents](documents.md) * [How to choose a database backend](databases.md) --- # Documents Source: https://patchlevel.dev/docs/odm/latest/documents.md A document is a plain PHP class that Patchlevel ODM maps to a collection in your database. You mark the class with the `#[Document]` attribute and one property with `#[Id]`. There is no base class to extend and no interface to implement, so your documents stay free of framework coupling. ## Defining a document The `#[Document]` attribute takes the collection name. Exactly one property must carry the `#[Id]` attribute, which becomes the document identifier and is stored as the `_id` field. ```php use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; #[Document('profiles')] final class Profile { /** @param list $skills */ public function __construct( #[Id] public readonly string $id, public string $name, public Status $status, public array $skills, ) { } } ``` :::warning A document needs exactly one `#[Id]` property. The library throws `NoIdPropertyFound` when none is present and `MultipleIdPropertiesFound` when more than one property is marked. ::: :::note The `#[Document]` attribute also takes an optional second argument to store the document in a specific [database](databases.md), for example `#[Document('profiles', database: 'analytics')]`. Otherwise the document lives in the manager's default database. ::: ## Identifiers The identifier is a string. The ODM always stores it under the reserved `_id` field, regardless of the property name. If you rename the id property with a [field mapping](field-mapping.md) attribute, the ODM still maps it to and from `_id` transparently. ```php $repository->insert(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')])); $profile = $repository->get('r-1'); ``` ## Property values Properties are mapped by the [hydrator](https://github.com/patchlevel/hydrator/). Scalars, enums, nested objects, arrays and value objects are all supported. Complex values are normalized into a storable representation and reconstructed on load. ```php enum Status: string { case ACTIVE = 'active'; case INACTIVE = 'inactive'; } ``` :::note How nested objects, enums and custom field names are stored is described on the [field mapping](field-mapping.md) page. ::: ## Indexes Indexes speed up queries and can enforce uniqueness. You declare them on the document with the `#[Index]` attribute, and the repository synchronizes them with the database on demand. The attribute is repeatable, so a document can carry as many indexes as it needs. ### Declaring an index `#[Index]` takes a name and a map of property names to a sort direction (`asc` or `desc`). The property names are mapped to their stored [field names](field-mapping.md) automatically. ```php use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; use Patchlevel\ODM\Attribute\Index; #[Document('profiles')] #[Index('by_status', ['status' => 'asc'])] #[Index('by_name', ['name' => 'asc'])] final class Profile { public function __construct( #[Id] public readonly string $id, public string $name, public Status $status, ) { } } ``` ### Unique indexes Set `unique: true` to enforce that no two documents share the same value for the indexed properties. ```php #[Document('profiles')] #[Index('by_email', ['email' => 'asc'], unique: true)] final class Profile { public function __construct( #[Id] public readonly string $id, public string $email, ) { } } ``` :::warning Inserting a document that violates a unique index fails with an `InsertionFailed` exception. Catch it to detect duplicates. ::: ### Synchronizing indexes Indexes are not created automatically when you insert documents. Call `updateIndexes()` to create the declared indexes, or `createCollection()`, which creates the collection together with its indexes. ```php $repository->updateIndexes(); $repository->createCollection(); ``` ### Removing stale indexes By default `updateIndexes()` only adds missing indexes. Pass `true` to also drop indexes that are no longer declared on the document. The primary key index is always preserved. ```php $repository->updateIndexes(dropUnknown: true); ``` :::tip Run index synchronization as part of a deployment or migration step rather than on every request, so your collections stay in sync with the document definitions. ::: ## Learn more * [How to store and load documents](repository.md) * [How to control field names and normalization](field-mapping.md) * [How to query documents efficiently](repository.md#querying) * [How to choose a database backend](databases.md) --- # Databases Source: https://patchlevel.dev/docs/odm/latest/databases.md Patchlevel ODM runs on two backends: MongoDB and PostgreSQL through [Rango](https://github.com/patchlevel/rango/). You pick the backend by choosing a repository manager. Both managers implement the same interface and hand you repositories with an identical API, so your documents and your application code stay the same across backends. :::note Both backends expose the same query API, so the [filter operators](repository.md#querying) and [index definitions](documents.md#indexes) you write work the same on either one. ::: ## PostgreSQL via Rango Require the Rango package and build a `RangoRepositoryManager` from a Rango client. The default database is `public`. ```php use Patchlevel\ODM\Repository\RangoRepositoryManager; use Patchlevel\Rango\Client; $client = new Client($_ENV['POSTGRES_URI']); $manager = RangoRepositoryManager::create($client); $repository = $manager->get(Profile::class); ``` ## MongoDB Require `mongodb/mongodb` and build a `MongoDBRepositoryManager` from a MongoDB client. The default database is `default`. ```php use MongoDB\Client; use Patchlevel\ODM\Repository\MongoDBRepositoryManager; $client = new Client($_ENV['MONGODB_URI']); $manager = MongoDBRepositoryManager::create($client); $repository = $manager->get(Profile::class); ``` ## Choosing the database A repository uses the manager's default database unless the document pins its own with the second argument of `#[Document]`. The default database is set when constructing the manager directly. ```php #[Document('profiles', database: 'analytics')] final class Profile { // ... } ``` The `create()` factory uses the backend default (`public` or `default`). To set a different default database, construct the manager yourself and pass the `defaultDatabase` argument. ## Manual construction `create()` wires up sensible defaults, including the hydrator and the metadata factory. When you need full control, for example to inject a custom hydrator for [encryption](encryption.md) or a shared metadata factory, construct the manager directly. ```php use Patchlevel\Hydrator\StackHydrator; use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; use Patchlevel\ODM\Metadata\StackHydratorFieldMappingResolver; use Patchlevel\ODM\Repository\RangoRepositoryManager; use Patchlevel\Rango\Client; $client = new Client($_ENV['POSTGRES_URI']); $hydrator = new StackHydrator(); $metadataFactory = new AttributeDocumentMetadataFactory( new StackHydratorFieldMappingResolver($hydrator), ); $manager = new RangoRepositoryManager( $client, $metadataFactory, $hydrator, defaultDatabase: 'public', ); ``` :::tip For most applications the static `create()` factory is enough. Reach for manual construction only when you need to customize the hydrator or share infrastructure across managers. ::: ## Learn more * [How to store and load documents](repository.md) * [How to encrypt sensitive data](encryption.md) * [How to define documents](documents.md) ===== # FAQ Source: https://patchlevel.dev/ > Frequently asked questions about the patchlevel event-sourcing libraries. ## Is the library free to use? Yes - and that's our promise: it will never cost anything. The event sourcing library and all integrations are open source under the MIT license, with no paid tiers and no feature gates - not today, not in the future. Every feature you see here, from snapshots to subscriptions, is and stays free. If you want hands-on help, the patchlevel team also offers professional consulting and training. ## What about CQRS? Event sourcing and CQRS are a natural fit, and the library supports both: aggregates handle your writes, while subscriptions build dedicated read models (projections) optimized for your queries. You can wire in any command bus - like Symfony Messenger - or none at all. CQRS is supported, not forced. ## Do I need a special event store database? No. The event store runs on the relational database you already operate - PostgreSQL, MariaDB, MySQL, or SQLite - powered by the battle-tested Doctrine DBAL. No extra infrastructure to deploy, back up, or monitor. ## Do I have to event-source my whole application? No. You can adopt event sourcing per aggregate, exactly where the business value lives - your orders, bookings, or payments - and keep simple CRUD for the rest. The library coexists peacefully with Doctrine ORM, Eloquent, and existing code. ## What happens when my events need to change? Software evolves, and so do events. Upcasters transform old event payloads on the fly when they are loaded, so you never have to migrate the event store itself. Your history stays immutable while your code moves forward. ## How stable is the library? Very. We are 100% committed to semantic versioning: no breaking changes within a major version, ever. Deprecations are announced ahead of time and every upgrade path is documented, so updating stays painless. And you are not on your own - we actively maintain the library and provide support on GitHub, with professional support available if you need more. ## What if my framework is not supported? No problem. The core library is plain PHP with no framework dependency, so it works in any project - Slim, Laminas, CodeIgniter, or no framework at all. If you use Symfony or Laravel, official integrations give you a head start: a Symfony bundle with autowiring, Messenger, and Doctrine migrations, and a Laravel package with auto-discovery and Artisan commands. ## Where do I get help? Start with the documentation - it covers everything from your first aggregate to advanced topics like snapshots and subscriptions. For questions and bug reports, the team is active on GitHub. And if you need deeper support, patchlevel offers consulting, code reviews, and workshops.