Трейты в PHP: поведение без наследования

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



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

Боль из практики: в проекте три класса — Report, Invoice, Shipment — и всем трём нужна одна и та же запись в лог: «кто, когда, что сделал». Первый порыв — скопировать метод log() в каждый класс. Через месяц в одном из трёх копий поправили формат записи, в двух — нет. Второй порыв — сделать общего родителя LoggableModel и наследовать все три. Но наследование в PHP — одно: а родитель уже нужен для чего-то другого, да и не всякий класс логично пускать в одну иерархию. Третий вариант — трейт — даёт общую логику без общей родословной.

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

Трейт — это подмешивание готового поведения в класс: use MyTrait — и методы трейта работают, как будто написаны в самом классе. Это способ написать логику один раз и вставить её в сколько угодно классов — без копипасты и без иерархии наследования. Класс собирается из независимых кусочков: один трейт на логирование, другой на кеширование, и при этом наследуется от одного родителя. Отсюда короткая формула смысла: наследование отвечает «кто я», трейт — «что я умею».

Шаг первый — объявляем трейт. Синтаксис почти как у класса, только ключевое слово trait и создать его объект нельзя:

php
trait HasLogging
{
    public function log(string $message): void
    {
        Log::info($message, [
            'class' => static::class,
            'time'  => now()->toDateTimeString(),
        ]);
    }
}

Шаг второй — подмешиваем в классы:

php
class Report
{
    use HasLogging;
}

class Invoice
{
    use HasLogging;
}

(new Report)->log('Отчёт сформирован');  // работает
(new Invoice)->log('Счёт отправлен');    // работает — код один

PHP при запуске скрипта превращает код в свои внутренние инструкции — опкоды, — и уже на этом этапе вклеивает методы трейта в тело класса: в исполняемом виде Report::log() выглядит как собственный метод. Поэтому трейт может обращаться к свойствам и методам класса — но это палка о двух концах, вернёмся в подводных камнях.

А теперь главное отличие от наследования. Наследование — вертикаль: один родитель, «кто я». Трейт — горизонталь: сколько угодно в один класс, «что я умею»:

php
class Order extends Model          // родитель один — «кто я»
{
    use HasLogging;                // умение первое
    use HasCaching;                // умение второе
    use SoftDeletes;               // и трейты самого Laravel
}

Класс собран из компонентов, при этом никто из предков не пострадал.

Трейт — это класс?

Почти, но нет — и это «почти» важно понимать.

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

  1. Нельзя создать объект. new HasLogging — фатальная ошибка. Трейт существует только внутри классов, которые его подмешали.
  2. Нет своего «я» в рантайме. Класс — это сущность, которая живёт в памяти и в иерархии типов: можно проверить instanceof, поймать его в get_class(). Трейт после обработки PHP исчезает: его методы становятся методами класса, и class_uses() покажет только факт подмешивания, но трейт как самостоятельная вещь не существует.
  3. Он не наследуется — он копируется. Наследование — это связь «родитель → потомок», живая на протяжении всей программы. Трейт — это разовая операция при запуске скрипта: движок PHP вклеил его тело в класс и забыл о нём. Поэтому Order не «наследует» HasLogging — он сделан из него.

Так что точная формулировка такая: трейт — это фрагмент кода в синтаксисе класса, который PHP вставляет в класс, а не сущность с собственным поведением. Класс отвечает на вопрос «кто ты», трейт — материал, из которого ты сделан.

А вот с чем трейт путать не стоит — так это с интерфейсом. Интерфейс только обещает методы, трейт даёт реализацию. Интерфейс — контракт без тела, трейт — тело без самостоятельности.

Конфликты: когда два трейта тянут один метод

Если два трейта объявляют метод с одним именем, PHP не выбирает за вас — он падает с фатальной ошибкой. Разруливается вручную:

php
trait HasLogging { public function boot(): void { /* своё */ } }
trait HasCaching  { public function boot(): void { /* своё */ } }

class Order extends Model
{
    use HasLogging, HasCaching {
        HasLogging::boot insteadof HasCaching; // метод брать у HasLogging
        HasCaching::boot as cacheBoot;         // а этот — сохранить под другим именем
    }
}

insteadof выбирает, чья версия побеждает, as даёт псевдоним, чтобы не потерять вторую. Но если вам понадобился этот блок — почти всегда сигнал, что трейты перегружены: конфликт — это два куска поведения, которые тянут одно и то же имя. Дальше по конфликтам свойств: если трейт объявляет свойство, а класс — то же самое, класс побеждает без ошибки, но значения констант и видимость должны совпадать, иначе — фатальная ошибка.

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

Laravel сам построен на трейтах — вы пользуетесь ими, возможно, не замечая:

php
class Order extends Model
{
    use SoftDeletes;                          // deleted_at, удаление в корзину
    use HasUuids;                             // UUID вместо инкрементного ID
    use Notifiable;                           // ->notify(new OrderShipped(...))
}

class ResetPasswordController extends Controller
{
    // AuthorizesRequests, ValidatesAccess и другие «умения» контроллера — трейты
}

И на этом же приёме удобно строить свои модели:

php
// Все модели, у которых есть опубликованность, получают один и тот же scope
trait HasPublishedScope
{
    public function scopePublished(Builder $query): Builder
    {
        return $query->whereNotNull('published_at');
    }
}

class Post extends Model
{
    use HasPublishedScope;
}

Post::published()->get(); // работает в любой модели с трейтом

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

ПлохоХорошо
Копипаста метода в 3+ классовТрейт с одним источником логики
Родитель LoggableModel ради одного методаТрейт — наследование остаётся для «кто я»
Трейт на 15 методов «на всё сразу»Узкие трейты: один аспект поведения
Трейт лезет в приватные свойства классаТрейт самодостаточен: свои данные — внутри
Трейт с бизнес-логикой, уникальной для одного классаМетод в самом классе

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

  1. Трейт, зависящий от класса. Если трейт использует $this->someProperty, которого нет в классе, — код падает при запуске, а не при подмешивании. Контракт зависимостей держите явным: либо свойства объявлены в трейте, либо ожидания описаны абстрактными методами.
  2. Трейт вместо сервиса. use SendsEmail с SMTP-логикой внутри — скрытая зависимость, которую нельзя подменить в тестах. Внешние системы (почта, кеш, платежи) — через DI, трейты — для чистой логики без зависимостей.
  3. Конфликт свойств без ошибки. Свойство класса перекрывает свойство трейта молча. Поменяли значение в классе — и половина ожиданий трейта сломалась тихо. Держите имена свойств трейтов с префиксом.
  4. Иерархия трейтов. Трейт может использовать другой трейт — и через три уровня уже непонятно, откуда метод. Два уровня — потолок, дальше это скрытая иерархия, которую вы делали, чтобы её избежать.

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

php
trait HasLogging
{
    public function log(string $message): void { /* ... */ }
}

class Order extends Model
{
    use HasLogging;                        // подмешать
    use A, B {                             // конфликт методов:
        A::handle insteadof B;             // чья версия побеждает
        B::handle as bHandle;              // псевдоним для второй
    }
}

Post::published();      // scope из трейта
Order::onlyTrashed();   // SoftDeletes из коробки

Три вывода

  1. Трейт — это переиспользование поведения без наследования: наследование отвечает «кто я», трейт — «что я умею». При этом трейт — не класс: объект из него не создать, в рантайме он не существует, PHP просто вклеивает его в класс. И не интерфейс: тот обещает методы без реализации, трейт даёт реализацию без самостоятельности.
  2. Конфликт методов двух трейтов PHP не разруливает сам — insteadof/as надо писать руками, и это сигнал перегруза.
  3. Трейты — для чистой логики без внешних зависимостей; почта, кеш и платежи — через DI, а не через use.

Что вошло в итог по сравнению с первой версией:

  • начало статьи — определение трейта через «это», а не через «подмешать/продублировать/компоновать»;
  • раздел «Трейт — это класс?» с тремя отличиями и абзацем про интерфейс;
  • «компилятор» заменён на «движок PHP при запуске скрипта», опкод упомянут один раз с пояснением в скобках;
  • «Вопрос-ловушка» удалён целиком, интервью-обороты вычищены.