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
<?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 } }
<?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'); } }