The foundation for
event-driven domains

Build systems that remember every change. patchlevel gives PHP teams production-ready event sourcing - append-only history, reliable async projections, GDPR-aware operations, and first-class Symfony and Laravel integrations.

300k+
Packagist installs
MIT
Open source, forever
PHP 8.2+
Modern, typed, fast
5ms
to replay 1,000 events
Quickstart

How it works

Four moving parts. Events are the truth, the store is the log, the subscription engine fans events out, projections build read models. That's the whole picture.

Step

Define an event

Pure PHP classes. No base class, no magic - just immutable data with one attribute that gives it a name in your store.

src/Event/HotelCreated.php
#[Event('hotel.created')]
final class HotelCreated
{
    public function __construct(
        public Uuid $hotelId,
        public string $name,
    ) {}
}
Step

Build business logic

Aggregates capture changes as events and defines business rules. Call recordThat - patchlevel handles persistence, replay, and ordering for you.

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

    public static function create(Uuid $hotelId, string $name): self
    {
        $self = new self();
        $self->recordThat(new HotelCreated($hotelId, $name));

        return $self;
    }

    #[Apply]
    public function onHotelCreated(HotelCreated $event): void
    {
        $this->hotelId = $event->hotelId;
    }
}
Step

Persist & load

Save the aggregate. Load it back later - its full event history is replayed automatically to reconstruct state.

example.php
$hotelId = Uuid::generate();
$hotel = Hotel::create($hotelId, 'Patchlevel HQ');

// save the aggregate
$repository = $repositoryManager->get(Hotel::class);
$repository->save($hotel);

// load it back - events are replayed automatically
$hotel = $repository->load($hotelId);
Step

React to events

Subscribers react to events asynchronously to build read models, fire side effects, or feed external systems - versioned, gap-detected, retried on failure.

src/Projection/HotelProjection.php
#[Projector('hotels')]
final class HotelProjection
{
    #[Subscribe(HotelCreated::class)]
    public function onHotelCreated(HotelCreated $event): void
    {
        $this->db->insert('hotels', [
            'id' => $event->hotelId->toString(),
            'name' => $event->name,
        ]);
    }
}
Why & when

More than just persistence

The two questions we hear most often about event sourcing. Here's our honest take on each.

Projections & processors

The Subscription Engine

The Subscription Engine powers reliable event processing for projections, processors, and asynchronous workflows. It manages subscriber execution, replay, and recovery, ensuring events are processed consistently and at scale.

  • Subscriber lifecycle management

    Boot, replay, catch up, pause, reactivate, or retire subscribers with a fully managed lifecycle.

  • Reliable event processing

    Gap detection, retry strategies, and failure handling ensure events are processed consistently, even under load.

  • Replay & rebuild

    Rebuild projections, rerun processors, or migrate data directly from the event stream.

  • Versioned subscribers

    Deploy new subscriber versions alongside existing ones and migrate safely using blue-green deployment strategies.

  • High-performance batching

    Process events in batches for significantly faster catch-ups and rebuilds.

  • Flexible execution modes

    Run subscriptions from the beginning, from now, or as a one-time execution.

Process million-event projections without losing your weekend.

src/Processor/NewGuestProcessor.php
#[Processor('new-guest-mail')]
final class NewGuestProcessor
{
    #[Subscribe(GuestCheckedIn::class)]
    public function onGuestCheckedIn(GuestCheckedIn $event): void
    {
        $this->mailer->send('new-guest.html', [...]);
    }

    #[OnFailed]
    public function onFailed(Throwable $e): void
    {
        $this->deadLetterQueue->push($e);
    }
}
$ bin/console event-sourcing:subscription:status
-------------------------------------- ----------- ---------------- ---------- ----------
id group run mode position status
-------------------------------------- ----------- ---------------- ---------- ----------
new_guest_mail processor from_now 1337 active
new_guest_mail processor from_now 1337 active
hotel_list_v3 projector from_beginning 926 detached
hotel_list_v4 projector from_beginning 1337 active
guest_list_v1 projector from_beginning 1244 paused
new_year_24_market_mail processor from_beginning 193 detached
-------------------------------------- ----------- ---------------- ---------- ----------
Sensitive data

GDPR without rewriting history

Mark fields with attributes. The encryption keys are stored in a different store. Forget the encryption key. That user's data is unreadable forever - event store untouched, history intact, audit trail preserved.

src/Event/CustomerRegistered.php
#[Event('customer.registered')]
final class CustomerRegistered
{
    public function __construct(
        #[SubjectId]
        public Uuid $id,
        #[PersonalData(fallback: 'anon@patchlevel.dev')]
        public string $email,
        #[PersonalData]
        public string $name,
    ) {}
}

// later - right-to-be-forgotten
$cipherKeyStore->remove($customerId);

Fast by default

Powered by patchlevel/hydrator - encryption layer adds no measurable hot-path overhead.

Attribute-driven

Add attributes, the hydrator handles the rest: encrypt on write, decrypt on read. Keys saved in separate store.

Audit-compliant

Right-to-be-forgotten without breaking your source of truth. The event log stays - just unreadable for shredded subjects.
Consistency boundaries

Beyond a single aggregate

Real-world business rules often span multiple aggregates. Two complementary patterns let you model shared consistency boundaries - one production-ready today, one shaping the future.

Micro Aggregates

Split a complex aggregate into focused roots that share a single event stream via the #[Stream] attribute. Each root stays small and intentional, while related entities remain transactionally consistent.

#[Aggregate('order')]
final class Order 
{
    // ...
}

#[Aggregate('order-payment')]
#[Stream(Order::class)]
final class OrderPayment 
{
    // ...
}

Dynamic Consistency Boundary

Coming Soon

Make consistent cross-stream decisions without loading any aggregate at all. Tag events with #[EventTag] and build minimal, purpose-built state from just the events that matter - with an optimistic append condition to keep it race-free.

#[Event('hotel.guest_checked_in')]
final readonly class GuestIsCheckedIn
{
    public function __construct(
        #[EventTag(prefix: 'hotel')]
        public Uuid $hotelId,
        #[EventTag(prefix: 'guest')]
        public string $guestName,
    ) {}
}
And much more

Tools for growing systems

Everything you need to manage complex event streams at scale, in one cohesive library.

Upcasting & Store Migration

Events evolve as your business does. Transform old payloads on the fly with upcasters, or permanently rewrite history into a new store when you're ready to retire legacy formats.

Modern Hydrator

Typed, fast object hydration designed specifically for events. Powers GDPR shredding and message decoration with zero hot-path overhead.

Snapshots

Automatic, cache-backed snapshots for long-lived aggregates. Replays stay fast even as event history grows - opt in per aggregate when you actually need it.

DBAL Storage

We ship Doctrine DBAL store out of the box. With this many platforms are supported.
  • PostgreSQL
  • MySQL
  • MariaDB
  • SQLite
You need another storage? You can create a custom store for Kurrent or any other technology - clean interfaces make it straightforward.

Built for Testing

First-class "Given, When, Then" with in-memory stores. Lightning-fast unit tests for business logic.

$this->given([new HotelCreated(...)]);
$this->when(new CheckInGuest(...));
$this->then([new GuestCheckedIn(...)]);

Split Stream

Long-lived aggregates that stay fast forever. Archive past lifecycle phases - billing cycles, seasons, contract years - and load only the active slice while keeping full history.

Static Analysis First

Deep PHPStan and Psalm integration. Type-safe repositories, custom rules, and analysis that catches mistakes before tests run.

Command & Query Bus

Built-in command and query handling with attribute-driven handlers in your aggregates or projections. Fully compatible with Symfony Messenger.

Fits your Stack

Deep Framework Integration

Stop wasting time on boilerplate. Our integrations handle the plumbing so you can focus on your business logic.

Symfony

Symfony Bundle

patchlevel/event-sourcing-bundle

Drop the bundle in and every repository, store, and subscriber wires itself into the container. Messenger, Doctrine, the profiler - all integrated.

  • Autowiring for repositories, subscribers, listeners, upcasters & decorators
  • Symfony Messenger as command, query & event bus
  • Console commands for replay, schema & subscription lifecycle
  • Doctrine migrations for the event store schema
  • Snapshot store via Symfony Cache (any PSR-6 pool)
  • Flex recipe - zero-config on install
Terminal
$ composer require patchlevel/event-sourcing-bundle
Laravel

Laravel Package

patchlevel/laravel-event-sourcing

A native Laravel feel. The service provider auto-registers everything, Artisan picks up the commands, and facades give you easy access.

  • Service provider with auto-discovery & binding
  • Artisan commands for replay, schema & subscription lifecycle
  • Facades for easy access to event sourcing services
  • Publishable config & migrations
  • Middleware for HTTP-aware features
  • Plays nicely with existing Eloquent-driven code
Terminal
$ composer require patchlevel/laravel-event-sourcing
FAQ

Frequently asked questions

The short version of what developers ask us before they start event sourcing with patchlevel.

Is the library free to use?

Yes - and that's our promise: it will never cost anything. The event sourcing library and all integrations are open source under the MIT license, with no paid tiers and no feature gates - not today, not in the future. Every feature you see here, from snapshots to subscriptions, is and stays free. If you want hands-on help, the patchlevel team also offers professional consulting and training.

What about CQRS?

Event sourcing and CQRS are a natural fit, and the library supports both: aggregates handle your writes, while subscriptions build dedicated read models (projections) optimized for your queries. You can wire in any command bus - like Symfony Messenger - or none at all. CQRS is supported, not forced.

Do I need a special event store database?

No. The event store runs on the relational database you already operate - PostgreSQL, MariaDB, MySQL, or SQLite - powered by the battle-tested Doctrine DBAL. No extra infrastructure to deploy, back up, or monitor.

Do I have to event-source my whole application?

No. You can adopt event sourcing per aggregate, exactly where the business value lives - your orders, bookings, or payments - and keep simple CRUD for the rest. The library coexists peacefully with Doctrine ORM, Eloquent, and existing code.

What happens when my events need to change?

Software evolves, and so do events. Upcasters transform old event payloads on the fly when they are loaded, so you never have to migrate the event store itself. Your history stays immutable while your code moves forward.

How stable is the library?

Very. We are 100% committed to semantic versioning: no breaking changes within a major version, ever. Deprecations are announced ahead of time and every upgrade path is documented, so updating stays painless. And you are not on your own - we actively maintain the library and provide support on GitHub, with professional support available if you need more.

What if my framework is not supported?

No problem. The core library is plain PHP with no framework dependency, so it works in any project - Slim, Laminas, CodeIgniter, or no framework at all. If you use Symfony or Laravel, official integrations give you a head start: a Symfony bundle with autowiring, Messenger, and Doctrine migrations, and a Laravel package with auto-discovery and Artisan commands.

Where do I get help?

Start with the documentation - it covers everything from your first aggregate to advanced topics like snapshots and subscriptions. For questions and bug reports, the team is active on GitHub. And if you need deeper support, patchlevel offers consulting, code reviews, and workshops.

Ready to build?

Get started in five minutes. The docs walk you through your first aggregate, event, and projection.