Design Value Objects for domain concepts and DTOs for data transfer with proper immutability and validation
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Value Objects are immutable objects defined by their attributes, not identity.
<?php
// src/Domain/ValueObject/Money.php
namespace App\Domain\ValueObject;
final readonly class Money
{
private function __construct(
private int $amount, // In cents
private string $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
if (strlen($currency) !== 3) {
throw new \InvalidArgumentException('Currency must be ISO 4217 code');
}
}
public static function of(int $amount, string $currency): self
{
return new self($amount, strtoupper($currency));
}
public static function EUR(int $amount): self
{
return new self($amount, 'EUR');
}
public static function zero(string $currency = 'EUR'): self
{
return new self(0, strtoupper($currency));
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(float $multiplier): self
{
return new self((int) round($this->amount * $multiplier), $this->currency);
}
public function getAmount(): int
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
public function format(): string
{
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
public function equals(self $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
public function isGreaterThan(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amount > $other->amount;
}
public function isZero(): bool
{
return $this->amount === 0;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Cannot operate on different currencies: {$this->currency} vs {$other->currency}"
);
}
}
}
<?php
// src/Domain/ValueObject/Email.php
namespace App\Domain\ValueObject;
final readonly class Email
{
private function __construct(
private string $value,
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
}
public static function fromString(string $email): self
{
return new self(strtolower(trim($email)));
}
public function getValue(): string
{
return $this->value;
}
public function getDomain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
<?php
// src/Domain/ValueObject/Address.php
namespace App\Domain\ValueObject;
final readonly class Address
{
public function __construct(
public string $street,
public string $city,
public string $postalCode,
public string $country,
public ?string $state = null,
) {
if (empty($street) || empty($city) || empty($postalCode) || empty($country)) {
throw new \InvalidArgumentException('Address fields cannot be empty');
}
}
public function withStreet(string $street): self
{
return new self($street, $this->city, $this->postalCode, $this->country, $this->state);
}
public function format(): string
{
$parts = [$this->street, $this->postalCode . ' ' . $this->city];
if ($this->state) {
$parts[] = $this->state;
}
$parts[] = $this->country;
return implode("\n", $parts);
}
public function equals(self $other): bool
{
return $this->street === $other->street
&& $this->city === $other->city
&& $this->postalCode === $other->postalCode
&& $this->country === $other->country
&& $this->state === $other->state;
}
}
Store Value Objects in database:
<?php
// src/Domain/ValueObject/Money.php
use Doctrine\ORM\Mapping as ORM;
#[ORM\Embeddable]
final readonly class Money
{
#[ORM\Column(type: 'integer')]
private int $amount;
#[ORM\Column(type: 'string', length: 3)]
private string $currency;
// ... rest of class
}
// src/Entity/Order.php
#[ORM\Entity]
class Order
{
#[ORM\Embedded(class: Money::class)]
private Money $total;
public function getTotal(): Money
{
return $this->total;
}
}
DTOs carry data between layers without behavior.
<?php
// src/Dto/CreateOrderInput.php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateOrderInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Positive]
public int $customerId,
#[Assert\NotBlank]
#[Assert\Count(min: 1, minMessage: 'Order must have at least one item')]
#[Assert\Valid]
public array $items,
#[Assert\Length(max: 20)]
public ?string $couponCode = null,
) {}
}
// src/Dto/OrderItemInput.php
final readonly class OrderItemInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Positive]
public int $productId,
#[Assert\NotBlank]
#[Assert\Positive]
#[Assert\LessThanOrEqual(100)]
public int $quantity,
) {}
}
<?php
// src/Dto/OrderOutput.php
namespace App\Dto;
use App\Entity\Order;
final readonly class OrderOutput
{
public function __construct(
public string $id,
public int $customerId,
public array $items,
public MoneyOutput $total,
public string $status,
public string $createdAt,
) {}
public static function fromEntity(Order $order): self
{
return new self(
id: $order->getId(),
customerId: $order->getCustomer()->getId(),
items: array_map(
fn($item) => OrderItemOutput::fromEntity($item),
$order->getItems()->toArray()
),
total: MoneyOutput::fromValueObject($order->getTotal()),
status: $order->getStatus()->value,
createdAt: $order->getCreatedAt()->format('c'),
);
}
}
// src/Dto/MoneyOutput.php
final readonly class MoneyOutput
{
public function __construct(
public int $amount,
public string $currency,
public string $formatted,
) {}
public static function fromValueObject(Money $money): self
{
return new self(
amount: $money->getAmount(),
currency: $money->getCurrency(),
formatted: $money->format(),
);
}
}
<?php
// src/Entity/Order.php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Dto\CreateOrderInput;
use App\Dto\OrderOutput;
#[ApiResource(
operations: [
new Post(
input: CreateOrderInput::class,
output: OrderOutput::class,
processor: CreateOrderProcessor::class,
),
],
)]
class Order { /* ... */ }
# config/packages/serializer.yaml
framework:
serializer:
mapping:
paths:
- '%kernel.project_dir%/config/serializer'
# config/serializer/Money.yaml
App\Domain\ValueObject\Money:
attributes:
amount:
groups: ['read']
currency:
groups: ['read']