twanhaverkamp/event-sourcing-with-php

1.1.0 2025-01-26 19:07 UTC

This package is auto-updated.

Last update: 2025-02-02 16:52:24 UTC


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:

Event sourced:

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:

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.

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’s load 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 and fromPayload 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 an apply method that receives an Event as argument. With a match you can map each Event to the correct apply[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'),
// ]

// ...

Contribute

You've found a bug or want to introduce a new feature? Awesome! 🤩

Fork

Create a fork by clicking this link and follow the instructions on that page.

Now you have the project copied into a new repository on your own GitHub account.

At this point I'm assuming you have a GitHub account.

Run the project locally

# Navigate to your working directory
cd ../[your-working-directory]

# Clone the project from Github
git clone git@github.com:[your-github-username]/event-sourcing-with-php.git

# Navigate to the project directory
cd event-sourcing-with-php/

# Start Docker Compose
docker compose up -d

If you want to get into the project's PHP container, run the following command:

docker compose exec -it php-8.3 sh

This requires you to have Docker installed on your computer. Personally I'm using Docker Desktop, but there are other alternatives like Rancher Desktop out there as well, it's totally up to you.

Testing

When you've fixed a bug or introduced a new feature you cover it with PHPUnit tests to make sure code quality won't decrease and more importantly; the code behaves as expected.

# Run PHP CodeSniffer
docker compose exec php-8.3 vendor/bin/phpcs example src tests --standard=PSR12
# Run PHPStan
docker compose exec php-8.3 vendor/bin/phpstan analyse example src tests --level=9
# Run PHPUnit
docker compose exec php-8.3 vendor/bin/phpunit

Pull request

Every git "push" triggers a GitHub Actions workflow called "quick-tests" that runs the following jobs:

If all checks pass ✅ you can create a pull request targeting this repository's main branch. I'll review it as soon™ as possible, I'll promise! 🤝🏻

Update your LinkedIn profile

Now you're officially an open-source software contributor, thank you! ❤️
Time to update your LinkedIn profile! 🏆