Events in Pillar
TL;DR
- All events you record are persisted in the aggregate’s event stream (for rehydration and replay).
- Local events (no marker interface) are not published to the outside world; they are private to the aggregate and only used to mutate its state.
ShouldPublishmarks an event for asynchronous publication via the Transactional Outbox (reliable, at‑least‑once delivery).- During replay, publication is suppressed; projectors are driven directly by the replayer.
Recording and applying
Aggregates typically use record() to mutate state:
$this->record(new TitleChanged('New title'));record() immediately calls applyTitleChanged() on the aggregate (if present) to update in‑memory state, and—unless we are reconstituting—queues the event for persistence at commit.
Rehydration uses the event history and calls
apply*methods again, but does not re‑record the events.
Event types
1) Local events (default)
Any event without a marker interface is considered local:
- Persisted to the event stream.
- Not published to external handlers.
- Used for aggregate internal state transitions.
final class TitleChanged
{
public function __construct(public string $title) {}
}2) Asynchronously published: ShouldPublish
Implement the marker interface to have the event enqueued into the Transactional Outbox in the **same DB transaction **. A worker delivers it to your bus with retries.
use Pillar\Event\ShouldPublish;
final class DocumentCreated implements ShouldPublish
{
public function __construct(public string $id, public string $title) {}
}See: Outbox page for delivery guarantees, partitioning, retries, etc.
Publication policy
tUnder the hood, a PublicationPolicy decides whether an event should be sent to the async publish pipeline (the transactional outbox). It does not affect which events are persisted or which events are delivered to projectors during replay. The default policy treats ShouldPublish (and any configured attribute) as a publishing signal and suppresses publication during replay:
if (EventContext::isReplaying()) {
return false; // never publish while replaying
}
return $event instanceof ShouldPublish;You can bind your own policy in the service container (see pillar.publication_policy.class in config). For more details and alternative policies (publish-all, projector-aware, etc.), see Publication Policy.
Replay semantics
When replaying, the framework sets EventContext::initialize(..., replaying: true). Effects:
PublicationPolicyreturns false → no async publication.- Inline publishing sites check
EventContext::isReplaying()and do nothing. - Your projectors receive events directly from the replayer.
This keeps replay pure and avoids side effects while still driving read models.
Versioning, aliases, upcasting
- Versioning: implement a
VersionedEventinterface (if present in your app) so each stored row carries anevent_version. Upcasters can adapt old events on read. - Aliases:
EventAliasRegistrycan map short names to FQCNs in storage. - StoredEvent: when fetching by global sequence, you get a
StoredEventwrapper with metadata (sequence, aggregate id, occurred at, correlation id, etc.).
Event context (timestamps, correlation IDs, aggregate IDs, replay flags)
Every event recorded or replayed in Pillar runs under an EventContext, which provides:
- occurredAt() → the UTC timestamp when the event actually happened
(during replay this is restored from event metadata). - correlationId() → a per-operation UUID used for tracing and log correlation.
- aggregateRootId() → the typed
AggregateRootIdinstance (for exampleCustomerId,DocumentId) when the event stream can be resolved to a registered aggregate id class, ornullotherwise. - isReconstituting() → true while rebuilding an aggregate from history.
- isReplaying() → true while driving projectors in a replay (suppresses publication).
EventContext is automatically set when:
- A command begins (fresh correlation ID, fresh timestamp)
- An event is appended (context timestamp is stored in the row)
- An event is read back during replay (context timestamp is restored)
Example:
EventContext::occurredAt(); // CarbonImmutable timestamp
EventContext::correlationId(); // UUID string
EventContext::aggregateRootId(); // AggregateRootId|nullBecause occurredAt survives replay, projectors can use actual historical timestamps—even long after the event occurred. Because aggregateRootId is resolved from the stream name, handlers and projectors can correlate work to the exact aggregate instance that produced the event—without having to duplicate ids in the event payload.
For convenience in handlers and projectors, you can also use the Pillar\Event\InteractsWithEvents trait, which exposes:
aggregateRootId()— returns the currentAggregateRootId|null.correlationId()andoccurredAt()— thin wrappers around the correspondingEventContextaccessors.
use Pillar\Event\InteractsWithEvents;
final class SendWelcomeEmail
{
use InteractsWithEvents;
public function __invoke(CustomerRegistered $event): void
{
/** @var CustomerId|null $id */
$id = $this->aggregateRootId();
// ...
}
}Best practices
- Idempotent handlers: Outbox is at‑least‑once; make handlers idempotent.
- Keep events small: Include identifiers and facts, not derived data.
- No I/O in inline handlers: treat them like DB triggers; keep them local & fast.
- Projectors on replay: Design projectors to consume events from both live flow and replay without branching logic.
Related docs
- Transactional Outbox → Outbox
- Outbox Worker (CLI) → Outbox Worker