SOLID в PHP 8.3: живые ошибки и рабочие приёмы
SOLID не требует превращать каждый класс в интерфейс. На примере оформления заказа в PHP 8.3 разбираем, где принципы действительно снижают цену изменений, а где добавляют лишние слои и усложняют код.

В сервисе оформления заказа одна новая скидка часто затрагивает расчёт суммы, оплату, отправку письма и запись в базу. Если всё это находится в одном классе, изменение правила скидки может сломать платёжный сценарий. Ниже — разбор на PHP 8.3: какие ошибки нарушают SOLID в реальном коде, чем за них платит команда и как исправлять их без десятков пустых интерфейсов.
Сначала задача, потом принципы
Представим интернет-магазин с таким сценарием:
- рассчитать стоимость заказа и скидку;
- провести платёж через выбранный шлюз;
- сохранить результат;
- отправить уведомление покупателю.
Наивная реализация быстро превращает OrderService в класс на несколько сотен строк. В нём появляются запросы к базе, вызовы платёжного API, условия для промокодов и отправка писем. Проблема не в размере класса как таковом. Проблема в том, что у него слишком много причин для изменения.
SOLID нужен не для красивой схемы классов. Его практическая ценность — уменьшить область изменений и сделать ошибку локальной.
Зависимость — объект или модуль, который нужен другому объекту для работы. Например, платёжный шлюз — зависимость сервиса оформления заказа.
Ошибка 1. Один класс знает обо всём
Это нарушение принципа единственной ответственности, или SRP. Частая ошибка — понимать его как «в классе должен быть только один метод». На практике речь о группе причин для изменения: расчёт заказа и отправка письма меняются по разным причинам, поэтому не должны быть связаны одним классом.
<?php
final class OrderService
{
public function checkout(array $items, string $email): void
{
$totalKopecks = 0;
foreach ($items as $item) {
$totalKopecks += $item['priceKopecks'] * $item['quantity'];
}
// Здесь же обычно появляются промокоды, SQL, платёж и письмо.
$pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'password');
$statement = $pdo->prepare('INSERT INTO orders (total) VALUES (:total)');
$statement->execute(['total' => $totalKopecks]);
mail($email, 'Заказ принят', 'Сумма: ' . $totalKopecks);
}
}
В рабочем проекте такой фрагмент опасен ещё и тем, что секреты и подключение к базе оказываются внутри бизнес-метода. Исправление — разделить роли, но не дробить каждую строку в отдельный класс.
<?php
interface OrderRepository
{
public function save(Order $order): void;
}
interface Notifier
{
public function orderCreated(Order $order): void;
}
final class CheckoutService
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payments,
private Notifier $notifier,
) {
}
public function checkout(Order $order): void
{
$paymentId = $this->payments->charge($order->totalKopecks());
$order->markAsPaid($paymentId);
$this->orders->save($order);
$this->notifier->orderCreated($order);
}
}
Теперь изменение шаблона письма не требует переписывать платёжный сценарий. Это не означает, что уведомление обязательно нужно выносить в отдельный микросервис.
Ошибка 2. Новая скидка требует правки большого if
Принцип открытости и закрытости, OCP, часто пересказывают как «класс открыт для расширения, закрыт для изменения». Буквально соблюдать это для любого кода невозможно. Полезный критерий проще: если бизнес регулярно добавляет новые виды скидок, цепочка условий — плохая точка расширения.
public function discountKopecks(Order $order): int
{
if ($order->coupon() === 'WELCOME10') {
return (int) round($order->subtotalKopecks() * 0.10);
}
if ($order->customerIsVip()) {
return 50000;
}
return 0;
}
Для двух стабильных правил такой код может быть дешевле стратегии. Но если правила добавляются каждую неделю, лучше вынести их в независимые объекты.
<?php
interface DiscountRule
{
public function supports(Order $order): bool;
public function discountKopecks(Order $order): int;
}
final class WelcomeDiscount implements DiscountRule
{
public function supports(Order $order): bool
{
return $order->coupon() === 'WELCOME10';
}
public function discountKopecks(Order $order): int
{
return (int) round($order->subtotalKopecks() * 0.10);
}
}
final class DiscountCalculator
{
/** @param list<DiscountRule> $rules */
public function __construct(private array $rules)
{
}
public function calculate(Order $order): int
{
foreach ($this->rules as $rule) {
if ($rule->supports($order)) {
return $rule->discountKopecks($order);
}
}
return 0;
}
}
Цена подхода — больше объектов и необходимость определить порядок правил. Если скидки могут складываться, метод должен возвращать не первое совпадение, а результат согласованной политики. Это уже отдельное бизнес-правило, а не автоматическое следствие SOLID.
Ошибка 3. Подкласс формально совместим, но ломает ожидания
Принцип подстановки Барбары Лисков, LSP, проверяется поведением: объект-наследник должен работать там, где ожидают базовый тип, не меняя скрытых контрактов.
Показательный пример из магазина — общий интерфейс с методом возврата денег:
interface PaymentGateway
{
public function charge(int $amountKopecks): string;
public function refund(string $paymentId): void;
}
Если конкретный провайдер поддерживает оплату, но не умеет возвраты, его реализация с исключением UnsupportedOperationException формально соответствует интерфейсу, но нарушает ожидания вызывающего кода. Ошибка не в исключении. Ошибка в слишком широком контракте.
interface PaymentGateway
{
public function charge(int $amountKopecks): string;
}
interface RefundGateway
{
public function refund(string $paymentId): void;
}
Теперь сервис возврата принимает именно RefundGateway. Не обещайте через общий тип операцию, которую часть реализаций заведомо не может выполнить.
Ошибка 4. Интерфейс заставляет реализовывать ненужные методы
Это нарушение принципа разделения интерфейсов, ISP. Большой интерфейс обычно появляется из удобства одного потребителя, а затем навязывается всем остальным.
interface OrderRepository
{
public function save(Order $order): void;
public function findById(int $id): ?Order;
public function delete(int $id): void;
public function exportToCsv(): string;
}
Компонент, который только читает заказ, не должен зависеть от сохранения, удаления и экспорта. Разделите контракт по ролям:
interface OrderReader
{
public function findById(int $id): ?Order;
}
interface OrderWriter
{
public function save(Order $order): void;
}
final class OrderPage
{
public function __construct(private OrderReader $orders)
{
}
public function show(int $orderId): ?Order
{
return $this->orders->findById($orderId);
}
}
Такой контракт проще тестировать и сложнее случайно использовать для записи из контроллера, который должен только показывать данные.
Ошибка 5. Зависимость создаётся внутри метода
Принцип инверсии зависимостей, DIP, не означает «везде нужен контейнер». Суть в том, что бизнес-логика зависит от абстракции, а конкретный клиент API или драйвер базы подключается снаружи.
final class CheckoutService
{
public function checkout(Order $order): void
{
$gateway = new StripeGateway('YOUR_API_KEY');
$gateway->charge($order->totalKopecks());
}
}
Такой класс нельзя нормально проверить без настоящего внешнего сервиса. Вариант с конструктором оставляет выбор реализации за конфигурацией приложения:
interface PaymentGateway
{
public function charge(int $amountKopecks): string;
}
final class CheckoutService
{
public function __construct(private PaymentGateway $gateway)
{
}
public function checkout(Order $order): string
{
return $this->gateway->charge($order->totalKopecks());
}
}
В тесте можно передать небольшой тестовый адаптер без сетевых запросов. В продакшене — реализацию конкретного платёжного провайдера. Имя SDK и способ создания клиента зависят от выбранного провайдера, поэтому не стоит подменять их вымышленным универсальным API.
Сколько стоит каждый подход
| Ситуация | Простой вариант | SOLID-подход | Цена изменения |
|---|---|---|---|
| Одна скидка без планов на расширение | Условие в сервисе | Отдельная стратегия | Стратегия добавляет классы и сборку зависимостей |
| Несколько платёжных провайдеров | Условия по типу клиента | Интерфейс адаптера | Нужно поддерживать контракт и конфигурацию |
| Чтение заказа в отчёте | Большой репозиторий | Интерфейс чтения | Больше типов, зато меньше лишних зависимостей |
| Отправка уведомлений | Вызов mail внутри сервиса | Notifier с внедрением зависимости | Нужно явно решить вопрос повторной отправки и ошибок |
Главный критерий выбора — частота и цена изменений. Если код меняется редко, абстракция может стоить дороже, чем условие. Если ошибка затрагивает деньги, оплату или массовую рассылку, изоляция зависимости обычно окупается быстрее.
Что ломается на практике
- Интерфейс ради интерфейса. Если у типа одна реализация и нет границы между модулями, сначала оставьте обычный класс. Вводите интерфейс там, где нужен заменяемый адаптер или независимый тест.
- Сервис-локатор вместо внедрения. Вызов глобального контейнера прячет зависимости. Сигнатура конструктора должна показывать, что нужно классу.
- Наследование вместо композиции. Если подкласс отключает метод, меняет смысл исключения или требует особого порядка вызовов, контракт выбран неправильно.
- Слишком умные стратегии. Пять классов скидок не помогут, если правила зависят от несогласованных данных. Сначала зафиксируйте расчёт в тестах.
- Побочные эффекты в расчёте. Метод, который считает сумму, не должен одновременно списывать деньги и отправлять письмо. Иначе повторный вызов может привести к повторной оплате.
Когда какой приём выбирать
Для небольшой функции с одним стабильным правилом оставьте прямой код. Для внешнего API используйте адаптер и зависимость через конструктор почти всегда: сеть, таймауты и формат ответа не должны протекать в доменную логику. Для часто меняющихся правил применяйте стратегии, но заранее определите порядок их вызова. Для интерфейсов выбирайте не «все методы сущности», а минимальный контракт конкретного потребителя.
Не смешивайте SOLID с архитектурой микросервисов. Эти принципы можно применять внутри монолита, а разбиение на сервисы не исправит плохие границы автоматически.
Что делать дальше
Возьмите один изменчивый сценарий — например, оформление заказа — и выпишите последние пять изменений в нём. Отметьте, какие классы приходилось править каждый раз, где возникали сетевые вызовы и какие тесты требовали базы или платёжного провайдера. Сначала выделите эти границы, затем добавляйте интерфейсы по одной причине.
После рефакторинга измеряйте не количество классов, а время изменения, число затронутых тестов и количество внешних вызовов в одном сценарии. Если эти показатели не уменьшаются, абстракция пока не окупилась.
Оформление по ГОСТ: Поля, шрифты, нумерация, сноски, титульный лист
Спросить за 10 ₽Источники: PHP Manual: Object-oriented programming, PHP-FIG PSR-11: Container Interface
Частые вопросы
Нужно ли создавать интерфейс для каждого класса в PHP 8.3?
SOLID требует отказаться от наследования?
Что делать, если после рефакторинга код не работает?
Как применять SOLID в проекте на другой версии PHP?
Какие подводные камни учитывать в продакшене?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.