Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

readonly + Constructor Promotion: DTOs in Five Lines

C
Carlo Fontanos
· 2 min read

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.

C
Written by Carlo Fontanos

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.

Message sent!

Your details are only used to reply to you.

Keep reading