ActiveRecord в Yii 2: частые ошибки новичков и как их избежать
Модели ActiveRecord в Yii 2 экономят время, но новички регулярно ловят N+1, теряют данные при save() и не понимают, почему валидация молча всё пропускает. Разбираем грабли и рабочие исправления.

Вы подключили Yii 2, сгенерировали модель через Gii, написали $post->save() — и получаете либо тихую тишину без сохранения, либо страницу, которая делает 300 запросов к базе на один список из 20 записей. Оба симптома лечатся за пять минут, если знать, где искать. Ниже — ошибки, которые почти каждый новичок в ActiveRecord совершает в первые недели, с текстом того, что вы реально увидите, и с исправлением. Всё проверено на Yii 2.0.x и PHP 8.1.
Что нужно иметь перед началом
- установленный Yii 2 (basic или advanced template), версия ветки 2.0;
- PHP 7.4 или новее, лучше 8.0+;
- подключённую базу в
config/db.phpи хотя бы одну таблицу; - модель, унаследованную от
yii\db\ActiveRecord— можно сгенерировать через Gii.
Ошибка 1. save() возвращает false, а вы этого не проверяете
Самая частая история: данные не сохраняются, ошибок в логе нет. Причина в том, что save() по умолчанию запускает валидацию, и если хоть одно правило не прошло, метод возвращает false и молча ничего не пишет.
$post = new Post();
$post->title = $title;
$post->body = $body;
// Неправильно: результат никто не смотрит
$post->save();
// Правильно: проверяем результат и смотрим ошибки
if (!$post->save()) {
// getErrors() вернёт массив [атрибут => [сообщения]]
Yii::error($post->getErrors());
}
Пока вы не вызвали getErrors() или не вывели их в форме через виджеты, валидация будет отбивать сохранение без единого следа. Это не баг, а поведение по умолчанию.
Почему save() «пропускает» поля
Второй слой той же проблемы — правило safe и массовое присвоение. Атрибут, не описанный в rules() или не помеченный как безопасный, не заполняется через load() и setAttributes(). Вы отправляете форму, поле приходит в запросе, а в модель не попадает.
public function rules()
{
return [
[['title', 'body'], 'required'],
[['title'], 'string', 'max' => 255],
// Без этой строки created_by не заполнится через load()
[['created_by'], 'safe'],
];
}
Проверка: после $model->load(Yii::$app->request->post()) сделайте var_dump($model->attributes) — если поле пустое, а в $_POST оно есть, дело в правилах.
Ошибка 2. N+1 запросов при выборке связанных данных
Вы выводите список постов с именами авторов. Работает, но медленно. Загляните в панель отладки (Yii Debugger, вкладка Database) — там будет один запрос на посты и по одному на каждого автора.
// Плохо: N+1. На 50 постов — 51 запрос
$posts = Post::find()->all();
foreach ($posts as $post) {
echo $post->author->name; // отдельный SELECT на каждой итерации
}
// Хорошо: жадная загрузка, всего 2 запроса
$posts = Post::find()->with('author')->all();
foreach ($posts as $post) {
echo $post->author->name; // берётся из уже загруженных данных
}
Метод with() делает два запроса вместо N+1: один за постами, один за всеми нужными авторами разом. Разница на списке из 100 записей — это 101 запрос против 2.
Ленивая загрузка удобна, пока запись одна. Как только вы в цикле — переходите на with(). Панель отладки Yii показывает точное число запросов, не гадайте на глаз.
Связь должна быть объявлена правильно
Чтобы with('author') работал, в модели нужен геттер связи. Имя метода — getAuthor(), обращение — через $post->author без get и без скобок.
// В модели Post
public function getAuthor()
{
// hasOne: у поста один автор; связь по author_id -> id
return $this->hasOne(User::class, ['id' => 'author_id']);
}
Частая путаница: местами перепутанные ключи в массиве. Формат — ['поле_в_связанной_таблице' => 'поле_в_текущей']. Ошиблись порядком — получите пустую связь или ошибку про несуществующий столбец.
Ошибка 3. Загрузка целого объекта, когда нужно одно поле
Чтобы проверить, существует ли запись, новички делают Post::findOne($id) и смотрят на результат. Это тянет из базы всю строку и создаёт объект модели. Если нужен только факт существования или одно значение — есть способы дешевле.
// Дорого: полный объект ради проверки
$exists = Post::findOne($id) !== null;
// Дёшево: только COUNT
$exists = Post::find()->where(['id' => $id])->exists();
// Одно скалярное значение вместо всей строки
$title = Post::find()->select('title')->where(['id' => $id])->scalar();
На одной записи разница незаметна. В цикле или на горячем эндпоинте — уже ощутима.
Ошибка 4. Путаница find(), findOne() и findAll()
Три похожих метода делают разное, и новички смешивают их синтаксис.
| Метод | Что возвращает | Когда использовать |
|---|---|---|
find() | объект ActiveQuery (запрос, не данные) | когда нужны where, orderBy, with, limit |
findOne($cond) | одну модель или null | получить одну запись по условию или id |
findAll($cond) | массив моделей (пустой, если нет) | получить несколько записей по условию |
Ключевая ловушка: find() сам по себе данные не отдаёт. После него обязателен all(), one(), column() или scalar(). Забыли — получите объект запроса там, где ждали массив.
Вторая ловушка — findOne() с пользовательским вводом. Не передавайте туда напрямую массив из запроса без проверки: с версий 2.0.15 Yii ужесточил обработку условий, чтобы закрыть инъекции через нестандартный ввод, но полагаться на это как на единственную защиту не стоит. Валидируйте тип id до передачи в метод.
Ошибка 5. Изменение атрибутов после save() без понимания isNewRecord
Один и тот же save() делает INSERT для новой записи и UPDATE для загруженной. Различает их флаг isNewRecord. Проблема возникает, когда вы вручную присваиваете id существующей записи или переиспользуете объект.
$post = Post::findOne($id);
$post->title = 'Новый заголовок';
$post->save(); // UPDATE, потому что isNewRecord === false
// А это создаст дубликат, а не обновит:
$copy = new Post();
$copy->attributes = $post->attributes;
$copy->save(); // INSERT, isNewRecord === true
Если нужно обновить только часть полей и пропустить валидацию всей модели — используйте updateAttributes(), он пишет напрямую и не запускает rules().
Ошибка 6. Валидация на клиенте вместо сервера
ActiveForm генерирует JS-валидацию, и новичок думает, что этого достаточно. Клиентская проверка отключается в браузере за секунду. Правила в rules() — единственная реальная защита, и они срабатывают на save() автоматически. Не отключайте валидацию на сервере ради того, чтобы «форма быстрее сохранялась».
Как отлаживать: включите панель Debug
Большинство описанного видно в Yii Debugger. В basic-шаблоне он подключён в config/web.php в блоке для окружения разработки. На вкладке Database — все запросы за один HTTP-вызов с временем выполнения. Именно там N+1 виден сразу: если запросов десятки на одну страницу списка, вы забыли with().
Если не получилось
- save() возвращает false, ошибок не видно — вызовите
$model->getErrors()сразу после save и выведите результат. Скорее всего, не прошло правило required или string max. - Getting unknown property / Setting unknown property — обращаетесь к атрибуту, которого нет в таблице, или к связи с опечаткой в имени геттера.
- Call to a member function ... on null — обращаетесь к связи (
$post->author->name), а связанной записи нет. Проверяйте на null перед доступом. - load() возвращает true, но модель пустая — имя формы в POST не совпадает с
formName(), либо поля не помечены safe в rules(). - Column not found в связи — перепутан порядок ключей в hasOne/hasMany. Формат: связанное поле => текущее.
Что делать дальше
Освоив базовые save/find, посмотрите в сторону beforeSave() и afterSave() для автозаполнения полей вроде created_at, а лучше — поведения TimestampBehavior и BlameableBehavior, которые делают это без ручного кода. Транзакции при связанных вставках — отдельная большая тема, здесь мы её не разворачивали. Первым делом включите панель отладки и приучитесь смотреть на число запросов после каждой правки списка.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Yii 2.0 Guide — Active Record, Yii 2.0 Guide — Querying Data (Eager and Lazy Loading)
Частые вопросы
У меня Yii 1.1, а не 2.0 — эти примеры подойдут?
save() всё равно возвращает false, а getErrors() пустой — что не так?
Обязательно ли использовать with() всегда, чтобы не было N+1?
Можно ли сохранить модель без валидации?
Чем findOne() отличается от find()->one()?
Опасно ли передавать в findOne() данные прямо из запроса?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.