Skip to content
Muhammet Şafak
tr
Asked by: Kaan Answered:

How do I cleanly implement "withX" copy methods on readonly value objects?


Question

In my domain layer I have value objects like Money and Address, and I made them immutable using PHP 8.2's `readonly` feature. My goal is that once an object is constructed it never changes. But when I try to produce immutable copies I keep getting "Cannot modify readonly property." For example, in a `withAmount()` method I try to update the property and it blows up. How do I write these "withX" copy methods cleanly and without repeating myself?

Answer

Short answer: with readonly there’s no mutation; instead of trying to change the property when you copy, you must return a new instance. The error is telling you exactly that — $this->amount = ... is forbidden, because a readonly property can only be set once, in the scope where it was initialized.

  1. Root cause. After the initial assignment you can’t rewrite a readonly property, not even inside clone (in an ordinary method); the runtime blocks this on purpose. So mutating $this inside withX is impossible — and that’s a feature, not a limitation to fight. Lean into producing a fresh object rather than trying to defeat the restriction. (Small correction: readonly shipped in PHP 8.1, not PHP 8.2.)
  2. Portable, clean pattern: new self + named arguments. Each withX calls the constructor with new values, so all invariants get re-validated. It works everywhere since PHP 8.1:
    public function withAmount(int $amount): static
    {
        return new self(amount: $amount, currency: $this->currency);
    }
  3. Named arguments kill the repetition as fields grow. In a multi-field VO each withX only writes the field that changes and carries the rest via $this->...; named arguments remove the positional coupling and keep it readable.
  4. PHP 8.3+: __clone is for deep-clone only. In 8.3 you can reinitialize readonly properties inside __clone, but that doesn’t carry a new value into withX from the outside; its real purpose is deep-copying nested objects (e.g. a DateTimeImmutable). Don’t try to solve withX with it.
  5. PHP 8.5: this is exactly what the wither needs. 8.5 lets you override properties while cloning, so the wither pattern collapses to one line:
    public function withAmount(int $amount): static
    {
        return clone($this, ['amount' => $amount]);
    }
  6. Don’t forget validation. Because new self runs every copy through the constructor, you get validation (negative amount, invalid currency) for free. clone($this, [...]) does not call the constructor; if you have invariant checks you have to trigger them by hand on the clone path.

Bottom line: personally, on PHP 8.2/8.3 I’d go with new self + named arguments — it’s portable and runs every copy through validation. If you’re on 8.5 and the validation is simple, clone($this, [...]) is more elegant and cuts the boilerplate; but if your invariants are strict, I’d still prefer the constructor-based path so every copy re-enters the same validation gate and I don’t quietly lose that guarantee.

Related Reading

Tags: #PHP#OOP
Expertise: PHP Developer
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind