Skip to content

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; // true

Value 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); // true

Value 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)); // true

Why 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 once

Domain 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);
}

Examples in Operations Module

app/Shared/Domain/ValueObjects/
└── Money.php

app/Modules/Operations/Domain/ValueObjects/
├── SKU.php
├── Quantity.php
├── ProductDimensions.php
└── StockLevel.php

Documentation for SynthesQ CRM/ERP Platform