Introduction

PHP's readonly keyword is one of those features that immediately makes code look safer. We can put it on a property or a class, and PHP will stop us from assigning a different value later.

That is useful, but it is only one part of immutability.

An immutable object is an object whose observable state cannot change after it is created. When we need a different state, we create a new object. That sounds like a small distinction, but it changes how we model business rules, how values move through an application, and how confident we can be when passing an object to another class.

The important part is that readonly protects a property assignment. It does not automatically make every value reachable through that property immutable. A readonly property can still contain a mutable object. A readonly class can still expose a mutable collaborator. And a clone can still share mutable nested objects with its original.

In this article, let's go beyond the keyword. We will see what immutability actually means in PHP, where readonly helps, the holes it does not close, and how to build value objects that remain safe as an application grows.

Immutability Is a Behavioral Promise

The simplest definition is this:

An immutable object never changes its own state after construction.

That means an operation that looks like a change returns a new value instead:

$nextRenewal = $renewal->extendByMonths(1);

After this line, $renewal still represents the original date. $nextRenewal represents the extended date. We can pass either value to another part of the application without worrying that a later method call will silently alter it.

This is especially valuable for values that mean something in the domain:

  • money amounts and currencies
  • date ranges and booking windows
  • email addresses, URLs, and identifiers
  • order lines and totals
  • filters used to build a report or query
  • request data after validation

When an object represents a value, rather than an entity with a changing lifecycle, immutability is usually a good default. A Money object representing 1,000 cents in USD should not become 900 cents because another service kept a reference to it. An Email object should not turn into a different email address halfway through an operation.

This is not about making every class in an application immutable. An Eloquent model, for example, represents persisted identity and intentionally has a lifecycle. It is expected to change before it is saved. The goal is to make the values around that mutable boundary stable and explicit.

What readonly Guarantees

PHP's readonly modifier prevents a property from being modified after its first assignment. A readonly class applies that rule to every instance property.

For a small value object made only of scalar values and enums, that is already a strong foundation:

enum Currency: string
{
    case EUR = 'EUR';
    case USD = 'USD';
}

final readonly class Money
{
    public function __construct(
        public int $amountInCents,
        public Currency $currency,
    ) {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('A money amount cannot be negative.');
        }
    }
}

This code cannot replace the amount later:

$price = new Money(1_000, Currency::USD);

$price->amountInCents = 900;
// Error: Cannot modify readonly property Money::$amountInCents

The class also has one clear construction point. That lets us reject invalid values immediately, before Money can travel into an invoice, a cart, or a payment request.

readonly gives us useful guarantees:

  • a property can be assigned only once
  • typed state is required for a readonly class
  • dynamic properties cannot be added to a readonly class
  • the object has a smaller, more predictable state surface

For values built from strings, integers, booleans, arrays of scalar values, enums, and other immutable values, that can be enough. But the last phrase matters: other immutable values.

readonly Is Not Deep Immutability

PHP protects the property, not every object behind it.

Consider a delivery window that receives a mutable DateTime instance:

final readonly class DeliveryWindow
{
    public function __construct(
        public DateTime $startsAt,
    ) {}
}

$window = new DeliveryWindow(new DateTime('2026-08-01 09:00:00'));

$window->startsAt->modify('+1 day');

echo $window->startsAt->format('Y-m-d');
// 2026-08-02

We did not assign a new value to $startsAt, so PHP correctly allows the code. But the state that DeliveryWindow exposes has changed. From the domain's perspective, the object is mutable.

This behavior is often called interior mutability. The container cannot be reassigned, while a mutable object inside the container can still be changed.

The same rule applies to a readonly class. This is still not deeply immutable:

final readonly class ReportSchedule
{
    public function __construct(
        public DateTime $nextRunAt,
    ) {}
}

readonly is not recursive. PHP has no language feature that automatically walks an object graph and makes every nested object immutable for us. That design work still belongs to us.

Choose Immutable Building Blocks

The first practical rule is simple:

An immutable object should contain only immutable values.

For dates, prefer DateTimeImmutable over DateTime. Its modifying methods return a new instance instead of changing the current one:

final readonly class TrialPeriod
{
    public function __construct(
        public DateTimeImmutable $endsAt,
    ) {}

    public function extendByDays(int $days): self
    {
        if ($days < 1) {
            throw new InvalidArgumentException('A trial extension must have at least one day.');
        }

        return new self($this->endsAt->modify("+{$days} days"));
    }
}

Now the operation makes the difference visible at the call site:

$trial = new TrialPeriod(new DateTimeImmutable('2026-08-01'));
$extendedTrial = $trial->extendByDays(14);

echo $trial->endsAt->format('Y-m-d');
// 2026-08-01

echo $extendedTrial->endsAt->format('Y-m-d');
// 2026-08-15

The PHP manual describes DateTimeImmutable exactly this way: calls such as modify() create a new object and leave the original untouched. This makes it a much better date type for a value object.

This principle applies to every dependency stored by an immutable object:

  • use DateTimeImmutable, not DateTime
  • use backed enums for a fixed set of states
  • use value objects for validated concepts such as Email or Money
  • use immutable collection elements
  • do not keep service clients, models, builders, or mutable caches inside a value object

If one child is mutable, the parent is only shallowly immutable.

flowchart LR
    A[Mutable input at the boundary] --> B[Validate and convert]
    B --> C[Immutable value object]
    C --> D[Operation]
    D --> E[New immutable value object]

Convert mutable input at the boundary, then keep domain values immutable as they move through the application.

Return New Values for Domain Operations

An immutable object should not have setters. It should expose operations that return a new object with the requested state.

Let's make our Money value object useful:

enum Currency: string
{
    case EUR = 'EUR';
    case USD = 'USD';
}

final readonly class Money
{
    public function __construct(
        public int $amountInCents,
        public Currency $currency,
    ) {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('A money amount cannot be negative.');
        }
    }

    public function add(self $other): self
    {
        $this->ensureSameCurrency($other);

        return new self(
            amountInCents: $this->amountInCents + $other->amountInCents,
            currency: $this->currency,
        );
    }

    public function discountBy(int $percentage): self
    {
        if ($percentage < 0 || $percentage > 100) {
            throw new InvalidArgumentException('The discount must be between 0 and 100.');
        }

        return new self(
            amountInCents: (int) round($this->amountInCents * (100 - $percentage) / 100),
            currency: $this->currency,
        );
    }

    private function ensureSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Money values must use the same currency.');
        }
    }
}

There is no setAmountInCents() method. Calling discountBy() produces a different value:

$listPrice = new Money(10_000, Currency::USD);
$salePrice = $listPrice->discountBy(15);

echo $listPrice->amountInCents;
// 10000

echo $salePrice->amountInCents;
// 8500

The original value remains usable. We can show the list price on an invoice, calculate the sale price for a customer, and use both values in the same request without copying values manually or restoring state after a calculation.

The method name should describe a domain operation, not the fact that it creates a copy. I prefer discountBy(), extendByDays(), withTaxRate(), or forCustomer() over generic methods like setValue() or copyWith(). The return type already communicates that a new value comes back.

Arrays Need an Intentional Design

Arrays are values in PHP. When an array is assigned and later modified, PHP uses copy-on-write semantics, so changing the new variable does not alter the original array.

That makes this safe for an immutable collection of immutable objects:

final readonly class CartLine
{
    public function __construct(
        public string $sku,
        public Money $unitPrice,
        public int $quantity,
    ) {
        if ($quantity < 1) {
            throw new InvalidArgumentException('A cart line must contain at least one item.');
        }
    }
}

final readonly class Cart
{
    /** @param list<CartLine> $lines */
    public function __construct(
        private array $lines = [],
    ) {}

    /** @return list<CartLine> */
    public function lines(): array
    {
        return $this->lines;
    }

    public function add(CartLine $line): self
    {
        return new self([...$this->lines, $line]);
    }
}

Calling add() cannot append to $this->lines, because the property is readonly. Instead, it builds a fresh list and gives that list to a new cart:

$cart = new Cart();
$cartWithBook = $cart->add(
    new CartLine('book-php-immutability', new Money(2_500, Currency::USD), 1),
);

count($cart->lines());
// 0

count($cartWithBook->lines());
// 1

Returning the array is also safe for the array structure. A caller can append to their returned copy, but that does not append to the cart:

$lines = $cartWithBook->lines();
$lines[] = new CartLine('php-stickers', new Money(500, Currency::USD), 1);

count($cartWithBook->lines());
// 1

There is one condition: the elements must be immutable too. If CartLine contained a mutable Product model or a mutable DateTime, both arrays would still point to the same object. The array would be protected, but its contents would not be.

So the question is not only "is this property an array?" It is also "what does this array contain?"

Normalize Mutable Input at the Boundary

Applications receive mutable values all the time. A controller can receive a DateTime, an Eloquent model, a request array, or an SDK object. We do not need to make every external type immutable. We do need to avoid letting it leak into the domain.

For a date value, convert any DateTimeInterface into DateTimeImmutable as soon as it enters our value object:

final readonly class PublicationDate
{
    private function __construct(
        public DateTimeImmutable $value,
    ) {}

    public static function fromDateTime(DateTimeInterface $date): self
    {
        return new self(DateTimeImmutable::createFromInterface($date));
    }
}

Now even if a caller passes a mutable DateTime, later changes to that object cannot change the PublicationDate:

$input = new DateTime('2026-08-01');
$publishedAt = PublicationDate::fromDateTime($input);

$input->modify('+1 week');

echo $publishedAt->value->format('Y-m-d');
// 2026-08-01

This is a good responsibility for a named constructor. It says that external date input is accepted, then establishes the stronger invariant the rest of the application needs.

The same pattern is useful with framework code. An Eloquent model can be mutable at the persistence layer while an action maps the fields it needs into an immutable command, DTO, or value object. The mutable model stays at the boundary; business logic receives stable values.

Cloning Is Not the Same as Immutability

It is tempting to use clone every time we want a safer copy. That can help in a few situations, but it is not an immutability strategy.

By default, PHP cloning is shallow. The outer object is copied, while nested object references remain shared:

final class Address
{
    public function __construct(
        public string $city,
    ) {}
}

final class Invoice
{
    public function __construct(
        public Address $shippingAddress,
    ) {}
}

$invoice = new Invoice(new Address('Berlin'));
$copy = clone $invoice;

$copy->shippingAddress->city = 'Lisbon';

echo $invoice->shippingAddress->city;
// Lisbon

Both invoices still point to the same Address. We can implement __clone() and explicitly clone nested objects, but then we must remember every mutable child, array of objects, and future property. That is easy to get wrong.

PHP also allows readonly properties to be reinitialized from __clone() in modern PHP versions. That is useful for a deliberate cloning design, but it is another reason not to treat readonly as a full immutability guarantee.

For a value object, an explicit constructor call is usually clearer than a clone:

public function moveTo(Address $shippingAddress): self
{
    return new self(
        number: $this->number,
        shippingAddress: $shippingAddress,
    );
}

This makes the changed value visible and keeps the constructor responsible for preserving invariants. Use cloning when copying object identity is truly the model you need, not as a shortcut around a mutable design.

Keep Invariants in the Constructor

Immutability is most useful when it is combined with valid state. If an object cannot change after construction, construction is the right time to establish its rules.

Here is a small date range example:

final readonly class DateRange
{
    public function __construct(
        public DateTimeImmutable $startsAt,
        public DateTimeImmutable $endsAt,
    ) {
        if ($endsAt <= $startsAt) {
            throw new InvalidArgumentException('The end date must be after the start date.');
        }
    }

    public function extendTo(DateTimeImmutable $endsAt): self
    {
        return new self($this->startsAt, $endsAt);
    }

    public function contains(DateTimeImmutable $date): bool
    {
        return $date >= $this->startsAt && $date < $this->endsAt;
    }
}

Every DateRange is valid. extendTo() cannot accidentally create an invalid range because it goes through the same constructor validation. The object does not need a later isValid() call, and the rest of the code does not need to defensively check whether its end date comes before its start date.

This is a strong pairing:

  • immutability means valid state cannot be changed behind our back
  • constructor validation means invalid state cannot enter in the first place

Together, they make values easier to trust.

Test the Contract, Not the Keyword

The useful test is not that a class has the readonly modifier. The useful test is that an operation returns a new value and leaves the original unchanged:

it('keeps the original trial period unchanged when extending it', function (): void {
    $trial = new TrialPeriod(new DateTimeImmutable('2026-08-01'));

    $extendedTrial = $trial->extendByDays(14);

    expect($extendedTrial)
        ->not->toBe($trial)
        ->and($trial->endsAt->format('Y-m-d'))->toBe('2026-08-01')
        ->and($extendedTrial->endsAt->format('Y-m-d'))->toBe('2026-08-15');
});

For a nested value, test the boundary too. Pass a mutable DateTime, change it after construction, and assert that the immutable value still has the original date. That proves the design protects the state that matters, rather than only proving that a modifier appears in the class declaration.

You should also test domain rules. A Money value should reject a negative amount and currency operations should reject mismatched currencies. An immutable object is not automatically a good value object if it can represent nonsense.

When Not to Use Immutability

Immutability has a cost. Every change creates another object, which can be unnecessary in code that intentionally manages a large mutable structure or an entity lifecycle.

I would not force an Eloquent model to become immutable. Eloquent is designed around setting attributes, tracking dirty values, saving, refreshing, and managing relationships. Fighting that design usually makes a Laravel application harder to work with.

I also would not create a new immutable wrapper around every primitive just because it is possible. A value object should earn its place by carrying meaning, validation, behavior, or a constraint that would otherwise be repeated.

Use an immutable object when:

  • the value has a clear domain meaning
  • keeping its state stable makes the code safer or easier to reason about
  • validation belongs with the value
  • an operation naturally produces another value
  • it crosses application layers, queues, events, or service boundaries

Keep ordinary mutable objects when:

  • the object models changing identity and lifecycle
  • a framework expects mutable state
  • performance measurements show that repeated copying is a real problem
  • a primitive is already clear and no behavior or invariant needs encapsulation

The point is not purity. The point is using immutability where it removes uncertainty.

A Practical Checklist

Before calling an object immutable, I ask these questions:

  1. Can any public method modify this object's state?
  2. Are all stored objects immutable too, or normalized into immutable values at construction?
  3. Does every operation that changes a value return a new instance?
  4. Are constructor invariants checked once and preserved by every named constructor and operation?
  5. Are collections made from immutable elements?
  6. Can a caller mutate an input object after construction and affect this value?
  7. Is this truly a value, or is it an entity that should have a mutable lifecycle?

If the answer to the second or sixth question is no, readonly alone is not enough. That is where most surprising bugs live.

Conclusion

readonly is a valuable PHP feature. It prevents accidental reassignment, narrows an object's state surface, and gives value objects a great foundation. But it is a property-level rule, not a complete definition of immutability.

Real immutability comes from the whole design: immutable building blocks, normalized inputs, constructor invariants, immutable collection elements, and operations that create new values instead of changing existing ones.

Start with the values that already cause the most defensive code in your application: dates, money, filters, identifiers, and request data. Make one of them deeply immutable, write a test that proves the original cannot change, and let that simpler contract remove a little uncertainty from the rest of the codebase.

I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!