tiny-blocks / building-blocks
Tactical DDD building blocks for PHP: Entity, Aggregate Root, and domain events with transactional outbox and event sourcing support. Persistence-agnostic and PSR-14 friendly.
Requires
- php: ^8.5
- ramsey/uuid: ^4.9
- tiny-blocks/collection: ^2.3
- tiny-blocks/immutable-object: ^1.1
- tiny-blocks/mapper: ^2.0
- tiny-blocks/time: ^1.5
- tiny-blocks/value-object: ^3.2
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- infection/infection: ^0.32
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.1
- squizlabs/php_codesniffer: ^4.0
This package is auto-updated.
Last update: 2026-04-18 22:28:58 UTC
README
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. It is persistence-agnostic and framework-agnostic.
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.
Installation
composer require tiny-blocks/building-blocks
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 implements a protected identityName() method returning the name of the property that holds its
Identity.
Single-field identity
-
SingleIdentity: identity backed by a single scalar value (UUID, auto-increment integer, etc.).use TinyBlocks\BuildingBlocks\Entity\SingleIdentity; use TinyBlocks\BuildingBlocks\Entity\SingleIdentityBehavior; final readonly class OrderId implements SingleIdentity { use SingleIdentityBehavior; public function __construct(public string $value) { } } $orderId = new OrderId(value: 'ord-1'); $orderId->getIdentityValue();
Compound identity
-
CompoundIdentity: identity composed of multiple fields treated as a tuple.use TinyBlocks\BuildingBlocks\Entity\CompoundIdentity; use TinyBlocks\BuildingBlocks\Entity\CompoundIdentityBehavior; final readonly class AppointmentId implements CompoundIdentity { use CompoundIdentityBehavior; public function __construct( public string $tenantId, public string $appointmentId ) { } } $appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); $appointmentId->getIdentityValue();
Identity access
-
getIdentity,getIdentityValue,sameIdentityOf,identityEquals: provided byEntityBehaviorfor any entity that implementsidentityName().use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior; final class User implements AggregateRoot { use AggregateRootBehavior; private function __construct(private UserId $userId, private string $email) { } protected function identityName(): string { return 'userId'; } } $user->sameIdentityOf(other: $otherUser); $user->identityEquals(other: new UserId(value: 'usr-1'));
Aggregate
AggregateRoot adds two pragmatic fields to Evans' aggregate: a monotonic SequenceNumber for optimistic concurrency
control and a ModelVersion for schema evolution of the aggregate type itself.
-
getSequenceNumber: the current sequence number, starting at zero for a blank aggregate.use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior; final class User implements AggregateRoot { use AggregateRootBehavior; protected function identityName(): string { return 'userId'; } } $user->getSequenceNumber();
-
getModelVersion: resolved from the protectedmodelVersion()method, defaults to zero when not overridden.final class Cart implements AggregateRoot { use AggregateRootBehavior; protected function identityName(): string { return 'cartId'; } protected function modelVersion(): int { return 1; } } $cart->getModelVersion();
-
buildAggregateName: short class name, used as the aggregate type identifier on eachEventRecord.$user->buildAggregateName();
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.
Declaring events
-
DomainEvent: empty marker interface. A domain event is a plain PHP object.use TinyBlocks\BuildingBlocks\Event\DomainEvent; final readonly class OrderPlaced implements DomainEvent { public function __construct(public string $item) { } }
Emitting events from the aggregate
-
push: protected method onEventualAggregateRootBehavior. Increments the sequence number and appends a fully-builtEventRecordto the recorded buffer. TheRevisionis provided on the call site, so the event class stays pure.use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior; use TinyBlocks\BuildingBlocks\Event\Revision; final class Order implements EventualAggregateRoot { use EventualAggregateRootBehavior; private function __construct(private OrderId $orderId) { } public static function place(OrderId $orderId, string $item): Order { $order = new Order(orderId: $orderId); $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial()); return $order; } protected function identityName(): string { return 'orderId'; } }
Draining events in the repository
-
recordedEvents: returns a fresh copy of the buffer, safe to iterate without mutating the aggregate. -
clearRecordedEvents: discards the buffer, typically called after persisting the events.$order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book'); foreach ($order->recordedEvents() as $record) { $outbox->append(record: $record); } $order->clearRecordedEvents();
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.use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class Cart implements EventSourcingRoot { use EventSourcingRootBehavior; private CartId $cartId; private array $productIds = []; public function addProduct(string $productId): void { $this->when(event: new ProductAdded(productId: $productId), revision: Revision::initial()); } public function applySnapshot(Snapshot $snapshot): void { $this->productIds = $snapshot->getAggregateState()['productIds'] ?? []; } protected function identityName(): string { return 'cartId'; } protected function whenProductAdded(ProductAdded $event): void { $this->productIds[] = $event->productId; } }
Creating a blank aggregate
-
blank: factory that instantiates the aggregate without invoking its constructor. All state must come from events or from a snapshot.$cart = Cart::blank(identity: new CartId(value: 'cart-1'));
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 sequence number is authoritative.$cart = Cart::reconstitute(identity: new CartId(value: 'cart-1'), records: $records);
$cart = Cart::reconstitute( identity: new CartId(value: 'cart-1'), records: $laterRecords, snapshot: $snapshot );
Snapshots
Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate.
Capturing a snapshot
-
Snapshot::fromAggregate: reads all declared properties exceptrecordedEventsandsequenceNumber. Both are tracked outsideaggregateStatebecause the snapshot has dedicated fields for them.use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; $snapshot = Snapshot::fromAggregate(aggregate: $cart);
Persisting a snapshot
-
Snapshotter: port for snapshot persistence. TheSnapshotterBehaviortrait captures the snapshot and delegates storage to a concretepersisthook.use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; use TinyBlocks\BuildingBlocks\Snapshot\Snapshotter; use TinyBlocks\BuildingBlocks\Snapshot\SnapshotterBehavior; final class FileSnapshotter implements Snapshotter { use SnapshotterBehavior; protected function persist(Snapshot $snapshot): void { file_put_contents('/var/snapshots/cart.json', $snapshot->getAggregateState()); } } $snapshotter = new FileSnapshotter(); $snapshotter->take(aggregate: $cart);
Deciding when to snapshot
-
SnapshotCondition: strategy for deciding whether a snapshot should be taken at a given point.use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; use TinyBlocks\BuildingBlocks\Snapshot\SnapshotCondition; final class EveryHundredEvents implements SnapshotCondition { public function shouldSnapshot(EventSourcingRoot $aggregate): bool { return $aggregate->getSequenceNumber()->value % 100 === 0; } }
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. Chains of upcasters handle multistep evolution. TheSingleUpcasterBehaviortrait binds the upcaster to a specific migration via three class constants.use TinyBlocks\BuildingBlocks\Upcast\SingleUpcasterBehavior; use TinyBlocks\BuildingBlocks\Upcast\Upcaster; final class ProductV1Upcaster implements Upcaster { use SingleUpcasterBehavior; private const string EXPECTED_EVENT_TYPE = 'ProductAdded'; private const int FROM_REVISION = 1; private const int TO_REVISION = 2; protected function doUpcast(array $data): array { return [...$data, 'quantity' => 1]; } }
Upcasting an event
-
upcast: transforms the event if it matches the expected(type, revision), otherwise returns it unchanged.use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); $upcasted = new ProductV1Upcaster()->upcast(event: $event);
Chaining upcasters
-
Upcasters: ordered collection ofUpcasterinstances.chainfolds them left-to-right over anIntermediateEvent, applying each upcaster in sequence. Upcasters that do not match the current(type, revision)pair pass the event through unchanged.use TinyBlocks\BuildingBlocks\Upcast\Upcasters; $upcasters = Upcasters::createFrom(elements: [ new ProductV1Upcaster(), new ProductV2Upcaster(), ]); $upcasted = $upcasters->chain(event: $event);
Reconstituting from an iterable
-
IntermediateEventimplementsObjectMapper, so it can be reconstituted from an iterable of typed field values. Pass already-constructedEventTypeandRevisioninstances — the mapper maps each field by name.use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; $event = IntermediateEvent::fromIterable(iterable: [ 'type' => EventType::fromString(value: 'ProductAdded'), 'revision' => Revision::of(value: 2), 'serializedEvent' => ['productId' => 'prod-1', 'quantity' => 1], ]);
Default values for new fields
-
DefaultValues: type-to-default-value map for common primitive types, used when an upcast introduces a new field.use TinyBlocks\BuildingBlocks\Upcast\DefaultValues; $defaults = DefaultValues::get();
FAQ
01. Why is DomainEvent an empty marker interface?
A domain event is a fact about something that happened in the domain. It has no technical contract beyond being that
fact. Persistence and transport concerns (type name, revision, aggregate identity) belong to EventRecord, not to
the event itself. Keeping the event pure prevents infrastructure concerns from leaking into the domain model.
02. Why does EventualAggregateRoot store EventRecord instead of DomainEvent?
Only the aggregate has the context needed to build the complete envelope: identity, sequence number, aggregate type
name. Storing raw events and wrapping them later would either duplicate that context or require a second pass.
push builds the full EventRecord immediately, and the outbox adapter reads them as-is with no translation.
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.
04. Why does Revision live on the call site instead of on the event class?
Keeping Revision on the push or when call site makes the aggregate the author of schema evolution. The
event class stays pure. Bumping the revision of an existing event does not require creating a new class.
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.
06. Why are recordedEvents and sequenceNumber excluded from Snapshot::aggregateState?
recordedEvents belongs to the current unit of work, not to the aggregate's intrinsic state. sequenceNumber is
already carried by the snapshot as a first-class field, so duplicating it inside aggregateState would force
consumers to decide which copy is authoritative.
07. Why are custom exceptions declared under Internal\Exceptions instead of the root namespace?
Custom exceptions such as InvalidEventType, InvalidRevision, InvalidSequenceNumber, and
MissingIdentityProperty are implementation details. They extend InvalidArgumentException or
RuntimeException from the PHP standard library, so consumers that catch the broad standard types continue to work;
consumers that need precise handling can catch the specific classes.
08. Why did IDENTITY and MODEL_VERSION move from constants to methods?
Class constants read by reflection inside traits are invisible to static analyzers such as PHPStan and Psalm. Every
concrete aggregate had to annotate @phpstan-ignore-next-line or equivalent suppressions just to satisfy level-9
analysis. Replacing them with a protected identityName(): string method and a protected modelVersion(): int
method makes the contract explicit in PHP's type system: the compiler enforces implementation, IDEs can navigate to
it, and static analyzers raise no warnings — in the library or at consumer sites.
09. Why do Revision, SequenceNumber, and EventType now have private constructors?
These value objects have named static factories that carry semantic meaning: Revision::initial() communicates
"first schema revision", SequenceNumber::first() communicates "first recorded event", and
EventType::fromEvent($event) communicates "derive the type name from this event". Leaving the constructor public
allowed new Revision(value: 1) at call sites, which bypasses the semantic intent and mixes raw construction with
factory conventions. A private constructor forces all creation through the factories, making the intent visible at
every call site. The of() factory on Revision and SequenceNumber covers the loading-from-persistence path.
License
Building Blocks is licensed under MIT.
Contributing
Please follow the contributing guidelines to contribute to the project.