Поведения и события в Yii 2: рабочие приёмы из практики
Когда логику модели выносить в behavior, а когда в event, как не сломать порядок обработчиков и почему beforeSave иногда молча ничего не делает. Разбор на живых сценариях Yii 2.0.x.

Модель Article раздулась до 600 строк: тут и генерация slug, и заполнение created_by, и логирование изменений, и уведомления. Всё это переопределённые beforeSave() и afterSave(), между которыми уже не разобрать, что от чего зависит. Yii 2 даёт два механизма разгрузить такую модель — поведения (behaviors) и события (events). Они пересекаются, и выбор между ними влияет на то, будет ли код переиспользуемым или превратится в скрытую магию, которую при отладке ищут полдня. Здесь — рабочие приёмы для Yii 2.0.x: когда что брать, как не сломать порядок обработчиков и на чём люди спотыкаются чаще всего.
Статья не учит с нуля, что такое ActiveRecord — предполагается, что вы уже писали модели и хотя бы раз переопределяли beforeSave. Инвалидацию кеша и очереди мы не трогаем: только behaviors и events как таковые.
Что это вообще такое в терминах Yii
Событие — это точка, в которой объект говорит «сейчас случилось X» и вызывает всех, кто подписался. ActiveRecord из коробки бросает EVENT_BEFORE_INSERT, EVENT_BEFORE_UPDATE, EVENT_AFTER_INSERT, EVENT_AFTER_UPDATE, EVENT_BEFORE_DELETE и другие. Методы вроде beforeSave() — это удобные обёртки, которые сами бросают соответствующее событие.
Поведение — это класс, наследник yii\base\Behavior, который подключается к компоненту и умеет две вещи: добавлять компоненту свойства и методы (примешивать функциональность) и подписываться на события компонента через метод events(). То есть behavior — это чаще всего аккуратно упакованный набор обработчиков событий плюс своё состояние.
Событие отвечает на вопрос «когда сработать», поведение — «что сработает и можно ли это переиспользовать в другой модели, не копируя код».
Когда behavior, когда прямой обработчик события
Практический критерий один: переиспользование и состояние.
- Логика нужна в одной модели и нигде больше, состояния не хранит — переопределяйте
beforeSave()/afterSave()прямо в модели. Это честнее и читается лучше, чем городить behavior ради одного места. - Логика повторяется в нескольких моделях (проставить автора, обновить
updated_at, сгенерировать slug) — выносите в behavior. Один класс, подключается вbehaviors()каждой модели. - Реакция должна происходить в другом компоненте, а не в самой модели (например, при сохранении заказа дёрнуть сервис уведомлений) — подписывайтесь на событие извне через
on()или через глобальную привязку в конфиге приложения.
Готовые behaviors, которые не надо писать руками
Половина типичных задач уже закрыта фреймворком. Проверьте, прежде чем писать своё:
| Класс | Что делает | Типичный случай |
|---|---|---|
yii\behaviors\TimestampBehavior | Проставляет created_at / updated_at при insert и update | Метки времени в любой таблице |
yii\behaviors\BlameableBehavior | Пишет created_by / updated_by из текущего пользователя | Кто создал и правил запись |
yii\behaviors\SluggableBehavior | Генерирует slug из атрибута | ЧПУ-адреса статей, товаров |
yii\behaviors\AttributeBehavior | Присваивает атрибутам значения на заданных событиях | База для своих behavior |
Приём 1: behavior с подпиской на события через events()
Свой behavior — это не только «переопределить метод модели». Правильный способ реагировать на жизненный цикл — метод events(), который возвращает карту «событие → обработчик».
<?php
// app/behaviors/StatusLogBehavior.php
// Yii 2.0.x
namespace app\behaviors;
use yii\base\Behavior;
use yii\db\ActiveRecord;
use yii\db\AfterSaveEvent;
class StatusLogBehavior extends Behavior
{
// атрибут, изменения которого логируем
public $attribute = 'status';
public function events()
{
return [
ActiveRecord::EVENT_AFTER_UPDATE => 'onAfterUpdate',
];
}
public function onAfterUpdate(AfterSaveEvent $event)
{
// changedAttributes хранит СТАРЫЕ значения тех полей, что изменились
if (!array_key_exists($this->attribute, $event->changedAttributes)) {
return; // статус не трогали — выходим
}
$old = $event->changedAttributes[$this->attribute];
$new = $this->owner->{$this->attribute};
Yii::info("Status: {$old} -> {$new}", 'status.change');
}
}
Подключение в модели:
public function behaviors()
{
return [
[
'class' => \app\behaviors\StatusLogBehavior::class,
'attribute' => 'status',
],
];
}
Здесь $this->owner — это сама модель, к которой прикреплён behavior. И AfterSaveEvent::changedAttributes — источник частой путаницы: в нём лежат старые значения полей, которые реально поменялись. Новое значение берём уже из модели.
Приём 2: остановить сохранение из behavior
События «before» (before insert, before update, before delete) отменяемы — у них есть свойство isValid. Ставите $event->isValid = false — и save() вернёт false, запись не уйдёт в базу.
public function events()
{
return [
ActiveRecord::EVENT_BEFORE_UPDATE => 'onBeforeUpdate',
];
}
public function onBeforeUpdate(\yii\base\ModelEvent $event)
{
// запрещаем менять уже отгруженный заказ
if ($this->owner->getOldAttribute('status') === 'shipped') {
$event->isValid = false;
}
}
Проблема, о которую спотыкаются: save() вернул false, но никаких ошибок в $model->errors нет. Пользователь видит «не сохранилось» без объяснения. Если отмена — это бизнес-правило, добавьте ошибку через $this->owner->addError(...) перед тем, как выставить isValid = false, иначе отладка превратится в гадание.
Приём 3: подписка снаружи модели
Когда на сохранение должен реагировать другой слой (уведомления, интеграция), не тащите его в behavior модели — это связывает модель с чужой ответственностью. Подписывайтесь на класс целиком в bootstrap или в конфиге:
use yii\base\Event;
use yii\db\ActiveRecord;
use app\models\Order;
// class-level подписка: сработает для ВСЕХ экземпляров Order
Event::on(
Order::class,
ActiveRecord::EVENT_AFTER_INSERT,
function ($event) {
// $event->sender — конкретный сохранённый заказ
Yii::$app->notifier->newOrder($event->sender);
}
);
Разница с $model->on(...): последний вешает обработчик только на один экземпляр. Event::on(Order::class, ...) ловит событие у любого объекта этого класса и его наследников. Для интеграций почти всегда нужен именно class-level вариант.
Порядок обработчиков и приоритет
Если на одно событие подписаны и behavior, и переопределённый метод модели, и class-level обработчик — порядок не случаен, и на нём горят.
- Обработчики одного события вызываются в порядке подключения. Class-level (
Event::on(Class::class, ...)) вызываются раньше instance-level. - Внутри
beforeSave()сначала отрабатывает ваш переопределённый код (точнее, то, что вы поставили до вызоваparent::beforeSave()), а само событие бросается внутриparent::beforeSave(). Забылиparent::beforeSave()— behaviors, подписанные на это событие, вообще не отработают. Это причина №1 «TimestampBehavior молча не проставил дату». - По умолчанию новый обработчик добавляется в конец очереди. Четвёртый параметр
on()/Event::on()со значениемfalseставит обработчик в начало — но злоупотреблять этим не стоит, читать такой код тяжело.
Практический вывод: если переопределяете beforeSave() или beforeValidate() в модели с подключёнными behaviors — всегда вызывайте parent:: и проверяйте его результат:
public function beforeSave($insert)
{
if (!parent::beforeSave($insert)) {
return false; // дали behaviors и событиям шанс отменить сохранение
}
// ваша логика
return true;
}
Что ломается на практике
- Забытый
parent::beforeSave(). Behaviors не срабатывают, дата не проставляется, автор пустой. Симптом — «раньше работало, добавил свой beforeSave, всё сломалось». - Ожидание нового значения в
changedAttributes. Там старое. Новое — в атрибуте модели. - Дублирование обработчиков. Class-level подписку зарегистрировали дважды (например, в bootstrap и в контроллере) — уведомление уходит два раза.
Event::onне дедуплицирует. - Отмена без ошибки.
isValid = falseбезaddError— тихий провал сохранения. - Тяжёлая работа в afterSave. Отправка письма прямо в обработчике блокирует запрос. Событие фиксирует факт — тяжёлое выносите в отложенную обработку, а не выполняйте синхронно.
Что делать дальше
Пройдитесь по моделям и вынесите в TimestampBehavior и BlameableBehavior всё, что руками писали в beforeSave — это уберёт десятки строк дублирования сразу. Class-level подписки соберите в одном месте (bootstrap-компонент), чтобы не искать их по контроллерам и не ловить двойные срабатывания. Для точных сигнатур событий и списка бросаемых ActiveRecord событий держите под рукой раздел Events и класс yii\db\BaseActiveRecord в официальном гайде — набор событий стабилен внутри ветки 2.0, но детали проще сверить, чем помнить.
Сценарист праздников: Тосты, квесты, сценарии корпоративов и дней рождения.
Спросить за 8 ₽Источники: Yii 2 Guide — Events, Yii 2 Guide — Behaviors
Частые вопросы
У меня Yii 2.0.x, а примеры с ::class — это точно сработает?
Что использовать — beforeSave() или behavior, если логика в одной модели?
Не срабатывает TimestampBehavior, дата не проставляется — почему?
В чём разница между $model->on() и Event::on(Model::class, ...)?
Почему save() возвращает false, но errors пустой?
Можно ли из behavior отправлять письмо или дёргать API?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.