Vortos
Domain

Commands

AbstractCommand and CommandInterface — define commands with readonly properties, idempotency keys via method override or

Commands

Define a Command

Commands extend AbstractCommand — a readonly abstract class:

src/User/Application/Command/RegisterUser.php
use Vortos\Domain\Command\AbstractCommand;

final readonly class RegisterUser extends AbstractCommand
{
    public function __construct(
        public string $email,
        public string $name,
        public string $password,
    ) {}
}

Commands are readonly — their properties cannot be changed after construction. They carry exactly the data needed to perform the operation.

CommandInterface

interface CommandInterface
{
    public function idempotencyKey(): ?string;
}

AbstractCommand provides a default idempotencyKey() returning null — no idempotency by default. Override to enable it.

Idempotency Key — Method Override

Override idempotencyKey() for custom logic:

final readonly class ProcessPayment extends AbstractCommand
{
    public function __construct(
        public string $paymentId,
        public int $amount,
        public string $currency,
    ) {}

    public function idempotencyKey(): ?string
    {
        return $this->paymentId;
    }
}

Idempotency Key — Property Attribute

Mark a single property with #[AsIdempotencyKey] for the common case:

use Vortos\Domain\Command\AsIdempotencyKey;

final readonly class RegisterUser extends AbstractCommand
{
    public function __construct(
        #[AsIdempotencyKey]
        public string $requestId,   // client-generated UUID
        public string $email,
        public string $name,
    ) {}
}

The IdempotencyKeyPass compiler pass detects #[AsIdempotencyKey] at compile time. At runtime the CommandBus reads $command->requestId directly — no reflection.

When to Add Idempotency

Add an idempotency key to commands where duplicate execution has bad consequences:

CommandNeed Idempotency?Why
RegisterUserYesDuplicate creates two accounts
ProcessPaymentYesDuplicate charges the customer twice
SendWelcomeEmailYesDuplicate sends two emails
UpdateUserNameNoLast-write-wins is acceptable
LogPageViewNoDuplicates are harmless

Naming Convention

Commands are named in the imperative mood: RegisterUser, PlaceOrder, CancelBooking. They express intent — what should happen.

RegisterUser        ✔
PlaceOrder          ✔
CancelBooking       ✔

UserRegistered      ✗ (past tense — that is an event)
UserData            ✗ (noun — ambiguous)

Client-Side Key Generation

The client generates the idempotency key before sending the request:

// Frontend — generate before form submit
const requestId = crypto.randomUUID();
fetch('/api/users/register', {
    method: 'POST',
    body: JSON.stringify({ requestId, email, name, password })
});

The same requestId is reused on retry. The server recognizes it as already processed and returns success without re-executing the handler.

On this page