---
title: Domain Events
type: concept
created: 2026-09-06
updated: 2026-09-06
sources: [codebase snapshot 2026-09-06]
tags: [domain-events, aggregates, events]
---

# Domain Events

Project-Board captures meaningful state changes inside aggregates as domain events, but the dispatch mechanism is **manual and in-process** — it does not use Laravel's event bus, listeners, or queue.

> **Note on provenance:** reflects the code as of 2026-09-06; no written ADR exists. The event primitives exist, but **no concrete event is currently defined** — the template's `OrderPlaced` event was removed on 2026-09-06 (see [log](../log.md)).

## Base primitives

- `Domain\Shared\DomainEvent` — abstract base; carries an `occurredAt()` `DateTimeImmutable`.
- `Domain\Shared\AggregateRoot` — extends `Entity` and adds event recording:
    - `record(DomainEvent $event)` — appends to an internal list.
    - `pullEvents(): array` — returns all recorded events and clears the collection.

An aggregate **records** events during a behavior; it never dispatches them itself.

## Current state

There are currently **no concrete domain events**. The original `OrderPlaced` event (and the `Order` aggregate that recorded it) was removed with the demo slice. `Board`, `Ticket`, `Workspace`, and `User` aggregates have no events yet.

## Manual dispatch pattern

The intended pattern — demonstrated by the removed `Order` slice — is that the **command handler pulls and iterates** recorded events itself rather than using a framework event bus:

```php
foreach ($aggregate->pullEvents() as $event) {
    if ($event instanceof SomeEvent) {
        // perform a side effect, e.g. via an Application port
    }
}
```

The side effect is performed by the Application layer (e.g. through a port), and the concrete adapter lives in Infrastructure. This is the mechanism the remaining contexts will use as they gain behavior.

## Observations / open questions

- **No automatic dispatch.** Events are not published on save; there is no `ShouldDispatchAfterCommit`, no listeners, no queue. This is deliberate but minimal.
- **Reconstitution does not record.** The `reconstitute()` factories (used by the mappers) bypass event recording — correct, but it means only factory/behavior paths emit events.
- **No concrete events yet.** The base primitives are in place; the tracking contexts (`Board`, `Ticket`, `Workspace`, `User`) have no behavior that records events (see [shared-bases](../entities/shared-bases.md)).
- Whether to move to Laravel's event system (or a bus) as the domain grows is an open design question worth an ADR.

## Related pages

- [cqrs](cqrs.md) — where the dispatch loop lives
- [repository-mapper-pattern](repository-mapper-pattern.md) — how aggregates are persisted (and reconstituted)
- [ddd-layering](ddd-layering.md) — why the port is needed