levelmax World 5 Explicit Design
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 5.3 ~12 min Target: PHPStan max

Immutable all the way down

1.5's trap said readonly is shallow: it stops you replacing an object, not changing it. In application code, that object is nearly always a date.

$this->trialEndsOn = $startedOn->modify('+14 days');

That line reads like "the trial ends fourteen days after the start". With DateTime, it moves the start date forward fourteen days and then stores a second reference to the same object. The subscription's start and trial end are now one date, and anything else holding $startedOn (the caller, the next subscription built from the same $today) has moved too.

Shared mutable objects

Objects are passed by handle. When two places hold the same mutable object, a change made through one shows up in the other, whether or not the other expected it. That's fine for an entity you mean to share and update, like a basket in a session. For a value, like a date, money or an email address, it's a bug waiting for a second caller.

An immutable object can be shared with anyone, because nobody can change it. You don't need to clone it defensively on the way in or out, and you don't need to wonder who else holds it.

DateTimeImmutable

DateTimeImmutable has the same methods as DateTime, but every one that would change the date returns a new object instead:

$start = new DateTimeImmutable('2026-03-02');
$trialEnds = $start->modify('+14 days'); // 16 March
// $start is still 2 March

Use it everywhere unless you have a reason not to, and type parameters as DateTimeImmutable so a mutable date can't come in. A boundary that has to accept either can take DateTimeInterface and convert once with DateTimeImmutable::createFromInterface().

The same goes for your own classes. A value object that holds another object is only immutable if that object is too: Money holding a Currency enum is fine, and a readonly Invoice holding a mutable Customer isn't.

Power-up

Before
<?php

final class Subscription
{
    public DateTime $trialEndsOn;

    public function __construct(public readonly DateTime $startedOn)
    {
        // Moves $startedOn too, and every other reference to it
        $this->trialEndsOn = $startedOn->modify('+14 days');
    }

    public function firstBillOn(): DateTime
    {
        return $this->trialEndsOn->modify('+1 day'); // and the trial moves a day each time
    }
}
After
<?php

final readonly class Subscription
{
    public DateTimeImmutable $trialEndsOn;

    public function __construct(public DateTimeImmutable $startedOn)
    {
        $this->trialEndsOn = $startedOn->modify('+14 days');
    }

    public function firstBillOn(): DateTimeImmutable
    {
        return $this->trialEndsOn->modify('+1 day');
    }
}

Quick check

1. public readonly DateTime $startedOn. Can the start date change?
2. After $date = new DateTimeImmutable('2026-03-02'); $date->modify('+1 day');, what is $date?
3. Why doesn't an immutable date need a defensive clone?

CHALLENGE

Ann's trial ended on the day it started, and it moves a day later every time anyone asks when her billing starts. Subscription holds DateTime objects, and DateTime::modify() changes the object it's called on, which here is the start date, then the trial end.

Make Subscription immutable all the way down: a readonly class built from a DateTimeImmutable, holding only DateTimeImmutables. Then make TrialReminder return one too, and let PHPStan check you use what modify() returns.

REFERENCE
$date = new DateTime('2026-03-02');
$date->modify('+14 days');           // $date is now 16 March, and so is every reference to it

$date = new DateTimeImmutable('2026-03-02');
$later = $date->modify('+14 days');  // a new object for 16 March; $date is still 2 March
Running on PHP 8.3…

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