Commands
Write command handlers —
Commands
Define a Command
Commands implement CommandInterface (or extend the convenience base AbstractCommand). The interface requires one method: idempotencyKey(): ?string. AbstractCommand provides a default returning null so you only override it when you need idempotency.
use Vortos\Domain\Command\CommandInterface;
final readonly class RegisterUserCommand implements CommandInterface
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly string $password,
private readonly ?string $idempotencyKey = null,
) {}
public function idempotencyKey(): ?string
{
return $this->idempotencyKey;
}
}Or extend AbstractCommand when you don't need idempotency at all:
use Vortos\Domain\Command\AbstractCommand;
final readonly class DeleteUserCommand extends AbstractCommand
{
public function __construct(public string $userId) {}
// idempotencyKey() inherited — returns null
}Write a Handler
use Vortos\Cqrs\Attribute\AsCommandHandler;
#[AsCommandHandler]
final class RegisterUserHandler
{
public function __construct(
private readonly UserWriteRepository $repository,
) {}
public function __invoke(RegisterUserCommand $command): User
{
$user = User::register(
name: $command->name,
email: $command->email,
plainPassword: $command->password,
);
$this->repository->save($user);
return $user; // Return aggregate — bus dispatches its domain events
}
}What the CommandBus Does Automatically
- Validates the command (if a
VortosValidatoris wired) — throwsValidationExceptionbefore opening a transaction - Checks idempotency — if key already processed, returns
nullsilently (or throws in strict mode) - Opens
UnitOfWorktransaction - Calls
$result = $handler($command)— return value is threaded through - Calls
$result->pullDomainEvents()on the returned aggregate - Dispatches each event to
EventBus(writes to outbox inside the transaction) - Commits
- Marks idempotency key as processed
- Returns
$resultto the caller
Your handler only does domain logic and saves. Never call beginTransaction(), commit(), or dispatch() inside a handler.
Return the Aggregate
Return the aggregate from your handler so the bus can pull and dispatch domain events. If your handler returns null, no events are dispatched — domain events are silently dropped.
Dispatch from a Controller
dispatch() returns mixed — whatever the handler returns. Capture it when you need the aggregate ID or other data from the handler:
use Vortos\Cqrs\Command\CommandBusInterface;
final class RegisterUserController
{
public function __construct(private readonly CommandBusInterface $commandBus) {}
public function __invoke(Request $request): JsonResponse
{
$dto = RegisterUserRequest::fromRequest($request, $this->validator);
/** @var User $user */
$user = $this->commandBus->dispatch(new RegisterUserCommand(
name: $dto->name,
email: $dto->email,
password: $dto->password,
idempotencyKey: $request->headers->get('Idempotency-Key'),
));
return new JsonResponse([
'id' => (string) $user->getId(),
'name' => $user->getName(),
'email' => (string) $user->getEmail(),
], 201);
}
}If you do not need the return value, discard it:
$this->commandBus->dispatch(new DeleteUser($id));
return new JsonResponse(null, 204);
## Handler Discovery — Inferred from __invoke()
`CommandHandlerPass` infers the command class from the `__invoke()` first parameter type — no explicit `handles:` needed:
```php
// These two are equivalent:
#[AsCommandHandler] // inferred
final class RegisterUserHandler
{
public function __invoke(RegisterUser $command): User { ... }
}
#[AsCommandHandler(handles: RegisterUser::class)] // explicit
final class RegisterUserHandler
{
public function __invoke(RegisterUser $command): User { ... }
}Use the explicit form when you want to make the relationship obvious in code review or for IDE navigation.
Compile-Time Validation
If two handlers are registered for the same command, CommandHandlerPass throws at compile time:
Two command handlers registered for "App\RegisterUser":
"RegisterUserHandler" and "LegacyRegisterUserHandler".
Each command must have exactly one handler.If __invoke() is missing or has no typed first parameter, CommandHandlerPass also throws with a clear message.
CommandHandlerNotFoundException
Dispatching a command with no registered handler throws at runtime:
use Vortos\Cqrs\Exception\CommandHandlerNotFoundException;
try {
$this->commandBus->dispatch(new SomeCommand());
} catch (CommandHandlerNotFoundException $e) {
// Programming error — handler not registered
}This is always a programming error, not a user error. It should never reach production.
AbstractCommand
abstract readonly class AbstractCommand implements CommandInterface
{
// Default returns null — override to enable idempotency
public function idempotencyKey(): ?string
{
return null;
}
}Override idempotencyKey() to return a client-generated UUID when idempotency is needed. See the Idempotency page for all options.
Handlers Are Transactional — No Manual Transactions
The CommandBus wraps every handler in UnitOfWork::run(). Never manage transactions inside a handler:
// WRONG — never do this
final class RegisterUserHandler
{
public function __invoke(RegisterUser $command): void
{
$this->db->beginTransaction(); // NO
// ...
$this->db->commit(); // NO
}
}
// CORRECT — just do domain logic and save
final class RegisterUserHandler
{
public function __invoke(RegisterUser $command): User
{
$user = User::register($command->email, $command->name);
$this->users->save($user);
return $user; // returned to caller via dispatch()
}
}