Skip to main content

Value Objects — Immutable Domain Objects That Encapsulate Business Rules

What is a value object?

The "domain objects" you learned about in the previous chapter come in two kinds: value objects and entities. This chapter looks at value objects in detail.

A value object is a domain object with the following characteristics.

  1. It has no identifier
  2. It holds data and business rules
  3. Identity is judged by all attribute values
  4. It is immutable (it cannot be changed once created)
  • add(Money): can only add amounts of the same currency
  • multiply(int): multiplies by a quantity and returns a new Money

Why use a value object?

You might think, "Aren't primitive types (int, string, etc.) enough?" Let's look at the benefits of using a value object.

Benefit 1: You can encapsulate business rules

// With a primitive type: the rules are scattered
$price = 1000;
$tax = $price * 0.1;
$total = $price + $tax; // The tax-inclusive calculation is scattered all over the code

// With a value object: the rules are collected
$price = new Money(1000, 'JPY');
$total = $price->withTax(0.1); // The tax-inclusive calculation is collected in the Money class

Benefit 2: You can prevent invalid values

// With a primitive type: an invalid value can get in
$email = "invalid-email"; // No validation

// With a value object: validation at creation time
$email = new EmailAddress("invalid-email"); // An exception is raised

Benefit 3: Safety through types

// With a primitive type: it is easy to get the argument order wrong
function createUser(string $name, string $email, string $phone) { ... }
createUser($email, $name, $phone); // You don't notice even if you get it wrong

// With a value object: the types protect you
function createUser(UserName $name, EmailAddress $email, PhoneNumber $phone) { ... }
createUser($email, $name, $phone); // Caught by a type error

What is immutability?

One of the important characteristics of a value object is immutability. Immutable means that you cannot change the internal state once it is created.

Why immutability matters

Immutability has the following benefits.

BenefitDescriptionExample
Preventing side effectsThe original object does not change on a method callWith $total = $money1->add($money2), $money1 does not change
Improved predictabilityThe same input always gives the same result$money->add($other) is safe no matter how many times you call it
Thread safetySafe even with concurrent access from multiple threadsThe state is not corrupted under concurrent processing
Safe value sharingYou can use the same instance in multiple placesNo copying needed, memory-efficient

Mutable vs immutable

// A mutable object (bad example)
class MutableMoney
{
private int $amount;

public function add(MutableMoney $other): void
{
$this->amount += $other->amount; // Changes itself
}
}

$price = new MutableMoney(1000);
$tax = new MutableMoney(100);
$price->add($tax); // $price is changed to 1100!

// Problem: the original price (1000 yen) is lost
// Problem: the value may change in an unexpected place

// An immutable object (good example)
final class Money
{
public function __construct(
private readonly int $amount,
) {}

public function add(Money $other): Money
{
return new Money($this->amount + $other->amount); // Returns a new instance
}
}

$price = new Money(1000);
$tax = new Money(100);
$total = $price->add($tax); // $price is unchanged; a new $total is created

// Benefit: the original price (1000 yen) is retained
// Benefit: changes to the value are easy to track
Ways to achieve immutability in PHP
  1. Use the readonly modifier (PHP 8.1+)
  2. Make properties private and do not create setters
  3. Have mutating methods return a new instance
  4. Make the class final to prevent inheritance

Implementation patterns for value objects

Pattern 1: A basic value object (Money)

// final: this class cannot be inherited (it is common not to let value objects be inherited)
final class Money
{
public function __construct(
// PHP 8.1+: the readonly modifier guarantees immutability
// It is private, so it cannot be accessed directly from outside the class
private readonly int $amount,
private readonly string $currency,
) {
// Validation in the constructor: prevent invalid values at creation time
// A value object guarantees it is "always in a valid state"
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be 0 or greater');
}
}

// Identity judgment: a value object compares all attributes (an entity compares by identifier)
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}

// Immutability: does not change itself, returns a new object
// This makes the code free of side effects and easier to predict
public function add(Money $other): Money
{
// Business rule: you cannot add amounts of different currencies
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
// Does not change itself; creates and returns a new Money
return new Money($this->amount + $other->amount, $this->currency);
}

public function multiply(int $multiplier): Money
{
return new Money($this->amount * $multiplier, $this->currency);
}

// Subtraction: like addition, allow only the same currency
public function subtract(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new Money($this->amount - $other->amount, $this->currency);
}

// Percentage calculation: compute an amount by a percentage (e.g. a 10% discount amount)
// multiply is only for integer multiples, so separate rate calculations into a dedicated method
// Use intdiv for integer division (truncating the remainder). If a rounding rule is needed, collect it here
public function percentage(int $percent): Money
{
return new Money(intdiv($this->amount * $percent, 100), $this->currency);
}

// Comparison methods: compare the magnitude of amounts
public function isGreaterThan(Money $other): bool
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return $this->amount > $other->amount;
}

public function isGreaterThanOrEqual(Money $other): bool
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return $this->amount >= $other->amount;
}

// Getters: methods for retrieving the values
// By making the properties private, you restrict reading and writing from outside the class
// and expose only the values you intentionally publish through getters
public function amount(): int { return $this->amount; }
public function currency(): string { return $this->currency; }
}

Pattern 2: Named constructors

When there are multiple ways to create an object, named constructors are handy.

final class Money
{
private function __construct(
private readonly int $amount,
private readonly string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be 0 or greater');
}
}

// A dedicated method to create JPY
public static function jpy(int $amount): self
{
return new self($amount, 'JPY');
}

// A dedicated method to create USD
public static function usd(int $amount): self
{
return new self($amount, 'USD');
}

// Create from a string
public static function fromString(string $value): self
{
// Parse a format like "1000 JPY"
[$amount, $currency] = explode(' ', $value);
return new self((int)$amount, $currency);
}

// ... other methods
}

// Example usage
$price = Money::jpy(1000); // The intent is clear
$usdPrice = Money::usd(10);
$parsed = Money::fromString('1000 JPY');

Pattern 3: A value object with complex validation

final class EmailAddress
{
private function __construct(
private readonly string $value,
) {
// Complex validation logic
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email address: {$value}");
}

// Business rules such as domain restrictions are also possible
$domain = substr($value, strpos($value, '@') + 1);
if ($domain === 'example.com') {
throw new InvalidArgumentException('The example.com domain cannot be used');
}
}

public static function fromString(string $value): self
{
return new self(trim(strtolower($value))); // Normalize before creating
}

public function value(): string
{
return $this->value;
}

public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}

public function equals(EmailAddress $other): bool
{
return $this->value === $other->value;
}
}

Pattern 4: A value object with a range

final class AgeRange
{
private function __construct(
private readonly int $min,
private readonly int $max,
) {
if ($min < 0 || $max < 0) {
throw new InvalidArgumentException('Age must be 0 or greater');
}
if ($min > $max) {
throw new InvalidArgumentException('The minimum must be less than or equal to the maximum');
}
}

public static function create(int $min, int $max): self
{
return new self($min, $max);
}

// Check whether an age is within the range
public function contains(int $age): bool
{
return $age >= $this->min && $age <= $this->max;
}

// Check whether the ranges overlap
public function overlaps(AgeRange $other): bool
{
return $this->min <= $other->max && $other->min <= $this->max;
}

public function min(): int { return $this->min; }
public function max(): int { return $this->max; }
}

Example usage

$money1 = new Money(1000, 'JPY');
$money2 = new Money(1000, 'JPY');
$money3 = new Money(1000, 'USD');

$money1->equals($money2); // true ← all attributes (amount, currency) are the same
$money1->equals($money3); // false ← the currency differs

// Immutability: add returns a new object
$total = $money1->add($money2); // new Money(2000, 'JPY')
// $money1 is not changed (still 1000 yen)

A practical pattern: ShippingAddress

final class ShippingAddress
{
public function __construct(
private readonly string $prefecture,
private readonly string $city,
private readonly string $street,
) {
if (empty($prefecture) || empty($city) || empty($street)) {
throw new InvalidArgumentException('Each address field is required');
}
}

public function fullAddress(): string
{
return $this->prefecture . ' ' . $this->city . ' ' . $this->street;
}

public function equals(ShippingAddress $other): bool
{
return $this->prefecture === $other->prefecture
&& $this->city === $other->city
&& $this->street === $other->street;
}

// Getters
public function prefecture(): string { return $this->prefecture; }
public function city(): string { return $this->city; }
public function street(): string { return $this->street; }
}

Identifiers are value objects too

By implementing an entity's identifier (ID)—entities are explained in detail in the next chapter—as a value object, you can increase type safety.

final class OrderId
{
public function __construct(private readonly int $value)
{
if ($value <= 0) {
throw new InvalidArgumentException('OrderId must be a positive integer');
}
}

public function value(): int { return $this->value; }

public function equals(OrderId $other): bool
{
return $this->value === $other->value;
}
}

The benefits of making an identifier a value object

// With a primitive type
function findOrder(int $orderId): Order { ... }
function findUser(int $userId): User { ... }

findOrder($userId); // You don't notice the mistake!

// With a value object
function findOrder(OrderId $orderId): Order { ... }
function findUser(UserId $userId): User { ... }

findOrder($userId); // A type error!

Design guidelines for value objects

GuidelineDescription
Make it immutableUse readonly and do not create setters
Validate at creation timeReject invalid values in the constructor
Implement equals()A method for judging identity
Give it business logicPut related calculations and conversions in methods

A value object does not necessarily "belong to" an entity

A value object does not have to be part of an entity.

A value object such as Money does not belong to a particular entity and is used in many places.

Further reading

If you want to learn more about value objects, see the following resource.

In the next chapter, we look at entities in detail.