Руководство по стилю объектного проектирования для PHP 8.5

Хватит писать тонны геттеров и сеттеров! Это руководство по стилю объектного проектирования покажет, как принципы Маттиаса Нобака реализуются в PHP 8.5. Readonly-классы, property hooks, clone with, pipe-оператор и другие возможности для чистого кода без бойлерплейта. Примеры и полный гайд.

Введение

Маттиас Нобак опубликовал Object Design Style Guide в 2019-м. Это была не очередная книга по фреймворкам, а манифест о том, какими должны быть объекты. Об их границах, ответственности и контрактах.

Принципы Нобака были верны. Они верны до сих пор.

Но была проблема. В PHP 7.3 каждый правильный паттерн требовал дисциплины и тонной бойлерплейта. Геттеры и сеттеры для инкапсуляции. Дублирование валидации в каждом методе. Ручное клонирование для иммутабельности. Wither-методы для каждого свойства. Код работал, философия соблюдалась, но соотношение «смысл / церемония» было удручающим.

С выходом PHP 8.5 ситуация кардинально изменилась. Теперь писать правильный объектный дизайн так же просто, как и писать плохой. В этой статье мы разберём, как новые возможности языка позволяют внедрить лучшие практики без лишнего кода.

Пропасть между «как надо» и «как удобно писать» была реальной.

В ноябре 2025 года вышла восьмая минорная версия PHP. И эта пропасть исчезла.

PHP 8.5 не придумал новую философию. Он вобрал классические паттерны прямо в синтаксис. То, что требовало двух экранов кода, теперь умещается в пару строк. То, что требовало пожизненной дисциплины, стало поведением по умолчанию.

Давай пройдёмся по ключевым принципам Нобака и посмотрим, как современный PHP превратил «правильно» в «естественно».

Инкапсуляция: от геттеров к декларативности

Первое, с чего Нобак начинает книгу — защита внутреннего состояния. Не выставляй свойства наружу. Контролируй доступ. Заставляй работать через интерфейс.

В PHP 7.3 это означало одно и то же из проекта в проект:

<?php

class User
{
private string $name;

public function __construct(string $name)
{
$this->setName($name);
}

public function getName(): string
{
return $this->name;
}

public function setName(string $name): void
{
if (strlen($name) < 2) {
throw new InvalidArgumentException('Имя слишком короткое');
}

$this->name = $name;
}
}

Шестнадцать строк на то, чтобы обернуть строку с валидацией. Инкапсуляция соблюдена. Валидация на месте. Но посмотри на соотношение кода к смыслу. Большая часть этих строк существует только ради принципа, а не ради предметной области.

Асимметричная видимость

PHP 8.4 завёз асимметричную видимость, и она меняет правила игры. Конструкция public private(set) объявляет: свойство можно читать отовсюду, но менять — только внутри самого объекта.

<?php

class User
{
public private(set) string $name;

public function __construct(string $name)
{
$this->setName($name);
}

private function setName(string $name): void
{
if (strlen($name) < 2) {
throw new InvalidArgumentException('Имя слишком короткое');
}

$this->name = $name;
}
}

Кода стало вдвое меньше, но дело не в количестве строк. Дело в ясности. Правила доступа объявлены один раз, на уровне свойства. Нет приватного поля, затенённого публичным аксессором. Нет геттера, который надо поддерживать. Инкапсуляция, которую проповедовал Нобак, теперь выражается прямо в системе типов.

Три рабочих паттерна

Асимметричная видимость даёт три практических подхода.

Публичное чтение, приватная запись — для идентификаторов и неизменяемых метаданных:

<?php

class Order
{
public private(set) string $id;
public private(set) DateTimeImmutable $createdAt;

public function __construct(string $id)
{
$this->id = $id;
$this->createdAt = new DateTimeImmutable();
}
}

Внешний код видит эти значения, но не может их изменить после создания. Базовый паттерн для всего, что должно оставаться неизменным.

Публичное чтение, защищённая запись — для иерархий наследования:

<?php

class Document
{
public protected(set) string $content;

public function __construct(string $content)
{
$this->content = $content;
}
}

class EditableDocument extends Document
{
public function update(string $newContent): void
{
// Наследник может менять, внешний код - нет
$this->content = $newContent;
}
}

Наследники могут менять состояние, внешний код — нет. Заменяет старый паттерн «защищённый сеттер», но без лишнего метода.

Полностью публичное с хуками — когда внешнему коду легитимно нужно менять свойство, но с контролем:

<?php

class Temperature
{
public float $celsius {
set {
if ($value < -273.15) {
throw new InvalidArgumentException('Ниже абсолютного нуля');
}
$this->celsius = $value;
}
}

public function __construct(float $celsius)
{
$this->celsius = $celsius;
}
}

Свойство открыто для всех, но хук при изменении перехватывает значение и проверяет. Свойство никогда не окажется в невалидном состоянии.

Инварианты: всегда валидный объект

Следующий принцип, которому Нобак посвящает целые главы — инварианты. Объект должен быть валиден с момента создания и оставаться валидным всё время жизни. Никаких временных невалидных состояний. Никаких паттернов «сначала создай пустой, потом вызови три сеттера, чтобы добить».

Звучит как базовая гигиена. Но реализация в PHP 7.3 заставляла дублировать код.

Старый подход: валидация протекает

<?php

class BankAccount
{
private float $balance;

public function __construct(float $initialBalance)
{
$this->setBalance($initialBalance);
}

public function deposit(float $amount): void
{
$this->setBalance($this->balance + $amount);
}

public function withdraw(float $amount): void
{
$this->setBalance($this->balance - $amount);
}

private function setBalance(float $newBalance): void
{
if ($newBalance < 0) {
throw new InvalidArgumentException('Баланс не может быть отрицательным');
}

$this->balance = $newBalance;
}
}

Вроде всё правильно. Валидация вынесена в приватный метод, чтобы не дублировать проверку в конструкторе, депозите и выводе. Но это всё ещё утечка: если завтра появится метод transfer, программист должен помнить, что вызывать setBalance, а не писать $this->balance += $amount. Забыл — получил отрицательный баланс в продакшене.

Проблема не в дисциплине программиста, а в том, что язык не даёт привязать правило к данным. Правило живёт отдельно от свойства.

Хуки свойств: валидация в одном месте

В PHP 8.4 хуки свойств решают эту проблему радикально. Правило переезжает туда, где ему и место — к самому свойству.

<?php

class BankAccount
{
public float $balance {
set {
if ($value < 0) {
throw new InvalidArgumentException('Баланс не может быть отрицательным');
}
$this->balance = $value;
}
}

public function __construct(float $initialBalance)
{
$this->balance = $initialBalance;
}

public function deposit(float $amount): void
{
$this->balance += $amount;
}

public function withdraw(float $amount): void
{
$this->balance -= $amount;
}
}

Неважно, откуда приходит значение — из конструктора, через += в методе deposit, или прямым присваиванием. Хук сработает всегда. Валидация живёт ровно в одном месте и не требует, чтобы программист помнил о ней при добавлении новой функциональности.

Это именно то, что описывал Нобак: инвариант, привязанный к объекту, а не к процессу его изменения.

Виртуальные свойства

Хуки дают ещё одну возможность — вычисляемые поля без хранения. То, что Нобак называл derived values.

<?php

class Rectangle
{
public function __construct(
public float $width,
public float $height
) {}

public float $area {
get => $this->width * $this->height;
}
}

Свойство $area не хранит значение — оно вычисляется на лету. Но для внешнего кода это обычное свойство: $rect->area. Никаких методов getArea(), никакого лишнего церемониала. Философия та же, синтаксис — прозрачный.

Ленивая инициализация

Ещё один паттерн, который раньше требовал аккуратной реализации — ленивая загрузка. Хуки делают и её декларативной.

<?php

class Subscription
{
private ?InvoiceCollection $invoices = null;

public InvoiceCollection $invoices {
get {
if ($this->invoices === null) {
$this->invoices = $this->loadInvoices();
}
return $this->invoices;
}
}

private function loadInvoices(): InvoiceCollection
{
// тяжёлый запрос к базе
}
}

Первый доступ к свойству дёргает базу. Последующие возвращают закешированный результат. Объект всегда в валидном состоянии, а тяжёлые операции откладываются до реальной необходимости.

Иммутабельность: объекты, которые не меняются

Отдельная большая тема у Нобака — иммутабельность для value object'ов. Хочешь другое значение — создавай новый объект. Никаких сеттеров, никаких мутаций. Это единственный способ сделать код предсказуемым и избавиться от побочных эффектов.

В PHP 7.3 это выглядело так:

<?php

class Money
{
private float $amount;
private string $currency;

public function __construct(float $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}

public function getAmount(): float
{
return $this->amount;
}

public function getCurrency(): string
{
return $this->currency;
}

public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new DomainException('Валюты должны совпадать');
}

$clone = clone $this;
$clone->amount += $other->amount;
return $clone;
}

// и так для каждой операции - clone, модификация, возврат
}

Всё верно. Но посмотрите на метод add: ручное клонирование, явное изменение поля, возврат нового экземпляра. И так для каждой операции. А если свойств десять — десять wither-методов с одним и тем же шаблоном.

И главная опасность: в любом методе можно забыть сделать clone и случайно изменить оригинал. Язык не поможет — только код-ревью.

Readonly-классы

PHP 8.2 ввёл readonly-классы, и это меняет всё:

<?php

readonly class Money
{
public function __construct(
public float $amount,
public string $currency
) {}

public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new DomainException('Валюты должны совпадать');
}

return new self(
$this->amount + $other->amount,
$this->currency
);
}
}

Движок сам запрещает изменение свойств. Написать $money->amount = 100 после создания — ошибка компиляции. Иммутабельность теперь не паттерн, а факт языка.

Clone with

Но создавать новый объект через new self каждый раз — всё ещё многословно. Особенно когда свойств много, а поменять нужно одно.

PHP 8.5 добавил clone with:

<?php

readonly class Configuration
{
public function __construct(
public string $host,
public int $port,
public bool $useTls,
public int $timeout,
public string $username,
public string $password
) {}

public function withHost(string $host): self
{
return clone $this with {
host: $host
};
}

public function withTimeout(int $timeout): self
{
return clone $this with {
timeout: $timeout
};
}
}

Конструкция clone $this with { ... } создаёт копию, меняет указанные поля и возвращает новый объект. Это тот самый wither-паттерн, но встроенный в язык. Никакого ручного клонирования, никакого риска забыть clone.

Даже простая замена на месте работает:

<?php

$dev = new Configuration('localhost', 3306, false, 30, 'root', '');
$prod = clone $dev with {
host: 'db.example.com',
useTls: true
};

Читается как декларация: «взять $dev, но с другими хостом и TLS».

Сложные трансформации

Для операций с бизнес-логикой метод по-прежнему возвращает новый экземпляр — но теперь это наглядно и безопасно:

<?php

readonly class Order
{
public function __construct(
public string $id,
public OrderStatus $status,
public array $items
) {}

public function markAsPaid(): self
{
if ($this->status !== OrderStatus::Pending) {
throw new DomainException('Только ожидающие заказы можно оплатить');
}

return clone $this with {
status: OrderStatus::Paid
};
}
}

Метод выражает бизнес-правило (статус можно сменить только с Pending на Paid), а клонирование и создание новой версии берёт на себя язык.

Защита от потери результата

Самая частая бага с иммутабельными объектами — забыть присвоить результат:

<?php

$order = new Order('123', OrderStatus::Pending, []);
$order->markAsPaid(); // забыли присвоить
// $order всё ещё Pending, ищем баг полдня

PHP 8.5 вводит атрибут #[NoDiscard], который ловит такие случаи:

<?php

readonly class Order
{
// ...

#[NoDiscard]
public function markAsPaid(): self
{
// ...
}
}

$order = new Order('123', OrderStatus::Pending, []);
$order->markAsPaid(); // Warning: The return value of markAsPaid() must be used

Движок видит, что метод с атрибутом вызван, но результат проигнорирован — и выдаёт предупреждение. Бага отлавливается на этапе разработки, а не в продакшене.

Типы как документация

Отдельная глава у Нобака — про типы. Не как способ заставить компилятор проверить код, а как документацию, встроенную в код. Типы должны говорить программисту, что за объект перед ним и как с ним работать.

В PHP 7 с этим было сложно. Типы были, но слишком грубые. Скалярные, классы, массивы. Всё, что сложнее — либо DocBlock, либо изобретать велосипеды.

DNF-типы: сложные контракты

Бывает, что параметр может быть разных типов, но не любых, а с чёткими комбинациями. Например: «или логгер, реализующий интерфейс и умеющий преобразовываться в строку, или ничего».

До PHP 8.2 это был DocBlock. С 8.2 — DNF-типы (Disjunctive Normal Form):

<?php

class LoggerAwareService
{
private ?LoggerInterface $logger;

public function __construct(
(LoggerInterface&Stringable)|null $logger = null
) {
$this->logger = $logger;
}
}

Запись (LoggerInterface&Stringable)|null читается: «объект должен реализовывать LoggerInterface И иметь метод __toString, ИЛИ быть null». Тип описывает контракт точно и не оставляет места для интерпретации.

Это именно то, что Нобак называл «самодокументирующийся код». Не нужно лезть в докблок, не нужно гадать — тип говорит сам за себя.

Enums вместо констант

Отдельная боль старых проектов — статусы, роли, типы. Раньше их делали через константы класса:

<?php

class Order
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';

private string $status;

public function __construct(string $status)
{
if (!in_array($status, [
self::STATUS_PENDING,
self::STATUS_PAID,
self::STATUS_CANCELLED
])) {
throw new InvalidArgumentException('Неверный статус');
}

$this->status = $status;
}
}

// где-то в коде
$order = new Order('paid'); // ок
$order = new Order('shipped'); // ошибка, но в рантайме

Константы есть, но тип — строка. Проверка — только в рантайме, и только если программист не забыл её написать. И никакой подсказки от IDE.

В PHP 8.1 пришли перечисления:

<?php

enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}

class Order
{
public function __construct(
public OrderStatus $status
) {}
}

// где-то в коде
$order = new Order(OrderStatus::Paid); // ок
$order = new Order('shipped'); // ошибка типов ещё до запуска

Движок гарантирует: в $status может быть только одно из значений перечисления. Никаких лишних проверок, никаких опечаток, IDE подсказывает варианты. Тип стал документацией.

Enums с поведением

Нобак идёт дальше: он предлагает класть поведение прямо на value object. Перечисления в PHP поддерживают методы:

<?php

enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';

public function canTransitionTo(self $newStatus): bool
{
return match($this) {
self::Pending => $newStatus === self::Paid || $newStatus === self::Cancelled,
self::Paid => false, // оплаченный заказ нельзя изменить
self::Cancelled => false // отменённый тоже
};
}
}

class Order
{
public function __construct(
public string $id,
public OrderStatus $status
) {}

public function transitionTo(OrderStatus $newStatus): self
{
if (!$this->status->canTransitionTo($newStatus)) {
throw new DomainException(
"Нельзя перейти из {$this->status->value} в {$newStatus->value}"
);
}

return clone $this with {
status: $newStatus
};
}
}

Логика переходов живёт там, где ей и место — на самом перечислении. А match-выражение с контролем полноты (exhaustive check) заставит добавить обработку, если появится новый статус. Тип не просто хранит значение, он диктует правила.

Сервисы: оркестрация без вложенности

Последняя крупная тема у Нобака — различие между сущностями и сервисами. Сущности хранят состояние и следят за его целостностью. Сервисы координируют процессы и оркестрируют действия. Их нельзя создавать через new в коде — их нужно внедрять.

В PHP 7.3 код, который использует сервисы, часто выглядел так:

<?php

class CheckoutController
{
public function __construct(
private CartRepository $cartRepository,
private OrderFactory $orderFactory,
private PaymentProcessor $paymentProcessor,
private InvoiceGenerator $invoiceGenerator
) {}

public function checkout(string $cartId): Response
{
$cart = $this->cartRepository->find($cartId);
$order = $this->orderFactory->createFromCart($cart);
$payment = $this->paymentProcessor->process($order);
$invoice = $this->invoiceGenerator->generate($order, $payment);

return new Response($invoice);
}
}

Вроде нормально. Но когда логика усложняется, начинается вложенность:

<?php

public function checkout(string $cartId): Response
{
$result = $this->invoiceGenerator->generate(
$this->paymentProcessor->process(
$this->orderFactory->createFromCart(
$this->cartRepository->find($cartId)
)
)
);

return new Response($result);
}

Читать такой код нужно изнутри наружу: сначала найти корзину, потом создать заказ, потом обработать платёж, потом сгенерировать счёт. Мозг тратит усилия на распутывание порядка операций.

Пайп-оператор

PHP 8.5 вводит pipe-оператор |>, который разворачивает вложенность в линейную цепочку:

<?php

public function checkout(string $cartId): Response
{
$invoice = $this->cartRepository
->find($cartId)
|> $this->orderFactory->createFromCart(...)
|> $this->paymentProcessor->process(...)
|> $this->invoiceGenerator->generate(...);

return new Response($invoice);
}

Поток читается сверху вниз, слева направо. Каждый сервис получает результат предыдущей операции через плейсхолдер .... Это Нобаковское Command/Query Separation, сделанное визуально очевидным.

Последовательность читается сверху вниз, как инструкция. Не нужно держать в голове вложенность — нужно просто читать по порядку.

Побочные эффекты в пайпе

Pipe-оператор работает с замыканиями для логирования или других побочных эффектов:

<?php

public function checkout(string $cartId): Response
{
$invoice = $this->cartRepository
->find($cartId)
|> $this->orderFactory->createFromCart(...)
|> fn($order) => $this->withLogging($order, 'Заказ создан')
|> $this->paymentProcessor->process(...)
|> fn($payment) => $this->withNotification($payment, 'Платёж прошёл')
|> $this->invoiceGenerator->generate(...);

return new Response($invoice);
}

private function withLogging($value, string $message)
{
$this->logger->info($message, ['value' => $value]);
return $value;
}

Встроенные замыкания внедряют побочные эффекты, не разрывая цепочку преобразований. Побочные эффекты остаются явными и локализованными.

Финальный пример: синтез всего вместе

Давай соберём всё, что мы разобрали, в одном классе. Это заказ с товарами, статусами и полной поддержкой современных возможностей PHP 8.5.

Полный листинг класса
readonly final class Order
{
public function __construct(
public private(set) OrderId $id,
public private(set) CustomerId $customerId,
public private(set) DateTime $createdAt,
public OrderStatus $status = OrderStatus::Pending,
public private(set) array $items = [],
) {
}

public Money $total {
get => array_reduce(
$this->items,
fn (Money $sum, OrderItem $item) => $sum->add($item->price),
new Money(0, 'USD'),
);
}

#[NoDiscard]
public function addItem(OrderItem $item): self
{
return clone $this with {
items: [...$this->items, $item],
};
}

#[NoDiscard]
public function markAsPaid(): self
{
if (!$this->status->canTransitionTo(OrderStatus::Paid)) {
throw new DomainException('Cannot mark order as paid from current status');
}

return clone $this with {
status: OrderStatus::Paid,
};
}
}

readonly final class OrderItem
{
public function __construct(
public ProductId $productId,
public int $quantity {
set {
if ($value < 1) {
throw new InvalidArgumentException('Quantity must be positive');
}

$field = $value;
}
},
public Money $price,
) {
}

public Money $subtotal {
get => new Money(
$this->price->amount * $this->quantity,
$this->price->currency,
);
}
}

enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';

public function canTransitionTo(self $newStatus): bool
{
return match ($this) {
self::Pending => in_array($newStatus, [self::Paid, self::Cancelled], true),
self::Paid => $newStatus === self::Shipped,
self::Shipped => false,
self::Cancelled => false,
};
}
}

readonly final class OrderService
{
public function __construct(
private OrderRepository $repository,
private PaymentGateway $gateway,
private EmailService $emailService,
) {
}

public function placeOrder(Order $order): Order
{
return $order
|> $this->validateOrder(...)
|> $this->gateway->charge(...)
|> fn ($o) => $o->markAsPaid()
|> $this->repository->save(...)
|> $this->emailService->sendConfirmation(...);
}

private function validateOrder(Order $order): Order
{
if (count($order->items) === 0) {
throw new DomainException('Order must have at least one item');
}

return $order;
}
}

Что здесь происходит:

Весь этот класс не содержит ни одного геттера, сеттера или wither-метода, написанного вручную. Инкапсуляция, валидация, иммутабельность — всё это обеспечено средствами языка. Код описывает предметную область, а не церемонии вокруг неё.

Раньше такой же уровень дисциплины требовал в два-три раза больше кода и пожизненной бдительности от всей команды. Теперь это просто способ писать на PHP.

Вывод: дисциплина без трения

Книга Маттиаса Нобака вышла в 2019 году и быстро стала настольной для тех, кто хотел писать чистый объектный код. Её принципы были правильными тогда. Они правильные и сейчас.

Но шесть лет в разработке — это огромный срок. За это время PHP прошёл путь от версии 7.3 до 8.5. И если раньше следование хорошим практикам означало постоянную дисциплину и тонны бойлерплейта, то теперь язык взял эту дисциплину на себя.

Что изменилось на практике

Инкапсуляция больше не требует писать геттеры и сеттеры для каждого поля. Асимметричная видимость public private(set) декларирует правила доступа один раз — и дальше за ними следит компилятор.

Инварианты перестали быть задачей "не забыть продублировать валидацию". Хуки свойств привязывают правила к самим данным. Неважно, откуда пришло значение — хук сработает всегда.

Иммутабельность из паттерна, требующего ручного клонирования и дисциплины "не забудь вернуть новый объект", превратилась в техническое ограничение. Readonly-классы не дают изменить объект. clone with делает модификации декларативными. #[NoDiscard] ловит забытые результаты на этапе компиляции.

Типы наконец-то стали документацией, а не просто подсказками для IDE. DNF-типы описывают сложные контракты. Enums заменяют целые категории value object'ов и могут содержать поведение.

Сервисы перестали требовать ментальных усилий на распутывание вложенных вызовов. Pipe-оператор |> с плейсхолдером ... превращает цепочки вызовов в линейный, читаемый сверху вниз поток.

Главный итог

Философия объектного дизайна, которую Нобак изложил в своей книге, не устарела. Но она перестала быть чем-то, что нужно "внедрять" в команде ценой код-ревью и бесконечных напоминаний.

Правильный путь стал путём наименьшего сопротивления.

Когда язык поддерживает хорошие практики на уровне синтаксиса, у разработчиков просто не возникает соблазна сделать "как проще, но неправильно". Проще — значит правильно. Быстрее — значит правильно. Понятнее — значит правильно.

Это и есть дисциплина без трения.

Комментарии


Дополнительные материалы

Предыдущая Статья

JavaScript: передаём параметры в addEventListener правильно