objects namespaces and application architecture

Readonly Properties and Classes

Readonly properties can be assigned once and cannot be reassigned afterward. Readonly classes make every instance property readonly.

They are useful for value objects, data transfer objects, commands, events, and result objects where values should not change after construction.

Readonly Properties

A readonly property must have a type and can be assigned only once.

PHP example
<?php

declare(strict_types=1);

final class EmailAddress
{
    public function __construct(
        public readonly string $value,
    ) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Email address is not valid.');
        }
    }
}

$email = new EmailAddress('ada@example.com');

echo $email->value . PHP_EOL;

// Prints:
// ada@example.com

After construction, code cannot assign a different value to $email->value. That makes the object easier to trust.

Readonly Classes

A readonly class makes all instance properties readonly. It is useful when the whole object is intended to be immutable.

PHP example
<?php

declare(strict_types=1);

readonly class OrderPlaced
{
    public function __construct(
        public int $orderId,
        public int $userId,
        public int $totalPence,
    ) {
        if ($totalPence < 0) {
            throw new InvalidArgumentException('Total cannot be negative.');
        }
    }
}

$event = new OrderPlaced(100, 10, 2999);

echo $event->orderId . ': ' . $event->totalPence . PHP_EOL;

// Prints:
// 100: 2999

Readonly classes are common for events and DTOs because they carry data without changing it.

Readonly Is Not Deep Immutability

Readonly stops reassignment of the property. It does not freeze an object stored inside the property.

PHP example
<?php

declare(strict_types=1);

final class MutableProfile
{
    public function __construct(
        public string $name,
    ) {
    }
}

readonly class ProfileSnapshot
{
    public function __construct(
        public MutableProfile $profile,
    ) {
    }
}

$profile = new MutableProfile('Ada');
$snapshot = new ProfileSnapshot($profile);

$profile->name = 'Grace';

echo $snapshot->profile->name . PHP_EOL;

// Prints:
// Grace

The snapshot still points at the same mutable profile object. If you need deep immutability, the nested objects must also be immutable or copied carefully.

Readonly Works Well With Validation

Readonly does not remove validation. It makes validated state harder to accidentally change.

PHP example
<?php

declare(strict_types=1);

readonly class Money
{
    public function __construct(
        public int $amountPence,
        public string $currency,
    ) {
        if ($amountPence < 0) {
            throw new InvalidArgumentException('Amount cannot be negative.');
        }

        if (!in_array($currency, ['GBP', 'EUR', 'USD'], true)) {
            throw new InvalidArgumentException('Unsupported currency.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currencies must match.');
        }

        return new self($this->amountPence + $other->amountPence, $this->currency);
    }
}

$total = (new Money(1200, 'GBP'))->add(new Money(800, 'GBP'));

echo $total->amountPence . ' ' . $total->currency . PHP_EOL;

// Prints:
// 2000 GBP

Immutable methods return a new object instead of changing the existing one.

When Not To Use Readonly

Do not use readonly for objects that naturally change over time, such as an entity being edited through a workflow.

For example, an Order entity may move from draft to paid to shipped. That object may need controlled mutation through methods such as markPaid() and ship(). Making every property readonly would fight the model.

Readonly is best for objects that represent facts, values, commands, events, and snapshots.

What You Should Be Able To Do

After this lesson, you should be able to use readonly properties, create readonly classes, explain the difference between reassignment and deep immutability, and choose readonly for stable data objects.

For junior work, this matters because readonly objects make code easier to reason about, especially when passing data through services, events, queues, and API response builders.

Practice

Practice: Create A Readonly Money Value Object

Create a readonly value object for money.

Task

Build a readonly Money class that:

  • stores amountPence and currency
  • rejects negative amounts
  • rejects unsupported currencies
  • has an add() method that returns a new Money object

Use strict types. Keep the expected output in the PHP code block as printed lines or comments.

Check Your Work

Run cases for:

  • adding two valid money values
  • trying a negative amount
  • trying to add different currencies

Afterward, explain why add() returns a new object instead of changing the existing one.

Show solution

This solution validates money during construction and returns a new object when adding values.

PHP example
<?php

declare(strict_types=1);

readonly class Money
{
    public function __construct(
        public int $amountPence,
        public string $currency,
    ) {
        if ($amountPence < 0) {
            throw new InvalidArgumentException('Amount cannot be negative.');
        }

        if (!in_array($currency, ['GBP', 'EUR', 'USD'], true)) {
            throw new InvalidArgumentException('Unsupported currency.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currencies must match.');
        }

        return new self($this->amountPence + $other->amountPence, $this->currency);
    }
}

$total = (new Money(1200, 'GBP'))->add(new Money(800, 'GBP'));

echo $total->amountPence . ' ' . $total->currency . PHP_EOL;

try {
    new Money(-1, 'GBP');
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

try {
    (new Money(1000, 'GBP'))->add(new Money(1000, 'EUR'));
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// 2000 GBP
// Amount cannot be negative.
// Currencies must match.

add() returns a new object because readonly properties cannot be reassigned after construction. That also makes the value object easier to reason about.