Трейты в PHP: поведение без наследования
Трейт — это способ написать логику один раз и вставить её в сколько угодно классов, когда наследование для этого не годится: родитель может быть уже занят, а роднить классы ради одного метода нелогично. Трейт не меняет, кем класс является, — он добавляет, что класс умеет.
Зачем это нужно
Боль из практики: в проекте три класса — Report, Invoice, Shipment — и всем трём нужна одна и та же запись в лог: «кто, когда, что сделал». Первый порыв — скопировать метод log() в каждый класс. Через месяц в одном из трёх копий поправили формат записи, в двух — нет. Второй порыв — сделать общего родителя LoggableModel и наследовать все три. Но наследование в PHP — одно: а родитель уже нужен для чего-то другого, да и не всякий класс логично пускать в одну иерархию. Третий вариант — трейт — даёт общую логику без общей родословной.
Как это работает
Трейт — это подмешивание готового поведения в класс: use MyTrait — и методы трейта работают, как будто написаны в самом классе. Это способ написать логику один раз и вставить её в сколько угодно классов — без копипасты и без иерархии наследования. Класс собирается из независимых кусочков: один трейт на логирование, другой на кеширование, и при этом наследуется от одного родителя. Отсюда короткая формула смысла: наследование отвечает «кто я», трейт — «что я умею».
Шаг первый — объявляем трейт. Синтаксис почти как у класса, только ключевое слово trait и создать его объект нельзя:
phptrait HasLogging
{
public function log(string $message): void
{
Log::info($message, [
'class' => static::class,
'time' => now()->toDateTimeString(),
]);
}
}
Шаг второй — подмешиваем в классы:
phpclass Report
{
use HasLogging;
}
class Invoice
{
use HasLogging;
}
(new Report)->log('Отчёт сформирован'); // работает
(new Invoice)->log('Счёт отправлен'); // работает — код один
PHP при запуске скрипта превращает код в свои внутренние инструкции — опкоды, — и уже на этом этапе вклеивает методы трейта в тело класса: в исполняемом виде Report::log() выглядит как собственный метод. Поэтому трейт может обращаться к свойствам и методам класса — но это палка о двух концах, вернёмся в подводных камнях.
А теперь главное отличие от наследования. Наследование — вертикаль: один родитель, «кто я». Трейт — горизонталь: сколько угодно в один класс, «что я умею»:
phpclass Order extends Model // родитель один — «кто я»
{
use HasLogging; // умение первое
use HasCaching; // умение второе
use SoftDeletes; // и трейты самого Laravel
}
Класс собран из компонентов, при этом никто из предков не пострадал.
Трейт — это класс?
Почти, но нет — и это «почти» важно понимать.
Трейт устроен как класс: в нём есть методы, свойства, константы, объявляется он почти тем же синтаксисом. Но три отличия делают его не классом:
- Нельзя создать объект.
new HasLogging— фатальная ошибка. Трейт существует только внутри классов, которые его подмешали. - Нет своего «я» в рантайме. Класс — это сущность, которая живёт в памяти и в иерархии типов: можно проверить
instanceof, поймать его вget_class(). Трейт после обработки PHP исчезает: его методы становятся методами класса, иclass_uses()покажет только факт подмешивания, но трейт как самостоятельная вещь не существует. - Он не наследуется — он копируется. Наследование — это связь «родитель → потомок», живая на протяжении всей программы. Трейт — это разовая операция при запуске скрипта: движок PHP вклеил его тело в класс и забыл о нём. Поэтому
Orderне «наследует»HasLogging— он сделан из него.
Так что точная формулировка такая: трейт — это фрагмент кода в синтаксисе класса, который PHP вставляет в класс, а не сущность с собственным поведением. Класс отвечает на вопрос «кто ты», трейт — материал, из которого ты сделан.
А вот с чем трейт путать не стоит — так это с интерфейсом. Интерфейс только обещает методы, трейт даёт реализацию. Интерфейс — контракт без тела, трейт — тело без самостоятельности.
Конфликты: когда два трейта тянут один метод
Если два трейта объявляют метод с одним именем, PHP не выбирает за вас — он падает с фатальной ошибкой. Разруливается вручную:
phptrait 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 сам построен на трейтах — вы пользуетесь ими, возможно, не замечая:
phpclass 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 методов «на всё сразу» | Узкие трейты: один аспект поведения |
| Трейт лезет в приватные свойства класса | Трейт самодостаточен: свои данные — внутри |
| Трейт с бизнес-логикой, уникальной для одного класса | Метод в самом классе |
Подводный камень
- Трейт, зависящий от класса. Если трейт использует
$this->someProperty, которого нет в классе, — код падает при запуске, а не при подмешивании. Контракт зависимостей держите явным: либо свойства объявлены в трейте, либо ожидания описаны абстрактными методами. - Трейт вместо сервиса.
use SendsEmailс SMTP-логикой внутри — скрытая зависимость, которую нельзя подменить в тестах. Внешние системы (почта, кеш, платежи) — через DI, трейты — для чистой логики без зависимостей. - Конфликт свойств без ошибки. Свойство класса перекрывает свойство трейта молча. Поменяли значение в классе — и половина ожиданий трейта сломалась тихо. Держите имена свойств трейтов с префиксом.
- Иерархия трейтов. Трейт может использовать другой трейт — и через три уровня уже непонятно, откуда метод. Два уровня — потолок, дальше это скрытая иерархия, которую вы делали, чтобы её избежать.
Мини-шпаргалка
phptrait 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 из коробки
Три вывода
- Трейт — это переиспользование поведения без наследования: наследование отвечает «кто я», трейт — «что я умею». При этом трейт — не класс: объект из него не создать, в рантайме он не существует, PHP просто вклеивает его в класс. И не интерфейс: тот обещает методы без реализации, трейт даёт реализацию без самостоятельности.
- Конфликт методов двух трейтов PHP не разруливает сам —
insteadof/asнадо писать руками, и это сигнал перегруза. - Трейты — для чистой логики без внешних зависимостей; почта, кеш и платежи — через DI, а не через
use.
Что вошло в итог по сравнению с первой версией:
- начало статьи — определение трейта через «это», а не через «подмешать/продублировать/компоновать»;
- раздел «Трейт — это класс?» с тремя отличиями и абзацем про интерфейс;
- «компилятор» заменён на «движок PHP при запуске скрипта», опкод упомянут один раз с пояснением в скобках;
- «Вопрос-ловушка» удалён целиком, интервью-обороты вычищены.