unserialize creates an object without calling the constructor and then writes the properties, which is precisely the thing readonly forbids.
final class Money
{
public function __construct(
public readonly int $cents,
) {}
}
unserialize(serialize(new Money(4900)));
// works — the engine has a documented exception for
// unserialize writing readonly properties once.
$c = clone $m; // fine
$c->cents = 1; // Error — clone does not reset the lock
Native serialisation is allowed by an explicit carve-out in the engine, and hydration libraries that use reflection to write properties mostly work for the same reason — ReflectionProperty::setValue can initialise an uninitialised readonly property. The case that genuinely does not work is a wither: clone copies the initialised state, so modifying the copy throws, and 8.1 has no clone with to fix it. The workaround is a new instance from the constructor, which is more verbose and correct.