twanhaverkamp / event-sourcing-with-php
Event Sourcing with PHP
Requires
- php: ^8.3
- ramsey/uuid: ^4.7
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5
- squizlabs/php_codesniffer: ^3.11
README
Table of Contents
What problem does Event Sourcing solve for you?
You not only want to know what the current state of an object is, but you also want to know how the object got in this state? In that case Event Sourcing might be the solution to your problem!
Compare data structures
CRUD with relations:
ID | Number | Subtotal | Tax | Total | CreatedAt | PaymentDueAt |
---|---|---|---|---|---|---|
1 | 12-34 | 22.80 | 3.75 | 16.55 | 2025-02-01 | 2025-03-01 |
ID | Invoice ID | Reference | Description | Quantity | Price | Tax |
---|---|---|---|---|---|---|
1 | 1 | prod.123.456 | Product | 3 | 5.95 | 21.00 |
2 | 1 | Shipping | 1 | 4.95 | 0.00 |
ID | Invoice ID | PaymentMethod | Amount | Status |
---|---|---|---|---|
1 | 1 | Manual | 10.00 | Completed |
Event sourced:
AggregateRootId | Event | Payload | RecordedAt |
---|---|---|---|
01941d8f-995... | invoice-was-created | {"number": "12-34", "items": []} | 2025-02-01 |
01941d8f-995... | payment-transaction-was-started | {"id": 1, amount": 10.00} | 2025-02-01 |
01941d8f-995... | payment-transaction-was-completed | {"id": 1} | 2025-02-01 |
Your read models can, of course, still be stored in relational tables as illustrated above, but your Aggregate will be built based on the stored Events.
Drawbacks
While Event Sourcing may solve problems, it also brings some challenges with it:
- Learning curve; When shifting from CRUD to Event Sourcing, you may experience a steep learning curve.
- Potentially slow; Especially when your aggregate has a long life cycle.
Considerations
Since this is supposed to be a lightweight library you will have to come up (for now) with a solution for the following:
- Snapshots; Cache your aggregate with a "Snapshot event" to reduce loading time.
- Projections; For working with read models.
- Anonymize; Protect (privacy) sensitive data.
Yes, I'm planning to implement these features soon™, but until then it's up to you. 😅
Components
Aggregate
The Aggregate encapsulates business logic and its public methods reflect your domain.
AggregateRootId
The AggregateRootId is the Aggregate's unique identifier, which is instantiated before the Aggregate is being created.
Available:
- UUID v7; wraps the ramsey/uuid library.
Event
An Event changed one or multiple properties of your Aggregate. New property values are "stored" in its payload and will be re-applied when the Aggregate is being rebuilt from storage.
As an Event took place in the past, it's considered good practice to reflect this when naming your Events.
EventStore
The EventStore is an interesting one. Instead of fetching an Aggregate directly from your storage you query it's related Events with the AggregateRootId sorted by their "recordedAt" value in ascending order. Each Event will be applied to the Aggregate, which eventually will get in it's expected state.
Available:
- Redis (not recommended)
Usage
Installation
Requirements:
- PHP 8.3 (or higher)
If you're using Composer in your project you can run the following command:
composer require twanhaverkamp/event-sourcing-with-php:^1.0
Implementation
To understand how to implement this library in your project I would encourage you to take a look at the /example directory and specifically the Invoice class as it represents an aggregate containing both business logic and the usage of events.
You'll see some
// ...
in the code snippets. It indicates there's more code, but it's not relevant for the given example.
Create an Aggregate class
Add public methods for your business logic, where their names reflect your domain.
<?php // ... use DateTimeImmutable; use DateTimeInterface; use TwanHaverkamp\EventSourcingWithPhp\Aggregate; use TwanHaverkamp\EventSourcingWithPhp\Aggregate\AggregateRootId; use TwanHaverkamp\EventSourcingWithPhp\Event; class Invoice extends Aggregate\AbstractAggregate { public string $number; /** * @var DTO\Item[] */ public array $items; /** * @var DTO\PaymentTransaction[] */ public array $paymentTransactions; public DateTimeInterface $createdAt; public static function init(string $aggregateRootId): self { return new self( AggregateRootId\Uuid7::fromString($aggregateRootId), ); } public static function create(string $number, DTO\Item ...$items): self { $invoice = new self($aggregateRootId = new AggregateRootId\Uuid7()); $this->number = $event->number; $this->items = $event->items; $this->createdAt = new DateTimeImmutable(); return $invoice; } public function startPaymentTransaction(string $paymentMethod, float $amount): DTO\PaymentTransaction { // ... } // ... }
I would recommend you to add a static
init
method that expects a string value as AggregateRootId. This method returns an empty Aggregate with the correct AggregateRootId instance type that you can use to pass to an EventStore’sload
function.
Create your Event classes
For every method that affects the Aggregate you create an Event class.
<?php // ... use DateTimeInterface; use TwanHaverkamp\EventSourcingWithPhp\Aggregate\AggregateRootId; use TwanHaverkamp\EventSourcingWithPhp\Event; readonly class InvoiceWasCreated extends Event\AbstractEvent { /** * @param DTO\Item[] $items */ public function __construct( AggregateRootId\AggregateRootIdInterface $aggregateRootId, public string $number, public array $items, public DateTimeInterface $createdAt, ) { parent::__construct($aggregateRootId, $createdAt); } public static function fromPayload( AggregateRootId\AggregateRootIdInterface $aggregateRootId, array $payload, DateTimeInterface $recordedAt, ): self { return new self( $aggregateRootId, (string)$payload['number'], array_map(fn (array $item) => DTO\Item::fromArray($item), $payload['items']), $recordedAt, ); } public function getPayload(): array { return [ 'number' => $this->number, 'items' => array_map(fn (DTO\Item $item) => $item->toArray(), $this->items), ]; } }
The
getPayload
andfromPayload
methods are used by the EventStore to store- and load an Event. An Event is supposed to be immutable and therefore should be readonly.
Record Events with your Aggregate
Add an apply[event-name]
method for every Event and replace your domain logic with a recordThat
call.
Move the domain logic to its designated apply[event-name]
method.
<?php use DateTimeImmutable; use TwanHaverkamp\EventSourcingWithPhp\Aggregate; use TwanHaverkamp\EventSourcingWithPhp\Aggregate\AggregateRootId; use TwanHaverkamp\EventSourcingWithPhp\Event; use TwanHaverkamp\EventSourcingWithPhp\Event\Exception; class Invoice extends Aggregate\AbstractAggregate { // ... public static function create(string $number, DTO\Item ...$items): self { $invoice = new self($aggregateRootId = new AggregateRootId\Uuid7()); $invoice->recordThat(new InvoiceWasCreated( $aggregateRootId, $number, $items, new DateTimeImmutable() )); return $invoice; } // ... public function apply(Event\EventInterface $event): void { match ($event::class) { InvoiceWasCreated::class => $this->applyInvoiceWasCreated($event), // ... default => throw new Exception\EventNotSupportedException( message: sprintf( 'Event "%s" is not supported by "%s" aggregate.', $event::class, $this::class, ), ), }; } // ... private function applyInvoiceWasCreated(InvoiceWasCreated $event): void { $this->number = $event->number; $this->items = $event->items; $this->createdAt = clone $event->createdAt; } // ... }
The
recordThat
method is part of the AbstractAggregate class and requires you to add anapply
method that receives an Event as argument. With a match you can map each Event to the correctapply[event-name]
method.
Save the Aggregate
This requires you to create your own EventStore that implements the EventStoreInterface.
<?php // ... use TwanHaverkamp\EventSourcingWithPhp\Event\EventStore; // ... $invoice = Invoice::create('12-34', new DTO\Item('prod.123.456', 'Product', 3, 5.95, 21.), new DTO\Item(null, 'Shipping', 1, 4.95, 0.), ); $invoice->startPaymentTransaction('Manual', 10.); // ... /** @var EventStore\EventStoreInterface $eventStore */ $eventStore = // ... // $invoice->aggregateRootId = AggregateRootId\Uuid7<'01941d8f-9951-72af-b5ce-5aa7aa23ea68'> $eventStore->save($invoice); // ...
Load the Aggregate
When loading an Aggregate its Events are applied one-by-one by the EventStore based on the related AggregateRootId
sorted by recordedAt
in ascending order.
<?php // ... use TwanHaverkamp\EventSourcingWithPhp\Event\EventStore; // ... $invoice = Invoice::init('01941d8f-9951-72af-b5ce-5aa7aa23ea68'); /** @var EventStore\EventStoreInterface $eventStore */ $eventStore = // ... $eventStore->load($invoice); // $invoice->number = '12-34' // $invoice->items = [ // DTO\Item(reference: 'prod.123.456', description: 'Product', quantity: 3, price: 5.95, tax: 21.), // DTO\Item(description: 'Shipping', quantity: 1, price: 4.95, tax: 0.), // ] // $invoice->paymentTransactions = [ // DTO\PaymentTransaction(paymentMethod: 'Manual', amount: 10., status: 'started'), // ] // ...