What is New in PHP Event Sourcing 3.12 to 3.19

David Badura
David Badura
· 7 min read
PHPEventSourcingRelease

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:

patchlevel_event_sourcing:
    command_bus:
        instant_retry:
            default_max_retries: 3
            default_exceptions:
                - Patchlevel\EventSourcing\Repository\AggregateOutdated

You can find all the details in the instant retry docs and the bundle configuration. Also part of this release: a fix for quoting issues with MySQL databases, thanks to a contribution by @robinlehrmann.

3.13: Better InMemoryStore

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:

patchlevel_event_sourcing:
    store:
        migrate_to_new_store:
            type: 'dbal_stream'
            options:
                table_name: 'my_stream_store'
            translators:
              - Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator

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
{
    // ...
}

You can read more about it in the shared apply context docs.

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.

Written by
David Badura
David Badura
Software Engineer

Other recent posts

RSS