Vortos
Domain

Aggregates

AggregateRoot — version-based optimistic locking, domain event recording, pullDomainEvents, and the reconstruct pattern.

Aggregates

AggregateRoot is the base class for all domain aggregates. It provides version tracking for optimistic locking and domain event collection.

Define an Aggregate

src/User/Domain/User.php
use Vortos\Domain\Aggregate\AggregateRoot;
use Vortos\Domain\Identity\AggregateId;

final class User extends AggregateRoot
{
    private function __construct(
        private readonly UserId $id,
        private readonly Email $email,
        private string $name,
        private string $passwordHash,
    ) {}

    // Named constructor for new aggregates
    public static function register(string $name, string $email, string $plainPassword): self
    {
        $id   = UserId::generate();
        $user = new self(
            id:           $id,
            email:        new Email($email),
            name:         $name,
            passwordHash: password_hash($plainPassword, PASSWORD_BCRYPT),
        );

        // Record event — dispatched by CommandBus after commit
        $user->recordEvent(new UserRegisteredEvent(
            aggregateId: (string) $id,
            email:       $email,
            name:        $name,
        ));

        return $user;
    }

    // Named constructor for loading from DB
    public static function reconstruct(
        UserId $id,
        Email $email,
        string $name,
        string $passwordHash,
    ): self {
        return new self(
            id:           $id,
            email:        $email,
            name:         $name,
            passwordHash: $passwordHash,
        );
    }

    public function getId(): UserId { return $this->id; }
    public function getEmail(): Email { return $this->email; }
    public function getName(): string { return $this->name; }
    public function getPasswordHash(): string { return $this->passwordHash; }

    // Command method — mutates state and records event
    public function updateName(string $name): void
    {
        $old = $this->name;
        $this->name = $name;
        $this->recordEvent(new UserNameUpdatedEvent((string) $this->id, $old, $name));
    }
}

AggregateRoot API

// Abstract — must implement
abstract public function getId(): AggregateId;

// Record an event (called inside command methods)
protected function recordEvent(DomainEventInterface $event): void;

// Pull all recorded events and clear the buffer
public function pullDomainEvents(): array;

// Check if any events are pending
public function hasDomainEvents(): bool;

// Optimistic locking
public function getVersion(): int;
protected function restoreVersion(int $version): void; // @internal — called by DbalStore::hydrate() automatically
public function incrementVersion(): void;              // @internal — called by DbalStore after save

Domain Event Recording

Aggregates never dispatch events — they record them. The CommandBus calls pullDomainEvents() inside the transaction and writes them to the outbox:

Command → CommandBus
    UnitOfWork::run()
        handler() → aggregate.recordEvent(...)
        repository.save(aggregate)
        foreach aggregate.pullDomainEvents() as event
            outbox.store(event)   ← same transaction
    commit()

Record events at the end of command methods, after state has been mutated:

public function updateEmail(Email $newEmail): void
{
    $old = $this->email;
    $this->email = $newEmail;           // mutate first
    $this->recordEvent(new UserEmailUpdatedEvent(...)); // record after
}

Version and Optimistic Locking

Every aggregate starts at version = 0. The repository increments it on every successful save:

new User()        version = 0
save()            version = 1   (INSERT)
save()            version = 2   (UPDATE WHERE version = 1)
save()            version = 3   (UPDATE WHERE version = 2)

If two processes load version 2 and both try to save, the second save fails because the database version is already 3. See Optimistic Locking for handling strategies.

Never call incrementVersion() or restoreVersion() directly. incrementVersion() is called by DbalStore after a successful save; restoreVersion() is called automatically by DbalStore::hydrate() when loading from the database.

The reconstruct() Pattern

reconstruct() is a plain named constructor — it does not need to restore lock_version. DbalStore calls hydrate() after fromRow(), which restores the version via Closure::bind() automatically:

// Aggregate — no lock_version parameter needed
public static function reconstruct(
    UserId $id,
    Email $email,
    string $name,
    string $passwordHash,
): self {
    return new self($id, $email, $name, $passwordHash);
}
// DbalMapper::fromRow() — no (int) $row['lock_version'] needed
public function fromRow(array $row): AggregateRoot
{
    return User::reconstruct(
        id:           UserId::fromString($row['id']),
        email:        new Email($row['email']),
        name:         $row['name'],
        passwordHash: $row['password_hash'],
    );
}

DbalStore::hydrate() is what actually calls restoreVersion() — never call it yourself. For custom query methods in your repository, use $this->store->hydrate($row) instead of $this->store->mapper()->fromRow($row) directly, so the version is always restored.

EventSourcedAggregateRoot

EventSourcedAggregateRoot is stubbed but not yet implemented. Extending it throws \LogicException. Use AggregateRoot for all production code.

Private Constructor

Use a private constructor and named static factories (register(), reconstruct()). This prevents invalid aggregates from being created — the factory enforces all invariants. Never expose a public constructor.

On this page