Value Objects Guide
Overview
Value Objects are immutable objects defined by their attributes rather than identity. They encapsulate domain concepts, enforce validation, and prevent primitive obsession. Unlike entities, two value objects with the same attributes are considered equal.
Core Concepts
Entity vs Value Object
Entity (has identity):
php
$product1 = Product::find(1);
$product2 = Product::find(1);
// Same product? YES (same ID)
$product1->id === $product2->id; // trueValue Object (defined by attributes):
php
$price1 = Money::fromFloat(19.99, 'USD');
$price2 = Money::fromFloat(19.99, 'USD');
// Same value? YES (same attributes)
$price1->equals($price2); // trueValue Object Characteristics
- Immutable: Cannot be changed after creation
- Self-validating: Validation in constructor
- Defined by attributes: Two VOs with same attributes are equal
- No identity: No ID or unique identifier
- Rich behavior: Domain operations built-in
Operations Module Value Objects
Money
Represents monetary amounts with currency:
php
<?php
namespace App\Shared\Domain\ValueObjects;
final class Money
{
private function __construct(
private readonly int $amount, // Amount in cents
private readonly string $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
if (strlen($currency) !== 3) {
throw new \InvalidArgumentException('Currency must be 3-letter code');
}
}
public static function fromFloat(float $amount, string $currency = 'USD'): self
{
return new self(
amount: (int) round($amount * 100),
currency: strtoupper($currency)
);
}
public static function fromCents(int $cents, string $currency = 'USD'): self
{
return new self(
amount: $cents,
currency: strtoupper($currency)
);
}
public function amount(): int
{
return $this->amount;
}
public function toFloat(): float
{
return $this->amount / 100;
}
public function currency(): string
{
return $this->currency;
}
public function format(): string
{
return sprintf('%s %.2f', $this->currency, $this->toFloat());
}
// Arithmetic operations (return new Money instance)
public function add(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(int|float $multiplier): self
{
return new self(
(int) round($this->amount * $multiplier),
$this->currency
);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
private function assertSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
}
}Usage:
php
// Creation
$price = Money::fromFloat(19.99, 'USD');
$cost = Money::fromCents(1500, 'USD'); // $15.00
// Access
$price->toFloat(); // 19.99
$price->amount(); // 1999 (cents)
$price->currency(); // "USD"
$price->format(); // "USD 19.99"
// Arithmetic
$total = $price->multiply(10); // $199.90
$profit = $price->subtract($cost); // $4.99
$combined = $price->add($cost); // $34.99
// Comparison
$price->equals(Money::fromFloat(19.99)); // trueWhy Money VO?
- Prevents floating-point precision errors
- Stores as integers (cents) for exact arithmetic
- Type-safe monetary operations
- Currency awareness built-in
SKU
Represents product identification codes:
php
<?php
namespace App\Modules\Operations\Domain\ValueObjects;
final class SKU
{
private function __construct(
private readonly string $value,
) {
if (empty($value)) {
throw new \InvalidArgumentException('SKU cannot be empty');
}
if (strlen($value) > 50) {
throw new \InvalidArgumentException('SKU too long (max 50 characters)');
}
if (!preg_match('/^[A-Z0-9\-_]+$/i', $value)) {
throw new \InvalidArgumentException('SKU contains invalid characters');
}
}
public static function fromString(string $value): self
{
return new self(strtoupper(trim($value)));
}
public static function generate(string $prefix = ''): self
{
$random = strtoupper(substr(uniqid(), -8));
$value = $prefix ? "{$prefix}-{$random}" : $random;
return new self($value);
}
public function value(): string
{
return $this->value;
}
public function equals(SKU $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}Usage:
php
$sku = SKU::fromString('DELL-XPS13-001');
$sku->value(); // "DELL-XPS13-001"
// Auto-generate
$sku = SKU::generate('PROD'); // "PROD-5F8A9B2C"Quantity
Represents stock quantities with unit of measure:
php
<?php
namespace App\Modules/Operations\Domain\ValueObjects;
final class Quantity
{
private function __construct(
private readonly int $value,
private readonly string $unit = 'unit',
) {
if ($value < 0) {
throw new \InvalidArgumentException('Quantity cannot be negative');
}
}
public static function of(int $value, string $unit = 'unit'): self
{
return new self($value, $unit);
}
public function value(): int
{
return $this->value;
}
public function unit(): string
{
return $this->unit;
}
public function add(Quantity $other): self
{
$this->assertSameUnit($other);
return new self($this->value + $other->value, $this->unit);
}
public function subtract(Quantity $other): self
{
$this->assertSameUnit($other);
return new self($this->value - $other->value, $this->unit);
}
public function equals(Quantity $other): bool
{
return $this->value === $other->value
&& $this->unit === $other->unit;
}
private function assertSameUnit(Quantity $other): void
{
if ($this->unit !== $other->unit) {
throw new \InvalidArgumentException('Unit mismatch');
}
}
}ProductDimensions
Represents product size and weight:
php
<?php
namespace App\Modules\Operations\Domain\ValueObjects;
final class ProductDimensions
{
private function __construct(
private readonly float $length,
private readonly float $width,
private readonly float $height,
private readonly float $weight,
private readonly string $dimensionUnit = 'cm',
private readonly string $weightUnit = 'kg',
) {}
public static function create(
float $length,
float $width,
float $height,
float $weight,
): self {
return new self($length, $width, $height, $weight);
}
public function volume(): float
{
return $this->length * $this->width * $this->height;
}
public function weight(): float
{
return $this->weight;
}
public function shippingCategory(): string
{
if ($this->weight > 30) return 'freight';
if ($this->weight > 10) return 'heavy';
return 'standard';
}
}StockLevel
Represents inventory levels with availability:
php
<?php
namespace App\Modules\Operations\Domain\ValueObjects;
final class StockLevel
{
private function __construct(
private readonly int $stock,
private readonly int $reserved,
private readonly int $reorderPoint,
) {}
public static function create(int $stock, int $reserved, int $reorderPoint): self
{
return new self($stock, $reserved, $reorderPoint);
}
public function available(): int
{
return max(0, $this->stock - $this->reserved);
}
public function isLow(): bool
{
return $this->stock <= $this->reorderPoint && $this->stock > 0;
}
public function isOutOfStock(): bool
{
return $this->stock === 0;
}
public function canReserve(int $quantity): bool
{
return $this->available() >= $quantity;
}
}Eloquent Integration
Custom Casts
php
<?php
namespace App\Shared\Infrastructure\Eloquent\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
final class MoneyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?Money
{
if ($value === null) {
return null;
}
$data = json_decode($value, true);
return Money::fromCents(
$data['amount'],
$data['currency']
);
}
public function set($model, string $key, $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
if (!$value instanceof Money) {
throw new \InvalidArgumentException('Value must be Money instance');
}
return json_encode([
'amount' => $value->amount(),
'currency' => $value->currency(),
]);
}
}Usage in Models:
php
final class Product extends Model
{
protected function casts(): array
{
return [
'selling_price' => MoneyCast::class,
'cost_price' => MoneyCast::class,
];
}
}
// Access
$product->selling_price->toFloat(); // 19.99
$product->selling_price->format(); // "USD 19.99"Database Storage
php
// Money stored as JSON
{
"amount": 1999,
"currency": "USD"
}
// SKU stored as VARCHAR
"DELL-XPS13-001"
// Dimensions stored as JSON
{
"length": 30.0,
"width": 20.0,
"height": 2.0,
"weight": 1.2
}Benefits
Type Safety
php
// ❌ Primitive obsession
function calculateTotal(float $price, int $quantity): float
{
return $price * $quantity; // No currency, precision issues
}
// ✅ Value Object
function calculateTotal(Money $price, Quantity $quantity): Money
{
return $price->multiply($quantity->value()); // Type-safe, precise
}Validation Centralized
php
// ❌ Scattered validation
if ($price < 0) throw new Exception();
if (empty($sku)) throw new Exception();
// Repeated everywhere
// ✅ Centralized in VO
$price = Money::fromFloat($input); // Validates once
$sku = SKU::fromString($input); // Validates onceDomain Operations
php
// ❌ Procedural
$total = $price1 + $price2;
$profit = $selling - $cost;
// ✅ Object-oriented
$total = $price1->add($price2);
$profit = $selling->subtract($cost);Best Practices
DO:
- ✅ Make value objects immutable
- ✅ Validate in constructor
- ✅ Provide named constructors
- ✅ Implement
equals()method - ✅ Add domain operations (add, subtract, etc.)
DON'T:
- ❌ Add setters (breaks immutability)
- ❌ Use identity (IDs)
- ❌ Make them mutable
- ❌ Skip validation
- ❌ Use primitive types where VOs fit
Testing Value Objects
php
public function test_money_creation(): void
{
$money = Money::fromFloat(19.99, 'USD');
$this->assertEquals(1999, $money->amount());
$this->assertEquals('USD', $money->currency());
$this->assertEquals(19.99, $money->toFloat());
}
public function test_money_arithmetic(): void
{
$price = Money::fromFloat(10.00, 'USD');
$cost = Money::fromFloat(7.50, 'USD');
$profit = $price->subtract($cost);
$this->assertEquals(2.50, $profit->toFloat());
}
public function test_rejects_negative_amount(): void
{
$this->expectException(\InvalidArgumentException::class);
Money::fromFloat(-10.00);
}Related Patterns
- Aggregates - VOs used in aggregates
- CQRS Pattern - VOs in DTOs
- Repository Pattern - VOs in persistence
Examples in Operations Module
app/Shared/Domain/ValueObjects/
└── Money.php
app/Modules/Operations/Domain/ValueObjects/
├── SKU.php
├── Quantity.php
├── ProductDimensions.php
└── StockLevel.php