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
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
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
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
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
amountPenceandcurrency - rejects negative amounts
- rejects unsupported currencies
- has an
add()method that returns a newMoneyobject
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
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.