SOLID в PHP: пять принципов на одном примере
SOLID — это пять правил проектирования классов, которые отвечают на вопрос «как писать код, который легко менять, не ломая»: SRP, OCP, LSP, ISP, DIP. Каждый принцип — ответ на конкретную боль: SRP — «класс делает всё сразу», OCP — «каждая новая фича правит старый код», LSP — «наследник ломает поведение родителя», ISP — «интерфейс заставляет реализовывать лишнее», DIP — «код привязан к конкретной реализации». Понимать их нужно не для того, чтобы выучить и называть аббревиатуры, а чтобы видеть, где класс начинает «пахнуть», до того как он разросся.
Зачем это нужно
Боль из практики: в проекте появился класс OrderProcessor — он считал скидку, отправлял письмо покупателю и писал лог. Сначала это выглядело удобно: всё в одном месте.
php// ПЛОХО: класс-монстр — три причины меняться в одном классе
class OrderProcessor
{
public function process(Order $order): void
{
// скидка
if ($order->user->isVip()) {
$order->total *= 0.9;
}
// сохранение
$order->save();
// письмо
Mail::raw('Ваш заказ оформлен', function ($m) use ($order) {
$m->to($order->user->email)->send();
});
// лог
Log::info('Заказ оформлен', ['id' => $order->id]);
}
}
Через два месяца: нужно добавить скидку по промокоду — правим OrderProcessor. Нужен другой текст письма — правим OrderProcessor. Нужен расчёт скидки в другом месте проекта — копипастим кусок, потому что он заперт внутри метода. Каждый чих правит класс, который при этом тестируется только целиком. Это и есть цена нарушения SRP — и её видно в коде:
php// ХОРОШО: каждый класс — одна причина для изменения
class DiscountCalculator
{
public function calculate(User $user, float $total): float
{
return $user->isVip() ? $total * 0.9 : $total;
}
}
class OrderProcessor
{
public function __construct(
private DiscountCalculator $discounts,
private OrderRepository $orders,
private Mailer $mailer,
private Logger $logger,
) {}
public function process(Order $order): void
{
$order->total = $this->discounts->calculate($order->user, $order->total);
$this->orders->save($order);
$this->mailer->sendOrderConfirmation($order);
$this->logger->info('Заказ оформлен', ['id' => $order->id]);
}
}
Скидка поменялась — правим один класс. В тесте OrderProcessor мокаем три зависимости — никаких писем и базы.
Как это работает
Разбираем все пять принципов на одном и том же примере — платежах.
S — Single Responsibility: одна ответственность
Принцип: у класса одна причина для изменения. «Ответственность» = «причина править класс».
php// ПЛОХО: генерация отчёта + форматирование + сохранение
class SalesReport
{
public function generate(): string
{
$data = DB::table('orders')->whereMonth('created_at', now()->month)->get();
$csv = '';
foreach ($data as $row) {
$csv .= implode(',', (array)$row) . "\n"; // формат CSV вшит
}
file_put_contents('report.csv', $csv); // хранение вшито
return $csv;
}
}
php// ХОРОШО: три класса, три причины меняться — одна у каждого
class SalesReportQueryBuilder // что достать
{
public function monthly(): Collection { /* ... */ }
}
class CsvFormatter // как показать
{
public function format(Collection $data): string { /* ... */ }
}
class ReportSaver // куда положить
{
public function save(string $content): string { /* ... */ }
}
O — Open/Closed: открыт для расширения, закрыт для изменения
Принцип: новое поведение добавляем новым кодом, а не правкой существующего.
php// ПЛОХО: каждый новый способ доставки = правка switch
class ShippingCostCalculator
{
public function calculate(Order $order): float
{
switch ($order->shippingType) {
case 'courier': return 300.0;
case 'post': return 150.0;
case 'pickup': return 0.0;
// добавили СПСР — правим этот метод, трогаем протестированный код
}
}
}
php// ХОРОШО: новая доставка = новый класс, старый код не трогаем
interface ShippingMethod
{
public function cost(Order $order): float;
}
final class CourierShipping implements ShippingMethod
{
public function cost(Order $order): float { return 300.0; }
}
final class PostShipping implements ShippingMethod
{
public function cost(Order $order): float { return 150.0; }
}
class ShippingCostCalculator
{
public function __construct(
/** @var iterable<ShippingMethod> */
private iterable $methods, // Laravel отдаёт тегged-коллекцию
) {}
public function calculate(Order $order): float
{
foreach ($this->methods as $method) {
if ($method->supports($order)) {
return $method->cost($order);
}
}
throw new \LogicException('Нет способа доставки');
}
}
L — Liskov Substitution: наследник не ломает родителя
Принцип: где работает родитель — работает и наследник, без сюрпризов.
php// ПЛОХО: наследник нарушает контракт родителя
class Bird
{
public function fly(): void { /* ... */ }
}
class Penguin extends Bird
{
public function fly(): void
{
throw new \LogicException('Пингвины не летают'); // код, ждущий Bird, падает
}
}
php// ХОРОШО: иерархия построена по возможностям, а не по «похожести»
interface Bird
{
public function eat(): void;
}
interface CanFly
{
public function fly(): void;
}
class Sparrow implements Bird, CanFly
{
public function eat(): void {}
public function fly(): void {}
}
class Penguin implements Bird // умеет то, что умеет, — и не обещает лишнего
{
public function eat(): void {}
}
Классический реальный пример: class Square extends Rectangle с квадратом, который ломает setWidth/setHeight — наследник переопределил поведение так, что код родителя перестал работать.
I — Interface Segregation: узкие интерфейсы
Принцип: клиент не должен зависеть от методов, которые ему не нужны.
php// ПЛОХО: «толстый» интерфейс — все обязаны реализовать всё
interface Report
{
public function toPdf(): string;
public function toHtml(): string;
public function email(): void;
}
class SalesReport implements Report
{
public function toPdf(): string { /* ... */ }
public function toHtml(): string { return ''; } // пустышка — интерфейс навязал
public function email(): void {} // пустышка — интерфейс навязал
}
php// ХОРОШО: узкие интерфейсы — реализуем только нужное
interface PdfExportable { public function toPdf(): string; }
interface HtmlExportable { public function toHtml(): string; }
interface Emailable { public function email(): void; }
class SalesReport implements PdfExportable, Emailable
{
public function toPdf(): string { /* ... */ }
public function email(): void { /* ... */ }
}
D — Dependency Inversion: зависимость от абстракций
Принцип: верхний код (бизнес-логика) не должен зависеть от деталей (Stripe, Redis, SMTP) — оба зависят от контракта.
php// ПЛОХО: логика сцеплена с деталью — сменить или протестировать нельзя
class CheckoutService
{
public function pay(Order $order): void
{
$stripe = new StripeGateway(config('services.stripe.key')); // «new» вшивает деталь
$stripe->charge($order->total);
}
}
php// ХОРОШО: логика зависит от контракта, деталь вешается снаружи
interface PaymentGateway
{
public function charge(float $amount): PaymentResult;
}
class CheckoutService
{
public function __construct(private PaymentGateway $gateway) {}
public function pay(Order $order): void
{
$this->gateway->charge($order->total);
}
}
// Привязка детали — в одном месте
$this->app->bind(PaymentGateway::class, StripeGateway::class);
DIP — принцип, на котором стоит весь Laravel: контейнер, DI, фасады, моки в тестах — всё это следствие.
SOLID в Laravel
Принципы в Laravel спрятаны в привычные приёмы:
- SRP — вынос логики из контроллера в сервис-класс, Form Request вместо валидации в контроллере, Job вместо отправки писем в контроллере.
- OCP — тегged bindings и форм contracts: новая реализация интерфейса без правки потребителей.
- LSP —
SoftDeletesне ломает контракт модели: любая модель с трейтом работает там, где ждутModel. - ISP — контракты Laravel узкие по замыслу:
Queue,Cache,Storage— каждый для своей задачи. - DIP — инъекция через конструктор, контракт вместо конкретного класса,
PaymentGatewayвместоStripeGateway.
Плохо / Хорошо
| Принцип | Плохо | Хорошо |
|---|---|---|
| SRP | Класс-монстр: скидка + письмо + лог | Класс на одну причину изменения |
| OCP | switch по типу на каждый новый вариант | Реализация интерфейса вместо правки кода |
| LSP | Наследник кидает исключение там, где родитель работал | Иерархия по возможностям, не по «похожести» |
| ISP | Интерфейс на 10 методов с пустышками | Узкие интерфейсы под конкретный клиент |
| DIP | new StripeGateway() внутри логики | Контракт в конструкторе, привязка в провайдере |
Подводный камень
- SOLID как религия. Класс на один метод, разнесённый на четыре «принципиальных» класса — это не архитектура, а раздутое дно. SOLID — инструмент, а не самоцель: на маленьком коде принципы иногда мешают.
- SRP ≠ «один метод». Ответственность — не «одна операция», а «одна причина для изменения».
UserServiceможет регистрировать, подтверждать и удалять пользователя — это всё про его ответственность, пока правки идут по одной причине. - Репозиторий ради DIP без реального запроса. Интерфейс + реализация + мок на таблицу, которая никогда не меняется и не мокается — слой ради слоя. DIP окупается, когда реально есть вторая реализация или тестовая подмена.
- LSP ломается «тихо». Наследник не упадёт на месте — упадёт код, который доверял контракту родителя: очередь, тест, другой разработчик. Это самый коварный из пяти: нарушение видно не там, где написано.
Мини-шпаргалка
php// SRP: одна причина изменения
class DiscountCalculator { /* только скидки */ }
// OCP: расширяем, не правим
interface ShippingMethod { public function cost(Order $o): float; }
// LSP: наследник везде заменяет родителя
class Sparrow implements Bird, CanFly { /* ... */ }
class Penguin implements Bird { /* без fly */ }
// ISP: узкие интерфейсы
interface PdfExportable { public function toPdf(): string; }
// DIP: контракт в конструкторе, деталь — снаружи
public function __construct(private PaymentGateway $gateway) {}
Три вывода
- SOLID — пять ответов на пять болей: «класс всё делает», «каждая фича правит старое», «наследник ломает родителя», «интерфейс навязывает лишнее», «код прилип к реализации».
- Главный практический эффект — тестируемость: класс с зависимостями по контрактам мокается за минуту, класс-монстр — только целиком и с реальной базой.
- Принципы работают вместе: DIP даёт заменяемость, OCP — расширяемость без правок, SRP — маленькие понятные классы; но у каждого оверкилл стоит дороже пользы.
Вопрос-ловушка
Спрашивают так: «Объясните SOLID». В джуниорской версии ждут расшифровку аббревиатуры — и большинство на этом останавливается. Отличаются те, кто на каждую букву даёт пример боли: не «SRP — принцип единственной ответственности», а «SRP — это когда класс считает скидку и шлёт письмо, и его невозможно протестировать». Хитрый поворот: «А нарушать SOLID можно?» Ожидаемый ответ — да, осознанно: маленький прототип с разросшимся классом лучше раздутое дно из «правильных» абстракций; принципы — инструмент для удержания сложности, а не чек-лист для код-ревью. Кандидат, который начинает с «нарушать нельзя», — читал статьи, но не строил.