Введение
Ключевое слово readonly в PHP — это одна из тех возможностей, которые сразу делают код более безопасным. Мы можем указать его для свойства или класса, и PHP не позволит нам присвоить другое значение в дальнейшем.
Это полезно, но это лишь одна из составляющих неизменяемости (immutability).
Неизменяемый объект — это объект, чье наблюдаемое состояние не может измениться после его создания. Когда нам требуется иное состояние, мы создаем новый объект. Это может показаться незначительным отличием, но оно меняет то, как мы моделируем бизнес-правила, как значения передаются через приложение и насколько уверенно мы можем передавать объект в другой класс.
Важный момент заключается в том, что readonly защищает присваивание конкретного свойства. Оно не делает автоматически неизменяемым каждое значение, доступное через это свойство. Свойство readonly все еще может содержать изменяемый объект. Класс readonly все еще может предоставлять доступ к изменяемому объекту-сотруднику. А клон все еще может разделять изменяемые вложенные объекты с оригиналом.
В этой статье мы выйдем за рамки простого ключевого слова. Мы увидим, что на самом деле означает неизменяемость в PHP, в чем помогает readonly , какие бреши оно не закрывает и как создавать объекты-значения, остающиеся безопасными по мере роста приложения.
Неизменяемость — это поведенческое обещание
Самое простое определение звучит так:
Неизменяемый объект никогда не меняет свое собственное состояние после конструирования.
Это значит, что операция, выглядящая как изменение, вместо этого возвращает новое значение:
$nextRenewal = $renewal->extendByMonths(1);
После этой строки $renewal по-прежнему представляет исходную дату. $nextRenewal представляет продленную дату. Мы можем передать любое из этих значений в другую часть приложения, не опасаясь, что последующий вызов метода незаметно изменит его.
Это особенно ценно для значений, имеющих определенный смысл в домене:
- денежные суммы и валюты
- диапазоны дат и окна бронирования
- адреса электронной почты, URL и идентификаторы
- позиции заказа и итоговые суммы
- фильтры, используемые для построения отчета или запроса
- данные запроса после валидации
Когда объект представляет значение, а не сущность с изменяющимся жизненным циклом, неизменяемость обычно является хорошим выбором по умолчанию. Объект Money , представляющий 1000 центов в USD, не должен превращаться в 900 центов из-за того, что какой-то другой сервис сохранил на него ссылку. Объект Email не должен превращаться в другой адрес электронной почты посреди выполнения операции.
Речь идет не о том, чтобы сделать неизменяемым каждый класс в приложении. Модель Eloquent, например, представляет персистентную идентичность и намеренно обладает жизненным циклом. Ожидается, что она изменится перед сохранением. Цель состоит в том, чтобы сделать значения вокруг этой изменяемой границы стабильными и явными.
Что гарантирует readonly
Модификатор readonly в PHP предотвращает изменение свойства после его первого присваивания. Класс readonly применяет это правило к каждому свойству экземпляра.
Для небольшого объекта-значения, состоящего только из скалярных значений и перечислений, это уже прочный фундамент:
enum Currency: string
{
case EUR = 'EUR';
case USD = 'USD';
}
final readonly class Money
{
public function __construct(
public int $amountInCents,
public Currency $currency,
) {
if ($amountInCents < 0) {
throw new InvalidArgumentException('A money amount cannot be negative.');
}
}
}
Этот код не сможет перезаписать сумму позже:
$price = new Money(1_000, Currency::USD);
$price->amountInCents = 900;
// Error: Cannot modify readonly property Money::$amountInCents
У класса также есть одна четкая точка конструирования. Это позволяет нам сразу отсеивать неверные значения до того, как Money попадет в инвойс, корзину или запрос на оплату.
Модификатор readonly дает нам полезные гарантии:
- свойство может быть присвоено только один раз
- для класса
readonlyобязательно требуется типизация состояния - в класс
readonlyнельзя добавлять динамические свойства - у объекта уменьшается и становится более предсказуемой поверхность состояния
Для значений, состоящих из строк, целых чисел, булевых значений, массивов скалярных величин, перечислений и других неизменяемых значений, этого может быть достаточно. Но последняя фраза имеет решающее значение: другие неизменяемые значения.
readonly не обеспечивает глубокую неизменяемость
PHP защищает свойство, но не каждый объект за ним.
Рассмотрим окно доставки, принимающее изменяемый экземпляр DateTime :
final readonly class DeliveryWindow
{
public function __construct(
public DateTime $startsAt,
) {}
}
$window = new DeliveryWindow(new DateTime('2026-08-01 09:00:00'));
$window->startsAt->modify('+1 day');
echo $window->startsAt->format('Y-m-d');
// 2026-08-02
Мы не присваивали новое значение переменной $startsAt , поэтому PHP корректно разрешает этот код. Но состояние, которое предоставляет DeliveryWindow , изменилось. С точки зрения домена объект является изменяемым.
Такое поведение часто называют внутренней изменяемостью (interior mutability). Контейнер нельзя переназначить, в то время как изменяемый объект внутри контейнера все еще может быть изменен.
То же самое правило применимо и к классу readonly . Это по-прежнему не обеспечивает глубокую неизменяемость:
final readonly class ReportSchedule
{
public function __construct(
public DateTime $nextRunAt,
) {}
}
Модификатор readonly не является рекурсивным. В PHP нет языковой возможности, которая автоматически обходила бы граф объектов и делала каждый вложенный объект неизменяемым за нас. Эту работу по проектированию по-прежнему должны выполнять мы.
Выбирайте неизменяемые строительные блоки
Первое практическое правило очень простое:
Неизменяемый объект должен содержать только неизменяемые значения.
Для дат предпочитайте DateTimeImmutable вместо DateTime . Его методы модификации возвращают новый экземпляр вместо изменения текущего:
final readonly class TrialPeriod
{
public function __construct(
public DateTimeImmutable $endsAt,
) {}
public function extendByDays(int $days): self
{
if ($days < 1) {
throw new InvalidArgumentException('A trial extension must have at least one day.');
}
return new self($this->endsAt->modify("+{$days} days"));
}
}
Теперь эта операция делает разницу очевидной в месте вызова:
$trial = new TrialPeriod(new DateTimeImmutable('2026-08-01'));
$extendedTrial = $trial->extendByDays(14);
echo $trial->endsAt->format('Y-m-d');
// 2026-08-01
echo $extendedTrial->endsAt->format('Y-m-d');
// 2026-08-15
В руководстве по PHP описывает DateTimeImmutable именно так: вызовы вроде modify() создают новый объект и оставляют оригинал нетронутым. Это делает его гораздо лучшим типом даты для объекта-значения.
Этот принцип применим к каждой зависимости, хранящейся в неизменяемом объекте:
- используйте
DateTimeImmutable, а неDateTime - используйте типизированные перечисления (backed enums) для фиксированного набора состояний
- используйте объекты-значения для валидируемых концептов, таких как
EmailилиMoney - используйте элементы неизменяемых коллекций
- не храните внутри объекта-значения клиенты сервисов, модели, строители (builders) или изменяемые кэши
Если хотя бы один вложенный потомок является изменяемым, то и родитель является неизменяемым лишь поверхностно.
flowchart LR
A[Mutable input at the boundary] --> B[Validate and convert]
B --> C[Immutable value object]
C --> D[Operation]
D --> E[New immutable value object]
Преобразуйте изменяемые входные данные на границе, а затем сохраняйте доменные значения неизменяемыми по мере их перемещения по приложению.
Возвращайте новые значения для доменных операций
У неизменяемого объекта не должно быть сеттеров. Он должен предоставлять операции, которые возвращают новый объект с запрошенным состоянием.
Давайте сделаем наш объект-значение Money полезным:
enum Currency: string
{
case EUR = 'EUR';
case USD = 'USD';
}
final readonly class Money
{
public function __construct(
public int $amountInCents,
public Currency $currency,
) {
if ($amountInCents < 0) {
throw new InvalidArgumentException('A money amount cannot be negative.');
}
}
public function add(self $other): self
{
$this->ensureSameCurrency($other);
return new self(
amountInCents: $this->amountInCents + $other->amountInCents,
currency: $this->currency,
);
}
public function discountBy(int $percentage): self
{
if ($percentage < 0 || $percentage > 100) {
throw new InvalidArgumentException('The discount must be between 0 and 100.');
}
return new self(
amountInCents: (int) round($this->amountInCents * (100 - $percentage) / 100),
currency: $this->currency,
);
}
private function ensureSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Money values must use the same currency.');
}
}
}
Здесь нет метода setAmountInCents() . Вызов discountBy() создает другое значение:
$listPrice = new Money(10_000, Currency::USD);
$salePrice = $listPrice->discountBy(15);
echo $listPrice->amountInCents;
// 10000
echo $salePrice->amountInCents;
// 8500
Исходное значение остается пригодным к использованию. Мы можем показать розничную цену в инвойсе, рассчитать цену со скидкой для клиента и использовать оба значения в рамках одного запроса, не копируя значения вручную и не восстанавливая состояние после вычислений.
Имя метода должно описывать доменную операцию, а не тот факт, что он создает копию. Я предпочитаю discountBy() , extendByDays() , withTaxRate() или forCustomer() вместо общих методов вроде setValue() или copyWith() . Возвращаемый тип уже сам по себе сообщает, что в результате мы получаем новое значение.
Массивы требуют осознанного проектирования
Массивы в PHP являются значениями. Когда массив присваивается, а затем изменяется, PHP использует семантику copy-on-write (копирование при записи), поэтому изменение новой переменной не затрагивает исходный массив.
Это делает такой подход безопасным для неизменяемой коллекции неизменяемых объектов:
final readonly class CartLine
{
public function __construct(
public string $sku,
public Money $unitPrice,
public int $quantity,
) {
if ($quantity < 1) {
throw new InvalidArgumentException('A cart line must contain at least one item.');
}
}
}
final readonly class Cart
{
/** @param list<CartLine> $lines */
public function __construct(
private array $lines = [],
) {}
/** @return list<CartLine> */
public function lines(): array
{
return $this->lines;
}
public function add(CartLine $line): self
{
return new self([...$this->lines, $line]);
}
}
Вызов add() не может добавить элемент в $this->lines , поскольку свойство является readonly. Вместо этого он формирует новый список и передает его в новую корзину:
$cart = new Cart();
$cartWithBook = $cart->add(
new CartLine('book-php-immutability', new Money(2_500, Currency::USD), 1),
);
count($cart->lines());
// 0
count($cartWithBook->lines());
// 1
Возврат массива также безопасен для структуры самого массива. Вызывающий код может добавить элемент в полученную им копию, но это не добавит элемент в корзину:
$lines = $cartWithBook->lines();
$lines[] = new CartLine('php-stickers', new Money(500, Currency::USD), 1);
count($cartWithBook->lines());
// 1
Здесь есть одно условие: элементы также должны быть неизменяемыми. Если бы CartLine содержал изменяемую модель Product или изменяемый DateTime , оба массива все равно указывали бы на один и тот же объект. Сам массив был бы защищен, но его содержимое — нет.
Таким образом, вопрос заключается не только в том, «является ли это свойство массивом?». Он также в том, «что содержит этот массив?».
Нормализуйте изменяемые входные данные на границе
Приложения постоянно получают изменяемые значения. Контроллер может получить DateTime , модель Eloquent, массив запроса или объект SDK. Нам не нужно делать каждый внешний тип неизменяемым. Нам нужно не допускать их проникновения в домен.
Для значения даты конвертируйте любой DateTimeInterface в DateTimeImmutable сразу же, как только он попадает в наш объект-значение:
final readonly class PublicationDate
{
private function __construct(
public DateTimeImmutable $value,
) {}
public static function fromDateTime(DateTimeInterface $date): self
{
return new self(DateTimeImmutable::createFromInterface($date));
}
}
Теперь даже если вызывающий код передает изменяемый DateTime , последующие изменения этого объекта не смогут изменить PublicationDate :
$input = new DateTime('2026-08-01');
$publishedAt = PublicationDate::fromDateTime($input);
$input->modify('+1 week');
echo $publishedAt->value->format('Y-m-d');
// 2026-08-01
Это отличная зона ответственности для фабричного метода (named constructor). Он показывает, что внешние входные данные даты принимаются, а затем устанавливает более строгий инвариант, необходимый остальной части приложения.
Тот же паттерн полезен и при работе с кодом фреймворка. Модель Eloquent может оставаться изменяемой на уровне персистентности, в то время как действие (action) маппит необходимые поля в неизменяемую команду, DTO или объект-значение. Изменяемая модель остается на границе; бизнес-логика получает стабильные значения.
Клонирование не равно неизменяемости
Возникает искушение использовать clone каждый раз, когда нам нужна более безопасная копия. В некоторых ситуациях это может помочь, но это не стратегия обеспечения неизменяемости.
По умолчанию клонирование в PHP является поверхностным. Внешний объект копируется, в то время как ссылки на вложенные объекты остаются общими:
final class Address
{
public function __construct(
public string $city,
) {}
}
final class Invoice
{
public function __construct(
public Address $shippingAddress,
) {}
}
$invoice = new Invoice(new Address('Berlin'));
$copy = clone $invoice;
$copy->shippingAddress->city = 'Lisbon';
echo $invoice->shippingAddress->city;
// Lisbon
Оба инвойса по-прежнему ссылаются на один и тот же объект Address . Мы можем реализовать метод __clone() и явно клонировать вложенные объекты, но в таком случае мы обязаны не забывать про каждого изменяемого потомка, массив объектов и каждое будущее свойство. В этом легко допустить ошибку.
Кроме того, в современных версиях PHP разрешено повторно инициализировать readonly-свойства из метода __clone() . Это полезно для осознанного проектирования клонирования, но является еще одной причиной не рассматривать readonly как полную гарантию неизменяемости.
Для объекта-значения явный вызов конструктора обычно понятнее, чем клонирование:
public function moveTo(Address $shippingAddress): self
{
return new self(
number: $this->number,
shippingAddress: $shippingAddress,
);
}
Это делает измененное значение наглядным и сохраняет за конструктором ответственность за поддержание инвариантов. Используйте клонирование тогда, когда копирование идентичности объекта — это действительно та модель, которая вам нужна, а не в качестве обходного пути для работы с изменяемым дизайном.
Храните инварианты в конструкторе
Неизменяемость наиболее полезна тогда, когда она сочетается с корректным состоянием. Если объект не может измениться после конструирования, то конструирование — это самый подходящий момент для установления его правил.
Вот небольшой пример диапазона дат:
final readonly class DateRange
{
public function __construct(
public DateTimeImmutable $startsAt,
public DateTimeImmutable $endsAt,
) {
if ($endsAt <= $startsAt) {
throw new InvalidArgumentException('The end date must be after the start date.');
}
}
public function extendTo(DateTimeImmutable $endsAt): self
{
return new self($this->startsAt, $endsAt);
}
public function contains(DateTimeImmutable $date): bool
{
return $date >= $this->startsAt && $date < $this->endsAt;
}
}
Каждый DateRange является валидным. Метод extendTo() не сможет случайно создать невалидный диапазон, поскольку он проходит через ту же валидацию в конструкторе. Объекту не нужен последующий вызов isValid() , а остальному коду не нужно защитным образом проверять, идет ли дата окончания раньше даты начала.
Это мощная комбинация:
- неизменяемость означает, что правильное состояние не может быть изменено у нас за спиной
- валидация в конструкторе означает, что некорректное состояние не сможет проникнуть в систему изначально
Вместе они делают значения более надежными.
Тестируйте контракт, а не ключевое слово
Полезный тест заключается вовсе не в проверке наличия модификатора readonly у класса. Полезный тест проверяет, что операция возвращает новое значение и оставляет оригинал неизменным:
it('keeps the original trial period unchanged when extending it', function (): void {
$trial = new TrialPeriod(new DateTimeImmutable('2026-08-01'));
$extendedTrial = $trial->extendByDays(14);
expect($extendedTrial)
->not->toBe($trial)
->and($trial->endsAt->format('Y-m-d'))->toBe('2026-08-01')
->and($extendedTrial->endsAt->format('Y-m-d'))->toBe('2026-08-15');
});
Для вложенного значения также тестируйте границу. Передайте изменяемый DateTime , измените его после конструирования и убедитесь, что неизменяемое значение по-прежнему имеет исходную дату. Это доказывает, что дизайн защищает важное состояние, а не просто доказывает присутствие модификатора в объявлении класса.
Вам также следует тестировать доменные правила. Значение Money должно отклонять отрицательную сумму, а операции с валютами должны отклонять несовпадающие валюты. Неизменяемый объект автоматически не становится хорошим объектом-значением, если он может представлять бессмыслицу.
Когда не стоит использовать неизменяемость
У неизменяемости есть цена. Каждое изменение создает новый объект, что может быть избыточным в коде, который намеренно управляет большой изменяемой структурой или жизненным циклом сущности.
Я бы не стал заставлять модель Eloquent становиться неизменяемой. Eloquent спроектирован вокруг установки атрибутов, отслеживания измененных (dirty) значений, сохранения, обновления и управления связями. Борьба с этим подходом обычно делает работу с приложением на Laravel сложнее.
Я бы также не стал создавать новую неизменяемую обертку вокруг каждого примитива просто потому, что это возможно. Объект-значение должен заслужить свое место, неся в себе смысл, валидацию, поведение или ограничение, которое в противном случае пришлось бы дублировать.
Используйте неизменяемый объект, когда:
- значение имеет четкий доменный смысл
- сохранение его состояния стабильным делает код безопаснее или проще для понимания
- валидация относится непосредственно к значению
- операция естественным образом порождает другое значение
- оно пересекает слои приложения, очереди, события или границы сервисов
Оставляйте обычные изменяемые объекты, когда:
- объект моделирует изменяющуюся идентичность и жизненный цикл
- фреймворк ожидает изменяемое состояние
- измерения производительности показывают, что повторяющееся копирование является реальной проблемой
- примитив и так понятен, а поведение или инварианты не требуют инкапсуляции
Смысл не в чистоте ради чистоты. Смысл в том, чтобы использовать неизменяемость там, где она убирает неопределенность.
Практический чек-лист
Прежде чем назвать объект неизменяемым, я задаю себе следующие вопросы:
- Может ли какой-либо публичный метод изменить состояние этого объекта?
- Являются ли все сохраненные объекты также неизменяемыми или они нормализуются в неизменяемые значения при конструировании?
- Возвращает ли каждый новый экземпляр любая операция, изменяющая значение?
- Проверяются ли инварианты конструктора единожды и сохраняются ли они каждым фабричным методом и операцией?
- Состоят ли коллекции из неизменяемых элементов?
- Может ли вызывающий код изменить входной объект после конструирования и повлиять на данное значение?
- Является ли это действительно значением или это сущность, у которой должен быть изменяемый жизненный цикл?
Если ответ на второй или шестой вопрос — нет, одного readonly недостаточно. Именно там обитает большинство неожиданных багов.
Заключение
Модификатор readonly — ценная возможность PHP. Он предотвращает случайное переназначение, сужает поверхность состояния объекта и дает отличную основу для объектов-значений. Но это правило на уровне свойств, а не полное определение неизменяемости.
Настоящая неизменяемость проистекает из всего дизайна в целом: неизменяемых строительных блоков, нормализованных входных данных, инвариантов конструктора, элементов неизменяемых коллекций и операций, которые создают новые значения вместо изменения существующих.
Начните со значений, которые уже вызывают больше всего защитного кода в вашем приложении: дат, денег, фильтров, идентификаторов и данных запроса. Сделайте одно из них глубоко неизменяемым, напишите тест, доказывающий, что оригинал не может измениться, и пусть этот более простой контракт избавит остальную часть кодовой базы от доли неопределенности.
Надеюсь, эта статья вам понравилась, и если это так, не забудьте поделиться ею со своими друзьями!!! Увидимся!
Комментарии (0)
Пока нет комментариев — будьте первым.