levelmax World 1 Strict Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 1.5 ~10 min Target: PHPStan level 5

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

Before
<?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");
    }
}
After
<?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);
    }
}

Quick check

1. Where can a readonly property be assigned?
2. Invoice has public readonly Customer $customer, and Customer is an ordinary mutable class. Does $invoice->customer->email = 'x' work?

CHALLENGE

A receipt is a record of what happened. This one can be edited by anyone holding it, and refund() rewrites history: the original receipt changes after the fact.

Make Receipt immutable. Replace refund() with withRefund(int $pence), which returns a new receipt with an extra ['Refund', -$pence] line and leaves the original untouched. Refuse refunds that are below 1p or more than the total with an InvalidArgumentException.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.