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.
Version Boundaries
PHP introduced readonly properties in PHP 8.1 and readonly classes in PHP 8.2. A package using either feature must declare a compatible PHP requirement; syntax cannot be hidden behind a runtime branch for older interpreters.
Readonly behavior has also gained precise additions. PHP 8.3 allows a readonly property to be reinitialized from __clone() on the cloned object. PHP 8.4 changed the implicit write scope of a readonly property from private-set to protected-set, allowing initialization from child scope unless a narrower set visibility is declared. These details matter mainly to framework and inheritance-heavy code; ordinary value objects should still initialize state plainly in their own constructor.
Initialization Is Different From Reassignment
A readonly property may be initialized once from an allowed class scope. After that, assigning even the same value is an error. Incrementing, appending to an array, assigning by reference, and unsetting an initialized readonly property are also modifications.
<?php
declare(strict_types=1);
final class BatchResult
{
public function __construct(
public readonly int $processed,
public readonly array $failedIds,
) {
}
}
$result = new BatchResult(20, [7, 12]);
echo $result->processed . PHP_EOL;
// These would fail:
// $result->processed++;
// $result->failedIds[] = 19;
A readonly property must have a type. Use mixed when that broad contract is genuinely intended. Readonly instance properties cannot be static, because static storage belongs to the class rather than one object instance.
A non-promoted readonly property cannot declare an ordinary default value. A promoted readonly property's apparent default belongs to the constructor parameter, and the selected argument is assigned during construction.
Delayed Initialization Is Possible But Often Unnecessary
Initialization does not have to occur directly in the constructor, provided it happens once from an allowed scope.
<?php
declare(strict_types=1);
final class RequestFingerprint
{
public readonly string $value;
public function initialise(string $method, string $path): void
{
$this->value = hash('sha256', $method . ':' . $path);
}
}
$fingerprint = new RequestFingerprint();
$fingerprint->initialise('GET', '/courses');
echo substr($fingerprint->value, 0, 8) . PHP_EOL;
A second call would fail because the property is already initialized. Although legal, this two-step lifecycle creates a period when reading the property fails. Prefer constructor initialization unless delayed initialization serves a clear framework, hydration, or cloning requirement.
Readonly does not mean "write once from anywhere." External code cannot initialize a public readonly property simply because it can read it. The write must occur from a permitted class scope.
Readonly Classes Apply Rules To Every Instance Property
Declaring readonly class makes all instance properties readonly. Those properties must be typed, and the class cannot use dynamic properties. A readonly class also cannot declare static properties.
<?php
declare(strict_types=1);
readonly class ApiResult
{
public function __construct(
public int $statusCode,
public string $body,
public array $headers,
) {
if ($statusCode < 100 || $statusCode > 599) {
throw new InvalidArgumentException('Invalid HTTP status code.');
}
}
}
A readonly class can contain methods and perform calculations. Readonly restricts property mutation; it does not turn the class into a passive data bag.
Inheritance must preserve the class-level promise. A readonly class can extend only another readonly class, and a child of a readonly class must also be readonly. This prevents a subtype from quietly adding mutable instance state where callers expect the readonly-class contract.
Arrays And Objects Behave Differently Inside Readonly State
An array stored in a readonly property cannot be changed through that property, because changing an element modifies the array value held by the property. An object stored in a readonly property can still mutate internally, because the property continues to refer to the same object.
<?php
declare(strict_types=1);
final class MutableAddress
{
public function __construct(public string $city)
{
}
}
readonly class CustomerSnapshot
{
public function __construct(public MutableAddress $address)
{
}
}
$address = new MutableAddress('London');
$snapshot = new CustomerSnapshot($address);
$address->city = 'Bristol';
echo $snapshot->address->city . PHP_EOL;
// Prints:
// Bristol
The snapshot's $address property was not reassigned. The referenced MutableAddress changed. This is often called interior mutability and is why readonly is not deep immutability.
For an immutable object graph, use immutable nested values such as DateTimeImmutable, readonly value objects, enums, and arrays that are not exposed through a mutable reference. Sometimes a defensive copy is needed at construction.
Returning New Values Instead Of Mutating
Immutable value objects express changes by returning another value.
<?php
declare(strict_types=1);
readonly class DateRange
{
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end,
) {
if ($end < $start) {
throw new InvalidArgumentException('End must not precede start.');
}
}
public function moveForward(DateInterval $interval): self
{
return new self(
$this->start->add($interval),
$this->end->add($interval),
);
}
}
$original = new DateRange(
new DateTimeImmutable('2026-06-01'),
new DateTimeImmutable('2026-06-07'),
);
$moved = $original->moveForward(new DateInterval('P7D'));
echo $original->start->format('Y-m-d') . PHP_EOL;
echo $moved->start->format('Y-m-d') . PHP_EOL;
// Prints:
// 2026-06-01
// 2026-06-08
The original remains valid and unchanged. This is useful for money, measurements, identifiers, date ranges, configuration, commands, events, and snapshots where identity is based on value rather than a changing lifecycle.
Cloning And Readonly Properties
Since PHP 8.3, __clone() may reinitialize readonly properties on the newly cloned object. This enables controlled with...() methods when cloning is clearer than rebuilding every constructor argument.
<?php
declare(strict_types=1);
final class PageRequest
{
public function __construct(
public readonly int $page,
public readonly int $perPage,
) {
}
public function __clone()
{
// A clone hook may reinitialize readonly properties in PHP 8.3+.
}
public function nextPage(): self
{
return new self($this->page + 1, $this->perPage);
}
}
The explicit constructor approach in nextPage() is often easier to audit. Use clone reinitialization when it materially improves an object with many preserved properties, and test that nested mutable objects are not accidentally shared.
Serialization, Hydration, And Frameworks
ORMs and serializers may create objects without following an ordinary public constructor path. Before making an entity readonly, verify that the framework officially supports readonly properties and the PHP version being deployed. Reflection-based initialization rules and proxy generation vary by tool and version.
Readonly is not a security boundary. Reflection, serialization bugs, or mutable nested services can still create risks, and public data can still be disclosed. Use readonly to express mutation rules in normal PHP code, not to replace authorization, validation, or careful deserialization.
Readonly Does Not Replace Encapsulation
A public readonly property is convenient for a stable value, but it exposes the representation permanently. If a library may later calculate the value differently, a method can preserve more freedom.
Likewise, readonly guarantees only that a property is not reassigned. It does not guarantee that the constructor validated the value, that two properties agree, or that a nested object is safe. Validation and domain methods remain necessary.
<?php
declare(strict_types=1);
readonly class Percentage
{
public function __construct(public float $value)
{
if ($value < 0 || $value > 100) {
throw new InvalidArgumentException('Percentage must be from 0 to 100.');
}
}
}
The readonly modifier preserves the validated number after creation. The constructor establishes that it was valid in the first place.
Values Versus Stateful Entities
Readonly is a strong fit for a value defined entirely by its contents. Two Money(1000, 'GBP') values mean the same thing, and adding money naturally produces another value.
An order, subscription, upload, or support ticket often has an identity and a lifecycle. It changes from draft to paid, pending to complete, or open to closed. That does not justify public mutable properties. It means controlled methods should perform valid transitions.
<?php
declare(strict_types=1);
final class Order
{
private string $status = 'draft';
public function markPaid(): void
{
if ($this->status !== 'draft') {
throw new LogicException('Only draft orders can be paid.');
}
$this->status = 'paid';
}
public function status(): string
{
return $this->status;
}
}
Forcing this entity into a readonly class could lead to replacing the entire order object at every transition and make identity tracking less natural. Choose the model based on meaning, not a preference that all objects must be immutable.
What You Should Be Able To Do
After this lesson, you should be able to use a readonly property and a readonly class, explain one-time initialization, and identify operations such as incrementing or appending that count as modification. You should know the PHP 8.1 and 8.2 version boundaries and the relevant cloning change in PHP 8.3.
You should also be able to explain why nested objects can remain mutable, construct an immutable object graph deliberately, return new value objects for changed values, and choose controlled mutation for entities whose lifecycle genuinely changes.
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.
Practice: Protect Nested Immutability
Create a readonly booking window whose nested date values cannot mutate behind its back.
Task
Build a readonly BookingWindow with DateTimeImmutable $opensAt and DateTimeImmutable $closesAt. Reject a closing time that is not later than the opening time. Add a postpone(DateInterval $interval): self method returning a new window.
Print the original and postponed opening times to prove the original is unchanged.
Check Your Work
Explain why DateTimeImmutable is safer here than mutable DateTime, and why readonly properties alone would not stop a mutable nested date object from changing internally.
Show solution
Both the outer object and its nested dates use immutable update behavior.
<?php
declare(strict_types=1);
readonly class BookingWindow
{
public function __construct(
public DateTimeImmutable $opensAt,
public DateTimeImmutable $closesAt,
) {
if ($closesAt <= $opensAt) {
throw new InvalidArgumentException('Closing time must follow opening time.');
}
}
public function postpone(DateInterval $interval): self
{
return new self(
$this->opensAt->add($interval),
$this->closesAt->add($interval),
);
}
}
$original = new BookingWindow(
new DateTimeImmutable('2026-07-01 09:00:00'),
new DateTimeImmutable('2026-07-01 17:00:00'),
);
$postponed = $original->postpone(new DateInterval('P1D'));
echo $original->opensAt->format('Y-m-d H:i') . PHP_EOL;
echo $postponed->opensAt->format('Y-m-d H:i') . PHP_EOL;
// Prints:
// 2026-07-01 09:00
// 2026-07-02 09:00
A readonly property prevents replacing its date object, while DateTimeImmutable ensures date operations return another object instead of mutating the nested one.
Practice: Model A Stateful Order
Choose controlled mutation instead of forcing a changing entity into a readonly class.
Task
Create an Order with a private status starting as draft. Add markPaid(), ship(), and status() methods. Permit only draft -> paid -> shipped; throw LogicException for any invalid transition.
Demonstrate the valid flow, then try to ship a new draft order and print the exception message.
Check Your Work
Explain why the order's identity persists while its lifecycle changes, and how private state plus transition methods protects rules better than public mutable properties.
Show solution
The entity changes in place, but only through methods that enforce its lifecycle.
<?php
declare(strict_types=1);
final class Order
{
private string $status = 'draft';
public function markPaid(): void
{
if ($this->status !== 'draft') {
throw new LogicException('Only a draft order can be paid.');
}
$this->status = 'paid';
}
public function ship(): void
{
if ($this->status !== 'paid') {
throw new LogicException('Only a paid order can be shipped.');
}
$this->status = 'shipped';
}
public function status(): string
{
return $this->status;
}
}
$order = new Order();
$order->markPaid();
$order->ship();
echo $order->status() . PHP_EOL;
try {
(new Order())->ship();
} catch (LogicException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// shipped
// Only a paid order can be shipped.
The same order identity moves through valid states. Its private property prevents arbitrary assignments, while its methods make permitted transitions explicit.