Наследование в PHP: когда extends оправдан, а когда это костыль

Унаследовать. Переопределить. Специализировать.

Унаследовали всё общее у родителя через extends, переопределили то, что работает иначе, через parent:: дополняя, а не дублируя, и получили класс, который является настоящей разновидностью родителя — а не копией с другим именем.



Зачем это нужно

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

Почему об этом стоит помнить: именно на наследовании чаще всего ломают архитектуру. Бывает так: нужен доступ к методам другого класса — и рука тянется написать extends. Вроде работает, код короче. А через полгода классы сцеплены намертво: поменял логику в родителе — у всех наследников что-то отвалилось, и ты не знаешь, у кого именно. Наследование — мощный инструмент, но с узкой зоной применения.

Как это работает

Синтаксис простой: extends — и наследник получает всё public и protected от родителя.

php
class Animal
{
    public function __construct(
        protected string $name
    ) {}

    public function info(): string
    {
        return "Меня зовут {$this->name}";
    }
}

class Dog extends Animal
{
    public function info(): string
    {
        return parent::info() . ', и я говорю «гав»';
    }
}

echo (new Dog('Рекс'))->info(); // Меня зовут Рекс, и я говорю «гав»

Здесь два движения:

  • наследник переопределяет метод info() — меняет поведение под себя;
  • через parent::info() дополняет родительскую логику, а не дублирует её заново.

Тест на оправданность: «это разновидность»

Прежде чем писать extends, задайте один вопрос: «наследник — это разновидность родителя?»

php
class Controller extends BaseController {}        // да, контроллер — разновидность BaseController
class AdminController extends Controller {}      // да, админский — разновидность контроллера
class ReportService extends DbService {}         // ❌ отчёт — НЕ разновидность сервиса БД

С контроллерами всё честно: базовый Controller даёт общую инфраструктуру (middleware, авторизацию, валидацию), а конкретный контроллер — его разновидность.

А вот ReportService extends DbService — красный флаг. Сервису отчётов нужен доступ к базе, но сам он базой не является. Ему нужно не наследование, а композиция — получить зависимость через конструктор:

php
// ПЛОХО: наследование ради доступа к методам
class ReportService extends DbService
{
    public function sales(): array
    {
        return $this->query('...'); // метод из родителя
    }
}

// ХОРОШО: композиция — нужен функционал, а не «родство»
class ReportService
{
    public function __construct(
        private DbService $db
    ) {}

    public function sales(): array
    {
        return $this->db->query('...');
    }
}

Разница тонкая, но важная. Наследование говорит: «я — это он, всё моё — его тоже». Композиция говорит: «я пользуюсь его помощью». Второе — почти всегда то, что имелось в виду.

Как это выглядит в Laravel

Laravel сам ставит пример правильного наследования — и правильного отказа от него.

Наследование оправдано:

php
// Иерархия исключений — классика «разновидностей»
class OrderException extends Exception {}
class PaymentFailedException extends OrderException {}

// Роут-контроллеры расширяют базовый
class OrderController extends Controller
{
    public function index(): View { /* ... */ }
}

Исключение — это разновидность исключения. Контроллер — разновидность контроллера. Логично.

А вот модели — интересный случай. Каждая модель наследует Model, но это единственный уровень — глубже не идём:

php
class Order extends Model { /* ... */ }
// class RefundedOrder extends Order {} // ❌ так делать не надо

Искушение сделать RefundedOrder extends Order велико: «перевозим всю логику заказов бесплатно». Но тогда все методы Order будут жить в RefundedOrder, включая те, которые для возврата не имеют смысла. Правильнее — тот же самый класс Order с методами-состояниями (помните инкапсуляцию? refund(), ship(), cancel()), а не параллельная иерархия классов.

Хрупкий родитель: главная цена

У наследования есть скрытая цена, о которой новичок узнаёт на своей шкуре. Пока иерархия маленькая — всё хорошо. Но родительский класс с течением времени меняется:

php
class BasePayment
{
    public function pay(float $amount): bool
    {
        $this->log($amount); // добавили логирование в новой версии
        return $this->doPay($amount);
    }
}

Вы обновили библиотеку, где этот класс лежит, — и логирование вдруг начало писать в базу, которой нет в тестовой среде. Вы не меняли ни строчки своего кода, а поведение сломалось. Это хрупкий базовый класс: любой, кто поменяет родителя, меняет всех наследников, и часто — непредсказуемым для них образом.

Композиция от этого защищает: вы зависите от интерфейса, а не от живого организма родителя.

Плохо / Хорошо

ПлохоХорошо
extends ради доступа к паре методовКомпозиция: зависимость через конструктор
Иерархия в 3–4 уровня «иначе код дублируется»Трейты для утилит, сервисы для логики
RefundedOrder extends Order ради переноса логикиОдин класс Order с методами-состояниями
Слепое доверие к родительскому классуПонимание, что родитель меняется — и наследники с ним

Подводный камень

  1. «Наследование = переиспользование» — ложное уравнение. Реиспользование кода дают трейты, композиция и сервисы. Наследование — для иерархий «разновидностей», и почти ничего больше.
  2. Глубокие цепочки — красный флаг. Три-четыре уровня extends — почти всегда признак того, что логику пора выносить в отдельные классы и композицию.
  3. parent:: не гарантирован. Родительский метод может измениться при обновлении — и ваши дополнения к нему будут работать не так, как вы рассчитывали.
  4. Не забывайте final. Если класс не рассчитан на наследование — пометьте его final. Это честная коммуникация: «не строй иерархию на мне».

Мини-шпаргалка

php
// Наследование — для «разновидностей»
class PaymentFailedException extends OrderException {}

// Переопределение + дополнение родителя
class Dog extends Animal
{
    public function info(): string
    {
        return parent::info() . ', и я говорю «гав»';
    }
}

// Композиция вместо наследования ради методов
class ReportService
{
    public function __construct(private DbService $db) {}
}

// Запрет наследования
final class Money { /* ... */ }

Три вывода

  1. Наследование — отношение «это разновидность», а не способ «взять методы».
  2. Композиция (зависимость через конструктор) почти всегда дешевле и надёжнее наследования — и легче меняется.
  3. Изменение родителя меняет всех наследников — поэтому иерархии держите плоскими, а ненаследуемые классы помечайте final.