Readonly properties
A receipt object gets passed to the mailer, the PDF generator, the accounting export, and a refund handler. If any of them can change it, every one of them has to wonder whether the others already did. That's how a refund ends up on the original receipt in the customer's inbox.
readonly
A readonly property can be assigned once, from inside the class, and never again. Usually that's in the constructor:
final class Invoice { public function __construct( public readonly string $number, public readonly int $totalPence, public readonly DateTimeImmutable $dueOn, ) {} }
Public and readonly together is a nice combination: anyone can read it, nobody can change it, and you don't need a getter for every property. Since PHP 8.2, final readonly class Invoice makes every property readonly in one go.
PHP enforces this at runtime with an Error, and PHPStan reports an assignment to a readonly property before it ever runs.
Changing something immutable
You don't. You make a new one. A with-er returns a copy with one thing changed and leaves the original alone:
public function postponedTo(DateTimeImmutable $dueOn): self { return new self($this->number, $this->totalPence, $dueOn); }
Going through the constructor means the new object is checked like any other: derived values are recalculated, invariants are enforced, and there's no half-updated state in between. The original invoice still says what it always said.
Power-up
<?php declare(strict_types=1); final class Invoice { public function __construct( public string $number, public int $totalPence, public DateTimeImmutable $dueOn, ) {} public function postpone(int $days): void { $this->dueOn = $this->dueOn->modify("+{$days} days"); } }
<?php declare(strict_types=1); final readonly class Invoice { public function __construct( public string $number, public int $totalPence, public DateTimeImmutable $dueOn, ) {} public function postponedTo(DateTimeImmutable $dueOn): self { return new self($this->number, $this->totalPence, $dueOn); } }