| name | symfony:value-objects-and-dtos |
| description | Design Value Objects for domain concepts and DTOs for data transfer with proper immutability and validation |
Value Objects and DTOs in Symfony
Value Objects
Value Objects are immutable objects defined by their attributes, not identity.
Money Value Object
<?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}"
);
}
}
}
Email Value Object
<?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;
}
}
Address Value Object
<?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;
}
}
Doctrine Embeddables
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 (Data Transfer Objects)
DTOs carry data between layers without behavior.
Input DTO
<?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,
) {}
}
Output DTO
<?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(),
);
}
}
API Platform Integration
<?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 { /* ... */ }
Serializer Configuration
# 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']
Best Practices
- Value Objects are immutable: Return new instances
- Validate in constructor: Fail fast
- Use readonly: PHP 8.1+ readonly properties
- Equality by value: Implement equals() method
- DTOs are simple: No behavior, just data
- Separate Input/Output: Different validation needs
- Use Embeddables: Store VOs in database naturally