From d8a6c8b525c59f494256bb46e10b69e858ae7d0f Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 15 Jun 2026 14:16:49 +0200 Subject: [PATCH 1/3] docs: review pass, fix 4.0 API drift and align canonical URLs - Add the orphaned Dynamic Consistency Boundary page to the navigation - Rename index.md to introduction.md per docs convention - message.md: replace removed AggregateHeader with StreamNameHeader/PlayheadHeader/RecordedOnHeader and refresh the built-in headers list - store.md: fix removed StreamStore/Stream/criteria classes (StreamCriterion, Store, Message\Stream, Criteria-based remove) - repository.md: point to InstantRetryCommandBus instead of the removed RetryOutdatedAggregateCommandBus - getting-started.md: fix undefined $hotelProjection variable and outdated bundle URL - Replace generic [here] link anchors, fix forward references and grammar - testing.md: add install command for the separate phpunit package - README/composer.json: switch to patchlevel.dev canonical URLs --- README.md | 34 ++++++++++---------- composer.json | 2 +- docs/command-bus.md | 1 + docs/dynamic-consistency-boundary.md | 8 ++--- docs/events.md | 4 +-- docs/getting-started.md | 8 ++--- docs/{index.md => introduction.md} | 0 docs/message.md | 28 +++++++++-------- docs/project.json | 6 +++- docs/repository.md | 4 +-- docs/split-stream.md | 2 +- docs/store.md | 46 +++++++++++++++------------- docs/testing.md | 14 ++++++++- 13 files changed, 92 insertions(+), 65 deletions(-) rename docs/{index.md => introduction.md} (100%) diff --git a/README.md b/README.md index 9fad01bff..48e15eef7 100644 --- a/README.md +++ b/README.md @@ -11,21 +11,16 @@ powered by the reliable Doctrine ecosystem and focused on developer experience. ## Features * Everything is included in the package for event sourcing -* Based on [doctrine dbal](https://github.com/doctrine/dbal) and their ecosystem +* Based on doctrine dbal and their ecosystem * Developer experience oriented and fully typed -* Automatic [snapshot](https://patchlevel.github.io/event-sourcing-docs/latest/snapshots/)-system to boost your - performance -* [Split](https://patchlevel.github.io/event-sourcing-docs/latest/split_stream/) big aggregates into multiple streams -* Versioned and managed lifecycle - of [subscriptions](https://patchlevel.github.io/event-sourcing-docs/latest/subscription/) like projections and - processors -* Safe usage of [Personal Data](https://patchlevel.github.io/event-sourcing-docs/latest/personal_data/) with - crypto-shredding -* Smooth [upcasting](https://patchlevel.github.io/event-sourcing-docs/latest/upcasting/) of old events -* Simple setup with [scheme management](https://patchlevel.github.io/event-sourcing-docs/latest/store/) - and [doctrine migration](https://patchlevel.github.io/event-sourcing-docs/latest/store/) -* Built in [cli commands](https://patchlevel.github.io/event-sourcing-docs/latest/cli/) - with [symfony](https://symfony.com/) +* Automatic snapshot-system to boost your performance +* Split big aggregates into multiple streams +* Versioned and managed lifecycle of subscriptions like projections and processors +* Safe usage of personal data with crypto-shredding +* Smooth upcasting of old events +* Simple setup with schema management and doctrine migration +* Built in cli commands with symfony +* Dynamic consistency boundary for decisions across streams * and much more... ## Installation @@ -36,14 +31,21 @@ composer require patchlevel/event-sourcing ## Documentation -* Latest [Docs](https://event-sourcing.patchlevel.io/latest/getting_started/) -* Related [Blog](https://patchlevel.de/blog) +* Latest [Docs](https://patchlevel.dev/docs/event-sourcing/latest) +* Related [Blog](https://patchlevel.dev/blog) ## Integration * [Symfony](https://github.com/patchlevel/event-sourcing-bundle) * [Psalm](https://github.com/patchlevel/event-sourcing-psalm-plugin) +## Contributing + +We are open to contributions as long as they are in line with +our [BC-Policy](https://patchlevel.dev/our-backward-compatibility-promise). + +Also note that the `composer.lock` is always generated with the newest supported PHP version as this is the version our tools run in the CI. + ## Supported databases We officially only support the databases and versions listed in the table, as these are tested in the CI. diff --git a/composer.json b/composer.json index ca069a1f1..30ef0ccc7 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "dynamic consistency boundary", "patchlevel" ], - "homepage": "https://event-sourcing.patchlevel.io", + "homepage": "https://patchlevel.dev/docs/event-sourcing/latest", "authors": [ { "name": "Daniel Badura", diff --git a/docs/command-bus.md b/docs/command-bus.md index 9e81f272f..61a0cd91d 100644 --- a/docs/command-bus.md +++ b/docs/command-bus.md @@ -425,3 +425,4 @@ $provider = new ChainHandlerProvider([ * [How to use clock](clock.md) * [How to use aggregate id](identifier.md) * [How to use query bus](query-bus.md) +* [How to decide across streams with a dynamic consistency boundary](dynamic-consistency-boundary.md) diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index e99bf9902..220acf11e 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -87,7 +87,7 @@ final class GuestIsCheckedOut ``` :::note -You can find out more about events [here](events.md). +You can find out more about [events](events.md). ::: ## Define Commands @@ -456,6 +456,6 @@ You can find this [Getting Started](./getting-started.md) section. ## Learn more -* [Events](./events.md) -* [Command Bus](./command-bus.md) -* [Store](./store.md) +* [How to define events](events.md) +* [How to dispatch commands](command-bus.md) +* [How to store events](store.md) diff --git a/docs/events.md b/docs/events.md index 9ebdb6407..3de402ee3 100644 --- a/docs/events.md +++ b/docs/events.md @@ -8,7 +8,7 @@ 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. +How to ensure this for complex values is shown in the [normalizer](#normalizer) section below. To register an event you have to set the `Event` attribute over the class, otherwise it will not be recognized as an event. @@ -80,7 +80,7 @@ $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. +Internally, an EventRegistry is used, which is described in the [Event Registry](#event-registry) section below. ## Normalizer diff --git a/docs/getting-started.md b/docs/getting-started.md index 016bd5df2..9ef230c43 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -265,7 +265,7 @@ You can find out more about [processors](subscription.md). 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](https://event-sourcing-bundle.patchlevel.io/latest/installation/) to skip this step. +If you use symfony, you can use our [symfony bundle](https://patchlevel.dev/docs/event-sourcing-bundle/latest) to skip this step. ::: ```php @@ -326,7 +326,7 @@ $hotelRepository = $repositoryManager->get(Hotel::class); ``` :::note -You can find out more about stores [here](store.md). +You can find out more about the [store](store.md). ::: ## Database setup @@ -386,12 +386,12 @@ $hotel2 = $hotelRepository->load(Uuid::fromString('d0d0d0d0-d0d0-d0d0-d0d0-d0d0d $hotel2->checkIn('David'); $hotelRepository->save($hotel2); -$hotels = $hotelProjection->getHotels(); +$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 [here](identifier.md). +You can find more about [identifiers](identifier.md). ::: ## Result diff --git a/docs/index.md b/docs/introduction.md similarity index 100% rename from docs/index.md rename to docs/introduction.md diff --git a/docs/message.md b/docs/message.md index c6cf5c44d..9bc6c6920 100644 --- a/docs/message.md +++ b/docs/message.md @@ -19,18 +19,17 @@ 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; +use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; +use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; $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(), - )); + ->withHeader(new StreamNameHeader('profile-bca7576c-536f-4428-b694-7b1f00c714b7')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($clock->now())); ``` :::note The message object is immutable. It creates a new instance with the new data. @@ -39,19 +38,24 @@ 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; +use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; /** @var Message $message */ -$message->header(AggregateHeader::class); // AggregateHeader object -$message->hasHeader(AggregateHeader::class); // true -$message->headers(); // [AggregateHeader object] +$message->header(PlayheadHeader::class); // PlayheadHeader object +$message->hasHeader(PlayheadHeader::class); // true +$message->headers(); // [StreamNameHeader object, PlayheadHeader 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. +* `StreamNameHeader` - The name of the stream the message belongs to, in the format `[aggregateName]-[aggregateId]`. +* `PlayheadHeader` - The position of the message within its stream. +* `RecordedOnHeader` - The date and time when the message was recorded. +* `EventIdHeader` - The unique id of the event. +* `IndexHeader` - The global position of the message in the store. +* `TagsHeader` - The tags attached to the message (experimental). * `ArchivedHeader` - Flag if the message is archived. * `StreamStartHeader` - Flag if the message is the first message in a new stream. diff --git a/docs/project.json b/docs/project.json index b2b8072b8..dc95ee61d 100644 --- a/docs/project.json +++ b/docs/project.json @@ -2,7 +2,7 @@ "navigation": [ { "title": "Introduction", - "file": "index.md" + "file": "introduction.md" }, { "title": "Getting Started", @@ -80,6 +80,10 @@ "title": "Split Stream", "file": "split-stream.md" }, + { + "title": "Dynamic Consistency Boundary", + "file": "dynamic-consistency-boundary.md" + }, { "title": "Time / Clock", "file": "clock.md" diff --git a/docs/repository.md b/docs/repository.md index 4716e5b0d..70b1d37d2 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -176,8 +176,8 @@ 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. +If you use the Command Bus, you can use the [instant retry](command-bus.md#instant-retry) decorator +to retry the command automatically when an `AggregateOutdated` exception occurs. ::: ### Load an aggregate diff --git a/docs/split-stream.md b/docs/split-stream.md index 11fd74896..02cb0de00 100644 --- a/docs/split-stream.md +++ b/docs/split-stream.md @@ -42,7 +42,7 @@ $repositoryManager = new DefaultRepositoryManager( ``` :::note -You can find out more about decorator [here](./message-decorator.md). +You can find out more about the [message decorator](message-decorator.md). ::: :::tip diff --git a/docs/store.md b/docs/store.md index 17e2c476c..a4545f247 100644 --- a/docs/store.md +++ b/docs/store.md @@ -78,17 +78,17 @@ $store = new InMemoryStore(); You can pass messages to the constructor to initialize the store with some events. ::: -### StreamReadOnlyStore +### ReadOnlyStore -Last but not least, we offer a read-only store named `StreamReadOnlyStore`. -It passes all methods to the underlying store, but throws an `StoreIsReadOnly` exception when trying to execute write +Last but not least, we offer a read-only store named `ReadOnlyStore`. +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\StreamStore; +use Patchlevel\EventSourcing\Store\Store; -/** @var StreamStore $store */ +/** @var Store $store */ $readOnlyStore = new ReadOnlyStore($store); ``` ## Schema @@ -121,7 +121,7 @@ $schemaDirector = new DoctrineSchemaDirector( ``` :::note -How to setup cli commands for schema director can be found [here](cli.md). +How to setup [cli commands](cli.md) for the schema director is described in the CLI documentation. ::: #### Create schema @@ -231,7 +231,7 @@ Here you can find more information on how to ::: :::note -How to setup cli commands for doctrine migration can be found [here](cli.md). +How to setup [cli commands](cli.md) for doctrine migrations is described in the CLI documentation. ::: ## Usage @@ -268,16 +268,15 @@ $stream = $store->load( 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; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; $criteria = new Criteria( - new AggregateNameCriterion('profile'), - new AggregateIdCriterion('e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), + new StreamCriterion('profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), new FromPlayheadCriterion(2), new FromIndexCriterion(100), new ArchivedCriterion(true), @@ -290,21 +289,24 @@ Or you can the criteria builder to create the criteria. use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; $criteria = (new CriteriaBuilder()) - ->aggregateName('profile') - ->aggregateId('e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e') + ->streamName('profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e') ->fromPlayhead(2) ->fromIndex(100) ->archived(true) ->events(['profile.created', 'profile.name_changed']) ->build(); ``` +:::tip +A stream name has the format `[aggregateName]-[aggregateId]`. To match every stream of an aggregate, +use a wildcard with `StreamCriterion::startWith('profile-')`. +::: #### 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; +use Patchlevel\EventSourcing\Message\Stream; /** @var Stream $stream */ $stream->index(); // get the index of the stream @@ -319,7 +321,7 @@ foreach ($stream as $message) { ``` :::note -You can find more information about the `Message` object [here](message.md). +You can find more information about the [`Message` object](message.md). ::: :::warning @@ -385,22 +387,24 @@ In event sourcing, the events are immutable. ### Remove -You can remove a stream with the `remove` method. +You can remove streams with the `remove` method by passing a criteria. ```php -use Patchlevel\EventSourcing\Store\StreamStore; +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Store; -/** @var StreamStore $store */ -$store->remove('profile-*'); +/** @var Store $store */ +$store->remove(new Criteria(StreamCriterion::startWith('profile-'))); ``` ### List Streams You can list all streams with the `streams` method. ```php -use Patchlevel\EventSourcing\Store\StreamStore; +use Patchlevel\EventSourcing\Store\Store; -/** @var StreamStore $store */ +/** @var Store $store */ $streams = $store->streams(); // ['profile-1', 'profile-2', 'profile-3'] ``` ### Transaction diff --git a/docs/testing.md b/docs/testing.md index a446f791f..07e8a28ed 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,6 +6,12 @@ a [PHPUnit testing library](https://github.com/patchlevel/event-sourcing-phpunit ## Testing with patchlevel/event-sourcing-phpunit +The helpers in this section live in a separate package that you install as a dev dependency: + +```bash +composer require --dev 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 @@ -245,5 +251,11 @@ final class ProfileTest extends TestCase :::warning The `FakeRamseyUuidFactory` is only for testing purposes -and supports only the version 7 what is used by the library. +and supports only the version 7 which is used by the library. ::: + +## Learn more + +* [How to define aggregates](aggregate.md) +* [How to control time with the clock](clock.md) +* [How to work with identifiers](identifier.md) From 3452ed8d65b691ee9dc2a51d8d5c2218ca70c243 Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 15 Jun 2026 14:16:49 +0200 Subject: [PATCH 2/3] test: allow CommandBus handler layer to depend on Identifier --- phpstan-baseline.neon | 6 ------ tests/Architecture/LayerDependenciesTest.php | 1 + 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f9169e78f..bf453ba17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -24,12 +24,6 @@ parameters: count: 1 path: src/Attribute/Subscriber.php - - - message: '#^Patchlevel\\EventSourcing\\CommandBus\\Handler\\UpdateAggregateHandler should not depend on Patchlevel\\EventSourcing\\Identifier\\Identifier$#' - identifier: phpat.testCommandBusCanOnlyDependOnAllowedLayers - count: 2 - path: src/CommandBus/Handler/UpdateAggregateHandler.php - - message: '#^Patchlevel\\EventSourcing\\CommandBus\\InstantRetryCommandBus should not depend on Patchlevel\\EventSourcing\\Store\\AppendConditionNotMet$#' identifier: phpat.testCommandBusCanOnlyDependOnAllowedLayers diff --git a/tests/Architecture/LayerDependenciesTest.php b/tests/Architecture/LayerDependenciesTest.php index 8deacd148..102b7c7cc 100644 --- a/tests/Architecture/LayerDependenciesTest.php +++ b/tests/Architecture/LayerDependenciesTest.php @@ -44,6 +44,7 @@ public function testCommandBusCanOnlyDependOnAllowedLayers(): Rule $this->layer('Attribute'), $this->layer('Metadata\AggregateRoot'), $this->layer('Repository'), + $this->layer('Identifier'), ], ); } From 315027004caa45a9d7b05024586daf3701cd38be Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 15 Jun 2026 14:24:51 +0200 Subject: [PATCH 3/3] docs: restore feature deep links in README using canonical docs URLs --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 48e15eef7..5d85c3eb1 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ powered by the reliable Doctrine ecosystem and focused on developer experience. ## Features * Everything is included in the package for event sourcing -* Based on doctrine dbal and their ecosystem +* Based on [doctrine dbal](https://github.com/doctrine/dbal) and their ecosystem * Developer experience oriented and fully typed -* Automatic snapshot-system to boost your performance -* Split big aggregates into multiple streams -* Versioned and managed lifecycle of subscriptions like projections and processors -* Safe usage of personal data with crypto-shredding -* Smooth upcasting of old events -* Simple setup with schema management and doctrine migration -* Built in cli commands with symfony -* Dynamic consistency boundary for decisions across streams +* Automatic [snapshot](https://patchlevel.dev/docs/event-sourcing/latest/snapshots)-system to boost your performance +* [Split](https://patchlevel.dev/docs/event-sourcing/latest/split-stream) big aggregates into multiple streams +* Versioned and managed lifecycle of [subscriptions](https://patchlevel.dev/docs/event-sourcing/latest/subscription) like projections and processors +* Safe usage of [personal data](https://patchlevel.dev/docs/event-sourcing/latest/personal-data) with crypto-shredding +* Smooth [upcasting](https://patchlevel.dev/docs/event-sourcing/latest/upcasting) of old events +* Simple setup with [schema management](https://patchlevel.dev/docs/event-sourcing/latest/store) and [doctrine migration](https://patchlevel.dev/docs/event-sourcing/latest/store) +* Built in [cli commands](https://patchlevel.dev/docs/event-sourcing/latest/cli) with [symfony](https://symfony.com/) +* Decisions across streams with a [dynamic consistency boundary](https://patchlevel.dev/docs/event-sourcing/latest/dynamic-consistency-boundary) * and much more... ## Installation