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.
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.
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.
#[Event('hotel.created')] final readonly class HotelCreated { public function __construct( public Uuid $hotelId, public string $name, ) {} }
Build business logic
Aggregates capture changes as events and define business rules. Call recordThat and patchlevel handles persistence, replay, and ordering for you.
#[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; } }
Persist & load
Save the aggregate. Load it back later and its full event history is replayed automatically to reconstruct state.
$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);
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.
#[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, ]); } }
More than just persistence
The two questions we hear most often about event sourcing.
Why should I use event sourcing?
- Complete business history.
Every decision and state change is preserved. - Answer tomorrow's questions.
Create new reports and projections from past events. - Built-in audit trail.
Trace who did what and when. - Faster debugging and testing.
Reproduce incidents and verify business behavior with real event streams.
When can I use event sourcing?
- When business logic is expected to grow.
Handles increasing complexity better than evolving CRUD models. - When you need a complete history of changes.
Every business action is stored and remains traceable. - When reporting and analytics matter.
Historical events enable insights beyond the current state. - When business processes are naturally event-driven.
Models real-world facts and state changes directly.
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.
#[Processor('new-guest-mail')] final class NewGuestProcessor { #[Subscribe(GuestCheckedIn::class)] public function onGuestCheckedIn(GuestCheckedIn $event): void { $this->mailer->send( 'new-guest.html', [ 'name' => $event->guestName, // more ], ); } #[OnFailed] public function onFailed(Message $message, Throwable $e): void { $this->deadLetterQueue->push($message, $e); } }
GDPR without rewriting history
Mark personal fields with an attribute. The hydrator encrypts them and keeps the keys in a separate store. Delete a subject's key and their data is unreadable forever. The event store stays untouched, the history intact, the audit trail preserved.
#[Event('customer.registered')] final readonly class CustomerRegistered { public function __construct( #[DataSubjectId] public Uuid $id, #[PersonalData(fallback: 'anon@patchlevel.dev')] public string $email, #[PersonalData] public string|null $name, ) {} } // later - right-to-be-forgotten $cipherKeyStore->remove($customerId);
Fast by default
patchlevel/hydrator. The encryption layer adds no measurable hot-path overhead.Attribute-driven
Audit-compliant
Beyond a single aggregate
Real-world business rules often span multiple aggregates. Two complementary patterns let you model shared consistency boundaries. One is production-ready today, the other is not released yet.
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('booking')] final class Booking extends BasicAggregateRoot { // ... } #[Aggregate('booking_payment')] #[Stream(Booking::class)] final class BookingPayment extends BasicAggregateRoot { // ... }
Dynamic Consistency Boundary
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, ) {} }
Tools for growing systems
Everything you need to manage complex event streams at scale, in one cohesive library.
Upcasting & Store Migration
Modern Hydrator
Snapshots
DBAL Storage
- PostgreSQL
- MySQL
- MariaDB
- SQLite
Built for Testing
First-class "Given, When, Then" with in-memory stores. Lightning-fast unit tests for business logic.
$this->given(new HotelCreated($id, 'Grand Budapest')) ->when(new CheckInGuest($id, 'Zero')) ->then(new GuestCheckedIn($id, 'Zero'));
Split Stream
Static Analysis First
Command & Query Bus
Built-in command and query handling with attribute-driven handlers in your aggregates or projections. Fully compatible with Symfony Messenger.
Deep Framework Integration
Stop wasting time on boilerplate. Our integrations handle the plumbing so you can focus on your business logic.
Symfony Bundle
patchlevel/event-sourcing-bundle
Drop the bundle in and every repository, store, and subscriber wires itself into the container. Messenger, Doctrine, and the profiler are 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 with zero config on install
$ composer require patchlevel/event-sourcing-bundle
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
$ composer require patchlevel/laravel-event-sourcing
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 is 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 and 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 such as 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, whether that is 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, whether that is 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.