PHP readonly Properties and Classes: Immutable Value Objects in WordPress

PHP 8.1 introduced readonly properties (written once at construction, immutable thereafter) and PHP 8.2 extended this to entire readonly classes where all properties are implicitly readonly. These features enable clean immutable value-object patterns in WordPress plugins — DTOs for REST API responses, money values, post metadata snapshots — without the boilerplate of private setters and clone methods.

Problem: PHP value objects — Money, Coordinates, DateRange — are passed around and mutated by different parts of the codebase because class properties are public or have no mutation guard, causing subtle bugs when a shared object is modified unexpectedly.

Solution: Use PHP 8.1 readonly properties: declare each property as readonly and initialise it only in the constructor — the property cannot be reassigned after construction. For PHP 8.2+, declare entire classes as readonly class Money { } to make all properties readonly by default. Clone and modify for "with" semantics.


The examples below show a readonly DTO for a WordPress post summary, a readonly Money value object with arithmetic that returns new instances, and how to use PHP 8.2 readonly classes with constructor promotion in a WooCommerce order summary context.


ID,
            title:       $post->post_title,
            slug:        $post->post_name,
            excerpt:     wp_trim_excerpt( '', $post ),
            publishedAt: $post->post_date,
        );
    }
}

$summary = PostSummary::fromWpPost( get_post( 42 ) );
echo $summary->title;    // works
// $summary->title = 'x'; // Fatal: Cannot modify readonly property

// ── 2. Immutable Money value object (PHP 8.1) ─────────────────────────────
class Money {
    public function __construct(
        public readonly int    $amount,   // minor units (cents)
        public readonly string $currency,
    ) {}

    public function add( Money $other ): self {
        if ( $this->currency !== $other->currency ) {
            throw new \InvalidArgumentException( 'Currency mismatch' );
        }
        return new self( $this->amount + $other->amount, $this->currency );
    }

    public function withTax( float $rate ): self {
        return new self( (int) round( $this->amount * ( 1 + $rate ) ), $this->currency );
    }

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

$price = new Money( 4999, 'USD' );           // $49.99
$tax   = $price->withTax( 0.08 );           // $53.99
$total = $tax->add( new Money( 500, 'USD' ) ); // $58.99

// ── 3. Readonly class (PHP 8.2) ───────────────────────────────────────────
readonly class OrderSummary {
    public function __construct(
        public int    $orderId,
        public string $status,
        public float  $total,
        public string $currency,
        public array  $itemNames,
    ) {}

    public static function fromWcOrder( WC_Order $order ): self {
        return new self(
            orderId:   $order->get_id(),
            status:    $order->get_status(),
            total:     (float) $order->get_total(),
            currency:  $order->get_currency(),
            itemNames: array_map(
                fn( WC_Order_Item $item ) => $item->get_name(),
                array_values( $order->get_items() )
            ),
        );
    }
}

$summary = OrderSummary::fromWcOrder( wc_get_order( 100 ) );
// All properties are implicitly readonly — no accidental mutation possible


NOTE: readonly classes cannot have non-readonly properties and cannot be extended by non-readonly classes — design your class hierarchy with this constraint in mind; if you need to override one property in a subclass, use individual readonly property declarations on a regular class instead.