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.
- 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$thisinside 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:readonlyshipped in PHP 8.1, not PHP 8.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); } - 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. - PHP 8.3+:
__cloneis 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. aDateTimeImmutable). Don’t try to solve withX with it. - 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]); } - Don’t forget validation. Because
new selfruns 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
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.