Medium PHP

PHP Readonly Classes and Value Objects: Money, Email and Immutable Data That Cannot Be Corrupted

Shared mutable objects cause silent data bugs. Use PHP readonly properties and classes to build Money and Email value objects, with-ers and Eloquent casts.

14 Nov, 2026 10 min Updated 23 Oct, 2026 10 Code blocks
Diagram
graph LR A[Raw input] --> B[Constructor validates] B -->|invalid| C[Exception at the boundary] B -->|valid| D[Readonly value object] D --> E[with-er returns new instance] D --> F[Eloquent cast to columns] F --> G[Database row]

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 mixed if 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:

  1. Immutable. Operations return new instances instead of changing the current one.
  2. Valid on construction. The constructor rejects bad input, so an instance that exists is always valid. You never need if ($money->isValid()).
  3. Equality by value. An equals() method compares fields, not object identity.
  4. Behaviour lives on the value. $price->add($tax) instead of $price + $tax scattered 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.

graph LR A[Raw input] --> B[Constructor validates] B -->|invalid| C[Exception at the boundary] B -->|valid| D[Readonly value object] D --> E[with-er returns new instance] D --> F[Eloquent cast to columns] F --> G[Database row]

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 DateTime or a readonly property holding a mutable service is still mutable inside. Use DateTimeImmutable and nest only immutable objects.
  • Floats for money. 0.1 + 0.2 is not 0.3. Store integers in minor units, or use a decimal library like brick/money if 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. Use equals(). 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 FirstName class 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 class request 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.

Interactive challenge

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

php
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
    }
}
python
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 revealed
Hint 1

Every operation should end with constructing a new Money; never assign to $this->amount or self.amount.

Hint 2

A regex like ^[A-Z]{3}$ (Python re.fullmatch with [A-Z]{3}) checks the currency code.

Hint 3

For allocate, compute all floor shares first, then distribute amount minus their sum starting at index 0.

Solution · php
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);
    }
}
Solution · python
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]