---
title: 'What is New in PHP Event Sourcing 3.12 to 3.19'
date: '2026-06-18'
author: 'david-badura'
tags: ['PHP', 'EventSourcing', 'Release']
contentPreview: 'A lot has happened since our last release post: eight minor releases for patchlevel/event-sourcing. We added an instant retry command bus, subscription cleanup, auto initialized aggregates, PHP 8.5 and Symfony 8 support and much more. Time to catch up on all the highlights from 3.12 to 3.19.'
---

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](/docs/event-sourcing/latest) went from
version [3.12.0](https://github.com/patchlevel/event-sourcing/releases/tag/3.12.0) all the way to
version [3.19.0](https://github.com/patchlevel/event-sourcing/releases/tag/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:

```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]
);
```

To opt a command into this behavior, you mark it with the `#[InstantRetry]` attribute. You can also override the
defaults per command:

```php
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](https://github.com/patchlevel/event-sourcing-bundle), the defaults can be configured globally:

```yaml
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](/docs/event-sourcing/latest/command-bus#instant-retry) and
the [bundle configuration](/docs/event-sourcing-bundle/latest/configuration#instant-retry). Also part of this release:
a fix for quoting issues with MySQL databases, thanks to a contribution by
[@robinlehrmann](https://github.com/patchlevel/event-sourcing/pull/744).

## 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](/docs/event-sourcing/latest/testing) more reliable, because your test setup
no longer diverges from production behavior. Thanks to [@ergosarapu](https://github.com/patchlevel/event-sourcing/pull/790)
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:

```yaml
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](/docs/event-sourcing-bundle/latest/configuration#data-migration). 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](https://github.com/patchlevel/event-sourcing/pull/812). 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](/docs/event-sourcing/latest/subscription#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:

```php
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](/docs/event-sourcing/latest/subscription#cleanup) 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](/docs/event-sourcing/latest/subscription#refresh) on the subscription engine updates them:

```php
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](/docs/event-sourcing/latest/aggregate#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:

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

You can read more about it in the [shared apply context docs](/docs/event-sourcing/latest/aggregate#shared-apply-context).

## 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](/docs/event-sourcing/latest/aggregate#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:

```php
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](/docs/event-sourcing/latest/command-bus#union-types) or even an interface, and the library
detects the commands automatically:

```php
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](/docs/event-sourcing/latest/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](https://github.com/patchlevel/event-sourcing/pull/828) 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](https://github.com/patchlevel/event-sourcing) or start
a [discussion](https://github.com/patchlevel/event-sourcing/discussions) there. The next release is already in the
pipeline, so stay tuned.
