PHP 8.1 added readonly properties, and 8.3 followed up with a shorthand — readonly class — for marking every property of a class readonly without repeating the keyword line by line. Together they're the closest PHP has come to giving value objects a real language-level guarantee instead of a convention enforced by discipline and a lot of private constructors with no setters.
What readonly actually enforces
A readonly property can be initialized exactly once, from within the scope of the declaring class, and never reassigned afterward — not even from inside the same class once the initial assignment has happened. Reading it is unrestricted; the restriction is entirely on writes.
final class Address
{
public function __construct(
public readonly string $line1,
public readonly string $city,
public readonly string $countryCode,
) {}
}
$addr = new Address('12 Kimathi St', 'Nairobi', 'KE');
$addr->city = 'Mombasa'; // Error: Cannot modify readonly property Address::$city
That error is thrown even from a method inside Address itself after construction — there's no back door for "just this once" mutation from within the class. If you need to change a value, you construct a new instance with the new value, which is the whole point: readonly pushes you toward "modify" meaning "return a new object," the same discipline immutable value objects require in any language.
PHP 8.3's readonly class shorthand
Before 8.3, marking every property readonly meant writing readonly in front of each one — tedious and easy to miss on a class with a dozen properties. 8.3 lets you put readonly on the class declaration instead, and it applies to every declared property, including ones added later.
final readonly class OrderLine
{
public function __construct(
public string $sku,
public int $quantity,
public int $unitPriceMinor,
) {}
public function totalMinor(): int
{
return $this->quantity * $this->unitPriceMinor;
}
}
// Every promoted property here is readonly automatically —
// no per-property keyword needed, and none can be added later
// without also being readonly.
Every property on a readonly class must have a type declaration — untyped properties aren't allowed at all on a readonly class, not even implicitly. This is usually not a real constraint since typed properties are already good practice, but it will surface as an error if you're converting an older, loosely-typed class.
Cloning a readonly object
You can still clone a readonly object — cloning itself isn't blocked — but as of PHP 8.1 you could not modify readonly properties inside __clone() either, which made the common "clone with one field changed" pattern impossible without reconstructing the whole object. PHP 8.3 relaxed this specifically: __clone() is now allowed to reinitialize a readonly property, as long as it hasn't already been initialized during the current clone operation. That makes a proper "with" method possible.
final readonly class OrderLine
{
public function __construct(
public string $sku,
public int $quantity,
public int $unitPriceMinor,
) {}
public function withQuantity(int $quantity): self
{
$clone = clone $this;
$clone->quantity = $quantity; // allowed in 8.3+: re-init during clone
return $clone;
}
}
$line = new OrderLine('SKU-100', 2, 5000);
$updated = $line->withQuantity(3); // $line is untouched, $updated is new
Where this fits into everyday code
The obvious use is DTOs and value objects: request payloads, API responses, money/currency pairs, anything that represents a fact rather than a mutable entity. It's a poor fit for Eloquent models or anything an ORM needs to hydrate and mutate in place — readonly properties are genuinely single-assignment, and an ORM that sets properties after construction (as Eloquent typically does) will conflict with that. Use readonly for the objects you pass around and compare, not the ones a framework owns the lifecycle of.
A readonly property holding an array or an object doesn't make the contents immutable — only the property binding itself is protected. If a readonly property holds an object with public mutable properties, or an array, callers can still mutate what's inside it (for arrays, only by reference; direct array mutation on a readonly property is blocked, but an object's internals are fully exposed).
Wrapping up
Readonly properties, and the readonly class shorthand that arrived a version later, give PHP DTOs and value objects an immutability guarantee enforced by the engine rather than by convention and hoping nobody adds a setter. It's a narrow feature — it doesn't replace an ORM model, and it doesn't make nested data deeply immutable — but for the specific job of representing an immutable fact passed around your codebase, it's a real upgrade over the private-constructor-and-getters pattern most PHP projects used before 8.1.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.