Download the PHP package tiny-blocks/building-blocks without Composer
On this page you can find all versions of the php package tiny-blocks/building-blocks. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download tiny-blocks/building-blocks
More information about tiny-blocks/building-blocks
Files in tiny-blocks/building-blocks
Package building-blocks
Short Description Implements tactical DDD building blocks for PHP: entities, aggregate roots, domain events, snapshots, and upcasters.
License MIT
Homepage https://github.com/tiny-blocks/building-blocks
Informations about the package building-blocks
Building Blocks
- Overview
- Installation
- How to use
- Entity
- Single-field identity
- Compound identity
- Identity access on entities
- Aggregate
- Domain events with transactional outbox
- Declaring events
- Emitting events from the aggregate
- Draining events
- Restoring aggregate version on reload
- Constructing event records directly
- Integration events and the Anti-Corruption Layer
- Declaring integration events
- Writing a translator
- Registering translators
- Constructing integration event records directly
- Event sourcing
- Applying events to state
- Creating a blank aggregate
- Replaying an event stream
- Snapshots
- Capturing aggregate state
- Taking a snapshot
- Persisting snapshots
- Built-in conditions
- Upcasting
- Defining an upcaster
- Chaining upcasters
- Default values for new fields
- Entity
- FAQ
- License
- Contributing
Overview
The Building Blocks library provides the tactical design building blocks of Domain-Driven Design: Entity,
Identity, AggregateRoot, and the infrastructure required to carry domain events through a transactional outbox
or an event-sourced store.
This library implements the tactical patterns from Evans (Entity, Identity, Aggregate Root, Value Object) and Vernon (Domain Event) together with pragmatic extensions that production code needs but the original DDD literature does not address: aggregate versioning for optimistic offline locking (Fowler PEAA), model versioning and rolling snapshots for event-sourced aggregates (Greg Young), event upcasting for schema evolution (Greg Young), and an event envelope decoupling domain events from infrastructure metadata (Hohpe/Woolf EIP). Every extension is annotated in its own PHPDoc with its source.
Domain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not
replace PSR-14, it defines what flows through it. Serialization to wire formats is delegated to adapters such as
tiny-blocks/outbox.
Installation
How to use
The library exposes three styles of aggregate modeling through sibling interfaces:
AggregateRootfor plain DDD modeling without events.EventualAggregateRootfor aggregates that persist state and emit events as side effects via a transactional outbox.EventSourcingRootfor aggregates whose state is derived entirely from their ordered event stream.
Entity
Every entity declares which property holds its Identity. By default, the property is named id, aggregates with a
differently named property override identityProperty().
Single-field identity
SingleIdentity: identity backed by a single scalar value (UUID, auto-increment integer, slug).
Compound identity
CompoundIdentity: identity composed of multiple fields treated as a tuple. Fields may carry any type the application requires, including primitive scalars (int,string) and value objects.
Identity access on entities
-
identity(),identityValue(),sameIdentityOf(),identityEquals(): provided byEntityBehaviorfor any entity that declares its identity property. - Override
identityProperty()only when the identity property has a name other thanid:
Aggregate
AggregateRoot adds two pragmatic fields to Evans' aggregate: a monotonic AggregateVersion for optimistic
concurrency control, and a ModelVersion for schema evolution of the aggregate type.
-
aggregateVersion(): the current aggregate version, starting at zero for a blank aggregate and advancing by one for every recorded event.AggregateVersion::isAfter()andAggregateVersion::isBefore()compare two versions when reasoning about replay progress or concurrency conflicts. -
modelVersion(): typed asModelVersion. Defaults toModelVersion::initial()(value0). Override on aggregates that have a versioned schema.ModelVersion::isAfter()andModelVersion::isBefore()compare two schema versions during migration logic. aggregateType(): short class name, used as the aggregate type identifier on eachEventRecord.
Domain events with transactional outbox
EventualAggregateRoot records domain events during the unit of work. State is the source of truth, events are
emitted as side effects and must be delivered at-least-once.
After persisting the aggregate state, the application service drains the recorded events with pullEvents(), which
returns them and clears the buffer, so a second save of the same instance does not re-emit the events already
drained. peekEvents() returns a non-destructive copy for inspection without touching the buffer. An instance models a
single unit of work: reload from the repository before operating on the same logical aggregate again rather than
reusing a drained instance.
Declaring events
-
DomainEvent: contract for a fact that happened in the domain. The only required method isrevision(), defaulted toRevision::initial()byDomainEventBehavior. Override only when bumping the event schema.Bumping a revision:
Comparing revisions:
Emitting events from the aggregate
pushEvent(): protected method onEventualAggregateRootBehavior. Increments the aggregate version and appends a fully-builtEventRecordto the recorded buffer.
Draining events
-
pullEvents(): drains the buffer. Returns the events recorded since the last drain and clears the buffer, so a subsequent call returns an empty collection until new events are recorded. This is the persistence path: drain into the outbox after the aggregate state has been saved. peekEvents(): returns a fresh copy of the buffer without clearing it, safe to iterate. The aggregate's own buffer is not mutated by external iteration, and a laterpullEvents()still drains every recorded event. Use it to inspect the buffer, for example in tests, without consuming it.
Restoring aggregate version on reload
-
reconstituteStrict(): the recommended static factory for repositories that rehydrate anEventualAggregateRootfrom a full persisted row. It delegates toreconstitutePartial()(honoring any override), then verifies by reflection that hydration left no declared property uninitialized, throwingIncompleteAggregateStatewhen a required property is still unset. Properties that carry a default value, and untyped properties, are always initialized by PHP, so they are never flagged. -
reconstitutePartial(): the hydration step on its own, without the completeness check. The default implementation provided byEventualAggregateRootBehaviorinstantiates the aggregate without invoking its constructor, assigns the identity to the property declared byidentityProperty(), hydrates the remaining state by reflection from the$aggregateStatemap (entries with keys absent from the aggregate are silently ignored), and assigns the aggregate version so subsequent events advance from the correct value. It throwsMissingIdentityPropertywhen the aggregate has no property named byidentityProperty(). The buffer of recorded events starts empty, so events emitted after reconstitution are drained withpullEvents()exactly as for a freshly created aggregate.Call
reconstitutePartial(...)with the same arguments when the persisted state is intentionally partial and the completeness check should be skipped.Aggregates may override
reconstitutePartial()to enforce a concrete identity type at the entry point.reconstituteStrict()delegates to it, so the override is honored on both paths. The static signature cannot narrow the parameter type per LSP, so the override keepsIdentityin the signature and guards withinstanceofinside:
Constructing event records directly
Every envelope carries $id, $event, $revision, $eventType, $occurredAt, $aggregateId,
$aggregateType, and $aggregateVersion. The aggregate normally builds the record, so consumers
read these fields off EventRecord directly without instantiating one.
-
EventRecord::from(): factory for the rare cases that require building an envelope outside the aggregate boundary, typically test code that fabricates envelopes as inputs to handlers, or consumer-side code deserializing payloads from a wire format. The constructor is private, sofrom()is the only construction path. TheidandoccurredAtparameters fall back to sensible defaults (Uuid::generateV7()andUtc::now()) when omitted. The id and occurrence timestamp are the value objectsTinyBlocks\BuildingBlocks\UuidandTinyBlocks\BuildingBlocks\Utc.Parameter Type Required Description id?UuidNo Explicit envelope identifier. Defaults to a fresh Uuid::generateV7().eventDomainEventYes The event being recorded. occurredAt?UtcNo Explicit occurrence timestamp. Defaults to Utc::now().aggregateIdIdentityYes The aggregate identity that produced the event. aggregateTypestringYes The short class name of the aggregate. aggregateVersionAggregateVersionYes The aggregate version assigned to this envelope.
Integration events and the Anti-Corruption Layer
DomainEvent describes facts that happened inside the bounded context and evolves freely with the
internal model. IntegrationEvent describes the stable public contract that flows to external
consumers and must remain backward-compatible. The two interfaces are siblings, not parent and
child. An IntegrationEvent is produced by an IntegrationEventTranslator, which acts as the
Anti-Corruption Layer (Vernon, IDDD Chapter 3) between the internal model and the public contract.
Declaring integration events
IntegrationEvent: marker interface for events that cross bounded-context boundaries. Carries arevision()method that versions the public schema independently of the underlying domain event's schema.IntegrationEventBehavior: default implementation that returnsRevision::initial(). Use it on every integration event unless the public schema has been bumped.
Class names for integration events must follow the bounded-context ubiquitous language and must
not carry a technical suffix such as IntegrationEvent. The domain event TransactionConfirmed
is translated into the integration event PaymentConfirmed, not PaymentConfirmedIntegrationEvent.
Bumping the public schema revision independently of the underlying domain event:
Writing a translator
IntegrationEventTranslator is the Anti-Corruption Layer seam. Each implementation declares
which EventRecord it handles via supports() and produces the corresponding
IntegrationEvent via translate(). Implementations must be pure functions with no side
effects or I/O.
Registering translators
IntegrationEventTranslators is an ordered collection of translators. findFor() returns the
first translator whose supports() returns true for a given record, or null when no
translator handles it. A null result is the canonical signal that the event is purely internal
and must not cross the bounded-context boundary.
Constructing integration event records directly
IntegrationEventRecord::from() envelopes a translated integration event with the transport
metadata from the originating EventRecord. The identifier is reused from the originating
record so that outbox relay retries remain idempotent. The revision and event type are derived
from the integration event, not from the domain event.
| Parameter | Type | Description |
|---|---|---|
eventRecord |
EventRecord |
Originating domain event record. Supplies transport metadata. |
integrationEvent |
IntegrationEvent |
Integration event produced by the translator. Supplies payload and public schema. |
Event sourcing
EventSourcingRoot stores no state of its own, state is derived by replaying the event stream.
Applying events to state
-
when(): protected method that records the event and immediately applies it to state by dispatching to awhen<EventShortName>method by reflection. eventHandlers(): explicit registration. Returns a map ofclass-string<DomainEvent>to callable. When the map is non-empty, the trait dispatches through it instead of using the implicitwhen<X>convention. Use this when handler names should not follow the convention or when static analysis on dispatch is desired.
Creating a blank aggregate
blank(): factory that instantiates the aggregate via reflection without invoking its constructor. All state must come from events or from a snapshot.
Replaying an event stream
reconstitute(): replays an ordered stream ofEventRecordinstances, optionally starting from a snapshot to skip earlier events. When a snapshot is provided, its aggregate version is authoritative.
Snapshots
Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate. A snapshot captures the aggregate's state at a specific version so that reconstitution can resume from that point instead of replaying the entire history.
Capturing aggregate state
Aggregates control what fields enter the snapshot by overriding snapshotState(). The default captures every
declared property except recordedEvents and aggregateVersion (which are tracked separately on the envelope).
Taking a snapshot
Snapshot::fromAggregate(): captures the aggregate's current state via thesnapshotState()hook.
Persisting snapshots
Snapshotter: port for snapshot persistence. TheSnapshotterBehaviortrait captures the snapshot and delegates storage to apersisthook implemented by the consumer.
Built-in conditions
SnapshotCondition: strategy for deciding whether a snapshot should be taken at a given point.SnapshotEvery::events(count: N): ready-made condition that triggers everyNevents (skipping version0).-
SnapshotNever::create(): condition that never triggers, useful in tests and when snapshotting is explicitly disabled.Custom conditions implement the interface directly:
Upcasting
Upcasters migrate serialized events across schema changes without touching the event classes.
Defining an upcaster
Upcaster: transforms one(type, revision)pair forward by one step. Returns the event unchanged when the type or revision does not match.SingleUpcasterBehavior: binds the upcaster to a specific migration via three class constants and delegates the payload transformation to an abstractdoUpcast()method.
Chaining upcasters
Upcasters::chain(): runs every upcaster in insertion order in a single forward pass. Upcasters whose type or revision does not match pass the event through.
Default values for new fields
DefaultValues::get(): type-to-default-value map for common primitive types, used when an upcast introduces a new field with a sensible zero-value default.
FAQ
01. Why is DomainEvent close to a marker interface?
A domain event is a fact about something that happened in the domain. The contract carries only revision() so
the library can route schema migrations through upcasters. Everything else (aggregate identity, aggregate version,
aggregate type, occurrence timestamp) is envelope metadata that belongs to EventRecord. Keeping the event itself
minimal prevents infrastructure concerns from leaking into the domain model.
Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8, "Domain Events".
02. Why does EventualAggregateRoot store EventRecord instead of DomainEvent?
Only the aggregate has the context needed to build the complete envelope: identity, aggregate version, aggregate
type name. Storing raw events and wrapping them later would either duplicate that context or require a second
pass. pushEvent() builds the full EventRecord immediately, and the outbox adapter reads them as-is with no
translation.
Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns (Addison-Wesley, 2003), "Envelope Wrapper".
03. Why are EventualAggregateRoot and EventSourcingRoot siblings instead of a hierarchy?
Outbox and event sourcing are mutually exclusive persistence strategies. An aggregate either persists its state
and emits events as side effects, or persists only its events as the source of truth. A common base beyond
AggregateRoot would imply the two patterns can coexist on the same aggregate, which they cannot.
Martin Fowler, Event Sourcing (martinfowler.com, 2005). Chris Richardson, Microservices Patterns (Manning, 2018), Chapter 3, "Transactional Outbox".
04. Why does Revision live on the DomainEvent instead of the call site?
The revision of an event is a property of the event's schema. Keeping it on the event means the call site (pushEvent,
when) does not need to know the schema version, the event class is the single source of truth. Bumping a
revision is always paired with a payload change (added field, removed field, renamed field), so creating a new
event class to carry the new revision is the natural unit of work.
Greg Young, Versioning in an Event Sourced System (Leanpub, 2017).
05. Why does blank() skip the constructor?
EventSourcingRootBehavior::blank() instantiates the aggregate via reflection without invoking its constructor
because all aggregate state in an event-sourced model must come from events or from a snapshot. Any invariants
established by the constructor would contradict that principle. Concrete aggregates should treat their constructor
as private and reserved for internal use during command handling.
Greg Young, CQRS Documents (2010), "Event Sourcing" section.
06. Why doesn't the library serialize envelopes to JSON or any other wire format?
Serialization is an infrastructure concern. Putting encoding methods on domain value objects mixes that concern
into the domain layer, which contradicts the library's persistence-agnostic stance. Adapters such as
tiny-blocks/outbox provide dedicated serializer ports. The domain layer exposes EventRecord, Snapshot, and
the value objects as pure data, downstream adapters decide how to map them onto bytes.
Alistair Cockburn, Hexagonal Architecture (alistair.cockburn.us, 2005).
07. What is the difference between ModelVersion and AggregateVersion?
AggregateVersion counts events per aggregate instance. It is the basis for optimistic concurrency control: a
save fails if the aggregate version in storage differs from the in-memory version the aggregate believed it had.
ModelVersion versions the aggregate type itself. When the aggregate schema changes in a backwards-incompatible
way (a property is removed, renamed, or its semantics shift), bumping the model version gives migration code a
single source of truth to branch on.
The two are different concepts that happen to share an integer representation. They are typed as separate value objects to prevent accidental comparisons across them at compile time.
Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002), "Optimistic Offline Lock", source of
AggregateVersionsemantics. Greg Young, Versioning in an Event Sourced System (Leanpub, 2017), source ofModelVersionsemantics.
08. How are recorded events drained from an EventualAggregateRoot?
After the aggregate state has been persisted, the application service calls pullEvents(), which returns the events
recorded since the last drain and clears the buffer. Draining through pullEvents() publishes each event once: a
second save of the same instance finds an empty buffer and re-emits nothing. peekEvents() is the non-destructive
counterpart, returning a fresh copy for inspection (in tests, for example) while leaving the buffer intact.
An instance models a single transactional unit of work. Reload from the repository before operating on the same logical aggregate again rather than reusing a drained instance, so its aggregate version and state reflect what storage holds.
Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), Chapter 6, "Aggregates" (single transactional unit per aggregate per request).
09. Should I add identity(), aggregateType(), or toArray() to my DomainEvent?
No. These three concerns live elsewhere:
- Identity and aggregate type are envelope metadata. They are added by the aggregate when it builds the
EventRecord(seeAggregateRootBehavior::buildEventRecord) and are accessed on the consumer side through the envelope, not the event. - Serialization is an infrastructure concern. The event remains a pure PHP object, serialization happens in the outbox writer and the consumer deserializer, both of which live downstream of the library.
A DomainEvent that grows methods like these duplicates envelope data already on the EventRecord and pulls
infrastructure into the domain layer.
10. Why does the library include AggregateVersion and ModelVersion if Evans never mentioned them?
Evans defined the tactical patterns of DDD, but optimistic concurrency control and aggregate schema evolution
are concerns that emerged later in mainstream production code. AggregateVersion carries the optimistic offline
lock formalized by Fowler in PEAA: the value travels with the aggregate, the persistence adapter compares the
in-memory value against the stored one, and a mismatch raises a concurrency exception instead of overwriting
another process's change. ModelVersion carries Greg Young's schema versioning for aggregate types, so migration
code has a single source of truth to branch on when older shapes show up in storage.
Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002), "Optimistic Offline Lock". Greg Young, Versioning in an Event Sourced System (Leanpub, 2017).
11. Why are reconstitutePartial() and
reconstituteStrict() static on the interface even though PHP's polymorphism for static methods is limited?
The interface declaration documents the contract: every EventualAggregateRoot exposes two static factories with
the shape (Identity, array, AggregateVersion): static that repositories can call. PHP does not dispatch static
calls through interfaces at runtime, so the consumer always names the concrete class
(Order::reconstituteStrict(...), Reservation::reconstitutePartial(...)). The interface still earns its keep: it
forces aggregates to expose the factories, the trait default provides both for free (reconstituteStrict delegates
to reconstitutePartial), and overrides remain bound to the declared signature. The parameter name is free per
LSP, so an override of reconstitutePartial can rename $identity to $orderId for readability, but the type
must remain Identity, narrowing to a concrete identity class would break LSP. Concrete types are enforced inside
the override with instanceof.
Barbara Liskov and Jeannette Wing, A Behavioral Notion of Subtyping (ACM TOPLAS, 1994).
12. Why was reconstituteAggregateVersion() removed?
It was never part of the external contract. The only caller was the trait's own reconstitute() factory, which
needed to set the aggregate version on the instance it had just built. Exposing that internal step as a public
instance method invited misuse (repositories calling it on aggregates they had not just reconstituted) without
adding any expressiveness over assigning the property directly. The factory now writes $aggregate->aggregateVersion
directly inside the trait, which is legal because the assignment happens in the static method of the same class
after the trait flattens into the aggregate. Eliminating the public method tightens the surface and removes the
documentation burden of explaining when calling it is correct.
13. Why are DomainEvent and IntegrationEvent siblings instead of parent and child?
Domain events evolve freely with the internal model. Integration events are a public contract
that must remain backward-compatible across bounded-context consumers. A parent/child relationship
would make every domain event eligible to cross the bounded-context boundary by virtue of typing,
reintroducing the very coupling the distinction exists to eliminate. Sibling interfaces force the
boundary crossing to be an explicit translation step, observable in the type system. There is no
accidental publication: the compiler rejects a DomainEvent where an IntegrationEvent is
expected, and the IntegrationEventTranslator is the only path between the two.
Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 3, "Context Maps".
14. Why doesn't the library let me publish a DomainEvent directly through the outbox?
The Anti-Corruption Layer exists precisely to keep the public contract isolated from the internal model. A shortcut that lets a domain event become an integration event without an explicit translation step erases that boundary.
Without translation, internal model refactors propagate silently to external consumers. A renamed field or a new value object on a domain event changes the published payload with no compile-time signal. Consumers break at runtime, not at the CI boundary where the change was introduced.
Domain events are versioned by the internal model; integration events are versioned by the public contract. Coupling them forces a single revision counter to serve two evolution speeds, which collapses the ability to evolve each side independently.
Even when a domain event and an integration event happen to share the same shape today, the cost of writing a translator that copies fields is a few seconds per event. In return: static analysis flags drift between the two shapes, refactor pressure surfaces in CI as a compile error inside the translator, and the public contract is locatable as a single namespace in the codebase.
Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 3, "Context Maps", section "Anticorruption Layer".
License
Building Blocks is licensed under MIT.
Contributing
Please follow the contributing guidelines to contribute to the project.
All versions of building-blocks with dependencies
ramsey/uuid Version ^4.9
tiny-blocks/collection Version ^2.5
tiny-blocks/mapper Version ^3.1
tiny-blocks/time Version ^2.3
tiny-blocks/value-object Version ^5.0