Coding Standards
Key points reference. PHP 8.4. Strict types everywhere. PSR-12 formatting.
When this document and
docs/examples/disagree, the example is authoritative — it is compiled and tested by CI. Please open an issue so the discrepancy gets fixed.
General PHP Baseline
- Always declare
declare(strict_types=1)at the top of every file. - Follow PSR-12 for formatting and file structure.
- Use
readonlyclasses and constructor property promotion for immutable types (Value Objects, Commands, Queries, Events). - Use PHP enums (
enum Foo: string) instead of constants or string literals for fixed sets of values. - Use
@templatePHPStan annotations for compile-time generics (PHP has no runtime generics). - Use named arguments when calling constructors with many parameters.
- No
publicmutable properties — usereadonlyor accessor methods.
<?php
declare(strict_types=1);
namespace MyService\Domain;
use SeedWork\Domain\ValueObject;
readonly class Email extends ValueObject
{
public function __construct(
public readonly string $value,
) {
parent::__construct();
}
protected function validate(): void
{
if (! filter_var($this->value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmail($this->value);
}
}
public function equals(ValueObject $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
}
Do
- Declare strict_types=1 in every file, no exceptions.
- Use constructor property promotion to eliminate boilerplate.
- Use readonly on all properties that must not change after construction.
Don't
- Use array where a typed collection or DTO is appropriate.
- Leave validation outside the constructor for value types.
Do / Don't Overview
| Do | Don't |
|---|---|
readonly class for immutable types |
Public mutable properties |
| Constructor property promotion | Verbose __construct + $this->x = $x |
@template T PHPStan annotations |
Assume PHP has runtime generics |
Maybe for query results |
null returns from query handlers |
Result for command outcomes |
Throwing \DomainException past the bus boundary |
RegistryCommandBus + CommandBusBuilder |
PSR-11 container |
DomainEventPublishingRepository decorator |
Manual domain event publication in handlers or repositories |
| PHP enums for status/type discriminators | String constants or magic values |
Domain Layer
Entity and AggregateRoot
Entity is the base for any domain object with a durable identity. AggregateRoot extends Entity and acts as the consistency boundary of the aggregate cluster — all external writes must go through it.
<?php
declare(strict_types=1);
namespace MyService\Domain;
use SeedWork\Domain\AggregateRoot;
abstract readonly class Account extends AggregateRoot
{
/**
* @param array<\SeedWork\Domain\DomainEvent> $domainEvents
*/
protected function __construct(
AccountId $id,
public readonly string $ownerId,
public readonly Money $balance,
array $domainEvents = [],
) {
parent::__construct($id, $domainEvents);
}
protected function validate(): void {}
public static function open(AccountId $id, string $ownerId, Money $initialDeposit): static
{
return new static(
id: $id,
ownerId: $ownerId,
balance: $initialDeposit,
domainEvents: [AccountOpened::create(
ownerId: $ownerId,
initialBalance: $initialDeposit,
aggregateId: (string) $id,
)],
);
}
public function deposit(Money $amount): static
{
if ($amount->amount <= 0) {
throw new InvalidDepositAmount((string) $this->id);
}
return new static(
id: $this->id,
ownerId: $this->ownerId,
balance: $this->balance->add($amount),
domainEvents: [...$this->getDomainEvents(), MoneyDeposited::create(
amount: $amount,
aggregateId: (string) $this->id,
)],
);
}
}
Key points
- Identity is established at construction; never reassigned.
- Behaviour methods return new instances — the PHP readonly class guarantees no mutation.
- Domain logic (invariants, rules) lives in the entity, not in handlers.
- Use static factory methods (open, reconstitute) — never expose the constructor directly to application code.
- reconstitute creates an entity from persisted state without emitting domain events.
Do
- Throw a \DomainException subclass when an invariant is violated.
- Pass new events in the domainEvents constructor argument when returning a new instance from a behaviour method.
Don't - Put orchestration, I/O, or repository calls inside an entity. - Expose raw state-mutation methods — model named behaviours instead.
Value Object
A Value Object models a domain concept with no identity — two instances with the same data are interchangeable. Immutability is enforced at the language level via readonly; validity is enforced at construction via validate().
<?php
declare(strict_types=1);
namespace MyService\Domain;
use SeedWork\Domain\ValueObject;
readonly class Money extends ValueObject
{
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {
parent::__construct();
}
protected function validate(): void
{
if ($this->amount < 0) {
throw new NegativeAmount($this->amount);
}
if (empty($this->currency)) {
throw new EmptyCurrency();
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new CurrencyMismatch($this->currency, $other->currency);
}
return new self($this->amount + $other->amount, $this->currency);
}
public function equals(ValueObject $other): bool
{
return $other instanceof self
&& $this->amount === $other->amount
&& $this->currency === $other->currency;
}
}
Key points
- readonly class — PHP enforces immutability at the language level.
- equals() is structural: all fields must match.
- Validated at construction — invalid instances cannot exist.
- validate() is protected and called in the constructor; subclasses may extend it.
Don't - Use a Value Object for a concept that needs to be tracked individually over time — that is an Entity.
Aggregate and Domain Events
A Domain Event represents something that happened in the domain that is relevant to the business — a meaningful fact, not a technical operation. Events are always recorded by the aggregate root itself; no external code creates or injects them.
<?php
declare(strict_types=1);
namespace MyService\Domain;
use SeedWork\Domain\DomainEvent;
final readonly class AccountOpened extends DomainEvent
{
private function __construct(
public readonly string $ownerId,
public readonly Money $initialBalance,
string $id,
string $aggregateId,
\DateTimeImmutable $occurredAt,
) {
parent::__construct($id, $aggregateId, $occurredAt);
}
public static function create(
string $ownerId,
Money $initialBalance,
string $aggregateId,
?string $id = null,
?\DateTimeImmutable $occurredAt = null,
): self {
return new self(
$ownerId,
$initialBalance,
$id ?? 'evt-' . uniqid('', true),
$aggregateId,
$occurredAt ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC')),
);
}
}
Key points
- Domain events are named in past tense, business language: AccountOpened, MoneyDeposited.
- readonly class — events are immutable facts.
- Only the aggregate root records domain events — handlers and repositories never create or inject them.
- aggregateId (the emitting aggregate's ID) is required on every domain event — pass it from the aggregate root.
- id and occurredAt are provided by the DomainEvent base; pass them through parent::__construct.
- Payload uses primitives or PHP value objects that can be serialized; no aggregate references.
- Processed synchronously within the same transaction by DomainEventHandler implementations.
- Deletion is not automatically a domain event. Use deleteById for technical removal with no business significance. If deletion is a meaningful domain occurrence (e.g. closing an account), model it as an aggregate behaviour (account->close()) that records the event — the handler then calls save() or deleteById() as appropriate. Never use deleteById when a domain event must be raised.
Do
- Keep payloads minimal and primitive.
- Use a static create() factory on the event class — pass event instances into the aggregate constructor's domainEvents argument.
Don't
- Embed aggregate instances or value object hierarchies in event payloads.
- Use domain events to communicate to other services — use Integration Events for that.
- Add type, version, or correlationId to domain events — those belong on IntegrationEvent.
- Create domain events outside the aggregate — handlers, repositories, and services must never instantiate events directly.
Repository (Domain Port)
A Repository is a domain port — defined in the domain layer, implemented in infrastructure. Its sole concern is the persistence of aggregates: it abstracts storage behind a collection-like interface so the domain never depends on a specific technology. Only aggregate roots have repositories; child entities and value objects are persisted as part of their aggregate, never independently.
<?php
declare(strict_types=1);
namespace MyService\Domain;
use SeedWork\Domain\Repository;
/**
* @extends Repository<AccountId, Account>
*/
interface AccountRepository extends Repository
{
}
Key points
- The interface is defined in the domain layer; the concrete implementation lives in infrastructure.
- Only identity-based operations: findById, save, deleteById.
- Returns full aggregates, never DTOs or raw arrays.
- findById returns ?Aggregate (null when not found) — let the caller decide how to handle absence.
- Use @extends PHPStan annotation to carry type information at analysis time.
Don't
- Add query methods (findByEmail, findByStatus) to the domain repository — define a read port in the application layer.
- Return partial aggregates or associative arrays from the repository.
Domain Exceptions
Domain exceptions represent business rule violations — named, typed, and defined in the domain layer. Each distinct invariant gets its own class extending PHP's \DomainException.
<?php
declare(strict_types=1);
namespace MyService\Domain;
final class AccountNotFoundException extends \DomainException
{
public function __construct(string $accountId)
{
parent::__construct("Account '{$accountId}' was not found.");
}
}
final class InsufficientFundsException extends \DomainException
{
public function __construct(string $accountId, int $available, int $requested)
{
parent::__construct(
"Account '{$accountId}' has insufficient funds: {$available} available, {$requested} requested."
);
}
}
Key points
- Extend PHP's stdlib \DomainException — the seedwork does not provide a custom base class.
- \DomainException subclasses are the only correct way to signal business rule violations.
- The class name is the machine-readable code: InsufficientFundsException becomes insufficient_funds_exception automatically — no need to declare a code manually.
- The constructor message is the human-readable description: include the relevant context (IDs, amounts) so logs and API responses are self-explanatory without having to look up the code.
- The class name is the published error code: RegistryCommandBus converts the short class name from
PascalCase to snake_case to build the ResultError code. InsufficientFunds emits insufficient_funds;
adding an Exception suffix emits insufficient_funds_exception. Renaming the class is therefore a
breaking change for API consumers, not a refactor. This package's examples use the Exception suffix — align
with that convention.
- Caught by RegistryCommandBus and converted to Result::failed(...) automatically — handlers never catch them.
- Infrastructure exceptions (\PDOException, etc.) propagate as-is — do not wrap in \DomainException.
- Define one subclass per distinct business rule violation — keep them in the domain layer of your service.
Application Layer
Command and CommandHandler
A Command expresses an intent to change state — it carries the input data and is validated on construction. A CommandHandler receives a guaranteed-valid command, loads or creates an aggregate, calls the domain method, and saves. No business logic lives in the handler.
<?php
declare(strict_types=1);
namespace MyService\Application\OpenAccount;
use SeedWork\Application\Command;
use SeedWork\Application\ValidationErrors;
use SeedWork\Application\ValidationErrorDetail;
final readonly class OpenAccountCommand extends Command
{
public function __construct(
public readonly string $accountId,
public readonly string $ownerId,
public readonly int $initialAmount,
public readonly string $currency,
) {
parent::__construct(); // triggers validate() — required
}
public function validate(): void
{
$errors = [];
if (empty($this->accountId)) {
$errors[] = new ValidationErrorDetail('account_id_required', 'Account ID is required.');
}
if ($this->initialAmount < 0) {
$errors[] = new ValidationErrorDetail('initial_amount_negative', 'Initial amount cannot be negative.');
}
if (!empty($errors)) {
throw new ValidationErrors($errors);
}
}
}
Each use case declares its own interface, which the handler implements. This is what the composition root and any caller depend on — never the concrete handler class. Name it after the use case in the imperative, with no suffix.
<?php
declare(strict_types=1);
namespace MyService\Application\OpenAccount;
use SeedWork\Application\CommandHandler;
/**
* Application service that handles OpenAccountCommand.
*
* @extends CommandHandler<OpenAccountCommand>
*/
interface OpenAccount extends CommandHandler {}
The generic parameter lives in the docblock: CommandHandler::handle() is typed against the base Command, so
the annotation is the only thing that tells PHPStan which command this use case accepts.
<?php
declare(strict_types=1);
namespace MyService\Application\OpenAccount;
use SeedWork\Application\Command;
use MyService\Domain\Account;
use MyService\Domain\AccountId;
use MyService\Domain\AccountRepository;
use MyService\Domain\Money;
final readonly class OpenAccountCommandHandler implements OpenAccount
{
public function __construct(
private AccountRepository $accounts,
) {}
/**
* @param OpenAccountCommand $command
*/
public function handle(Command $command): void
{
$account = Account::open(
new AccountId($command->accountId),
$command->ownerId,
new Money($command->initialAmount, $command->currency),
);
$this->accounts->save($account);
}
}
Key points
- The handler's sole responsibility: obtain aggregate (or create new) → call domain method → save. No business logic.
- handle() takes the base Command type. PHP forbids narrowing a parameter type in an implementation, so
declaring the concrete command in the signature makes the class fail to load. Narrow it with a
@param XCommand $command docblock instead.
- Do not call publish($aggregate->getDomainEvents()) — DomainEventPublishingRepository does this automatically after save().
- validate() is called automatically in the Command constructor — the handler receives a guaranteed-valid command.
- The handler returns void. The command bus wraps the result in Result::ok() on success or Result::failed(...) on \DomainException.
- ValidationErrors propagates as an exception — it is not converted to Result::failed(). Capture it in a global handler at the entry point.
Don't
- Put conditions over domain state in the handler.
- Manually publish domain events after saving.
- Return values from handle().
- Catch ValidationErrors in the controller — use a global exception handler.
Query and QueryHandler
A Query expresses an intent to read data — validated on construction, always read-only. A QueryHandler returns a Maybe wrapping the result; it never loads full aggregates nor triggers side effects.
<?php
declare(strict_types=1);
namespace MyService\Application\GetBalance;
use SeedWork\Application\Query;
use SeedWork\Application\ValidationErrors;
use SeedWork\Application\ValidationErrorDetail;
final readonly class GetBalanceQuery extends Query
{
public function __construct(
public readonly string $accountId,
) {
parent::__construct(); // triggers validate() — required
}
public function validate(): void
{
if (empty($this->accountId)) {
throw new ValidationErrors([
new ValidationErrorDetail('account_id_required', 'Account ID is required.'),
]);
}
}
}
Each use case declares its own interface, which the handler implements. This is what the composition root and any caller depend on — never the concrete handler class. Name it after the use case in the imperative, with no suffix.
<?php
declare(strict_types=1);
namespace MyService\Application\GetBalance;
use SeedWork\Application\QueryHandler;
/**
* Application service that handles GetBalanceQuery and returns Maybe<BalanceResult>.
*
* @extends QueryHandler<GetBalanceQuery>
*/
interface GetBalance extends QueryHandler {}
The generic parameter lives in the docblock: QueryHandler::handle() is typed against the base Query, so
the annotation is the only thing that tells PHPStan which query this use case accepts.
<?php
declare(strict_types=1);
namespace MyService\Application\GetBalance;
use SeedWork\Application\Maybe;
use SeedWork\Application\Query;
use MyService\Application\ReadRepositories\AccountReadRepository;
final readonly class GetBalanceQueryHandler implements GetBalance
{
public function __construct(
private AccountReadRepository $readRepository,
) {}
/**
* @param GetBalanceQuery $query
*
* @return Maybe<BalanceResult>
*/
public function handle(Query $query): Maybe
{
$balance = $this->readRepository->findBalance($query->accountId);
return $balance !== null
? Maybe::just($balance)
: Maybe::nothing();
}
}
Key points
- handle() returns Maybe — never null, never a raw value.
- handle() takes the base Query type. PHP forbids narrowing a parameter type in an implementation, so
declaring the concrete query in the signature makes the class fail to load. Narrow it with a
@param XQuery $query docblock instead.
- Query handlers are strictly read-only. No save(), no command dispatching.
- Prefer a read repository (application-layer port returning projections/DTOs) once a query spans more
than one aggregate or needs a shape the domain repository doesn't expose. For a single-aggregate read, it
is acceptable to depend on the domain repository directly and project inside the handler — see
GetBankAccountStatusQueryHandler in the example. Don't introduce a read port purely on principle.
- Return Maybe::nothing() for both not-found and unauthorized — do not leak resource existence.
- Validation error codes (like account_id_required above) reach API clients as part of the failure result —
name the rule that was violated, not the field, and use snake_case.
Don't - Load a full aggregate just to extract two fields. - Trigger side effects from a query handler. - Return domain aggregates as query results.
Integration Events
An Integration Event communicates a meaningful business fact from this bounded context to the outside world — other services or bounded contexts that need to react to it. Unlike domain events (internal, synchronous), integration events cross service boundaries and are delivered asynchronously via a message broker. They carry a stable, versioned contract: once published, their schema must not break consumers.
<?php
declare(strict_types=1);
namespace MyService\Application;
use SeedWork\Application\IntegrationEvent;
final readonly class AccountOpenedIntegrationEvent extends IntegrationEvent
{
public const string TYPE = 'account.opened';
public const string VERSION = '1';
public function __construct(
public readonly string $ownerId,
public readonly int $balance,
public readonly string $currency,
string $aggregateId,
string $correlationId,
?string $causationId = null,
) {
parent::__construct(
type: self::TYPE,
version: self::VERSION,
aggregateId: $aggregateId,
payload: [
'ownerId' => $ownerId,
'balance' => $balance,
'currency' => $currency,
],
correlationId: $correlationId,
causationId: $causationId,
metadata: null,
);
}
}
Published from a DomainEventHandler (see below), not from the aggregate and not from the command handler.
IntegrationEventHandler is used on the consumer side (Subscriber entry point):
<?php
declare(strict_types=1);
namespace MyService\Application;
use SeedWork\Application\IntegrationEventHandler;
final class AccountOpenedIntegrationEventHandler implements IntegrationEventHandler
{
public function handle(AccountOpenedIntegrationEvent $event): void
{
// React to an integration event from another bounded context
}
}
Key points
- type and version are constants on the concrete class, passed to parent::__construct.
- aggregateId identifies the source aggregate — pass it from the domain event.
- correlationId is mandatory on every integration event — propagate it from the execution context.
- causationId is the ID of the domain event that triggered this integration event.
- Version from day one: '1' is a version. Schema evolution adds optional fields; never rename or remove existing ones.
- Publish via IntegrationEventPublisher from a DomainEventHandler — never from the aggregate, never from the command handler.
Don't
- Publish integration events directly to the broker from a handler — always go through IntegrationEventPublisher.
- Omit correlationId.
- Mutate an existing integration event schema — introduce a new version instead.
Background Tasks
A Background Task defers work that must happen eventually but does not need to complete within the current transaction — sending emails, triggering webhooks, calling external APIs. Tasks are written to an outbox before the transaction commits, guaranteeing at-least-once execution by an async worker even if the process crashes mid-flight.
<?php
declare(strict_types=1);
namespace MyService\Application;
use SeedWork\Application\BackgroundTask;
final readonly class SendWelcomeEmailTask extends BackgroundTask
{
public const string TYPE = 'send_welcome_email';
public function __construct(
public readonly string $accountId,
public readonly string $email,
string $correlationId,
?string $causationId = null,
string $id = '',
?array $metadata = null,
) {
parent::__construct(
id: $id ?: \Ramsey\Uuid\Uuid::uuid4()->toString(),
type: self::TYPE,
payload: ['accountId' => $accountId, 'email' => $email],
correlationId: $correlationId,
causationId: $causationId,
metadata: $metadata,
);
}
}
<?php
declare(strict_types=1);
namespace MyService\Application;
use SeedWork\Application\TaskHandler;
final class SendWelcomeEmailTaskHandler implements TaskHandler
{
public function handle(SendWelcomeEmailTask $task): void
{
// Send welcome email to $task->email
// Must be idempotent — may be called more than once for the same task ID
}
}
Scheduled from a DomainEventHandler:
$this->taskScheduler->schedule(new SendWelcomeEmailTask(
accountId: $event->aggregateId,
email: $event->ownerEmail,
correlationId: $this->requestContext->correlationId(),
causationId: $event->id,
));
Key points
- TYPE is a static string constant — used by the worker to route the task to the correct TaskHandler.
- Schedule from a DomainEventHandler via $taskScheduler->schedule($task) — never from the aggregate.
- TaskHandler implementations must be idempotent by task ID — at-least-once execution is the delivery guarantee.
- correlationId is mandatory — read it from the execution context (RequestContext), not from the domain event.
- Set causationId to the domain event ID that triggered the task.
Don't
- Perform fire-and-forget work without going through TaskScheduler — tasks not in the outbox are lost on crash.
- Use background tasks to communicate with other services — use integration events for that.
Execution context — correlationId propagation
correlationId is a cross-cutting tracing concern. It is set at the entry point and read by any component that needs it — without threading it through function signatures. The Command and DomainEvent do not carry correlationId.
In traditional synchronous PHP (one process per request), a request-scoped service is the idiomatic mechanism:
<?php
declare(strict_types=1);
namespace MyService\Infrastructure;
final class RequestContext
{
private string $correlationId = '';
public function initialize(string $correlationId): void
{
$this->correlationId = $correlationId;
}
public function correlationId(): string
{
return $this->correlationId;
}
}
At the entry point (controller, subscriber):
// Read from HTTP header or generate a new one
$correlationId = $request->headers->get('X-Correlation-ID') ?? (string) Uuid::uuid4();
$requestContext->initialize($correlationId);
Inject RequestContext into any DomainEventHandler or infrastructure component that needs to propagate correlationId. The seedwork does not provide this abstraction — define it in your service.
In async PHP (Swoole, FrankenPHP workers), use fiber-local storage or an equivalent mechanism to avoid cross-request leakage.
DomainEventHandler (wiring Integration Events and Tasks)
A DomainEventHandler reacts to a domain event inside the same transaction. It is the only place where integration events are published and background tasks are scheduled — never from the aggregate or the command handler.
<?php
declare(strict_types=1);
namespace MyService\Application;
use SeedWork\Application\DomainEventHandler;
use SeedWork\Application\IntegrationEventPublisher;
use SeedWork\Application\TaskScheduler;
use MyService\Infrastructure\RequestContext;
final class AccountOpenedDomainEventHandler implements DomainEventHandler
{
public function __construct(
private readonly IntegrationEventPublisher $integrationEventPublisher,
private readonly TaskScheduler $taskScheduler,
private readonly RequestContext $requestContext,
) {}
public function handle(AccountOpened $event): void
{
$this->integrationEventPublisher->publish(
new AccountOpenedIntegrationEvent(
ownerId: $event->ownerId,
balance: $event->initialBalance->amount,
currency: $event->initialBalance->currency,
aggregateId: $event->aggregateId,
correlationId: $this->requestContext->correlationId(),
causationId: $event->id,
),
);
$this->taskScheduler->schedule(
new SendWelcomeEmailTask(
accountId: $event->aggregateId,
email: $event->ownerEmail,
correlationId: $this->requestContext->correlationId(),
causationId: $event->id,
),
);
}
}
Key points
- Inject IntegrationEventPublisher and TaskScheduler — never instantiate them in the handler.
- Both publish() and schedule() write to the outbox inside the current transaction. Actual delivery is eventual.
- IntegrationEventPublisher::publish() takes a single IntegrationEvent — call it once per event.
- Read correlationId from RequestContext — DomainEvent does not carry it.
- Set causationId to the domain event's id.
- Use $event->aggregateId (available on the DomainEvent base) — no need to duplicate it as a field on the event.
- This is structurally enforced when the bus is DeferredDomainEventBus: if a handler dispatches another command that reaches the same bus instance while the outer dispatch() is still running, the bus raises \LogicException rather than silently corrupting the pending buffer. Route side effects through IntegrationEventPublisher/TaskScheduler, not back through the command bus.
Result and Maybe
Result is the return type of every command dispatch: ok on success, failed with typed errors on a business rule violation. Maybe is the return type of every query: just(value) when found, nothing() when absent. Both eliminate null returns and unchecked exceptions from the application boundary.
// Command bus returns Result — inspect at the entry point (controller)
$result = $commandBus->dispatch($command);
if ($result->isFailed()) {
// $result->errors() returns array<ResultError>
return $this->errorResponse($result->errors());
}
// Query bus returns Maybe — inspect at the entry point
$maybe = $queryBus->ask($query);
if (!$maybe->hasValue()) {
return $this->notFoundResponse();
}
$data = $maybe->value();
Key points
- Result::ok() — success. Result::failed(array<ResultError> $errors) — domain failure.
- Maybe::just($value) — found. Maybe::nothing() — absent.
- Maybe::hasValue() checks presence; Maybe::value() retrieves the value (throws if nothing).
- The command bus catches \DomainException and converts it to Result::failed(...). Handlers never return Result directly.
- ValidationErrors propagates as an exception — not converted to Result::failed(). Capture it globally at the entry point.
- Infrastructure exceptions propagate as-is — not wrapped in Result.
Infrastructure Layer
DomainEventBus Wiring
The DeferredDomainEventBus is the standard implementation of DomainEventBus. Instantiate it once, wire subscriptions, then pass it to DomainEventPublishingRepository and CommandBusBuilder.
<?php
declare(strict_types=1);
use SeedWork\Infrastructure\DeferredDomainEventBus;
use SeedWork\Infrastructure\DomainEventPublishingRepository;
use MyService\Domain\AccountOpened;
use MyService\Application\AccountOpenedDomainEventHandler;
// 1. Instantiate the bus
$domainEventBus = new DeferredDomainEventBus();
// 2. Register subscriptions (composition root)
$domainEventBus->subscribe(
AccountOpened::class,
new AccountOpenedDomainEventHandler(
$integrationEventPublisher,
$taskScheduler,
$requestContext,
),
);
// 3. Wrap the domain repository with the publishing decorator.
// Extend DomainEventPublishingRepository and implement your domain repository
// interface so command handlers are typed against the domain port, not the decorator.
$publishingRepository = new ConcretePostgresAccountRepository(
$pdo,
$domainEventBus, // satisfies DomainEventBusPublisher
);
DeferredDomainEventBusbuffers pending events in a plain instance property with no per-context isolation. Under classic PHP-FPM/CLI, one process handles one request, so this is safe. Under long-lived async PHP runtimes (Swoole, OpenSwoole, RoadRunner, FrankenPHP worker mode, ReactPHP, AMPHP, native Fibers) the DI container is bootstrapped once per worker and reused across concurrently-interleaved requests/coroutines — wiringDeferredDomainEventBus(or any customDomainEventBusimplementation with internal buffering state) as a container singleton lets one coroutine'sdiscard()/dispatch()corrupt another coroutine's still-buffered events. Instantiate it per request/fiber (as in the example above), never as a singleton, in these runtimes.
Key points
- DeferredDomainEventBus buffers events by ID (idempotent) and dispatches them after the handler completes but before commit.
- Subscribe with the fully-qualified class name of the domain event (AccountOpened::class).
- Pass the bus as DomainEventBusPublisher to the repository constructor — the handler never sees it.
- Pass the bus as DomainEventBus to CommandBusBuilder::withDomainEventCoordination().
- DomainEventPublishingRepository is an abstract decorator — extend it in your typed infrastructure class and call parent::__construct($repository, $eventBus). It cannot be instantiated directly; extending it preserves the typed domain repository interface for command handlers.
- publish(), dispatch(), and discard() guard against reentrancy: calling any of them from within a handler that runs during an in-flight dispatch() on the same instance raises \LogicException. This is not a scoping concern (see per-request instantiation above) — it fires even within a single request/fiber if a handler dispatches a command that reaches this same bus instance again before the outer dispatch() returns. Sequential, non-nested usage (the pattern shown above) is unaffected.
CommandBusBuilder — Full Wiring
The correct bus stack for a write operation is:
TransactionalCommandBus → DomainEventCoordinatorCommandBus → RegistryCommandBus
<?php
declare(strict_types=1);
use SeedWork\Infrastructure\CommandBusBuilder;
use SeedWork\Infrastructure\RegistryCommandBus;
// 1. Build the registry and register handlers
$registry = new RegistryCommandBus();
$registry->register(OpenAccountCommand::class, new OpenAccountHandler($publishingRepository));
$registry->register(DepositMoneyCommand::class, new DepositMoneyHandler($publishingRepository));
// 2. Compose the bus pipeline
$commandBus = (new CommandBusBuilder($registry))
->withTransaction($unitOfWork) // TransactionalCommandBus (outermost)
->withDomainEventCoordination($domainEventBus) // DomainEventCoordinatorCommandBus
->build(); // RegistryCommandBus (innermost)
Key points
- Always use CommandBusBuilder — never instantiate bus decorators manually.
- No PSR-11 container. Handlers are registered by command class name with concrete instances via RegistryCommandBus::register().
- withTransaction($unitOfWork) must come before withDomainEventCoordination() — events are dispatched inside the transaction.
- withDomainEventCoordination($domainEventBus) calls dispatch() on success and discard() on failure.
- withTransaction($unitOfWork) commits only on Result::ok(). A Result::failed() (a \DomainException thrown by the handler) or any exception rolls the transaction back, so a rejected command never leaves partial writes.
- Command validation happens automatically in the Command constructor — no additional validation layer is needed.
Don't
- Register handlers with a PSR-11 container string — pass instances directly.
- Place withDomainEventCoordination() outside withTransaction() — events must be dispatched within the open transaction.
QueryBusBuilder
QueryBusBuilder composes the query pipeline. Unlike the command bus, it requires no transaction or domain event coordination — query handlers are strictly read-only.
use SeedWork\Infrastructure\QueryBusBuilder;
use SeedWork\Infrastructure\RegistryQueryBus;
$registry = new RegistryQueryBus();
$registry->register(GetBalanceQuery::class, new GetBalanceHandler($readRepository));
$queryBus = (new QueryBusBuilder($registry))->build();
Key points
- Query handlers must not open transactions — no withTransaction() on the query bus.
- Query validation happens automatically in the Query constructor.
InMemoryRepository
Use InMemoryRepository from the Testing namespace in tests — it is a concrete class implementing the InMemoryRepositorySpy interface.
<?php
declare(strict_types=1);
namespace MyService\Tests\Infrastructure;
use SeedWork\Testing\InMemoryRepository;
use MyService\Domain\Account;
use MyService\Domain\AccountId;
use MyService\Domain\AccountRepository;
/**
* @extends InMemoryRepository<AccountId, Account>
*/
final class InMemoryAccountRepository extends InMemoryRepository implements AccountRepository
{
}
Key points
- Located at SeedWork\Testing\InMemoryRepository — not in Infrastructure.
- Implements InMemoryRepositorySpy: exposes all() and reset() for test assertions.
- Do not override findById() or deleteById() with narrower parameter types — the base signatures use mixed $id. Rely on the @extends PHPStan annotation for compile-time type checking.
DeferredDomainEventBusSpy (Testing)
For tests that need to assert on buffered domain events or reset the bus between scenarios, use DeferredDomainEventBusSpy instead of DeferredDomainEventBus:
use SeedWork\Testing\DeferredDomainEventBusSpy;
$domainEventBus = new DeferredDomainEventBusSpy();
// after dispatching a command:
$this->assertCount(1, $domainEventBus->pending());
$this->assertInstanceOf(AccountOpened::class, $domainEventBus->pending()[0]);
// reset between test scenarios (preserves handler subscriptions):
$domainEventBus->reset();
pending(): list<DomainEvent>— events buffered but not yet dispatched.reset()clears the buffer without dispatching. Different fromdiscard()(production lifecycle call):reset()bypasses the reentrancy guard entirely — it exists for test introspection between scenarios, not for production dispatch flow.- Use
DeferredDomainEventBusin production wiring;DeferredDomainEventBusSpyin tests only.
Naming Conventions
| Concept | Convention | Example |
|---|---|---|
| Classes, interfaces, enums | PascalCase |
AccountRepository, OpenAccountCommand |
| Methods | camelCase |
findById, getDomainEvents, handle |
| Properties | camelCase |
$accountId, $occurredAt |
| Files | PascalCase.php |
OpenAccountCommandHandler.php |
| Namespaces | PascalCase, mirror directory structure |
MyService\Application\OpenAccount |
| Constants | UPPER_SNAKE_CASE |
TYPE, VERSION |
| Commands | {Verb}{Noun}Command |
OpenAccountCommand, DepositMoneyCommand |
| Queries | {Get/Find}{Noun}Query |
GetBalanceQuery, FindTransactionQuery |
| Use case interfaces | imperative, no suffix | OpenAccount, WithdrawMoney |
| Command handlers | {Command}Handler |
OpenAccountCommandHandler |
| Query handlers | {Query}Handler |
GetBankAccountStatusQueryHandler |
| Domain Events | Past tense, PascalCase |
AccountOpened, MoneyDeposited |
| Integration Events | Past tense + suffix | AccountOpenedIntegrationEvent |
| Background Tasks | Imperative + suffix | SendWelcomeEmailTask |
| Repositories | {Aggregate}Repository |
AccountRepository |
| Read Repositories | {Aggregate}ReadRepository |
AccountReadRepository |
| InMemory test doubles | InMemory{Aggregate}Repository |
InMemoryAccountRepository |
PHPStan Annotations for Generics
PHP 8.4 has no runtime generics. Use PHPStan @template annotations to carry type information at static analysis time.
/**
* A concrete port binds the type parameters — it does not re-declare them.
*
* @extends Repository<AccountId, Account>
*/
interface AccountRepository extends Repository {}
/**
* @template TId
* @template TAgg of AggregateRoot<TId>
* @implements InMemoryRepositorySpy<TId, TAgg>
*/
final class InMemoryAccountRepository extends InMemoryRepository implements AccountRepository
{
/** @var array<string, TAgg> */
// inherited store from InMemoryRepository
}
Key points
- Always annotate generic interfaces and base classes with @template.
- Use @extends and @implements to propagate type parameters in subclasses.
- A concrete port (like AccountRepository above) binds the base interface's type parameters — it does not
re-declare @template itself, and it does not need to re-implement inherited methods just to narrow a
docblock.
- QueryHandler takes one template parameter — the query type. The result type is not a second parameter;
@extends QueryHandler<GetBalanceQuery, BalanceResult> does not type-check. Declare the result on the
handler's @return Maybe<BalanceResult> docblock instead.
- PHPStan enforces these at analysis time — they have no runtime effect.
- IDs are mixed — use (string) $id to normalize before storage.
- A @template parameter can carry a default, so untyped consumers keep working unchanged
(@template TPayload of array<string, mixed> = array<string, mixed> on BackgroundTask and
IntegrationEvent). Subclasses that want a narrower payload opt in with
@extends BackgroundTask<array{orderId: string}>; subclasses that don't specialize it are
unaffected — this is how a generic parameter is added to an existing base class without a
breaking change.