Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 087 / 100

Readonly properties and classes in PHP, explained simply

Immutable objects in modern PHP.

Expona Admin8 min read

The problem with mutable objects

Picture a user service that updates an address object. One part of your codebase modifies the city, another changes the country, and by the time your code finishes, nobody's sure what the actual state is. This is the silent killer of bugs in production systems. Readonly properties and classes in PHP, explained simply: they're your guard rails against accidental mutations and the confusion that follows.

Immutability isn't new, but PHP 8.1 and 8.2 finally give us native language support. No more defensive copying, no more checking if someone changed your object behind your back. Let's walk through how readonly properties and classes work, and why they matter for the systems we build.

Readonly properties — Set once in the constructor, never changed

A readonly property can be initialized exactly once, typically in the constructor. After that, any attempt to modify it throws an error at runtime. This is stronger than a docstring comment saying "don't change this"—it's enforced by the engine.

php
class User {
    public readonly string $email;
    public readonly int $id;
    
    public function __construct(string $email, int $id) {
        $this->email = $email;
        $this->id = $id;
    }
}

$user = new User('alice@example.com', 123);
echo $user->email; // Works fine

$user->email = 'bob@example.com'; // Error: Cannot modify readonly property User::$email

The key insight: you decide once, during construction, and then the property is sealed. This works whether you're building a value object, a DTO, or an event object that travels through your message queue. The compiler and the runtime work together to prevent accidental overwrites.

Readonly properties also work with visibility modifiers and typed properties, so you get full type safety:

php
class PaymentEvent {
    public readonly string $transactionId;
    private readonly float $amount;
    protected readonly \DateTime $processedAt;
    
    public function __construct(string $transactionId, float $amount) {
        $this->transactionId = $transactionId;
        $this->amount = $amount;
        $this->processedAt = new \DateTime();
    }
    
    public function amount(): float {
        return $this->amount;
    }
}

In a service handling thousands of payment events per second, knowing that the amount and transaction ID can never drift out of sync is priceless. Each event is a complete snapshot, immutable from the moment it's born.

Readonly classes — PHP 8.2 makes every property readonly

PHP 8.2 introduced the readonly class syntax. When you mark an entire class as readonly, every property automatically becomes readonly, even if you don't write the keyword on each one. This reduces boilerplate for objects that are meant to be value holders.

php
readonly class Address {
    public function __construct(
        public string $city,
        public string $country,
        public string $postalCode
    ) {}
}

$addr = new Address('Berlin', 'Germany', '10115');
echo $addr->city; // Works

$addr->country = 'France'; // Error: Cannot modify readonly property

Notice the promoted constructor properties. We're combining two features: promoted properties (PHP 8.0) and readonly classes (PHP 8.2) to write minimal, clear code. The properties are declared, typed, assigned to parameters, and initialized—all in one line.

In a real e-commerce system, you might have readonly classes for addresses, phone numbers, or product SKUs:

php
readonly class PhoneNumber {
    public function __construct(
        public string $countryCode,
        public string $number
    ) {}
    
    public function format(): string {
        return "+{$this->countryCode} {$this->number}";
    }
}

readonly class OrderLineItem {
    public function __construct(
        public int $productId,
        public int $quantity,
        public float $unitPrice
    ) {}
    
    public function total(): float {
        return $this->quantity * $this->unitPrice;
    }
}

Each instance is a value object. It holds data, provides calculation methods, but never mutates. When an order changes quantity, you create a new OrderLineItem, you don't mutate the old one.

With-ers — Return a modified copy instead of mutating

So if readonly objects can't change, how do you "update" them? You don't. You create a new instance with the changes. This pattern is called a with-er (or copy-with), borrowed from languages like Kotlin and Rust.

php
readonly class Address {
    public function __construct(
        public string $city,
        public string $country,
        public string $postalCode
    ) {}
    
    public function withCity(string $city): self {
        if ($city === $this->city) {
            return $this;
        }
        return new self($city, $this->country, $this->postalCode);
    }
    
    public function withCountry(string $country): self {
        if ($country === $this->country) {
            return $this;
        }
        return new self($this->city, $country, $this->postalCode);
    }
}

$berlin = new Address('Berlin', 'Germany', '10115');
$paris = $berlin->withCity('Paris')->withCountry('France');

// $berlin is unchanged
echo $berlin->city; // "Berlin"
echo $paris->city;  // "Paris"

The caller now has two distinct objects. The old address is untouched. This is crucial in concurrent systems or when objects flow through a pipeline: each transformation is explicit, and no unexpected side effects hide in mutation.

In PHP 8.1+, you can use named arguments to make with-ers even cleaner:

php
readonly class User {
    public function __construct(
        public string $name,
        public string $email,
        public bool $verified = false
    ) {}
    
    public function with(string $name = null, string $email = null, bool $verified = null): self {
        return new self(
            $name ?? $this->name,
            $email ?? $this->email,
            $verified ?? $this->verified
        );
    }
}

$user = new User('Alice', 'alice@example.com');
$verified = $user->with(verified: true);
$renamed = $user->with(name: 'Alicia');

Some teams build helper libraries to generate these methods automatically, but writing them by hand forces you to think about which fields actually make sense to change together.

Great for DTOs — Value objects and events stay predictable

Data Transfer Objects live at system boundaries: coming from an API, leaving to a database, flowing across a message queue. Readonly classes make DTOs inherently safer because there's no way for intermediate code to accidentally corrupt the data.

php
readonly class CreateOrderRequest {
    public function __construct(
        public int $customerId,
        public string $shipAddress,
        public array $items
    ) {}
}

readonly class OrderCreatedEvent {
    public function __construct(
        public string $orderId,
        public int $customerId,
        public \DateTime $createdAt
    ) {}
}

When this OrderCreatedEvent is serialized to JSON and published to a Kafka topic, every subscriber knows the data hasn't been tampered with inside your application. When it's deserialized on another service, you can be confident the orderId and customerId match what was originally published.

In a ride-hailing system, ride location snapshots should be immutable:

php
readonly class RideSnapshot {
    public function __construct(
        public string $rideId,
        public float $latitude,
        public float $longitude,
        public string $status,
        public \DateTime $recordedAt
    ) {}
    
    public function distance(self $other): float {
        $lat1 = deg2rad($this->latitude);
        $lat2 = deg2rad($other->latitude);
        $lon1 = deg2rad($this->longitude);
        $lon2 = deg2rad($other->longitude);
        
        $dlat = $lat2 - $lat1;
        $dlon = $lon2 - $lon1;
        
        $a = sin($dlat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($dlon / 2) ** 2;
        $c = 2 * asin(sqrt($a));
        
        return 6371 * $c; // Earth radius in km
    }
}

$snap1 = new RideSnapshot('ride-123', 40.7128, -74.0060, 'active', new \DateTime());
$snap2 = new RideSnapshot('ride-123', 40.7180, -74.0050, 'active', new \DateTime());

echo $snap1->distance($snap2) . ' km';

Each snapshot is locked in time. No code path can sneakily update the latitude or longitude. Any change requires creating a new snapshot, which makes testing and auditing straightforward.

Common mistakes

  • Forgetting that readonly only applies to the property itself, not nested objects. If a readonly property holds an array or object, that nested structure can still be mutated. Use readonly on the property, but think about what it contains.
  • Using with-ers inconsistently. If you have a with-er for one property, teammates might expect with-ers for all properties. Document the pattern or generate them.
  • Trying to set a readonly property outside the constructor. Even in the same class, you can't assign to it after initialization. Plan your initialization carefully.
  • Mixing readonly classes with inheritance. A readonly class cannot extend a non-readonly class, and parent readonly classes impose readonly on children. Keep the hierarchy simple or don't mix them.
  • Over-applying readonly to mutable workflows. If your domain logic genuinely requires mutation (like accumulating totals in a transaction), don't force readonly. Use it where it makes sense semantically.

Key takeaways

  • Readonly properties prevent accidental mutation after initialization, making objects reliable and easier to reason about.
  • Readonly classes (PHP 8.2+) mark every property readonly in one declaration, reducing boilerplate for value objects and DTOs.
  • With-ers return a modified copy instead of mutating, preserving the original and making changes explicit.
  • Immutable objects shine in concurrent code, event-driven architectures, and systems that flow data across boundaries.
  • Always consider what your object contains: readonly protects the reference, not the contents of nested arrays or objects.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles