readonly + Constructor Promotion: DTOs in Five Lines
The reason PHP codebases pass associative arrays everywhere isn't that arrays are good at it - it's that value objects used to cost 40 lines of boilerplate each. That price is gone:
final class Money
{
public function __construct(
public readonly int $cents,
public readonly string $currency = 'USD',
) {}
}
$price = new Money(4900);
$price->cents; // 4900 - public access, no getter ceremony
$price->cents = 100; // Error: cannot modify readonly property
Constructor promotion (8.0) declares and assigns properties right in the signature; readonly (8.1) makes them write-once. Public readonly properties give you getter semantics without getters - safe to expose because nobody can write them.
Why immutable carriers beat arrays
// The array version of the same idea
function applyDiscount(array $price): array { ... } // what keys? what types? who knows
// The object version documents and enforces itself
function applyDiscount(Money $price, Percent $discount): Money { ... }
Typos in key names become undefined-property errors at the right line; types are checked at the boundary; IDEs autocomplete the shape; and readonly means no function can mutate your input as a side effect - the whole class of "who changed this array between here and there" bugs evaporates.
Updates via clone: the with-er pattern
final class OrderDraft
{
public function __construct(
public readonly array $items = [],
public readonly ?string $coupon = null,
) {}
public function withCoupon(string $code): static
{
return new static(items: $this->items, coupon: $code);
}
}
Immutable doesn't mean unchangeable-forever; it means changes produce new objects. Named arguments (8.0) make the re-construction readable, skipping defaults and self-documenting the call site: new Money(cents: 4900, currency: 'EUR'). (PHP 8.5's clone-with lands sugar for this; until then the named-args constructor does fine.)
The sharp edges, so they don't cut you
- readonly is shallow: a readonly array property can't be reassigned, but a readonly object property's own properties can still be mutated. Compose readonly objects of readonly objects (or arrays/scalars) for real immutability.
- Initialization scope: readonly props must be set inside the class - typically the constructor. No lazy public setting later; that's the point.
- readonly classes (8.2) stamp every property at once:
final readonly class Money { ... }- my default for new DTOs. - Pair with backed enums for fields like currency and status, and the primitive-obsession era of your codebase officially ends.
The pattern to adopt: any array that crosses a function boundary more than once, and any shape with three or more keys, becomes a five-line readonly class. Cheap to write now; the type errors it converts from runtime mysteries into IDE red squiggles were never cheap.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.