Symfonypatchlevel/event-sourcing-bundle

Event Sourcing for Symfony

The official Symfony bundle for patchlevel/event-sourcing. Autowired repositories, Symfony Messenger as command and query bus, Doctrine migrations for the event store, and console commands for the whole projection lifecycle - event sourcing that feels like Symfony.

Terminal
$ composer require patchlevel/event-sourcing-bundle

# the Flex recipe sets up the configuration
$ bin/console event-sourcing:database:create
$ bin/console event-sourcing:schema:create
Flex
Zero-config recipe on install
Messenger
Command, query & event bus
Doctrine
DBAL store & migrations
MIT
Open source, forever
Quickstart

Event sourcing, the Symfony way

Define events and aggregates as plain PHP, then let the bundle do the wiring: repositories are autowired, subscribers are autoconfigured, and every service is a first-class citizen of the container.

Step

Define an event

Pure PHP classes with a single attribute - no base class, no framework coupling. Your domain model stays clean and testable.

src/Hotel/Domain/Event/GuestIsCheckedIn.php
#[Event('hotel.guest_is_checked_in')]
final class GuestIsCheckedIn
{
    public function __construct(
        public readonly string $guestName,
    ) {}
}
Step

Model the aggregate

Business rules live in the aggregate. Record events with recordThat - the library replays them to rebuild state, in Symfony and everywhere else.

src/Hotel/Domain/Hotel.php
#[Aggregate('hotel')]
final class Hotel extends BasicAggregateRoot
{
    #[Id]
    private Uuid $id;

    /** @var list<string> */
    private array $guests = [];

    public function checkIn(string $guestName): void
    {
        if (in_array($guestName, $this->guests, true)) {
            throw new GuestAlreadyCheckedIn($guestName);
        }

        $this->recordThat(new GuestIsCheckedIn($guestName));
    }

    #[Apply]
    protected function applyGuestIsCheckedIn(GuestIsCheckedIn $event): void
    {
        $this->guests[] = $event->guestName;
    }
}
Step

Autowire the repository

Type-hint Repository<Hotel> in any controller or service and the bundle injects the right repository. Load, call business methods, save - persistence and replay are handled for you.

src/Hotel/Infrastructure/Controller/HotelController.php
#[AsController]
final class HotelController
{
    /** @param Repository<Hotel> $hotelRepository */
    public function __construct(
        private readonly Repository $hotelRepository,
    ) {}

    #[Route('/hotel/{hotelId}/check-in', methods: ['POST'])]
    public function checkIn(Uuid $hotelId, Request $request): JsonResponse
    {
        $hotel = $this->hotelRepository->load($hotelId);
        $hotel->checkIn($request->getPayload()->get('name'));
        $this->hotelRepository->save($hotel);

        return new JsonResponse();
    }
}
Step

React with plain services

Projectors and processors are regular Symfony services - inject the Mailer, a DBAL connection, or anything else. Autoconfiguration registers them with the subscription engine.

src/Hotel/Application/Processor/SendCheckInEmailProcessor.php
#[Processor('admin_emails')]
final class SendCheckInEmailProcessor
{
    public function __construct(
        private readonly MailerInterface $mailer,
    ) {}

    #[Subscribe(GuestIsCheckedIn::class)]
    public function onGuestIsCheckedIn(GuestIsCheckedIn $event): void
    {
        $this->mailer->send(
            (new Email())
                ->to('hq@patchlevel.de')
                ->subject('Guest is checked in')
                ->text(sprintf('"%s" checked in', $event->guestName)),
        );
    }
}
Deep integration

Built for the Symfony ecosystem

Not a generic library with a thin adapter. The bundle plugs event sourcing into the tools you already use every day.

Flex Recipe

composer require and you are done. The Symfony Flex recipe creates the configuration and registers the bundle - zero boilerplate before your first aggregate.

Autowiring & Autoconfiguration

Repositories, subscribers, listeners, upcasters, and message decorators wire themselves into the container. Type-hint and go.

Symfony Messenger

Use Messenger as command and query bus. Aggregates handle commands directly via the #[Handle] attribute, with instant-retry support built in.

Console Commands

Schema management, database setup, event debugging, and the full subscription lifecycle - boot, run, replay, pause, reactivate - as bin/console commands.

Doctrine Migrations

The event store schema is managed with Doctrine migrations: event-sourcing:migrations:diff and migrate keep every environment in sync.

Snapshots via Symfony Cache

Speed up long-lived aggregates with automatic snapshots stored in any PSR-6 cache pool - Redis, APCu, or whatever your framework.cache config provides.
CQRS with Messenger

Commands handled by your aggregates

Point the bundle at your Messenger buses and aggregates become command handlers. No handler classes full of load-call-save boilerplate - dispatch a command and the bundle loads the aggregate, invokes the method, and saves the recorded events.

  • Command bus

    Mark aggregate methods with #[Handle] - the bundle registers them as Messenger handlers, including aggregate loading and saving.

  • Query bus

    Answer queries from services with the #[Answer] attribute and dispatch them through a dedicated query bus.

  • Instant retry

    Optimistic-lock conflicts like AggregateOutdated are retried automatically with a configurable strategy.

config/packages/messenger.yaml
framework:
    messenger:
        default_bus: command.bus
        buses:
            command.bus: ~
            query.bus: ~
config/packages/patchlevel_event_sourcing.yaml
patchlevel_event_sourcing:
    command_bus:
        service: command.bus
    query_bus:
        service: query.bus
Batteries included

The full power of the core library

The integration ships everything from patchlevel/event-sourcing - production features you would otherwise build yourself.

FAQ

Frequently asked questions

Everything you need to know about event sourcing in Symfony with patchlevel.

How do I add event sourcing to a Symfony application?

Run composer require patchlevel/event-sourcing-bundle. The Symfony Flex recipe registers the bundle and creates the default configuration, so you can define your first aggregate right away. The event store schema is created with bin/console event-sourcing:schema:create or via Doctrine migrations.

Does the bundle integrate with Symfony Messenger?

Yes. You can use Symfony Messenger as command bus and query bus. Aggregates and services handle commands via the #[Handle] attribute, queries via #[Answer] - the bundle registers the handlers with Messenger automatically.

Which databases does the event store support?

The event store is built on Doctrine DBAL and supports PostgreSQL, MySQL, MariaDB, and SQLite out of the box. Custom stores for other technologies can be implemented against clean interfaces.

Can I use Doctrine ORM next to event sourcing?

Yes. Event-sourced aggregates and Doctrine entities coexist in the same application. Projections often write read models as plain database tables or Doctrine entities, and the bundle manages the event store schema with Doctrine migrations.

How do I rebuild projections in Symfony?

Projections are managed by the subscription engine. Console commands like bin/console event-sourcing:subscription:boot and event-sourcing:subscription:run let you set up, replay, and rebuild projections directly from the event stream - including blue-green deployments of new projection versions.

Is patchlevel/event-sourcing-bundle production ready?

Yes. The patchlevel libraries have over 300,000 Packagist installs, are MIT licensed, fully typed for PHPStan and Psalm, and power production systems with GDPR-compliant crypto-shredding, snapshots, and a managed subscription lifecycle.

Start event sourcing in Symfony

Install the bundle and build your first aggregate, event, and projection in minutes - the getting-started guide walks you through it.