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.
Short answer
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. I wrote about what readonly buys you in domain models in the PHP 8.1 post; the withX pattern is the natural consequence of that guarantee.
Why
-
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. (Small correction:readonlyshipped in PHP 8.1, not PHP 8.2.) -
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. -
The two paths differ in validation behavior. 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.
What to do
-
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. -
On PHP 8.5, collapse the wither to one line. 8.5 lets you override properties while cloning:
public function withAmount(int $amount): static { return clone($this, ['amount' => $amount]); }
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.