<? phpukraine СПІВБЕСІДИ
Пошук по платформі

Питання на співбесіду Yii

Питання для Yii-розробників: Active Record і Query Builder, поведінки й події, DI-контейнер у Yii 3, міграції, RBAC і специфіка підтримки Yii 2 проєктів.

Тема
Рівень
3 питання
YII
Yii·Junior ·Yii2 ·ActiveRecord ·Query Builder

ActiveRecord у Yii2 — це надбудова над Query Builder: `ActiveQuery` успадковує `yii\db\Query`, будує той самий SQL, але замість масивів повертає обʼєкти моделей із валідацією, подіями, поведінками й звʼязками; Query Builder беруть там, де обʼєкти не потрібні — агрегати, звіти, масові операції.

Чим `Post::find()` відрізняється від `(new Query())->from('post')`?
Сторінка зі списком статей робить 40 запитів замість двох — де шукати причину?
Звіт по мільйону рядків падає з Allowed memory size exhausted, хоча запит один. Чому?
Ви написали `Post::updateAll()` — чому в таблиці не оновився `updated_at`?

У Yii2 це не два конкуруючі інструменти, а один із двома режимами. Post::find() повертає yii\db\ActiveQuery, який успадковує yii\db\Query — той самий Query Builder із select(), where(), join(), limit(). Різниця настає на останньому кроці: Query::all() віддає масив асоціативних масивів прямо з драйвера, а ActiveQuery::all() проганяє кожен рядок через populateRecord(), створює обʼєкт моделі, запамʼятовує oldAttributes для відстеження змін і викликає afterFind(). Тому «AR повільніший за Query Builder» — неточне формулювання: SQL однаковий, платите ви за гідратацію та за обʼєкти в памʼяті. Це видно й зі зворотного боку: Post::find()->asArray()->all() — це вже майже чистий Query Builder, хоча запит починався з моделі.

Що AR дає натомість — це поведінка навколо рядка. save() спершу викликає validate() за правилами моделі, потім beforeSave, пише лише змінені атрибути (getDirtyAttributes()), піднімає afterSave, і всі підписані поведінки — TimestampBehavior, BlameableBehavior, ваші власні — спрацьовують саме тут. Звʼязки описуються методами getAuthor() через hasOne()/hasMany(), і звернення $post->author виконує запит на місці. Саме звідси береться N+1: двадцять постів у циклі — це двадцять один запит. Ліки не в індексі на author_id, а в with('author'), який робить один додатковий SELECT * FROM user WHERE id IN (...) і розкладає результат по моделях уже в PHP.

Тут же живе найчастіша помилка новачка: with() не додає JOIN, тому фільтрувати чи сортувати по колонці приєднаної таблиці після нього не можна — SQL впаде на Unknown column. Для умови на полі звʼязку потрібен joinWith(), який будує справжній JOIN. Але й він має пастку: для hasMany JOIN розмножує рядки, і limit(20) після joinWith('comments') обмежить рядки JOIN, а не пости. Коли треба і фільтр по звʼязку, і чесний ліміт — фільтрують через joinWith(), а дані звʼязку довантажують окремим with().

Межа проходить по обсягу й меті. Якщо ви редагуєте одну-дві сутності, показуєте сторінку списку на 20–50 рядків, покладаєтесь на валідацію й події — це AR, і читабельність важливіша за мікросекунди. Якщо результат — це агрегат, звіт, експорт або масова зміна статусів, обʼєкти не потрібні: (new Query()) з groupBy()/having() поверне готові масиви, а updateAll() закриє 200 000 рядків одним UPDATE. Ціна updateAll() — вона ж і причина його швидкості: жодних validate(), подій і поведінок, тому updated_at доведеться додати в масив значень руками.

Про безпеку варто сказати без міфів. Значення в where(['status' => $status]), ['>', 'created_at', $time], ['like', 'title', $q] завжди передаються параметрами — інʼєкція через них неможлива. А от імена колонок у select(), orderBy(), groupBy() та вирази в new Expression() вставляються в SQL текстом, тож поле й напрямок сортування з запиту користувача звіряють з білим списком. І окремо: вхідний id перед findOne() приводьте до (int) — у 2.0.15.1 закрили саме той сценарій, коли масив із запиту перетворювався на довільну умову вибірки.

use app\models\Post;
use yii\db\Query;

// 1. Ліниве завантаження: 1 запит на пости + по одному на кожного автора (N+1)
foreach (Post::find()->limit(20)->all() as $post) {
    echo $post->author->name;          // окремий SELECT при першому зверненні
}

// 2. with() — рівно два запити: пости, далі SELECT * FROM user WHERE id IN (...)
$posts = Post::find()
    ->with('author')
    ->where(['status' => Post::STATUS_PUBLISHED])
    ->orderBy(['created_at' => SORT_DESC])
    ->limit(20)
    ->all();                           // масив обʼєктів Post

// 3. joinWith() потрібен, коли умова стоїть на полі звʼязку
$active = Post::find()
    ->joinWith('author')               // додає JOIN user ON user.id = post.author_id
    ->where(['user.is_active' => 1])   // з with() тут була б помилка Unknown column
    ->all();

// 4. Звіт: обʼєкти не потрібні, Query повертає масиви
$stats = (new Query())
    ->select(['author_id', 'total' => 'COUNT(*)'])
    ->from('{{%post}}')                // {{%...}} підставляє tablePrefix
    ->where(['status' => Post::STATUS_PUBLISHED])
    ->andWhere(['>=', 'created_at', $from])  // значення йде параметром, не текстом
    ->groupBy('author_id')
    ->having(['>', 'COUNT(*)', 5])
    ->all();

// 5. Масове оновлення повз модель: без validate(), beforeSave() і TimestampBehavior
Post::updateAll(['status' => Post::STATUS_ARCHIVED], ['<', 'created_at', $cutoff]);
Що `ActiveQuery` розширює `yii\db\Query`: це не два незалежні інструменти, а один запит із різною гідратацією результату.
Що `$post->author` — ліниве завантаження й окремий SELECT на кожну модель, а `with('author')` дає один додатковий запит з `WHERE id IN (...)`.
Різницю між `with()` і `joinWith()`: перший не вміє фільтрувати й сортувати по полю звʼязку, другий додає JOIN і робить умову `user.is_active` можливою.
Що `save()` тягне за собою `validate()`, події `beforeSave`/`afterSave` і поведінки на кшталт `TimestampBehavior`, а `updateAll()` іде повз усе це одним UPDATE.
Що ціна AR — гідратація: обʼєкт зберігає атрибути й `oldAttributes` для відстеження змін, тому на десятках тисяч рядків беруть `asArray()`, `Query` або `batch()`.
Вважати, що Query Builder «швидший», бо генерує кращий SQL: SQL той самий, різниця лише в тому, що робиться з рядками після вибірки.
Лікувати N+1 індексом на зовнішній ключ: індекс пришвидшує кожен із 40 запитів, але їх лишається 40.
Ставити `with('author')` і фільтрувати `->where(['user.is_active' => 1])` — SQL впаде з Unknown column, бо JOIN немає; тут потрібен `joinWith()`.
Робити `foreach` по `->all()` на сотнях тисяч рядків замість `batch()` або `each()` і дивуватись вичерпаній памʼяті.
Підставляти назву колонки чи напрямок сортування з `$_GET` у `orderBy()`: значення в `where()` біндяться параметрами, а імена колонок — ні.
ПОРАДА

Скажіть одним реченням: «ActiveQuery успадковує Query, тому SQL однаковий — вибір між ними це вибір, чи потрібні мені обʼєкти з подіями й звʼязками, чи достатньо масиву». Далі покажіть на прикладі: список статей — AR з `with()`, звіт із `GROUP BY` — `Query`.

Сторінка питання →
YII
Yii·Middle ·RBAC ·авторизація ·Yii 2

RBAC у Yii2 будується на ієрархії ролей і дозволів з правилами: ієрархія зберігається в БД або файлі, перевірка йде через Yii::$app->user->can(), а правила додають динамічні умови на кшталт авторства.

Чим правило (rule) відрізняється від permission?
Як зберігати RBAC-дерево: в БД чи у файлах?
Як зробити, щоб автор міг редагувати лише свої пости?

RBAC у Yii2 складається з трьох видів елементів. Permission — атомарна дія на кшталт updatePost. Role — група дозволів або інших ролей: author, admin. Rule — клас із методом execute(), який під час перевірки отримує користувача, елемент і параметри й повертає bool. Разом вони утворюють орієнтований граф, і Yii::$app->user->can('updatePost', ['post' => $post]) шукає шлях від призначень користувача до потрібного дозволу, виконуючи правила на кожному вузлі.

Правила роблять RBAC динамічним. Дозвіл updateOwnPost з правилом AuthorRule є дочірнім для updatePost: автор проходить до updatePost лише тоді, коли правило підтверджує, що пост його. Адміністратор отримує updatePost напряму без умов. Так авторство описується один раз у графі, а не через if у кожному екшні.

Ієрархія зберігається в DbManager або PhpManager, які реалізують один інтерфейс. Для продакшену з динамічними призначеннями потрібен DbManager з увімкненим кешем, інакше кожна перевірка робить кілька запитів до auth-таблиць. PhpManager підходить для невеликих застосунків із фіксованими ролями і дозволяє тримати права в git.

У коді перевіряють дозволи, а не ролі: can('updatePost'), а не can('admin'). Тоді зміна ролей не вимагає правки коду, а при міграції на Laravel чи Symfony дозволи з правилами майже один в один стають Policies або Voters.

// Правило: дозвіл спрацьовує лише для власного поста
final class AuthorRule extends yii\rbac\Rule
{
    public $name = 'isAuthor';

    public function execute($user, $item, $params): bool
    {
        return isset($params['post']) && $params['post']->author_id == $user;
    }
}

// Консольна команда: будуємо ієрархію один раз
$auth = Yii::$app->authManager;

$updatePost = $auth->createPermission('updatePost');
$auth->add($updatePost);

$rule = new AuthorRule;
$auth->add($rule);

$updateOwnPost = $auth->createPermission('updateOwnPost');
$updateOwnPost->ruleName = $rule->name;
$auth->add($updateOwnPost);
$auth->addChild($updateOwnPost, $updatePost);   // updateOwnPost => updatePost за умови правила

$author = $auth->createRole('author');
$auth->add($author);
$auth->addChild($author, $updateOwnPost);

$admin = $auth->createRole('admin');
$auth->add($admin);
$auth->addChild($admin, $updatePost);          // admin редагує будь-який пост
$auth->addChild($admin, $author);

$auth->assign($author, 15);

// У контролері: перевіряємо дозвіл, а не роль
if (! Yii::$app->user->can('updatePost', ['post' => $post])) {
    throw new ForbiddenHttpException('Not your post.');
}
Структуру: permission це атомарна дія, role групує permissions або інші ролі, і разом вони утворюють орієнтований граф, де can() шукає шлях від призначень користувача до потрібного дозволу.
Що rule це клас із execute(), який отримує користувача, елемент і параметри й повертає bool, тому дозвіл updateOwnPost з правилом AuthorRule перевіряє, що пост належить користувачу.
Різницю сховищ: DbManager для продакшену з кешем, PhpManager для невеликих застосунків без динамічних призначень, і що обидва реалізують один інтерфейс.
Що перевірка викликається в контролерах через AccessControl-фільтр або явний can(), а призначення ролей робиться в консольній команді або адмінці.
Уміння порівняти з Voters у Symfony, Gates і Policies у Laravel, і розуміння, як перенести правила при міграції.
Плутати RBAC з AccessControl-фільтром по ролях '@' і '?': фільтр лише вирішує, кого пускати в екшн, а RBAC описує ієрархію дозволів.
Перевіряти ролі замість дозволів: can('admin') замість can('updatePost'), через що зміна ролей вимагає правки коду.
Забувати про кеш DbManager і робити десятки запитів до auth-таблиць на кожен запит.
Реалізовувати авторство через if ($post->author_id === Yii::$app->user->id) у кожному екшні замість правила.
Не розуміти, що rule виконується для кожного елемента на шляху, і важкі запити в execute() виконуються багато разів.
ПОРАДА

Порівняйте з Voters у Symfony або Policies у Laravel: питання про перенесення логіки прав при міграції задають часто. І скажіть, що в коді перевіряєте дозволи, а не ролі.

Сторінка питання →
YII
Yii·Senior ·міграція ·легасі ·strangler

Найбезпечніший підхід — strangler pattern: новий застосунок ставиться поруч, роутинг поступово перекидається на нього, спільною лишається база даних; переписувати все одразу означає роками тримати дві версії продукту.

З якого модуля починати міграцію?
Як тримати дві системи в парі під час переходу?
Переписати з нуля чи мігрувати поступово?

Міграція з Yii2 — це не технічне питання про фреймворк, а питання ризику. Повне переписування рідко закінчується: стара система містить роки неявних правил, бізнес продовжує її змінювати, і розрив між системами росте швидше, ніж закривається. Тому робочий підхід — strangler pattern: новий застосунок ставиться поруч, і маршрути переносяться в нього по одному, поки стара система не залишиться порожньою.

Порядок має значення. Спершу характеризаційні тести на критичні сценарії поточної системи: оформлення замовлення, оплата, розрахунок звіту. Вони фіксують поведінку, яку треба зберегти, і закривають суперечки про те, чи баг був раніше. Потім інфраструктура переходу: маршрутизація на рівні nginx або фронт-контролера, спільна автентифікація через Redis-сесію або підписаний токен, спільна база з чіткими правилами володіння таблицями. І лише потім перший модуль: малий, ізольований, але реальний, щоб перевірити весь ланцюжок від деплою до моніторингу.

Далі модулі беруть за пріоритетом бізнесу й частотою змін: те, що змінюється часто, вигідніше мати в новій системі раніше. Логіку з ActiveRecord-моделей спершу виділяють у сервіси ще всередині Yii, а схему бази змінюють лише тоді, коли таблицею володіє одна система. Продукт при цьому не зупиняється: нові фічі пишуться в новій системі, а стара отримує лише виправлення.

// Фронт-контролер перехідного періоду: один вхід, два застосунки
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

$migrated = [
    '#^/api/v2/#',
    '#^/reports/#',
    '#^/account/invoices#',
];

foreach ($migrated as $pattern) {
    if (preg_match($pattern, $path)) {
        require __DIR__.'/../new/public/index.php';   // Symfony або Laravel
        return;
    }
}

(new yii\web\Application(require __DIR__.'/../config/web.php'))->run();

// Той самий підхід на рівні nginx: маршрути перекидаються без релізу PHP-коду
// location ~ ^/(api/v2|reports)/ { proxy_pass http://new-app; }
// location / { fastcgi_pass yii-fpm; }

// Спільна автентифікація: обидві системи перевіряють один підписаний cookie
final class SharedSessionGuard
{
    public function userId(string $cookie): ?int
    {
        [$payload, $signature] = explode('.', $cookie, 2) + [null, null];
        return hash_equals(hash_hmac('sha256', $payload, $this->secret), $signature)
            ? (int) json_decode(base64_decode($payload), true)['uid']
            : null;
    }
}
Порядок: спершу характеризаційні тести на критичні сценарії, потім міграція, а не навпаки.
Що strangler pattern це маршрутизація на рівні проксі або фронт-контролера: нові й перенесені маршрути йдуть у новий застосунок, решта в Yii, і бізнес не помічає переходу.
Що спільна база даних на перехідний період є нормою, а спільні сесії, автентифікація й кеш вимагають окремого рішення від першого дня.
Критерії вибору першого модуля: ізольований, з невеликою кількістю залежностей, але з реальною цінністю, щоб перевірити весь ланцюжок від деплою до моніторингу.
Тверезу оцінку: повне переписування рідко закінчується, бо фічі в старій системі не зупиняються, а різниця між системами росте.
Переписати все за одну ітерацію й переключити в один день.
Починати з найскладнішого модуля або з того, який усі ненавидять, замість малого ізольованого.
Мігрувати без тестів на поточну поведінку й потім сперечатись, чи баг був у старій системі.
Ігнорувати спільні сесії й авторизацію: користувач логіниться в Yii і виявляється анонімним у новій частині.
Зупиняти розвиток продукту до завершення міграції або, навпаки, дублювати кожну нову фічу в обидві системи.
ПОРАДА

Наголосіть на порядку: спершу тести на критичні сценарії, потім міграція, а не навпаки. І поясніть, як вирішили спільну сесію між Yii і новим застосунком.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти
ПІДТЕМИ
Active Record Behaviors Події DI-контейнер Міграції RBAC
НА ЧОМУ ВАЛЯТЬСЯ

Більшість Yii-вакансій — це підтримка наявних Yii 2 проєктів, тому питають не тільки фреймворк, а й уміння працювати з чужим кодом і поступово оновлювати легасі.