Domain Events
Pure POPO domain events and the EventEnvelope wrapper — immutable facts about what happened, separated from framework metadata.
Domain Events
Domain events are immutable records of something that happened in the domain. They are named in the past tense and carry only business data — no base class, no framework dependency.
Define an Event
final readonly class UserRegistered
{
public function __construct(
public string $email,
public string $name,
) {}
}That is the entire event. No imports, no base class, no aggregateId, no occurredAt. Those are framework concerns that live on EventEnvelope — not on the payload.
Rules enforced at compile time (F1–F5):
| Rule | Requirement |
|---|---|
| F1 | final |
| F2 | readonly |
| F3 | All properties constructor-promoted |
| F4 | No extra methods beyond __construct |
| F5 | No base class or interface |
Violating any rule throws a descriptive error at container build time — never in production.
EventEnvelope
EventEnvelope is the wrapper the framework creates around your POPO when an event is recorded. It carries all identity and timing metadata:
final readonly class EventEnvelope
{
public string $eventId;
public string $aggregateId;
public string $aggregateType;
public int $aggregateVersion;
public string $payloadType; // FQCN of the POPO
public int $schemaVersion;
public \DateTimeImmutable $occurredAt;
public object $payload; // your POPO
public Metadata $metadata;
}Metadata carries cross-cutting context:
final readonly class Metadata
{
public ?string $correlationId;
public ?string $causationId;
public ?string $traceId;
public ?string $tenantId;
public ?string $userId;
public array $custom;
}You never construct EventEnvelope yourself. AggregateRoot::recordEvent() does it automatically.
Recording Events in Aggregates
Pass the POPO directly to recordEvent() — no aggregateId argument needed:
final class User extends AggregateRoot
{
public static function register(string $email, string $name): self
{
$user = new self(UserId::generate());
$user->email = $email;
$user->name = $name;
$user->recordEvent(new UserRegistered(
email: $email,
name: $name,
));
return $user;
}
}recordEvent() wraps the POPO in an EventEnvelope, sets aggregateId from $this->getId(), occurredAt to now, and queues it for dispatch. The CommandBus pulls the envelopes after the handler returns and writes them to the outbox — inside the same transaction as the aggregate save.
Reading Envelope Data in Handlers
Handlers that need identity or timing metadata declare EventEnvelope as a second parameter:
#[AsEventHandler(handlerId: 'user.registered.audit', consumer: 'user.events')]
final class AuditHandler
{
public function __invoke(UserRegistered $event, EventEnvelope $envelope): void
{
// business data from the POPO
$email = $event->email;
// framework metadata from the envelope
$aggregateId = $envelope->aggregateId;
$occurredAt = $envelope->occurredAt;
$version = $envelope->aggregateVersion;
}
}If your handler only needs the business data, omit EventEnvelope entirely:
public function __invoke(UserRegistered $event): void
{
$this->mailer->send($event->email);
}Naming Convention
Domain events are named in past tense with no Event suffix:
UserRegistered ✔
OrderPlaced ✔
PaymentCharged ✔
UserRegisteredEvent ✗ (suffix is redundant — the namespace already scopes it)
RegisterUser ✗ (imperative — that is a command)Schema Versioning
schemaVersion on EventEnvelope starts at 1. Consumers can filter by version using the version parameter on #[AsEventHandler]:
// Only handles version 1 payloads
#[AsEventHandler(handlerId: 'user.registered.v1', consumer: 'user.events', version: 1)]
final class HandleV1Registration
{
public function __invoke(UserRegistered $event): void { ... }
}
// Only handles version 2 payloads (added 'phone' field)
#[AsEventHandler(handlerId: 'user.registered.v2', consumer: 'user.events', version: 2)]
final class HandleV2Registration
{
public function __invoke(UserRegistered $event): void { ... }
}Events Are Facts, Not Commands
Domain events describe what happened — they do not describe what should happen next. A UserDeactivated event says "the user was deactivated". What happens in response (send email, cancel subscriptions) is handled by event handlers in other bounded contexts.
Never add business logic to an event constructor. Events carry data only.