PHP Readonly Classes and Immutable Data Patterns

Readonly properties arrived in PHP 8.1. Readonly classes — where every property is implicitly readonly — arrived in PHP 8.2. Together they enable a clean immutability model for value objects, DTOs, and configuration records without boilerplate. This article covers the full semantics, all the sharp edges, how to implement "wither" patterns for functional updates, and how Drupal codebases can adopt these patterns today.

Readonly Properties (PHP 8.1)

A readonly property can be written exactly once from the declaring class scope. After initialisation, any attempt to reassign or unset it throws an Error.

<?php
class Product {
    public readonly string $sku;
    public readonly Money $price;

    public function __construct(string $sku, Money $price) {
        $this->sku   = $sku;
        $this->price = $price;
    }
}

$p = new Product('ABC-123', Money::of(1999, 'DKK'));
echo $p->sku;         // "ABC-123"
$p->sku = 'XYZ';     // Fatal: Cannot modify readonly property Product::$sku
$p->price->amount = 0; // LEGAL — interior mutation of the Money object

Important: readonly enforces that the property reference cannot change. If the property holds an object, the object itself can still be mutated. For true deep immutability, every nested object also needs to be readonly (or a scalar/enum).

Rules and Constraints

  • Readonly properties must be typedmixed is allowed but the type declaration is required
  • No default values are allowed in the property declaration
  • Can only be initialised from the declaring class scope (not child classes, not outside the class) — changed in PHP 8.4, see below
  • Compound assignments (+=, ++, array push) are all forbidden after initialisation
  • References to readonly properties are not allowed

Constructor Promotion with Readonly

<?php
// PHP 8.1+ — constructor promotion makes readonly terse
class Address {
    public function __construct(
        public readonly string $street,
        public readonly string $city,
        public readonly string $postalCode,
        public readonly string $countryCode = 'DK',
    ) {}
}

$addr = new Address(
    street:      'Vesterbrogade 1',
    city:        'Copenhagen',
    postalCode:  '1620',
);
echo $addr->city; // Copenhagen

Readonly Classes (PHP 8.2)

Marking an entire class readonly applies the readonly constraint to every property implicitly. Every property in a readonly class must be typed; you cannot have untyped properties.

<?php
readonly class Money {
    public function __construct(
        public int    $amount,   // Minor currency units (øre, cents)
        public string $currency,
    ) {}

    public function add(Money $other): static {
        if ($this->currency !== $other->currency) {
            throw new \DomainException("Currency mismatch: {$this->currency} vs {$other->currency}");
        }
        // Must return a new instance — cannot mutate $this->amount
        return new static($this->amount + $other->amount, $this->currency);
    }

    public function format(): string {
        return number_format($this->amount / 100, 2) . ' ' . $this->currency;
    }
}

$price    = new Money(19900, 'DKK');
$tax      = new Money(4975, 'DKK');
$total    = $price->add($tax);

echo $total->format(); // 248.75 DKK

What Readonly Classes Forbid

<?php
readonly class Config {
    public int $timeout = 30;   // Fatal: Readonly property cannot have default value
                                 // (must be set in constructor)
    public $raw;                 // Fatal: Readonly property must have type
    public static int $count;   // Fatal: Static properties cannot be readonly
}

Static properties are never readonly. Dynamic properties (via __get/__set) are not affected — only declared properties get the readonly constraint.

The Wither Pattern: Functional Updates

Immutable objects need a way to create modified copies. The "wither" (or "with*") pattern returns a new instance with one field changed:

<?php
readonly class UserProfile {
    public function __construct(
        public string $name,
        public string $email,
        public bool   $verified,
        public ?string $avatarUrl,
    ) {}

    public function withName(string $name): static {
        return new static($name, $this->email, $this->verified, $this->avatarUrl);
    }

    public function withEmail(string $email): static {
        return new static($this->name, $email, $this->verified, $this->avatarUrl);
    }

    public function asVerified(): static {
        return new static($this->name, $this->email, true, $this->avatarUrl);
    }
}

$profile = new UserProfile('Lars', 'lars@example.com', false, null);
$updated = $profile->withEmail('lars@linkhub.dk')->asVerified();

echo $updated->email;    // lars@linkhub.dk
echo $profile->email;   // lars@example.com (original unchanged)

Wither with clone and __clone (PHP 8.3+)

PHP 8.3 allows reinitialising readonly properties inside __clone(). The way to pass new values into the clone is via a bound closure:

<?php
// PHP 8.3+
readonly class Order {
    public function __construct(
        public string $id,
        public string $status,
        public Money  $total,
    ) {}

    public function withStatus(string $newStatus): static {
        $clone = clone $this;
        // Bind a closure to the clone to reinitialise the readonly property
        Closure::bind(function(string $s) { $this->status = $s; }, $clone, static::class)($newStatus);
        return $clone;
    }
}

$order   = new Order('ORD-001', 'pending', new Money(19900, 'DKK'));
$shipped = $order->withStatus('shipped');

echo $shipped->status; // shipped
echo $order->status;   // pending (original unchanged)

This avoids reconstructing the full argument list for every wither method. In practice, for objects with many fields the new static(...) approach with named arguments (shown below) is often cleaner and easier to follow. For large value objects with many fields, use named arguments to reduce verbosity:

<?php
readonly class SearchQuery {
    public function __construct(
        public string  $term     = '',
        public int     $page     = 1,
        public int     $perPage  = 20,
        public string  $sort     = 'relevance',
        public string  $order    = 'desc',
        public ?string $bundle   = null,
        public ?string $language = null,
    ) {}

    public function with(mixed ...$overrides): static {
        return new static(
            term:     $overrides['term']     ?? $this->term,
            page:     $overrides['page']     ?? $this->page,
            perPage:  $overrides['perPage']  ?? $this->perPage,
            sort:     $overrides['sort']     ?? $this->sort,
            order:    $overrides['order']    ?? $this->order,
            bundle:   $overrides['bundle']   ?? $this->bundle,
            language: $overrides['language'] ?? $this->language,
        );
    }
}

$query   = new SearchQuery(term: 'drupal', perPage: 10);
$page2   = $query->with(page: 2);
$sorted  = $query->with(sort: 'date', order: 'asc');

PHP 8.4: Asymmetric Visibility Complements Readonly

PHP 8.4 introduced asymmetric visibility (public private(set)) and changed readonly's implicit set visibility from private(set) to protected(set), enabling child classes to initialise inherited readonly properties:

<?php
// PHP 8.4
class Entity {
    public function __construct(
        public readonly int $id,   // Now protected(set) — child classes can initialise
    ) {}
}

class Node extends Entity {
    public function __construct(
        int $id,
        public readonly string $title,
    ) {
        parent::__construct($id);  // Works in PHP 8.4; was Error in 8.1/8.2
    }
}

Readonly in Drupal

Value Objects for Field Data

<?php
// A readonly DTO for a Drupal node summary — suitable for API responses
readonly class NodeSummary {
    public function __construct(
        public int     $nid,
        public string  $title,
        public string  $bundle,
        public string  $langcode,
        public bool    $published,
        public string  $url,
        public ?string $imageUrl,
    ) {}

    public static function fromNode(\Drupal\node\NodeInterface $node, string $url): static {
        $imageUrl = null;
        if ($node->hasField('field_image') && !$node->field_image->isEmpty()) {
            $file     = $node->field_image->entity;
            $imageUrl = $file?->createFileUrl(false);
        }
        return new static(
            nid:       (int) $node->id(),
            title:     $node->label(),
            bundle:    $node->bundle(),
            langcode:  $node->language()->getId(),
            published: (bool) $node->isPublished(),
            url:       $url,
            imageUrl:  $imageUrl,
        );
    }
}

Configuration Value Objects

<?php
// Replace associative arrays in service configuration with typed readonly objects
readonly class SmtpConfig {
    public function __construct(
        public string $host,
        public int    $port,
        public string $username,
        public string $password,
        public bool   $useTls = true,
    ) {}

    public static function fromDrupalConfig(\Drupal\Core\Config\ImmutableConfig $config): static {
        return new static(
            host:     $config->get('smtp.host'),
            port:     (int) $config->get('smtp.port'),
            username: $config->get('smtp.username'),
            password: $config->get('smtp.password'),
            useTls:   (bool) $config->get('smtp.use_tls'),
        );
    }
}

Serialisation and readonly

Readonly properties serialise and deserialise normally with json_encode/serialize, but unserialize can reinitialise them:

<?php
readonly class Point {
    public function __construct(
        public float $x,
        public float $y,
    ) {}
}

$p    = new Point(1.5, 2.7);
$json = json_encode($p);          // {"x":1.5,"y":2.7}

// Deserialise back — use a named constructor, not json_decode directly
$data = json_decode($json, true);
$p2   = new Point($data['x'], $data['y']);

Avoid relying on unserialize() for readonly objects in untrusted contexts — it can be used to bypass constructors. Use explicit factory methods from decoded arrays instead.

Summary Checklist

  • Use readonly on individual properties (PHP 8.1) when only some fields of a class should be immutable
  • Mark the entire class readonly (PHP 8.2) for value objects and DTOs where all properties should be immutable
  • Combine with constructor promotion for zero-boilerplate value objects
  • Implement wither methods (withX(), with()) that return new static(...) for functional updates
  • Use named arguments in withersmethods to avoid positional-argument fragility when properties change
  • Remember: readonly prevents reassignment of the property reference, not mutation of a held object — nest readonly objects for true deep immutability
  • In PHP 8.3, __clone() can reinitialise readonly properties, enabling efficient clone-based wither patterns
  • In PHP 8.4, readonly's implicit set visibility changed to protected(set), enabling child-class initialisation in parent::__construct()
  • Use readonly DTOs instead of associative arrays for typed, self-documenting data transfer in Drupal services and API responses

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.