You've been there. A bug report lands on your desk that says the order total is wrong only sometimes. You spend an hour reading the controller, the service, the repository, the listener. Nothing looks wrong in isolation. Then you find it: somewhere four function calls deep, a piece of code did $dto->amount = $dto->amount - $discount on a value object that was supposed to be the user's input. The same DTO got reused two requests later in a place that re-applied the discount, and now half your orders are off by ten dollars.
PHP has had a quiet little fix for this kind of bug since 2021, and a louder one since 2022. Readonly properties and readonly classes are not glamorous features. They don't get keynote slides. But once you actually use them on a real codebase, the number of "where did this value mutate?" tickets in your bug tracker drops noticeably.
This piece is about what readonly actually buys you, where it leaks, and the patterns that hold up when you start building a real system on top of it.
The pain readonly was invented to fix
Before PHP 8.1, the only way to make a property "immutable" was social. You'd write a class like this:
<?php
final class Money
{
private int $amount;
private string $currency;
public function __construct(int $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
public function amount(): int { return $this->amount; }
public function currency(): string { return $this->currency; }
}
No setters, private fields, only getters. From the outside, the object looks immutable. From the inside, anybody who edits the class can still reassign $this->amount in a method you haven't reviewed yet. Code reviewers catch some of it. Most of it slips through, especially in classes that grow a recalculate() method "just this once."
The bigger problem is intent. When someone reads final class Money, they don't know whether it's supposed to be a value object or just a service that happens to have no children. There's no language-level signal that says "the moment this is constructed, the fields are done."
readonly is exactly that signal.
What readonly actually does
PHP 8.1 added a single keyword you can put in front of a typed property:
<?php
final class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {}
}
Two things now hold at the language level. First, those properties can be assigned exactly once, and only from inside the class's own scope. Second, after that first assignment, any attempt to write to them throws an Error at runtime. There is no "but I really need to" escape hatch from the outside.
$price = new Money(1999, 'USD');
$price->amount = 0;
// Error: Cannot modify readonly property Money::$amount
This is enforced even for reflection-style tricks that worked before. You can still read it. You cannot write it.
A couple of mechanical rules to keep in your head:
The property must be typed. public readonly $amount won't parse. Readonly without a type was deliberately disallowed because part of what makes the guarantee useful is that the type is fixed too.
The property cannot be static. Static state is shared across instances, and there's no clean "construct once per instance" moment for a static field. The language just refuses the combination.
It cannot have a default value at declaration unless that default is the only assignment it'll ever get. In practice, you initialize readonly properties in the constructor (or via constructor property promotion, which is what most code does in 2026).
You can unset() a readonly property only before it's been initialized. Once initialized, unset() throws too. This matters for serialization libraries that try to reset state.
Constructor property promotion is the sweet spot
The reason readonly feels good in 2026 is that it composes with constructor property promotion. The two features together turn what used to be twelve lines of DTO boilerplate into four:
<?php
declare(strict_types=1);
final class CreateOrderRequest
{
public function __construct(
public readonly string $customerId,
public readonly array $lineItems,
public readonly ?string $couponCode = null,
public readonly string $currency = 'USD',
) {}
}
That's the whole class. No private fields. No getters. No setters. No __set to forbid. The reader sees the shape of the object, the types, the defaults, the immutability guarantee, all in the signature.
This shape is what people mean when they say "modern PHP DTO." It's a typed, constructed-once bag of values with public access. You don't need getters because there's nothing to encapsulate. The data is exactly what's on the front of the class.
Readonly classes (PHP 8.2)
PHP 8.2 noticed that you almost never want one readonly property and three mutable ones in the same class. So it added the shorter form:
<?php
final readonly class LineItem
{
public function __construct(
public string $sku,
public int $quantity,
public int $unitPriceCents,
) {}
}
readonly class is sugar that marks every declared property as readonly. The rules tighten a bit when you opt in at the class level:
- Every property must be typed. A readonly class with an untyped property fails to compile.
- The class cannot declare dynamic properties. The compiler implicitly applies
#[AllowDynamicProperties]-incompatible rules, so$instance->extraField = 1throws. - A readonly class cannot extend a non-readonly class, and a non-readonly class cannot extend a readonly one. The two are inheritance-incompatible on purpose.
That last rule is the one people stumble on. If you have a base Event class that's mutable and you try to extend it with a readonly class OrderPlaced extends Event, the engine refuses. Either both are readonly or neither is. It's a coherence guarantee. You can't have a child that promises immutability when the parent doesn't.
When readonly class works, it's the cleanest possible DTO shape:
<?php
declare(strict_types=1);
final readonly class WebhookPayload
{
public function __construct(
public string $event,
public string $deliveryId,
public int $timestamp,
public array $data,
) {}
}
There is nothing to remember. No setter to write. No accidental mutation possible from any code in the project.
The "with" pattern
Immutable objects pose an obvious question: how do you change them?
The answer is that you don't change them. You build a new instance with the changes baked in. The convention from Java, C#, and most functional-leaning languages is a family of with* methods:
<?php
declare(strict_types=1);
final readonly class CreateOrderRequest
{
public function __construct(
public string $customerId,
public array $lineItems,
public ?string $couponCode = null,
public string $currency = 'USD',
) {}
public function withCoupon(string $code): self
{
return new self(
customerId: $this->customerId,
lineItems: $this->lineItems,
couponCode: $code,
currency: $this->currency,
);
}
public function withoutCoupon(): self
{
return new self(
customerId: $this->customerId,
lineItems: $this->lineItems,
couponCode: null,
currency: $this->currency,
);
}
}
Two things to notice. The return type is self, so the caller always gets back the same class even from a subclass. And named arguments make this readable even when the object has eight or nine fields, so you don't end up with a thirty-position constructor call where everyone has to count commas.
The cost is one allocation per change. For DTOs that flow through a request and disappear, that's free. For objects on a hot loop, you should measure before assuming it's fine. But in 99% of DTO use cases, you'll never notice the allocation.

Cloning, deep cloning, and the PHP 8.3 fix
The "with" pattern above works fine when the class is small. For larger DTOs, manually retyping every field gets tedious and bug-prone. Forget one parameter and the new instance silently drops it. The natural reach is for clone:
public function withCoupon(string $code): self
{
$copy = clone $this;
$copy->couponCode = $code; // <- uh oh
return $copy;
}
That second line is the problem. $copy->couponCode is readonly. Even though you just constructed a fresh-looking object via clone, PHP 8.1 and 8.2 treated those properties as already-initialized for the clone too. Writing to them threw an Error.
PHP 8.3 fixed this with a deliberate carve-out: readonly properties can be re-initialized inside __clone(). Outside __clone(), the rule is unchanged.
<?php
declare(strict_types=1);
final readonly class CreateOrderRequest
{
public function __construct(
public string $customerId,
public array $lineItems,
public ?string $couponCode = null,
public string $currency = 'USD',
) {}
public function withCoupon(string $code): self
{
return $this->cloneWith(fn (self $copy) => $copy->couponCode = $code);
}
public function withoutCoupon(): self
{
return $this->cloneWith(fn (self $copy) => $copy->couponCode = null);
}
private function cloneWith(\Closure $mutator): self
{
$copy = clone $this;
// The closure runs in the scope of $copy via Closure::bind / Closure::call,
// which means it counts as being inside the class for the purposes of
// both the readonly scope rule and the __clone re-init rule.
$mutator->call($copy, $copy);
return $copy;
}
public function __clone(): void
{
// No-op here - the cloneWith() helper is doing the re-init.
// If you needed to deep-clone nested objects, you'd do it here.
}
}
The exact ergonomics vary by codebase. Some people write a cloneWith() helper, some prefer the explicit retype-every-field constructor call (which has the advantage of being obvious to read), some build a Pmt/Reflection mini-framework. None of them are wrong. Pick the one your team can read at 4pm on a Friday.
Readonly is shallow, and that's a real trap
Here's the line that catches almost everyone the first time. readonly protects the property slot, not what's inside it.
<?php
final readonly class CartSnapshot
{
public function __construct(
public array $items,
) {}
}
$snapshot = new CartSnapshot(['sku-1', 'sku-2']);
// $snapshot->items = []; // correctly forbidden by readonly
// But this works fine:
$snapshot->items[] = 'sku-3';
// And after this, the "immutable" snapshot has three items.
Wait, what? The array is a value type in PHP, with copy-on-write semantics and no shared references between assignments. So $snapshot->items[] = ... looks like it should fail because it's modifying the property.
It does fail, actually. In PHP 8.1+ this throws an Error: "Cannot modify readonly property ... indirectly". The engine sees the property write through the array offset and rejects it. Good news.
The bad news is the case where the readonly property holds an object:
<?php
final class MutableLogger
{
private array $entries = [];
public function log(string $entry): void { $this->entries[] = $entry; }
public function entries(): array { return $this->entries; }
}
final readonly class RequestContext
{
public function __construct(
public MutableLogger $logger,
) {}
}
$ctx = new RequestContext(new MutableLogger());
$ctx->logger->log('request started');
$ctx->logger->log('user authenticated');
// $ctx->logger is still readonly - you cannot reassign it.
// But the logger object itself happily kept mutating.
The RequestContext::$logger slot holds an object handle. The handle never changes. The object on the other end of the handle has its own internal state and its own rules, and readonly has no opinion about it.
This is the same as final in Java or const in C++: the modifier protects the binding, not the graph. If you want a "deeply immutable" DTO, every nested object also needs to be immutable, all the way down. In practice that means: for DTOs, every nested type is also a readonly class or a scalar. The moment you reach for a service, a logger, a connection, those don't belong inside a DTO anyway. They belong on the service that uses the DTO.
DTOs in practice: where readonly pays off
DTOs are a vague term. People use it for everything from "anything with public fields" to "command objects in a strict CQRS bus." For this section, I mean the boring, useful definition: a typed, constructed-once data carrier that moves between layers of an application. Three places that benefit most:
API input
Validate once at the edge, then carry a typed object inward. Symfony, Laravel, API Platform, and most modern frameworks have first-class support for hydrating request bodies straight into a typed class:
<?php
declare(strict_types=1);
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateInvoiceRequest
{
public function __construct(
#[Assert\NotBlank]
public string $customerId,
#[Assert\Count(min: 1)]
public array $lineItems,
#[Assert\Regex('/^[A-Z]{3}$/')]
public string $currency = 'USD',
#[Assert\Type('\\DateTimeImmutable')]
public ?\DateTimeImmutable $dueDate = null,
) {}
}
The controller receives a CreateInvoiceRequest, not an array. Validation already ran. The shape is in the type system. The object cannot mutate as it threads through the call stack. The service it's passed into can write its code against a real type with autocomplete, and a test for that service is just new CreateInvoiceRequest(...) instead of fixture-juggling.
Command and query objects
If your codebase has a command bus (for CQRS, for queued jobs, for an HTTP-and-CLI symmetric entrypoint), the commands themselves are perfect readonly classes:
<?php
declare(strict_types=1);
final readonly class ShipOrder
{
public function __construct(
public string $orderId,
public string $carrier,
public ?string $trackingNumber = null,
public \DateTimeImmutable $shippedAt = new \DateTimeImmutable(),
) {}
}
The bus serializes the command to a queue. The handler picks it up and runs against a fixed-shape input. Nothing in the chain can quietly rewrite the order ID, and nothing has to defensively copy.
Value objects
Money, EmailAddress, PhoneNumber, OrderStatus, Latitude, BoundingBox: the small typed primitives that aren't quite enums and aren't quite collections. These are the original use case readonly was designed for. A Money(1999, 'USD') is the same object whether you pass it through ten layers or one. Operations like add, subtract, convertTo return new instances. Nobody can hand you a Money whose currency code changed under you between log line 1 and log line 4.
<?php
declare(strict_types=1);
final readonly class Money
{
public function __construct(
public int $amountCents,
public string $currency,
) {
if ($amountCents < 0) {
throw new \DomainException('Money must be non-negative');
}
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
throw new \DomainException("Invalid currency: {$currency}");
}
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amountCents + $other->amountCents, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
return new self(max(0, $this->amountCents - $other->amountCents), $this->currency);
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \DomainException(
"Currency mismatch: {$this->currency} vs {$other->currency}",
);
}
}
}
The constructor validates. Every operation returns a new Money. No code path in your application can produce an invalid Money and no code path can mutate one that already exists. That guarantee is worth a lot of bug-tracker tickets you'll never have to file.

Where readonly is the wrong tool
It's tempting, after the first few wins, to mark every class readonly. Don't.
Entities, the domain objects with identity that live across requests, usually need to change. A User who changes their email is still the same user, and the right model is to mutate the email field, persist, and emit an event. Forcing every entity to be a readonly chain of clones makes the ORM's job harder, the audit trail noisier, and your code longer for no semantic gain. Use readonly for the inputs and outputs, not for the long-lived domain state.
Service classes don't need to be readonly. A repository, a mailer, a payment gateway: these are stateless or near-stateless dependency containers. They have constructor-injected fields that never change. You could mark them readonly. It buys nothing because there's nothing to mutate in the first place. Save the cognitive budget for the places where readonly actually changes how code is written.
Mutable collections inside readonly classes are a known sharp edge. If your DTO holds an ArrayObject or a custom collection class, you have to make the collection itself immutable too, or accept that the "immutable DTO" is a half-truth. Either go all the way down (readonly collection classes, defensive copying on construction) or stop pretending. Half-immutability is worse than no immutability because it lies to the reader.
Serialization-heavy code can fight you. Some older serializers reset properties after construction, expect setters, or use reflection to write into private fields. Modern Symfony Serializer, Laravel's array casting, and the bigger libraries all handle readonly properties cleanly. Older or hand-rolled ones may not. Test the round-trip if you depend on it.
What changes if you're on PHP 8.4
PHP 8.4 added two features that brush up against readonly: property hooks and asymmetric visibility. Neither replaces readonly; they cover different ground.
Asymmetric visibility lets you say "the property is publicly readable but only writable inside the class":
public private(set) string $name;
That's not the same guarantee as readonly. It restricts who can write, not how many times. A private(set) property can be reassigned a hundred times from inside the class. A readonly property can be assigned once total. For DTOs, you want readonly. For long-lived objects that need internal mutation but a controlled external API, asymmetric visibility is the right pick.
Property hooks let you compute a property on access, validate on write, or do work without going through getter/setter ceremony. They don't replace readonly either; they're for properties that have logic. Most DTO fields are pure data and don't want hooks. The two features compose, but in practice you'll pick one or the other per property, not both.
Test what you're claiming
If a class is supposed to be immutable, write a test that proves it. Reflection lets you ask the engine directly:
<?php
use PHPUnit\Framework\TestCase;
final class MoneyTest extends TestCase
{
public function test_money_class_is_readonly(): void
{
$reflection = new \ReflectionClass(Money::class);
$this->assertTrue($reflection->isReadOnly());
}
public function test_subtract_returns_new_instance(): void
{
$a = new Money(1000, 'USD');
$b = new Money(300, 'USD');
$result = $a->subtract($b);
$this->assertNotSame($a, $result);
$this->assertSame(1000, $a->amountCents);
$this->assertSame(700, $result->amountCents);
}
public function test_direct_mutation_throws(): void
{
$a = new Money(1000, 'USD');
$this->expectException(\Error::class);
$a->amountCents = 0;
}
}
That second test is the important one. It encodes the contract, "after construction, you don't get to change this," in a way that a future refactor cannot quietly break. If somebody drops the readonly modifier in a year, that test fails before the bug ships.
The honest summary
Readonly properties and readonly classes are one of the rare PHP features that pay off the first day you use them. The mental model is small. The syntax is short. The guarantee is real. The trap (shallowness) is the only thing you have to actively remember, and even that gets less surprising once you've written one nested DTO that has a "wait, this still mutates" moment.
The pattern that works best on a real codebase looks like this. DTOs at the edges are final readonly class. Value objects are final readonly class with operations that return self. Entities stay mutable but get the same construction-time validation discipline that readonly classes use. Services don't bother. And if you find yourself fighting the model, constantly cloning huge objects, threading with* calls four layers deep, the answer is usually that the DTO is too big, not that readonly is the wrong tool.
Build one immutable DTO and watch what happens to the bug tickets that mention "the value changed somewhere." That's the feature.






