Vortos
CQRS

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.

src/User/Application/Command/RegisterUserCommand.php
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

src/User/Application/Command/RegisterUserHandler.php
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

  1. Validates the command (if a VortosValidator is wired) — throws ValidationException before opening a transaction
  2. Checks idempotency — if key already processed, returns null silently (or throws in strict mode)
  3. Opens UnitOfWork transaction
  4. Calls $result = $handler($command) — return value is threaded through
  5. Calls $result->pullDomainEvents() on the returned aggregate
  6. Dispatches each event to EventBus (writes to outbox inside the transaction)
  7. Commits
  8. Marks idempotency key as processed
  9. Returns $result to 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()
    }
}

On this page