Immutable Objects: with-Methods Instead of Setters, Implemented in Practice
AI generated
<?php
8.4
PHP 8.4 · Immutable Objects · Value Objects · readonly
Immutable Objects: with-methods instead of setters
implemented in practice in PHP 8.4

Immutable objects fundamentally change how reliably PHP code behaves: once constructed, a value object can no longer be secretly changed from anywhere else in the program, because there simply are no setters left. Instead of mutating existing instances, with-methods create a new, fully initialized copy with the changed value on every change. This article uses a running Money example to show how readonly properties, with-methods, and named constructors work together, and why cloning followed by mutation simply does not work technically for readonly properties in PHP.

12 min read readonly · with-methods · Named Constructors PHP 8.1+ · PHP 8.4

1. What immutability means for an object

An immutable object is an instance whose state cannot be changed after construction. There is no setAmount() method, no public property that gets assigned from outside, no state that shifts at runtime. The value of an object is fixed once the constructor has run, and stays identical for the entire lifetime of the instance. This stands in direct contrast to a classic mutable object with public setters, where any line of code holding a reference to the object can change its state at any time, often without the rest of the program ever finding out.

This exact shared state is the root of one of the most common and hardest to find classes of bugs in object-oriented PHP code: the aliasing bug. Two variables point to the same object instance, one line of code changes the object through one variable, and the other variable suddenly sees a changed state, even though it was never touched itself. The following example shows this effect on a classic mutable Money object, as it appears in many grown shopping cart implementations.


<?php

declare(strict_types=1);

namespace Shop\Pricing;

// Mutable value object: any code holding a reference can change it
final class Money
{
    private int $amountInCents;
    private string $currency;

    public function __construct(int $amountInCents, string $currency)
    {
        $this->amountInCents = $amountInCents;
        $this->currency = $currency;
    }

    public function setAmountInCents(int $amountInCents): void
    {
        $this->amountInCents = $amountInCents;
    }

    public function getAmountInCents(): int
    {
        return $this->amountInCents;
    }
}

// Aliasing bug: two variables reference the SAME instance
$subtotal = new Money(10000, 'EUR'); // 100.00 EUR
$cartTotal = $subtotal; // no copy, just another reference to the same object

// Somewhere else in the codebase, a discount gets applied...
$cartTotal->setAmountInCents(8000); // 80.00 EUR

// $subtotal changed too, even though nothing touched it directly
echo $subtotal->getAmountInCents(); // 8000, not 10000

The problem in this example is not an obvious typo, but the structure itself: as long as Money is mutable, any place in the code that holds a reference can change the state for every other place at the same time. An immutable object eliminates this class of bug structurally, not through discipline or code review, but simply because there is no method that could change the state afterward.

2. readonly properties and constructor property promotion

Since PHP 8.1, readonly exists as a native language feature for exactly this problem. A property declared as readonly can only be initialized once, and only from the scope of the declaring class, typically the constructor. Any further write access, whether from outside the class or a second assignment attempt within the same method, results in an Error. Combined with constructor property promotion, an immutable object can be declared in PHP 8.4 in a few lines without boilerplate.

This turns the constructor into the only place in the entire class body where the state is ever set. This is not just a stylistic choice, but a property enforced by the language runtime itself: there is no setter, because a setter method on a readonly property would only throw an exception after the first initialization anyway. The following example shows the minimal immutable variant of Money and the error that results from an external write attempt.


<?php

declare(strict_types=1);

namespace Shop\Pricing;

// Immutable value object: state is fixed once the constructor returns
final class Money
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {
    }
}

$price = new Money(10000, 'EUR');

// Attempting to write from outside the declaring class scope...
$price->amountInCents = 8000;
// Error: Cannot modify readonly property Money::$amountInCents

The precise wording of the rule matters: readonly forbids not only access from outside, but any second assignment at all, even from within the constructor itself. Anyone who accidentally tries to set $this->amountInCents again in a second method of the same class gets the same error. This strictness is intentional: it is the foundation on which the entire with-method pattern for immutable objects in the next section builds.

3. The withX() pattern in detail

This is where a common misunderstanding arises: anyone who needs a changed copy of an immutable object might think of cloning the object and then setting the changed property on the copy. This does not work with readonly properties in PHP, however. clone does create a shallow copy of the object, but every readonly property of the copy is still considered already initialized, exactly as in the original. PHP has no language feature for a "clone followed by a changed readonly property". A write attempt on the cloned instance outside the declaring scope throws exactly the same error as in the previous section.

The correct way is a withX() method that does not change an existing instance, but constructs and returns a completely new instance via new self(...). All unchanged values are passed through unchanged to the new constructor call, only the one changed value is replaced. The result is a new, independent object, while the original instance remains untouched and still shows its old value everywhere it is referenced.


<?php

declare(strict_types=1);

namespace Shop\Pricing;

final class Money
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {
    }

    // withX(): build and return a brand new instance, never mutate this one
    public function withAmountInCents(int $amountInCents): self
    {
        return new self($amountInCents, $this->currency);
    }

    public function withCurrency(string $currency): self
    {
        return new self($this->amountInCents, $currency);
    }

    public function add(self $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new \InvalidArgumentException('Cannot add Money in different currencies');
        }

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

$price = new Money(10000, 'EUR');
$discounted = $price->withAmountInCents(8000); // new instance, $price stays untouched
$inUsd = $discounted->withCurrency('USD');      // yet another new instance

echo $price->amountInCents;      // 10000, still the original value
echo $discounted->amountInCents; // 8000

This chain, $price->withAmountInCents(8000)->withCurrency('USD'), creates two intermediate instances, each of which is fully immutable and fully valid on its own. No step in this chain changes an already existing object, and that is exactly what makes the with-method pattern the right answer to the question of how to change immutable objects in PHP in practice, without undermining the language guarantee of readonly.

4. Named constructors as a companion pattern

A single, general-purpose constructor quickly hits its limits as soon as a value object needs to be created from different sources: from a float amount, from an API response in minor units, as an explicit zero value. Instead of overloading a single constructor with optional parameters and internal branching logic, the actual constructor is kept minimal, often even private, and named static factory methods are provided instead. Each of these methods describes, through its name, from which context an instance is created, and can encapsulate its own validation and conversion logic.

Because withX() methods and named constructors live in the same class body, they may still call a constructor declared as private via new self(...). PHP checks visibility relative to the declaring class, not relative to the calling line of code, as long as the call happens within the same class.


<?php

declare(strict_types=1);

namespace Shop\Pricing;

final class Money
{
    private function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {
    }

    public static function fromFloat(float $amount, string $currency): self
    {
        return new self((int) round($amount * 100), $currency);
    }

    public static function fromMinorUnits(int $amountInCents, string $currency): self
    {
        return new self($amountInCents, $currency);
    }

    public static function zero(string $currency): self
    {
        return new self(0, $currency);
    }

    // withX() lives in the same class, so it may still call the private constructor
    public function withAmountInCents(int $amountInCents): self
    {
        return new self($amountInCents, $this->currency);
    }
}

$price = Money::fromFloat(99.90, 'EUR');
$empty = Money::zero('EUR');
$fromApi = Money::fromMinorUnits(4599, 'USD');

Named constructors make the entry point for an immutable object readable, instead of forcing you to guess a single overloaded signature. Money::zero('EUR') reads as self-explanatory at every call site, while a generic constructor with a silent zero value would obscure that intent.

5. Equality and comparability of immutable value objects

With value objects, the question of whether two instances should be considered equal comes up almost every time. PHP's === operator checks object identity, that is, whether both variables reference exactly the same instance in memory. Two separately constructed Money instances with an identical amount and identical currency are still unequal under ===, because they are two different objects. That is almost never what you actually want from a domain perspective: 100 euros is 100 euros, regardless of which concrete instance represents it. The loose comparison operator == compares objects of the same class property by property, and already returns true for two value-equal Money instances.

Even so, it is usually the more robust choice to implement an explicit equals() method rather than relying on == alone. An explicit method makes the comparison intent visible in the code, can be scoped specifically to the properties that matter for the domain, and stays stable even if additional properties irrelevant to equality get added later. Immutability greatly simplifies this consideration: because an immutable object never changes its state after construction, the result of a comparison cannot shift afterward either. An instance once found equal stays equal for as long as it exists, which is by no means guaranteed with mutable objects and shared references.

6. Validation and invariants during construction

An immutable object can only be reliably immutable if it never exists in an invalid state. That is why validation consistently belongs in the constructor, or in the named constructors that call it. If an exception is thrown there for an invalid value, the instance can never leave the construct without satisfying all invariants in the first place. There is then no later setter location where an invalid value could slip in unnoticed, because there simply are no setters.


<?php

declare(strict_types=1);

namespace Shop\Pricing;

final class Money
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {
        if ($amountInCents < 0) {
            throw new \InvalidArgumentException('Amount must not be negative');
        }

        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new \InvalidArgumentException(sprintf('Invalid currency code: %s', $currency));
        }
    }

    public function withAmountInCents(int $amountInCents): self
    {
        // Validation runs again automatically, because it lives in the constructor
        return new self($amountInCents, $this->currency);
    }
}

// Throws immediately, the invalid instance never exists
$broken = new Money(-500, 'EUR');

With a mutable object using setters, the same validation logic must instead be repeated in every single setter, because each setter is a potential entry point for an invalid value. If the check is forgotten in just one of several setters, the object can end up in an invalid state through exactly that forgotten path. With an immutable object, this risk does not exist, because every withX() method internally calls the same constructor, so validation runs automatically without having to be maintained in multiple places.

7. Practical benefits beyond avoiding mutation bugs

The most obvious benefit of immutable objects is avoiding aliasing bugs, but the practical value goes considerably further. An immutable object can be passed across function boundaries without hesitation, without the calling function needing to make a defensive copy to protect itself from unwanted side effects. Since no method exists that could change the state, passing the plain reference is already just as safe as a full copy, only without the copying cost.

The same property considerably simplifies reasoning about concurrent or asynchronous code. When multiple fibers, coroutines, or parallel requests reference the same instance, there is no race for write access, because there is no write access at all. Additionally, immutable objects are excellent for caching and memoization: since the domain value of an instance never deviates from its initial state, a once-computed result that depends on this object can be safely cached, without a later mutation invalidating the cached result unnoticed.

The benefit also shows when using arrays and collections: a value object once inserted into an array is guaranteed to still behave exactly the same later as it did at the moment of insertion. With mutable objects, an already stored element can change afterward through a shared reference, without the structure containing it ever finding out, leading to inconsistent states that are hard to trace.

8. When immutability is the wrong tool

Immutability is not a universal principle to apply to every class. For very large objects or in hot loops that perform thousands of changes per second, every withX() call costs a new object allocation along with a copy of all unchanged values. For small value objects like Money or Address, this overhead is negligible in practice, but for large, deeply nested structures with a high change frequency, it can become noticeable.

Just as important is the distinction between value objects and entities with real identity. An Order object that is tracked via an ID and whose status actually changes over the lifecycle of an order, from pending through paid to shipped, is usually better modeled as a mutable object that changes its state in a controlled way through clearly named methods like markAsPaid(). The key difference: a value object is identified by its value, an entity by its ID, and only for the former can the question "what if the value changes" be cleanly answered with "then it is a different object".

In practice, this leads to a mixed approach: value objects like monetary amounts, addresses, or time periods are consistently modeled as immutable, while entities with identity and a real lifecycle, such as orders, user accounts, or shopping carts, deliberately stay mutable. Both modeling styles are not mutually exclusive within the same project, they solve different problems and are deliberately used side by side.

9. Mutable vs. immutable directly compared

The choice between a classic mutable object with setters and an immutable object with with-methods can be made systematically using a few recurring criteria. The following table compares both approaches along the points that most often lead to bugs or discussions in practice.

Criterion Mutable object with setters Immutable object with with-methods
Aliasing risk High: shared references can be changed unnoticed None: every change creates a new instance
Location of validation Must be repeated in every single setter Once, in the constructor or named constructor
Thread/async safety Risky under concurrent access from multiple processes Safe, since state is fixed after construction
Equality semantics State can shift between two comparisons equals() stays stable over the object's lifetime
Typical use case Entities with identity and lifecycle (Order, User) Value objects (Money, Address, DateRange)

The table makes clear that this is not purely a matter of taste. Aliasing risk, validation effort, and concurrency safety are structurally worse for mutable objects, regardless of how carefully the individual developer codes. Conversely, the last row is the reason not to model every class as immutable: objects with real identity and lifecycle benefit more from controlled mutation through clearly named methods than from a chain of ever new instances.

10. Summary

Immutable objects solve the aliasing problem not through convention, but through language guarantees: readonly properties can only be set once, from the declaring scope, and any further write attempt, even after a clone, is prevented by PHP with an Error. The correct way to produce an immutable object with a changed value is therefore not cloning and mutating, but withX() methods that construct a completely new instance via new self(...) and pass through all unchanged values.

Named constructors complement this pattern with readable, validated entry points, while an explicit equals() method ensures domain-correct value comparisons. Constructor validation ensures that an immutable object never exists in an invalid state, which is considerably more error-prone with mutable objects and repeated setter validation. Not every class benefits from this: entities with real identity and lifecycle deliberately stay mutable, while value objects like monetary amounts or addresses are consistently modeled as immutable.

Immutable objects in PHP, the essentials at a glance

readonly properties

Can only be set once, from the declaring scope. Every further write attempt, even after clone, throws an Error.

withX() instead of clone

Always construct a new instance via new self(...), never mutate a cloned copy afterward.

Named constructors

fromFloat(), zero(), fromMinorUnits() as readable, validated entry points instead of an overloaded constructor.

When not immutable

Entities with identity and lifecycle (Order, User) are better off staying mutable instead of constantly creating new instances.

11. FAQ: Immutable Objects in PHP

1What is an immutable object in PHP?
An instance whose state no longer changes after construction. No setters, only the constructor and with-methods that return a new instance.
2Overwrite a readonly property after clone?
No. The cloned copy's property is still considered initialized. A write attempt throws the same error. The correct way is new self(...) instead of clone.
3with-methods vs. setters?
A setter mutates the existing instance. A with-method returns a new instance with the changed value and leaves the original untouched.
4Why equals() instead of ===?
=== checks identity, not value. Two value-equal but separate instances are unequal under ===. equals() explicitly compares the relevant properties.
5Prevent an invalid state?
Validation in the constructor or named constructors. An exception is thrown on an invalid value, the instance then never exists.
6readonly like const?
No. const is fixed at compile time and identical for all instances. readonly is set per instance in the constructor and can vary per object.
7What does immutability cost in performance?
Every with-method allocates a new instance. Negligible for small value objects, noticeable for large objects in hot loops.
8When to prefer mutable?
With entities that have identity and lifecycle, like an Order with a changing status. Controlled mutation fits better here than the with-pattern.
9Combine with-methods with named constructors?
Named constructors create the first validated instance, with-methods handle every subsequent change. Both use new self(...) internally.
10Array in a readonly property immutable?
Not automatically. readonly only prevents reassigning the property itself, not changing mutable content within it.

Mironsoft

PHP development, value object design, and code review

Want to introduce immutable objects cleanly in your PHP project?

We help teams consistently model value objects with readonly properties, with-methods, and named constructors, and show where mutable entities remain the better choice.

Code review

Checking existing value objects for aliasing risks and missing invariants

Refactoring

Migrating setter-based classes step by step to readonly properties and with-methods

Architecture workshop

Cleanly separating value objects and entities, directly on your existing domain model