Implementing Event Sourcing in PHP with Doctrine

Event Sourcing is a powerful architectural pattern that fundamentally changes how applications persist state. Instead of saving the current state of an object, you save a sequence of immutable events that describe every change to that object. This approach offers significant benefits for auditing, debugging, and building highly scalable, resilient systems. When combined with Domain-Driven Design (DDD) principles and a robust ORM like Doctrine in PHP, Event Sourcing enables the creation of sophisticated, maintainable, and highly observable applications.

Understanding Event Sourcing

At its core, Event Sourcing revolves around the concept of an "event store" – a chronological ledger of all changes that have occurred within a system. Each change is represented as an event, an immutable fact that happened in the past. To reconstruct the current state of an aggregate (a DDD concept representing a cluster of domain objects that can be treated as a single unit), you simply replay all events associated with that aggregate from the beginning of time.

Key Principles of Event Sourcing

  • Events as the Source of Truth: The sequence of events, not the current state, is the primary source of truth.
  • Immutability: Events are immutable; once recorded, they cannot be changed or deleted.
  • Append-Only Log: Events are appended to an event store in chronological order.
  • State Reconstruction: The current state is derived by replaying events.

Benefits of Event Sourcing

  • Auditing and Debugging: A complete history of all changes makes auditing and debugging significantly easier.
  • Temporal Queries: You can reconstruct the state of your system at any point in time.
  • Decoupling: Events provide a natural decoupling point between different parts of your system.
  • Scalability: Read models can be optimized for specific queries, independent of the write model.
  • Conflict Resolution: Easier to handle concurrent updates and resolve conflicts.

Integrating Event Sourcing with Doctrine ORM

While Doctrine ORM is traditionally designed for persisting the current state of entities, it can be effectively used in an Event Sourcing context, primarily for managing the event store itself and potentially for materialized views (read models). The key is to shift your mindset from direct entity persistence to event persistence.

Designing the Event Store Entity

First, define a Doctrine entity that represents an event in your event store. This entity will store the essential details of each event.

// src/Entity/DomainEvent.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\DomainEventRepository")
 * @ORM\Table(name="domain_events")
 */
class DomainEvent
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $aggregateId;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $eventType;

    /**
     * @ORM\Column(type="json")
     */
    private $payload = [];

    /**
     * @ORM\Column(type="datetime_immutable")
     */
    private $recordedOn;

    // Getters and setters
}

Persisting Events

When a command is executed and results in a state change, instead of updating an entity, you create and persist a new DomainEvent.

// src/Service/UserService.php
namespace App\Service;

use App\Entity\DomainEvent;
use Doctrine\ORM\EntityManagerInterface;

class UserService
{
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function registerUser(string $userId, string $email, string $password):
    {
        // ... business logic to validate and create user ...

        $event = new DomainEvent();
        $event->setAggregateId($userId);
        $event->setEventType('UserRegistered');
        $event->setPayload(['email' => $email, 'password' => $password]);
        $event->setRecordedOn(new \DateTimeImmutable());

        $this->entityManager->persist($event);
        $this->entityManager->flush();
    }
}

Reconstructing Aggregate State

To reconstruct an aggregate's state, you retrieve all events for a given aggregateId from the DomainEventRepository and apply them to a blank aggregate instance.

// src/Repository/DomainEventRepository.php
namespace App\Repository;

use App\Entity\DomainEvent;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class DomainEventRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, DomainEvent::class);
    }

    public function findEventsForAggregate(string $aggregateId): array
    {
        return $this->findBy(['aggregateId' => $aggregateId], ['recordedOn' => 'ASC']);
    }
}

// src/Model/UserAggregate.php
namespace App\Model;

use App\Entity\DomainEvent;

class UserAggregate
{
    private $id;
    private $email;
    private $status = 'pending'; // Initial state

    private function __construct() {}

    public static function reconstitute(array $events): self
    {
        $aggregate = new self();
        foreach ($events as $event) {
            $aggregate->apply($event);
        }
        return $aggregate;
    }

    private function apply(DomainEvent $event):
    {
        switch ($event->getEventType()) {
            case 'UserRegistered':
                $this->id = $event->getAggregateId();
                $this->email = $event->getPayload()['email'];
                break;
            case 'UserActivated':
                $this->status = 'active';
                break;
            // ... handle other events
        }
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function getStatus(): string
    {
        return $this->status;
    }
}

// In your application logic
$events = $domainEventRepository->findEventsForAggregate($userId);
$userAggregate = UserAggregate::reconstitute($events);
echo $userAggregate->getEmail();

Domain-Driven Design and PHP Best Practices

Event Sourcing naturally aligns with Domain-Driven Design (DDD). DDD emphasizes focusing on the core business domain and building a ubiquitous language. Events, being business facts, fit perfectly into this paradigm.

Key DDD Concepts in Event Sourcing

  • Aggregates: Events are typically grouped by an aggregate ID, defining the boundary within which consistency is maintained.
  • Entities and Value Objects: While aggregates are rebuilt from events, the internal state of the aggregate can still be composed of DDD entities and value objects.
  • Ubiquitous Language: Event names and their payloads should directly reflect the business domain's language.

PHP Best Practices

  • Immutable Events: Represent events as immutable PHP objects. Libraries like ramsey/uuid can be useful for generating unique aggregate IDs.
  • Event Dispatching: After persisting events, consider dispatching them to a message bus (e.g., Symfony Messenger, League/Event) for asynchronous processing, such as updating read models or triggering side effects.
  • Snapshots: For aggregates with a very long history of events, consider implementing snapshots. A snapshot is a stored state of an aggregate at a certain point, allowing you to replay events only from that point forward, optimizing performance.
  • Serialization: Carefully choose how you serialize event payloads (e.g., JSON, Avro, Protobuf). JSON is often a good starting point due to its simplicity.
  • Testing: Event-sourced systems are highly testable. You can test aggregate behavior by providing a sequence of events and asserting the resulting state.

Conclusion

Implementing Event Sourcing in PHP with Doctrine provides a robust and auditable approach to state management, particularly for complex domains. By treating events as the single source of truth and leveraging Doctrine for event persistence, developers can build applications that offer unparalleled insight into their history, facilitate easier debugging, and provide a strong foundation for future scalability and flexibility. While it requires a shift in architectural thinking, the long-term benefits of Event Sourcing, especially when combined with Domain-Driven Design, are significant for building resilient and adaptable software systems. Explore libraries like Prooph Event Store for more comprehensive solutions and production-ready implementations.

Resources

  • CQRS (Command Query Responsibility Segregation): A pattern often used in conjunction with Event Sourcing to separate read and write models.
  • Message Queues: Learn how message queues like RabbitMQ or Apache Kafka can enhance your Event Sourcing architecture for event dispatching and consumption.
← Back to php tutorials