Getting Started — php-seedwork
This guide walks you through the core building blocks of php-seedwork, using a simplified bank-account scenario. A complete working example is in docs/examples/BankAccount/.
Installation
composer require aseguragonzalez/php-seedwork
Requires PHP 8.4 or later.
1. Creating a Value Object
Value objects are immutable and equal by value, not identity. Extend ValueObject, add readonly properties, and implement equals() and validate().
use SeedWork\Domain\ValueObject;
final readonly class Money extends ValueObject
{
public function __construct(
public int $amount,
public string $currency,
) {
parent::__construct(); // triggers validate()
}
public function equals(ValueObject $other): bool
{
return $other instanceof self
&& $this->amount === $other->amount
&& $this->currency === $other->currency;
}
protected function validate(): void
{
if ($this->amount <= 0) {
throw new \DomainException('Amount must be greater than 0');
}
}
}
2. Creating a Domain Event
Domain events are immutable records of something that happened. Extend DomainEvent and add readonly properties for the domain-specific facts. Use a private constructor and a static factory (create()).
use SeedWork\Domain\DomainEvent;
final readonly class MoneyDeposited extends DomainEvent
{
private function __construct(
public int $amount,
string $id,
string $aggregateId,
\DateTimeImmutable $occurredAt,
) {
parent::__construct($id, $aggregateId, $occurredAt);
}
public static function create(
string $aggregateId,
int $amount,
?string $id = null,
?\DateTimeImmutable $occurredAt = null,
): self {
return new self(
$amount,
$id ?? 'evt-' . uniqid('', true),
$aggregateId,
$occurredAt ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC')),
);
}
}
Key rules:
- Name events in past tense (MoneyDeposited, not DepositMoney).
- Use UTC for occurredAt.
- equals() is inherited and compares by string id.
3. Creating an Aggregate Root
Aggregates enforce invariants and raise domain events. Extend AggregateRoot, keep state immutable (return a new instance from every state-change method), and record events by passing them to the constructor.
use SeedWork\Domain\AggregateRoot;
final readonly class BankAccount extends AggregateRoot
{
private function __construct(
BankAccountId $id,
private int $balance,
array $domainEvents = [],
) {
parent::__construct($id, $domainEvents);
}
public static function open(BankAccountId $id, int $initialBalance = 0): self
{
return new self($id, $initialBalance, [AccountOpened::create($id, $initialBalance)]);
}
public function deposit(int $amount): self
{
$event = MoneyDeposited::create($this->id->value, $amount);
return new self($this->id, $this->balance + $amount, [...$this->getDomainEvents(), $event]);
}
public function getBalance(): int
{
return $this->balance;
}
protected function validate(): void {}
}
4. Defining the Repository (interface)
Repositories belong to the domain layer. Define an interface per aggregate that extends Repository<TId, T>. Implementations live in the infrastructure layer.
use SeedWork\Domain\Repository;
/**
* @extends Repository<BankAccountId, BankAccount>
*/
interface BankAccountRepository extends Repository {}
The Repository<TId, T> interface declares:
- save(AggregateRoot $aggregateRoot): void
- findById(mixed $id): ?AggregateRoot
- deleteById(mixed $id): void
5. Creating a Command and Handler
Commands are immutable DTOs for write use cases. Handlers orchestrate the domain and return void; the bus wraps them in Result.
use SeedWork\Application\Command;
use SeedWork\Application\ValidationErrorDetail;
use SeedWork\Application\ValidationErrors;
final readonly class DepositMoneyCommand extends Command
{
public function __construct(
public string $accountId,
public int $amount,
public string $currency,
) {
parent::__construct();
}
public function validate(): void
{
$errors = [];
if (empty($this->accountId)) {
$errors[] = new ValidationErrorDetail('account_id_required', 'Account ID is required.');
}
if ($this->amount <= 0) {
$errors[] = new ValidationErrorDetail('amount_must_be_positive', 'Amount must be positive.');
}
if (count($errors) > 0) {
throw new ValidationErrors($errors);
}
}
}
use SeedWork\Application\Command;
use SeedWork\Application\CommandHandler;
final readonly class DepositMoneyCommandHandler implements CommandHandler
{
public function __construct(
private Repository $repository,
) {}
public function handle(Command $command): void
{
/** @var DepositMoneyCommand $command */
$accountId = BankAccountId::fromString($command->accountId);
$account = $this->repository->findById($accountId)
?? throw new \DomainException("BankAccount '{$accountId}' not found");
$updated = $account->deposit($command->amount);
$this->repository->save($updated);
}
}
6. Creating a Query and Handler
Queries are immutable DTOs for read use cases. Handlers return Maybe<T> — either a value (Maybe::just($result)) or nothing (Maybe::nothing()).
use SeedWork\Application\Query;
use SeedWork\Application\ValidationErrorDetail;
use SeedWork\Application\ValidationErrors;
final readonly class GetAccountBalanceQuery extends Query
{
public function __construct(public string $accountId)
{
parent::__construct();
}
public function validate(): void
{
if (empty($this->accountId)) {
throw new ValidationErrors([new ValidationErrorDetail('account_id_required', 'Account ID is required.')]);
}
}
}
use SeedWork\Application\Maybe;
use SeedWork\Application\Query;
use SeedWork\Application\QueryHandler;
final readonly class GetAccountBalanceQueryHandler implements QueryHandler
{
public function __construct(private BankAccountRepository $repository) {}
public function handle(Query $query): Maybe
{
/** @var GetAccountBalanceQuery $query */
$account = $this->repository->findById(BankAccountId::fromString($query->accountId));
if ($account === null) {
return Maybe::nothing();
}
return Maybe::just(new AccountBalanceResult($account->id->value, $account->getBalance()));
}
}
7. Wiring the Bus with CommandBusBuilder
Use CommandBusBuilder to compose a CommandBus pipeline without any DI container (no PSR-11). Decorators are applied outermost-first: validation runs before the transaction, which runs before event coordination.
use SeedWork\Infrastructure\CommandBusBuilder;
use SeedWork\Infrastructure\DeferredDomainEventBus;
use SeedWork\Infrastructure\RegistryCommandBus;
$domainEventBus = new DeferredDomainEventBus();
$registry = new RegistryCommandBus();
$registry->register(DepositMoneyCommand::class, new DepositMoneyCommandHandler($publishingRepository));
$commandBus = (new CommandBusBuilder($registry))
->withTransaction($unitOfWork) // wraps in DB transaction
->withDomainEventCoordination($domainEventBus) // dispatches or discards events after command
->build();
$result = $commandBus->dispatch(new DepositMoneyCommand($accountId, 100, 'USD'));
if ($result->isOk()) {
// success
} elseif ($result->isFailed()) {
foreach ($result->errors() as $error) {
echo $error->code . ': ' . $error->description;
}
}
The full stack (outermost → innermost):
TransactionalCommandBus → DomainEventCoordinatorCommandBus → RegistryCommandBus
Note:
withTransaction()requires aUnitOfWorkimplementation (e.g. a Doctrine wrapper). Omit it when there is no database transaction boundary (e.g. in tests).
8. Publishing Domain Events with DomainEventPublishingRepository
Extend DomainEventPublishingRepository with a thin typed wrapper that also implements your domain repository interface. This is required because PHP has no runtime generics: the base class only implements the seedwork Repository interface, so passing it directly where a BankAccountRepository is expected causes a TypeError.
use SeedWork\Application\DomainEventBusPublisher;
use SeedWork\Infrastructure\DomainEventPublishingRepository;
final class PublishingBankAccountRepository
extends DomainEventPublishingRepository
implements BankAccountRepository
{
public function __construct(
BankAccountRepository $repository,
DomainEventBusPublisher $eventBus,
) {
parent::__construct($repository, $eventBus);
}
}
use SeedWork\Infrastructure\DeferredDomainEventBus;
$domainEventBus = new DeferredDomainEventBus();
// Subscribe handlers before wiring
$domainEventBus->subscribe(
AccountOpened::class,
new AccountOpenedDomainEventHandler($integrationEventPublisher)
);
// Typed wrapper — satisfies BankAccountRepository, publishes events after save()
$publishingRepository = new PublishingBankAccountRepository($repository, $domainEventBus);
DeferredDomainEventBus buffers events keyed by event.id (idempotent). The DomainEventCoordinatorCommandBus calls dispatch() on success or discard() on failure/exception.
9. Running it
A minimal composition root that ties everything together:
use SeedWork\Infrastructure\CommandBusBuilder;
use SeedWork\Infrastructure\DeferredDomainEventBus;
use SeedWork\Testing\InMemoryIntegrationEventPublisher;
use SeedWork\Infrastructure\RegistryCommandBus;
$repository = new InMemoryBankAccountRepository();
$integrationPub = new InMemoryIntegrationEventPublisher();
$domainEventBus = new DeferredDomainEventBus();
$domainEventBus->subscribe(AccountOpened::class, new AccountOpenedDomainEventHandler($integrationPub));
$publishingRepo = new PublishingBankAccountRepository($repository, $domainEventBus);
$registry = new RegistryCommandBus();
$registry->register(DepositMoneyCommand::class, new DepositMoneyCommandHandler($publishingRepo));
$commandBus = (new CommandBusBuilder($registry))
->withDomainEventCoordination($domainEventBus)
->build();
$result = $commandBus->dispatch(new DepositMoneyCommand($accountId, 100, 'USD'));
See the full BankAccount example for a complete working implementation including queries, transfers, and a composition root.