It has been a while since our last release post, and quite a lot has happened in the meantime. Our PHP
library patchlevel/event-sourcing went from
version 3.12.0 all the way to
version 3.19.0. Instead of writing eight separate
posts, we are catching up in one go and walking through the highlights of each release.
3.12: Instant Retry Command Bus
When you use optimistic locking, it can happen that two processes load the same aggregate at the same time and one of
them fails on save with an AggregateOutdated exception. In most cases, simply retrying the command fixes the problem,
because the aggregate is then loaded fresh from the store.
That's exactly what the new InstantRetryCommandBus decorator does. You wrap your existing command bus and define how
often a command should be retried and which exceptions should trigger a retry:
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]);
To opt a command into this behavior, you mark it with the #[InstantRetry] attribute. You can also override the
defaults per command:
use Patchlevel\EventSourcing\Attribute\InstantRetry;use Patchlevel\EventSourcing\Repository\AggregateOutdated;#[InstantRetry(3, [AggregateOutdated::class])]final class ChangeProfileName{ public function __construct( public readonly ProfileId $id, public readonly string $name, ) { }}
In the bundle, the defaults can be configured globally:
The InMemoryStore is mostly used in tests, and it now behaves much closer to the real database stores. It supports
more criteria for loading events and the messages now carry the Index, EventId and RecordedOn headers, just like
the Doctrine stores. This makes testing more reliable, because your test setup
no longer diverges from production behavior. Thanks to @ergosarapu
for spotting and fixing an off-by-one issue in the InMemoryStore index right after the release.
3.14: PHP 8.5 and Symfony 8 Support
A short but important one: the library now officially supports PHP 8.5 and Symfony 8. Both are part of our test
matrix, so you can upgrade your stack without waiting for us.
3.15: Store Migration Command in Core
The StoreMigrateCommand moved from the integration repositories into the core library. It allows you to migrate all
events from your current store to a new one, for example from the aggregate-based store to the stream store, while
translating the events on the way. In the bundle, you define the target store and the translators, and you get a new
event-sourcing:store:migrate CLI command:
More about this in the data migration docs. We also
deprecated the SubscriberHelper and SubscriberUtil classes; using a simple constant for the subscriber id is the
better way to go.
3.16: Custom Argument Resolvers
This release was driven entirely by a community contribution from
@fritz-gerneth. The subscriber ArgumentMetadata now exposes
whether an argument is nullable, which makes it possible to build smarter custom argument resolvers for your
subscribers. On top of that, the documentation now has a dedicated section on
implementing custom resolvers. Thank you for that!
3.17: Subscription Cleanup, Refresh and Shared Apply Context
This was one of the bigger releases, with three features around long-term maintainability.
Subscription Cleanup
Until now, removing a subscription required the subscriber code to still exist, because the teardown method had to be
executed. That's annoying: you want to delete a projector from your codebase, but you have to keep it around until the
subscription is removed everywhere.
The new cleanup method solves this. It is called when the subscription is created, and the returned tasks are
stored in the subscription store. When the subscriber is later removed from the code, the engine executes the stored
tasks instead of calling code that no longer exists:
use Patchlevel\EventSourcing\Attribute\Cleanup;use Patchlevel\EventSourcing\Attribute\Projector;use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropTableTask;#[Projector(self::TABLE)]final class ProfileProjector{ private const TABLE = 'profile_v1'; #[Cleanup] public function cleanup(): array { return [new DropTableTask(self::TABLE)]; } // ...}
Out of the box we provide DropTableTask and DropIndexTask for doctrine/dbal, and the whole mechanism is
extensible, so you can write your own tasks and handlers, for example for MongoDB. See
the cleanup docs for details. Since 3.18, the dbal cleanup task
handler also accepts a connection registry, so the tasks work in multi-connection setups too.
Refresh Subscriptions
If you change the metadata of a subscriber, like the runMode, the group or the new cleanup tasks, the existing
subscriptions in the store don't know about it. The new
refresh method on the subscription engine updates them:
use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine;use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria;/** @var SubscriptionEngine $subscriptionEngine */$subscriptionEngine->refresh(new SubscriptionEngineCriteria());
Shared Apply Context
When working with micro aggregates, events from one stream
are often applied by different aggregates. An aggregate may then receive events it does not handle itself, which led to
a lot of false "missing apply" warnings. With the new #[SharedApplyContext] attribute, you declare which aggregates
share an apply context, and a missing apply is only reported if none of them handles the event:
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{ // ...}
3.18: Auto Initialize and More Flexible Command Handlers
Auto Initialize
Sometimes you want to dispatch a command against an aggregate that may not exist yet, and you don't care: it should
simply be created on the fly. The new experimental auto initialize
feature does exactly that. If the aggregate cannot be found in the store, the repository calls the method marked with
#[AutoInitialize] instead of throwing an exception:
use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;use Patchlevel\EventSourcing\Aggregate\Uuid;use Patchlevel\EventSourcing\Attribute\Aggregate;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; } // ...}
Union Types and Inheritance for Command Handlers
The command bus got more flexible: a handler can now handle multiple commands. You can use multiple #[Handle]
attributes, union types or even an interface, and the library
detects the commands automatically:
use Patchlevel\EventSourcing\Attribute\Handle;final class ProfileHandler{ #[Handle] public function __invoke(CreateProfile|UpdateProfile $command): void { // handle both commands }}
3.19: Cipher Key Store for the Hydrator Extension
If you handle personal data with crypto shredding, you need a place to
store the encryption keys. With this release, we added a Doctrine-based cipher key store for the new hydrator extension
system, so you can use crypto shredding with the new extension-based hydrator without writing your own key store. On
top of that, @DanielBadura switched the PostgreSQL listen/notify
integration to the new Pdo\Pgsql::getNotify API on PHP 8.4 and newer.
Conclusion
Eight releases, one theme: making event sourcing easier to operate in the long run. Retrying outdated aggregates,
cleaning up removed projections, migrating stores and auto initializing aggregates all remove friction from day-to-day
work with the library. A big thank you to everyone who contributed code, issues and documentation. As always, we would
love to hear your feedback, so don't hesitate to open an issue on
GitHub or start
a discussion there. The next release is already in the
pipeline, so stay tuned.