Vortos
Domain

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

src/User/Domain/User/Event/UserRegistered.php
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):

RuleRequirement
F1final
F2readonly
F3All properties constructor-promoted
F4No extra methods beyond __construct
F5No 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.

On this page