A customer complains that their invoice reminder arrived three days before the due date, and the due date shown on the invoice itself had also moved three days earlier. The culprit was a helper that calculated the reminder date by calling modify('-3 days') on the invoice's DateTime. DateTime is mutable, the object was shared, and one innocent line changed data that two other parts of the system were relying on.
final class Invoice
{
public function __construct(public DateTime $dueDate) {}
}
$invoice = new Invoice(new DateTime('2026-11-30'));
function reminderDate(Invoice $invoice): DateTime
{
return $invoice->dueDate->modify('-3 days');
}
$remindAt = reminderDate($invoice);
echo $invoice->dueDate->format('Y-m-d');
That prints 2026-11-27. PHP gives you real tools to stop this class of bug: readonly properties (8.1), readonly classes (8.2) and, since 8.3, the ability to reinitialize readonly properties during __clone. Combined with the value object pattern, they let you model money, emails, date ranges and other small concepts as values that cannot be corrupted after they are created.
Readonly Properties and Readonly Classes
A readonly property can be initialized exactly once, from inside the class, and never changed afterwards. Any later write, from inside or outside, throws an Error:
final class Temperature
{
public function __construct(
public readonly float $celsius,
) {}
}
$t = new Temperature(21.5);
echo $t->celsius;
try {
$t->celsius = 30.0;
} catch (Error $e) {
echo $e->getMessage();
}
The rules worth memorizing:
- Readonly properties must be typed. Use
mixedif you really need no constraint. - They cannot have a default value in the declaration; they get their value in the constructor (promoted properties make this a one-liner).
- Initialization is only allowed from the declaring class's scope. Code outside cannot initialize an uninitialized readonly property either.
unset()is also forbidden once the property is initialized.
A readonly class (PHP 8.2) applies readonly to every declared property and also forbids dynamic properties. It cannot have untyped or static properties. For value objects, final readonly class is the declaration you want almost every time: everything is immutable, and final prevents a subclass from adding mutable state through the back door.
One important limit: readonly is shallow. A readonly property holding a DateTime cannot be reassigned, but the DateTime itself can still be modified, which is exactly the bug above. Store immutable things in readonly properties: scalars, arrays, enums, DateTimeImmutable, and other value objects.
What Makes a Value Object
A value object is a small object defined entirely by its data. Two Money objects of 100,000 VND are interchangeable, the way two 100,000 VND notes are. That gives you a short checklist:
- Immutable. Operations return new instances instead of changing the current one.
- Valid on construction. The constructor rejects bad input, so an instance that exists is always valid. You never need
if ($money->isValid()). - Equality by value. An
equals()method compares fields, not object identity. - Behaviour lives on the value.
$price->add($tax)instead of$price + $taxscattered across controllers with ad hoc currency checks.
Here is the lifecycle in one picture: raw input is validated once at the boundary, and from then on the code passes around values it can trust.
A Money Value Object
Money is the classic example because its bugs are expensive: float rounding, mixed currencies, shared prices changed by accident.
namespace App\Support\ValueObjects;
use InvalidArgumentException;
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if (! preg_match('/^[A-Z]{3}$/', $currency)) {
throw new InvalidArgumentException("Invalid currency code [{$currency}].");
}
}
public static function vnd(int $amount): self
{
return new self($amount, 'VND');
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function multiply(int|float $factor): self
{
return new self((int) round($this->amount * $factor), $this->currency);
}
public function withAmount(int $amount): self
{
return new self($amount, $this->currency);
}
public function equals(self $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException("Cannot combine {$this->currency} with {$other->currency}.");
}
}
}
Notice the design choices. The amount is an integer in the currency's minor unit (cents for USD; VND has no minor unit, so it is the amount in đồng). Every operation returns new self(...), so $price->add($tax) leaves $price untouched. Mixing currencies is impossible to do by accident. And because the constructor validates the currency code, a Money with currency "vnd " cannot exist anywhere in the system.
An Email Value Object
Emails look like strings, so they usually get validated five different ways across a codebase. Wrapping them puts normalization and validation in one place:
namespace App\Support\ValueObjects;
use InvalidArgumentException;
use Stringable;
final readonly class EmailAddress implements Stringable
{
public string $value;
public function __construct(string $value)
{
$normalized = strtolower(trim($value));
if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("[{$value}] is not a valid email address.");
}
$this->value = $normalized;
}
public function domain(): string
{
return substr($this->value, strrpos($this->value, '@') + 1);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
The property is not promoted here because the constructor normalizes the input before assigning it. That is allowed: a readonly property can be initialized anywhere inside the declaring class's scope, as long as it happens only once. Implementing Stringable means the object still works in string contexts like Mail::to((string) $email) or Blade's {{ $email }}.
With-ers: Changing a Value by Making a New One
Immutable objects still need "modification", for example a date range that gets extended. The convention is a with... method that returns a new instance with one field changed. On PHP 8.3 and 8.4 the clearest way is to call the constructor again, which also re-runs validation:
final readonly class DateRange
{
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end,
) {
if ($end < $start) {
throw new InvalidArgumentException('End must not be before start.');
}
}
public function withEnd(DateTimeImmutable $end): static
{
return new static($this->start, $end);
}
public function extendByDays(int $days): static
{
return $this->withEnd($this->end->modify("+{$days} days"));
}
}
Going through the constructor re-checks invariants, so withEnd() cannot produce a range that ends before it starts.
For classes with many fields, repeating every constructor argument gets tedious. PHP 8.5 adds "clone with", which copies the object and overrides selected properties in one expression. It respects readonly scope, so it belongs inside the class:
// PHP 8.5+: clone with, inside the class so readonly scope rules are satisfied
public function withEnd(DateTimeImmutable $end): static
{
return clone($this, ['end' => $end]);
}
Be aware that clone-based with-ers skip the constructor, so validation does not run unless you call it explicitly. For value objects with invariants, the constructor route is usually the safer default.
Eloquent Casts for Value Objects
Value objects pay off most when they come straight out of your models. A custom cast converts between columns and the object. Money needs two columns, total_amount and total_currency, and set() can return an array to write both:
namespace App\Casts;
use App\Support\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
final class MoneyCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
$amount = $attributes["{$key}_amount"] ?? null;
return $amount === null ? null : new Money((int) $amount, $attributes["{$key}_currency"]);
}
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
if ($value === null) {
return ["{$key}_amount" => null, "{$key}_currency" => null];
}
if (! $value instanceof Money) {
throw new InvalidArgumentException("The [{$key}] attribute must be a Money instance.");
}
return ["{$key}_amount" => $value->amount, "{$key}_currency" => $value->currency];
}
}
namespace App\Models;
use App\Casts\EmailCast;
use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;
final class Order extends Model
{
protected function casts(): array
{
return [
'total' => MoneyCast::class,
'customer_email' => EmailCast::class,
];
}
}
An EmailCast works the same way with a single column: return new EmailAddress($value) in get() and (string) $value in set(). Using it feels like using any other attribute:
use App\Models\Order;
use App\Support\ValueObjects\EmailAddress;
use App\Support\ValueObjects\Money;
$order = new Order();
$order->customer_email = new EmailAddress(' [email protected] ');
$order->total = Money::vnd(450_000);
$order->total = $order->total->add(Money::vnd(30_000));
$order->save();
echo $order->customer_email->domain();
Pay attention to the second line of arithmetic. Because Money is immutable, $order->total->add(...) alone does nothing to the order. You must assign the result back. With mutable value objects, Eloquent caches the cast instance and syncs in-place changes back to the attributes before saving, so a mutation anywhere in the request quietly ends up in the database. Immutable objects turn every change into an explicit assignment you can see in the code.
Testing Value Objects
Value objects are the easiest code to test: no database, no container, no mocks. Cover construction, operations, immutability and equality:
use App\Support\ValueObjects\EmailAddress;
use App\Support\ValueObjects\Money;
it('adds money of the same currency without touching the originals', function () {
$price = Money::vnd(100_000);
$shipping = Money::vnd(25_000);
$total = $price->add($shipping);
expect($total->amount)->toBe(125_000)
->and($price->amount)->toBe(100_000)
->and($total)->not->toBe($price);
});
it('refuses to mix currencies', function () {
Money::vnd(100)->add(new Money(100, 'USD'));
})->throws(InvalidArgumentException::class, 'Cannot combine VND with USD.');
it('cannot be modified after construction', function () {
$money = Money::vnd(100);
$money->amount = 999;
})->throws(Error::class, 'Cannot modify readonly property');
it('compares by value, not identity', function () {
expect(Money::vnd(500)->equals(Money::vnd(500)))->toBeTrue()
->and(Money::vnd(500)->equals(new Money(500, 'USD')))->toBeFalse();
});
it('normalizes and validates emails', function () {
$email = new EmailAddress(' [email protected] ');
expect((string) $email)->toBe('[email protected]')
->and($email->domain())->toBe('example.com');
expect(fn () => new EmailAddress('not-an-email'))->toThrow(InvalidArgumentException::class);
});
The "cannot be modified" test looks trivial, but if someone drops readonly to "just set one field", it fails in CI instead of in production.
Common Pitfalls
- Readonly is shallow. A readonly
DateTimeor a readonly property holding a mutable service is still mutable inside. UseDateTimeImmutableand nest only immutable objects. - Floats for money.
0.1 + 0.2is not0.3. Store integers in minor units, or use a decimal library likebrick/moneyif you need arbitrary precision and currency metadata. - Validation outside the constructor. A static
isValid()that callers may forget to call is not a guarantee. Throw in the constructor. - Comparing with
===. Two equal values are different instances, so===is false. Useequals(). Loose==happens to compare properties for objects of the same class, but an explicit method documents intent and handles normalization rules. - Forgetting to reassign with Eloquent.
$order->total->add($fee)without$order->total = ...is a no-op that will not show up until someone checks the totals. - Wrapping everything. A
FirstNameclass with no rules is ceremony.
When to Use Readonly Value Objects and When Not To
Use them for:
- money, quantities and measurements, where units or currencies must never be mixed;
- identifiers and contact data: emails, phone numbers, tax codes, bank account numbers with checksums;
- ranges and periods: date ranges, price ranges, opening hours, with invariants like "end after start";
- DTOs passed between layers: a
readonly classrequest DTO from a form request, so a service cannot mutate its input behind the caller's back.
Skip them, or use plain mutable objects, for:
- entities with identity and lifecycle, like Eloquent models. An order changes status over time; that is what models are for;
- builders and accumulators that deliberately collect state step by step;
- hot loops over millions of items, where allocating per operation is measurable.
The invoice bug from the beginning disappears with one change: public readonly DateTimeImmutable $dueDate. modify() now returns a new date, the original stays put, and the reminder no longer drags the due date along with it.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement an immutable Money value object. PHP: final readonly class Money with public int $amount and public string $currency. Python: a frozen dataclass Money(amount: int, currency: str). The constructor must reject any currency that is not exactly three uppercase letters A-Z (PHP: InvalidArgumentException, Python: ValueError). add(other) returns a new Money with the summed amount and must throw the same exception type if currencies differ. withAmount(amount) (Python: with_amount) returns a new Money with the same currency. PHP only: equals(other) returns true when both amount and currency match (Python gets equality from the dataclass). allocate(ratios) splits the amount into a list of Money, one per integer ratio: each part first gets floor(amount * ratio / sum of ratios), then the leftover units are added one at a time to the parts from the first onwards, so the parts always sum to the original amount. Original objects must never change.
Starter code
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
// TODO: throw InvalidArgumentException unless $currency is exactly 3 uppercase letters A-Z
}
public function add(Money $other): Money
{
// TODO: same currency required (else InvalidArgumentException); return a NEW Money with the sum
}
public function withAmount(int $amount): Money
{
// TODO: return a NEW Money with the given amount and the same currency
}
public function equals(Money $other): bool
{
// TODO: true when amount and currency are both equal
}
/** @return list<Money> */
public function allocate(array $ratios): array
{
// TODO: split the amount by integer ratios; each part gets intdiv(amount * ratio, sum of ratios)
// TODO: hand out the leftover units one by one to the first parts so the parts sum to amount
}
}
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount: int
currency: str
def __post_init__(self) -> None:
# TODO: raise ValueError unless currency is exactly 3 uppercase letters A-Z
pass
def add(self, other: "Money") -> "Money":
# TODO: same currency required (else ValueError); return a NEW Money with the sum
raise NotImplementedError
def with_amount(self, amount: int) -> "Money":
# TODO: return a NEW Money with the given amount and the same currency
raise NotImplementedError
def allocate(self, ratios: list[int]) -> list["Money"]:
# TODO: split the amount by integer ratios; each part gets amount * ratio // sum(ratios)
# TODO: hand out the leftover units one by one to the first parts so the parts sum to amount
raise NotImplementedError
Your solution
Hints
All hints revealedEvery operation should end with constructing a new Money; never assign to $this->amount or self.amount.
A regex like ^[A-Z]{3}$ (Python re.fullmatch with [A-Z]{3}) checks the currency code.
For allocate, compute all floor shares first, then distribute amount minus their sum starting at index 0.
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if (! preg_match('/^[A-Z]{3}$/', $currency)) {
throw new InvalidArgumentException("Invalid currency [{$currency}].");
}
}
public function add(Money $other): Money
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Currency mismatch.');
}
return new Money($this->amount + $other->amount, $this->currency);
}
public function withAmount(int $amount): Money
{
return new Money($amount, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
/** @return list<Money> */
public function allocate(array $ratios): array
{
$total = array_sum($ratios);
$shares = array_map(fn (int $r): int => intdiv($this->amount * $r, $total), $ratios);
$remainder = $this->amount - array_sum($shares);
for ($i = 0; $remainder > 0; $i++, $remainder--) {
$shares[$i % count($shares)]++;
}
return array_map(fn (int $s): Money => new Money($s, $this->currency), $shares);
}
}
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount: int
currency: str
def __post_init__(self) -> None:
if not re.fullmatch(r"[A-Z]{3}", self.currency):
raise ValueError(f"Invalid currency {self.currency!r}")
def add(self, other: "Money") -> "Money":
if other.currency != self.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
def with_amount(self, amount: int) -> "Money":
return Money(amount, self.currency)
def allocate(self, ratios: list[int]) -> list["Money"]:
total = sum(ratios)
shares = [self.amount * r // total for r in ratios]
remainder = self.amount - sum(shares)
for i in range(remainder):
shares[i % len(shares)] += 1
return [Money(s, self.currency) for s in shares]

