Domain Events
Domain events¶
Domain events are immutable messages that represent something that has happened in your domain. They are simple Data Transfer Objects (DTOs) with no business logic, capturing facts about state changes.
Overview¶
In Gember Event Sourcing, any DTO can be used as a domain event. With DCB, domain events are not limited to a single aggregate root. Instead, the same event can be shared across multiple use cases and aggregates, enabling decentralized business logic.
Domain tags¶
Domain tags are identifiers that link events to specific domain concepts (e.g., Course, Student). They enable the event sourcing system to:
- Route events to the correct use cases
- Query events by domain context
- Support DCB patterns with multiple domain concepts per use case
How domain tags work across components¶
Domain tags appear on commands, use cases, and events, each serving a different purpose:
| Component | Domain tag purpose |
|---|---|
| Command | Which events to load from the event store |
| Use case | Optimistic lock scope (must match command) |
| Event | How to index the event for future retrieval |
When loading a use case, Gember builds a stream query combining the domain tags (from the command) with the subscribed event types (from the use case's
#[DomainEventSubscriber]methods). Only events matching at least one tag and a subscribed type are loaded.
An event's domain tags do not need to match the command's domain tags. They serve independent purposes. A command tag determines what gets loaded; an event tag determines how the event is discoverable in the future.
Choosing event domain tags¶
Add a domain tag for each use case that will need to load this event in the future. For example:
- If
CreateTimeslotneeds to seeTimeslotDiscardedEventviapractitionerId, the event must havepractitionerIdas a domain tag - If
PreReserveTimeslotneeds to seeTimeslotCreatedEventviatimeslotId, the event must havetimeslotIdas a domain tag - An event can have multiple domain tags if multiple use cases need it via different identifiers
There are two ways to define domain tags on events:
Option 1: Using the #[DomainTag] attribute¶
Mark properties with the #[DomainTag] attribute. This is the most common and recommended approach for simple scenarios:
use Gember\EventSourcing\UseCase\Attribute\DomainTag;
final readonly class CourseCreatedEvent
{
public function __construct(
#[DomainTag]
public string $courseId,
public string $name,
public int $capacity,
) {}
}
Key points:
- Multiple properties can be marked with #[DomainTag]
- Only annotated properties are used as domain tags
- Works well when domain tags map directly to constructor properties
Option 2: Implementing the SpecifiedDomainTags interface¶
For more control over domain tag extraction, implement the SpecifiedDomainTags interface:
use Gember\EventSourcing\UseCase\SpecifiedDomainTags;
final readonly class ComplexEvent implements SpecifiedDomainTags
{
public function __construct(
public string $compositeId,
public string $secondaryId,
public int $value,
) {}
/**
* @return list<string|Stringable>
*/
public function getDomainTags(): array
{
// Custom logic to extract or transform domain tags
return [
$this->compositeId,
$this->secondaryId,
];
}
}
Key points:
- Provides full control over domain tag extraction
- Can return computed or transformed values
- Completely replaces attribute-based resolution - if this interface is implemented, #[DomainTag] attributes are ignored
When to use SpecifiedDomainTags over #[DomainTag] attributes:
- Domain tags need to be derived or computed from multiple properties (e.g., a composite key)
- Domain tags require conditional logic (e.g., only include a tag when a property is non-null)
- Domain tags need transformation before use (e.g., prefixing, hashing, or formatting)
For most events where domain tags map directly to constructor properties, the #[DomainTag] attribute is simpler and recommended.
Event naming¶
The event name is a unique identifier used to match stored events with their corresponding PHP classes during deserialization. Gember Event Sourcing provides three ways to define event names, checked in this order:
1. Using the #[DomainEvent] attribute¶
Define the event name explicitly using the #[DomainEvent] attribute:
use Gember\EventSourcing\UseCase\Attribute\DomainEvent;
use Gember\EventSourcing\UseCase\Attribute\DomainTag;
#[DomainEvent(name: 'course.created')]
final readonly class CourseCreatedEvent
{
public function __construct(
#[DomainTag]
public string $courseId,
public string $name,
) {}
}
This is the recommended approach as it provides explicit control and makes refactoring safer.
2. Implementing the NamedDomainEvent interface¶
For dynamic event naming or when you need more control, implement the NamedDomainEvent interface:
use Gember\EventSourcing\UseCase\Attribute\DomainTag;
use Gember\EventSourcing\UseCase\NamedDomainEvent;
final readonly class CourseCreatedEvent implements NamedDomainEvent
{
public function __construct(
#[DomainTag]
public string $courseId,
public string $name,
) {}
public static function getName(): string
{
return 'course.created';
}
}
3. Fallback to FQCN¶
If neither the attribute nor the interface is used, Gember Event Sourcing automatically generates a readable event name from the class's Fully Qualified Class Name (FQCN).
For example: App\Domain\Course\CourseCreatedEvent might become app.domain.course.course-created-event.
Best practice: Always use explicit naming (attribute or interface) to maintain control over event names, especially when refactoring or reorganizing code.
Serialization¶
Events must be serializable to be stored in the event store. Gember Event Sourcing uses a configurable Serializer to convert events to and from their stored representation (typically JSON).
There are two approaches to event serialization:
Option 1: Automatic serialization (Recommended)¶
When using framework integrations like gember/event-sourcing-symfony-bundle, events are automatically serialized using the Symfony Serializer component. This means you don't need to implement any interface - just define your events as simple DTOs:
use Gember\EventSourcing\UseCase\Attribute\DomainEvent;
use Gember\EventSourcing\UseCase\Attribute\DomainTag;
#[DomainEvent(name: 'course.created')]
final readonly class CourseCreatedEvent
{
public function __construct(
#[DomainTag]
public string $courseId,
public string $name,
public int $capacity,
) {}
}
Key benefits:
- Zero boilerplate - No interface implementation required
- Automatic handling - Works with any DTO structure
- Complex type support - Can handle DateTimeImmutable, nested objects, and more
- Framework integration - Configured automatically by the Symfony bundle
Note: This is the recommended approach for most applications, especially when using Symfony.
Option 2: Explicit serialization with Serializable interface¶
For more control over serialization or when not using a framework integration, implement the Serializable interface:
use Gember\EventSourcing\UseCase\Attribute\DomainEvent;
use Gember\EventSourcing\UseCase\Attribute\DomainTag;
use Gember\EventSourcing\Util\Serialization\Serializable;
/**
* @implements Serializable<array{
* courseId: string,
* name: string,
* capacity: int
* }, CourseCreatedEvent>
*/
#[DomainEvent(name: 'course.created')]
final readonly class CourseCreatedEvent implements Serializable
{
public function __construct(
#[DomainTag]
public string $courseId,
public string $name,
public int $capacity,
) {}
/**
* Convert the event to an array for storage.
*/
public function toPayload(): array
{
return [
'courseId' => $this->courseId,
'name' => $this->name,
'capacity' => $this->capacity,
];
}
/**
* Reconstruct the event from stored data.
*/
public static function fromPayload(array $payload): self
{
return new self(
$payload['courseId'],
$payload['name'],
$payload['capacity'],
);
}
}
Key points about the Serializable interface:
- Generic type parameters - Document the payload structure and return type using PHPStan/Psalm annotations
- First parameter: The array shape (
array{courseId: string, ...}) - Second parameter: The implementing class (
CourseCreatedEvent) toPayload()- Converts the event to an array for JSON serializationfromPayload()- Static factory method to reconstruct the event from stored data- When to use - Useful when you need explicit control, want to avoid framework dependencies, or are not using a framework integration
How the StackedSerializer works¶
When using the Symfony bundle, both serialization approaches work together via the StackedSerializer:
- First, it tries
SerializableInterfaceSerializer- for events implementingSerializable - Then, it falls back to
SymfonySerializer- for automatic reflection-based serialization - Finally, if both fail, it throws an exception with detailed error information
This means you can mix both approaches in your application - some events with explicit Serializable implementation, others with automatic serialization.
Best practices for event properties¶
Events are serialized and stored permanently in the event store. Use primitive types (string, int, float, bool, DateTimeImmutable) for all properties rather than domain Value Objects. This ensures:
- Simple serialization - Primitives serialize to JSON without custom logic
- Schema evolution - Historical events remain readable even when domain classes change
- No replay dependencies - Events can be deserialized without depending on domain model classes
// Good: primitive types
#[DomainEvent(name: 'timeslot.created')]
final readonly class TimeslotCreatedEvent
{
public function __construct(
#[DomainTag]
public string $timeslotId,
#[DomainTag]
public string $practitionerId,
public DateTimeImmutable $startAt,
public DateTimeImmutable $endAt,
) {}
}
// Avoid: Value Objects in events
final readonly class TimeslotCreatedEvent
{
public function __construct(
public TimeslotId $timeslotId, // couples event to domain class
public TimePeriod $timePeriod, // breaks if TimePeriod structure changes
) {}
}
Additional guidelines:
- Keep events flat - Avoid deeply nested structures
- Make events readonly - Use
readonlyclasses or properties to ensure immutability - Include all data - An event should carry everything needed to describe the change, so consumers don't need to look up additional data
Choosing between approaches¶
Use automatic serialization when: - You're using Symfony or another framework with serializer integration - You want minimal boilerplate code - You prefer convention over explicit configuration
Use the Serializable interface when: - You need explicit control over the serialization format - You want to avoid framework dependencies - You're working without a framework integration - You prefer explicit, testable code
Custom serializers¶
For advanced scenarios (e.g., encryption, compression, custom formats), you can provide custom serializer implementations by implementing the Serializer interface from gember/dependency-contracts. These can be added to the StackedSerializer chain alongside the built-in serializers.
Saga IDs on events¶
When using Sagas, domain events can carry #[SagaId] attributes to route events to the correct saga instance. The #[SagaId] and #[DomainTag] attributes are independent concerns - a property can have both, one, or neither:
| Attribute | Purpose | Used by | Storage |
|---|---|---|---|
#[DomainTag] |
Event store indexing for use case loading | Use cases | Event store relations |
#[SagaId] |
Saga routing and identification | Sagas | Saga store relations |
use Gember\EventSourcing\Saga\Attribute\SagaId;
use Gember\EventSourcing\UseCase\Attribute\DomainEvent;
use Gember\EventSourcing\UseCase\Attribute\DomainTag;
#[DomainEvent(name: 'student.subscribed')]
final readonly class StudentSubscribedEvent
{
public function __construct(
#[DomainTag]
#[SagaId] // Routes to saga AND indexes in event store
public string $courseId,
#[DomainTag]
#[SagaId]
public string $studentId,
) {}
}
A property with only #[SagaId] (no #[DomainTag]) routes to a saga but is not indexed for use case loading. A property with only #[DomainTag] (no #[SagaId]) is indexed in the event store but does not trigger any saga.
See Sagas for complete documentation on saga routing.
Event envelope¶
When stored, events are wrapped in a DomainEventEnvelope containing:
| Field | Type | Description |
|---|---|---|
eventId |
string |
Unique event ID |
domainTags |
array |
Identifiers from #[DomainTag] properties |
event |
object |
The actual event instance |
metadata |
Metadata |
Additional context (e.g., user, correlation ID) |
appliedAt |
DateTimeImmutable |
When the event was applied |
The event store persists two types of data: - The event itself (ID, name, serialized payload, metadata, timestamp) - The domain tag relations linking the event to its domain tags, enabling efficient retrieval by tag
Metadata¶
The Metadata class is an immutable key-value container attached to each DomainEventEnvelope. It allows you to enrich events with contextual information that is not part of the domain event itself, such as correlation IDs, user context, or request tracing data.
How metadata flows¶
- Created empty — When a domain event is applied via
$this->apply(), theDomainEventEnvelopeFactorywraps it in an envelope with empty metadata - Enriched before persistence — Metadata can be added to the envelope before it reaches the event store, typically via middleware or a decorator on the repository
- Stored alongside the event — The metadata array is persisted as part of the event record
- Available during reconstitution — When events are replayed, the metadata is available on each
DomainEventEnvelope
Enriching events with metadata¶
The DomainEventEnvelope provides a withMetadata() method that returns a new envelope with replaced metadata. This is typically used in a repository decorator or middleware to enrich events before they are persisted:
use Gember\EventSourcing\UseCase\DomainEventEnvelope;
use Gember\EventSourcing\UseCase\Metadata;
// Create enriched envelope (immutable - returns a new instance)
$enrichedEnvelope = $envelope->withMetadata(
$envelope->metadata->addMetadata([
'correlationId' => $this->correlationIdProvider->get(),
'userId' => $this->securityContext->getUserId(),
]),
);
Common metadata use cases¶
| Key | Purpose |
|---|---|
correlationId |
Links related events across use cases and services |
causationId |
Identifies the event that caused this event |
userId |
The user who triggered the action |
requestId |
Traces the originating HTTP request |
userAgent |
Client information for audit trails |