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

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

Що реально питають на першій роботі: синтаксис і типи, основи ООП, базовий SQL, робота з запитами й простий Laravel або WordPress.

Тема
Рівень
18 питань
OPS
DevOps·Junior ·Composer ·semver ·залежності

composer.json описує допустимі діапазони версій, composer.lock фіксує точні версії, які реально встановлені; install ставить строго з lock і дає однакові залежності всюди, update перераховує діапазони й переписує lock.

Локально працює, а на сервері фатальна помилка про неіснуючий метод — чому?
Чи треба комітити composer.lock, якщо версії й так є в composer.json?
Що станеться, якщо запустити composer update на продакшені?
Чим `^1.2.3` відрізняється від `~1.2.3` і що з них дозволить версію 2.0.0?

У Composer два файли з різними ролями. composer.json — це декларація намірів: «мені підійде будь-який Laravel 12.x». composer.lock — протокол результату: точні версії всіх пакетів, включно з транзитивними, які ви ніколи не згадували, плюс хеш коміту кожного з них, посилання на дистрибутив і content-hash — відбиток тих секцій composer.json, що впливають на резолюцію. Саме тому lock зазвичай на кілька тисяч рядків: у ньому не десяток ваших залежностей, а все дерево цілком.

composer install за наявності lock не виконує резолву взагалі — просто розпаковує перелічені версії. Це швидко, детерміновано і дає однаковий vendor/ на ноутбуці, в CI і на сервері. composer update навпаки: ігнорує зафіксовані версії, звіряється з composer.json, витягує з packagist найновіше, що вкладається в діапазони, розв'язує конфлікти між залежностями залежностей і переписує lock. Якщо composer.json змінили, а lock ні, install виведе попередження про невідповідність content-hash і все одно поставить старе; у CI цю розбіжність ловлять командою composer validate --strict, яка на ній падає.

Діапазони записують семантичним версіонуванням: MAJOR.MINOR.PATCH, де мажор означає несумісні зміни, мінор — сумісні доповнення, патч — виправлення. Практично використовують два оператори. Каретка ^1.2.3 дозволяє все до наступного мажора: >=1.2.3 <2.0.0. Тильда ~1.2.3 піднімає лише останню вказану позицію: >=1.2.3 <1.3.0, а от ~1.2 — це вже >=1.2 <2.0. Окремо треба знати виняток для нульових мажорів: у версіях 0.x Composer вважає ламким лівий ненульовий сегмент, тому ^0.3.1 означає >=0.3.1 <0.4.0, а не «до 1.0». Composer не читає код пакета — він довіряє номеру, тож захист від автора, який зламав API в мінорному релізі, лише один: зафіксований lock і тести після кожного оновлення.

Звідси робочий процес. Lock комітять у репозиторій (для застосунку — обов'язково; у бібліотеці він ігнорується споживачами, але корисний для власного CI). Оновлюють локально й точково: composer update vendor/package -W, потім тести, потім коміт зміненого lock окремою гілкою — так у код-рев'ю видно, що саме змінилося у версіях. composer require робить те саме автоматично, тобто частковий update лише для нового пакета. На деплої запускають виключно composer install --no-dev --optimize-autoloader: --no-dev викидає PHPUnit і решту інструментів розробки, --optimize-autoloader будує статичну мапу класів замість пошуку файлів на диску.

Межа цього підходу в тому, що lock фіксує версії, але не гарантує їхньої безпеки: зафіксований на пів року пакет може мати опубліковану вразливість. Тому в пайплайні тримають composer audit (доступний з Composer 2.4), який звіряє lock з базою відомих CVE, а оновлення роблять регулярними невеликими порціями, а не одним стрибком через два мажори раз на рік. Другий нюанс — платформа: якщо на ноутбуці PHP 8.4, а на сервері 8.2, резолюція локально дозволить те, що на сервері не встановиться. Лікується це config.platform.php у composer.json, а не прапорцем --ignore-platform-reqs, який просто вимикає перевірку й переносить падіння на продакшен.

# composer.json — діапазони, тобто наші наміри
#   "laravel/framework": "^12.0"   → >=12.0.0  <13.0.0  (мажор фіксуємо)
#   "nesbot/carbon":     "~3.8.0"  → >=3.8.0   <3.9.0   (патчі, без мінорів)
#   "league/csv":        "^0.9.2"  → >=0.9.2   <0.10.0  (нульовий мажор: ламає мінор)

# 1. Розробник: ставимо строго те, що у lock. Резолву немає, тому швидко.
composer install

# 2. Оновлення — тільки локально, у гілці, точково.
composer outdated --direct          # що взагалі має новіші версії
composer update nesbot/carbon -W    # -W = разом із залежностями цього пакета
vendor/bin/pest                     # тести на новому наборі версій
git add composer.json composer.lock && git commit -m "Bump carbon"

# 3. Конфлікт у composer.lock після мержу: файл не редагують руками.
git checkout --ours composer.json
git checkout --ours composer.lock
composer update --lock              # перерахувати content-hash без зміни версій

# 4. Деплой: install і нічого більше. update тут = нетестований код у релізі.
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
composer audit                      # відомі CVE у зафіксованих версіях
Розділення ролей: composer.json — це наміри (діапазони), composer.lock — це факт (точні версії, посилання на коміт і content-hash).
Що `composer install` за наявності lock не робить резолву взагалі й ставить те саме на ноутбуці, в CI і в продакшені; `composer update` ігнорує зафіксовані версії й перезбирає дерево залежностей.
Правило семантичного версіонування MAJOR.MINOR.PATCH і що `^1.2.3` означає `>=1.2.3 <2.0.0`, `~1.2.3` — `>=1.2.3 <1.3.0`, а для нульових мажорів `^0.3.1` це `>=0.3.1 <0.4.0`.
Що lock комітять для застосунків, а на деплої запускають `composer install --no-dev --optimize-autoloader`, ніколи не `update`.
Що оновлюють локально й точково: `composer update vendor/package -W`, потім тести, потім коміт зміненого lock у гілці.
Додати composer.lock у .gitignore «щоб не було конфліктів у мержі» — після цього кожна машина отримує свій набір версій.
Запускати `composer update` на сервері під час деплою й підтягувати щойно випущений мінорний реліз без жодного тесту.
Розв'язувати конфлікт у composer.lock руками в редакторі замість `git checkout --theirs composer.lock && composer update --lock` чи повторного `composer require`.
Вважати, що `^1.2.3` не пустить 1.9.0, або що `~1.2` і `~1.2.0` — це те саме (перше дозволяє 1.9, друге лише 1.2.x).
Ставити `"*"` або `dev-master` у composer.json і потім дивуватися, що збірка місячної давності не відтворюється.
Забути `--no-dev` на продакшені й тягнути PHPUnit разом із його залежностями в реліз.
ПОРАДА

Скажіть одним реченням: «composer.json — це те, що ми дозволяємо, composer.lock — це те, що ми перевірили». Далі додайте, що update робиться локально в окремій гілці з тестами, а на сервері живе тільки install — це відповідь рівня людини, яка деплоїла, а не читала документацію.

Сторінка питання →
SQL
SQL·Junior ·GROUP BY ·HAVING ·агрегати

WHERE фільтрує рядки до групування й не бачить агрегатів, GROUP BY згортає рядки в групи, HAVING фільтрує вже готові групи за значенням агрегату.

Чому запит із WHERE COUNT(*) > 3 падає з помилкою?
У якому порядку база виконує WHERE, GROUP BY і HAVING?
Чи можна писати HAVING без GROUP BY і що тоді буде?
Чому MySQL лається на колонку, якої немає в GROUP BY?

Щоб зрозуміти різницю, треба тримати в голові логічний порядок виконання запиту: FROM і JOIN збирають рядки, WHERE фільтрує їх по одному, GROUP BY згортає рядки в групи за однаковими значеннями ключа групування, всередині груп обчислюються агрегати (COUNT, SUM, AVG, MIN, MAX), HAVING відкидає непотрібні групи, і тільки потім працюють SELECT, ORDER BY та LIMIT. Це не порядок написання, а порядок сенсу — фізично планувальник може робити по-своєму, але результат зобовʼязаний збігатися з цією моделлю.

Звідси головна відповідь: WHERE не бачить COUNT(*), бо на момент його роботи груп ще не існує — є лише окремі рядки, і запит WHERE COUNT(*) >= 3 падає з помилкою (Invalid use of group function у MySQL, aggregate functions are not allowed in WHERE у PostgreSQL). HAVING, навпаки, виконується після агрегації, тому оперує вже порахованими значеннями. Симетрично HAVING майже не має сенсу для звичайної колонки: після групування окремих рядків уже немає, є ключ групування й агрегати.

Другий бік — продуктивність. Технічно можна винести умову на звичайну колонку в HAVING (MySQL це проковтне, бо дозволяє в HAVING посилатися на колонки й аліаси SELECT; PostgreSQL стандартно вимагатиме агрегат або колонку з GROUP BY). Але тоді база згрупує зайві рядки й лише потім їх викине, а індекс по цій колонці не спрацює для звуження пошуку. Правило просте: умова про значення рядка — у WHERE, умова про результат агрегації — у HAVING.

Після GROUP BY у SELECT дозволені тільки колонки з ключа групування та агрегати. MySQL до 5.7.5 із вимкненим ONLY_FULL_GROUP_BY мовчки повертав довільне значення з групи, і це давало нестабільні результати між запусками; з 5.7.5 режим увімкнений за замовчуванням, а PostgreSQL поводився так завжди. Виняток — функційна залежність: якщо групувати за первинним ключем (GROUP BY u.id), інші колонки цієї таблиці брати можна, бо вони визначені однозначно (PostgreSQL з 9.1, MySQL 8).

Дві деталі, на яких найчастіше помиляються джуни. Перша: COUNT(*) рахує рядки групи, а COUNT(column), SUM, AVG ігнорують NULL — тому COUNT(o.coupon) менший за COUNT(*), і це не баг. Друга: GROUP BY не гарантує сортування. У старих MySQL воно було побічним ефектом реалізації, у 8.0 неявне сортування й модифікатори ASC/DESC для GROUP BY прибрані, тож потрібний порядок задають явним ORDER BY.

-- Питання: користувачі, у яких від 3 оплачених замовлень за 2026 рік,
-- на суму понад 10000, найбільші зверху.
SELECT
    u.id,
    u.email,
    COUNT(*)        AS paid_orders,   -- рядків у групі
    SUM(o.total)    AS revenue,       -- SUM ігнорує NULL
    COUNT(o.coupon) AS with_coupon    -- рахує лише не-NULL купони
FROM users u
JOIN orders o ON o.user_id = u.id
-- WHERE працює до групування: відсікає рядки, може використати індекс
WHERE o.status = 'paid'
  AND o.created_at >= '2026-01-01'
-- GROUP BY згортає рядки в групи; u.email можна брати,
-- бо u.id — первинний ключ (функційна залежність)
GROUP BY u.id, u.email
-- HAVING працює після агрегації: тут і тільки тут доступні COUNT/SUM
HAVING COUNT(*) >= 3 AND SUM(o.total) > 10000
-- GROUP BY не сортує сам: у MySQL 8.0 неявне сортування прибрано
ORDER BY revenue DESC
LIMIT 20;

-- Помилка: агрегата у WHENE ще не існує, груп немає
-- WHERE COUNT(*) >= 3        -- ERROR: Invalid use of group function / aggregate not allowed

-- Кілька лічильників за один прохід (PostgreSQL)
SELECT user_id,
       COUNT(*)                                   AS all_orders,
       COUNT(*) FILTER (WHERE status = 'paid')    AS paid_orders
FROM orders
GROUP BY user_id;
Логічний порядок виконання: FROM/JOIN → WHERE → GROUP BY → агрегати → HAVING → SELECT → ORDER BY → LIMIT; звідси все інше випливає.
Що WHERE не бачить COUNT/SUM, бо на момент його роботи груп ще немає, а HAVING бачить, бо працює вже після агрегації.
Що умову на звичайну колонку треба ставити у WHERE, а не в HAVING: менше рядків для групування і доступний індекс.
Що після GROUP BY у SELECT можна брати лише колонки з GROUP BY або агрегати — ONLY_FULL_GROUP_BY у MySQL з 5.7.5 увімкнено за замовчуванням.
Що COUNT(*) рахує рядки, а COUNT(col) і SUM/AVG ігнорують NULL — це різні числа.
Писати WHERE COUNT(*) > 3 — синтаксична помилка, агрегат у WHERE неможливий.
Переносити всі умови в HAVING «щоб працювало»: запит дає правильний результат, але групує зайві рядки й не використовує індекс.
Плутати «фільтр до групування» і «фільтр після»: WHERE status = 'paid' і HAVING SUM(...) > 0 дають різні відповіді на різні питання.
Вважати, що GROUP BY сам сортує результат: у MySQL 8.0 неявне сортування прибрано, без ORDER BY порядок не визначений.
Використовувати COUNT(column) там, де треба порахувати всі рядки групи, і дивуватися меншому числу через NULL.
ПОРАДА

Скажіть одним реченням: «WHERE — про рядки, HAVING — про групи», і одразу назвіть порядок виконання. Далі додайте, що умову на звичайну колонку завжди тримають у WHERE, бо це менше роботи для GROUP BY.

Сторінка питання →
PHP
Core PHP·Junior ·порівняння ·приведення типів ·PHP 8

Оператор == порівнює значення з приведенням типів, === порівнює і значення, і тип без жодних перетворень.

Що поверне 0 == 'abc' у PHP 8? А в PHP 7?
Чому in_array знайшов елемент, якого там немає?
Коли ви свідомо використовуєте нестроге порівняння?

Оператор == порівнює значення після приведення до спільного типу, === порівнює значення і тип без перетворень. Через це === завжди передбачуваний: '1' === 1 ніколи не стане true, бо це рядок і ціле число.

У PHP 8 правила приведення для == стали строгішими. Число порівнюється з рядком як число лише тоді, коли рядок числовий ('42', ' 1.5'). Нечисловий рядок більше не перетворюється на 0: замість цього число приводиться до рядка, і порівняння йде посимвольно. Саме тому 0 == 'abc' у PHP 8 повертає false, а в PHP 7 повертало true.

Нестроге порівняння живе не лише в операторі. in_array, array_search, array_keys з другим аргументом і switch за замовчуванням використовують ==. Тому в них потрібно передавати true третім аргументом або замінити switch на match, який порівнює строго.

Окремий випадок: float. === порівнює біти, тому 0.1 + 0.2 === 0.3 дає false через похибку представлення. Тут не допомагає жоден оператор, потрібен допуск або тип без плаваючої точки.

var_dump(0 == 'abc');      // PHP 8: false, PHP 7: true
var_dump('1' == '01');     // true: обидва числові рядки
var_dump('1' === '01');    // false: різні рядки
var_dump(null == false);   // true
var_dump(null === false);  // false: різні типи

// Та сама пастка всередині in_array
var_dump(in_array('1e1', ['10']));        // true: '1e1' == '10' як числа
var_dump(in_array('1e1', ['10'], true));  // false: строгий режим

// switch порівнює нестрого: '0' == false дає true, тому спрацює перша гілка
switch ('0') {
    case false: echo 'false'; break;
    case '0':   echo 'zero';  break;
}

// match порівнює строго
echo match ('0') { false => 'false', '0' => 'zero' };  // zero
Що == спершу приводить операнди до спільного типу, а === не робить цього ніколи, тому воно передбачуване й швидше.
Що PHP 8 змінив правила: число порівнюється з нечисловим рядком як рядок, тому 0 == 'abc' тепер false.
Що ця ж різниця живе всередині in_array, array_search і switch, і там теж потрібен strict-режим.
Що в продакшн-коді ви за замовчуванням пишете === і declare(strict_types=1), а == лишаєте для свідомих випадків.
Казати, що === «просто суворіше», без пояснення, що саме відбувається з типами.
Плутати правила PHP 7 і PHP 8: у PHP 7 0 == 'abc' було true, у PHP 8 уже ні.
Забувати, що in_array і switch за замовчуванням нестрогі: пошук 0 у масиві рядків дає хибне знайдено.
Порівнювати float через === і дивуватись, що 0.1 + 0.2 === 0.3 дає false: тут потрібен допуск, а не оператор.
ПОРАДА

Скажіть, що в продакшн-коді за замовчуванням використовуєте === і declare(strict_types=1), а == лишаєте для випадків, де приведення справді потрібне, і наведіть один такий випадок.

Сторінка питання →
SQL
SQL·Junior ·JOIN ·SQL

INNER JOIN лишає лише ті рядки, для яких знайшлася пара в обох таблицях; LEFT JOIN лишає всі рядки лівої таблиці, підставляючи NULL там, де пари немає.

Чому LEFT JOIN повернув менше рядків, ніж очікувалось?
Як знайти користувачів без жодного замовлення?
У чому різниця між умовою в ON і в WHERE?

INNER JOIN повертає лише ті рядки, для яких умова звʼязку знайшла пару в обох таблицях: користувачі без замовлень у результат не потрапляють. LEFT JOIN бере всі рядки лівої таблиці й додає до них збіги з правої; якщо збігу немає, колонки правої таблиці заповнюються NULL. RIGHT JOIN є дзеркалом LEFT, а FULL OUTER JOIN обʼєднує обидва й у MySQL відсутній.

Найважливіше для практики — різниця між умовою в ON і у WHERE. ON описує, як шукати пару, і при LEFT JOIN відсутність пари дає NULL. WHERE застосовується вже до зібраного результату, і порівняння NULL з чим завгодно не є true, тому рядки без пари зникають. Так LEFT JOIN orders o ... WHERE o.status = 'paid' непомітно стає INNER JOIN. Якщо потрібні всі користувачі, а замовлення лише оплачені, умова на статус іде в ON.

Цей самий NULL є інструментом: WHERE o.id IS NULL після LEFT JOIN знаходить рядки без відповідника, і це стандартний anti-join поруч із NOT EXISTS. NOT IN з підзапитом, який може повернути NULL, дає порожній результат, тому в таких запитах його уникають.

Окрема пастка — множення рядків. JOIN один-до-багатьох повертає по рядку на кожен збіг, тому агрегати після нього подвоюються. Агрегувати праву таблицю варто до JOIN, у підзапиті або CTE.

-- INNER: лише користувачі, у яких є замовлення
SELECT u.id, o.id AS order_id
FROM users u
INNER JOIN orders o ON o.user_id = u.id;

-- LEFT: усі користувачі; без замовлень order_id буде NULL
SELECT u.id, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

-- Пастка: умова у WHERE відкидає NULL-рядки, це вже фактично INNER JOIN
SELECT u.id, o.id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';

-- Правильно: умова на праву таблицю в ON, користувачі без оплат лишаються
SELECT u.id, o.id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';

-- Користувачі без жодного замовлення
SELECT u.id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;

-- Те саме без JOIN, безпечно до NULL
SELECT u.id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
Чітке визначення обох через результат: INNER це перетин, LEFT це вся ліва таблиця плюс збіги з правої або NULL.
Що умова на праву таблицю у WHERE відфільтровує NULL-рядки й перетворює LEFT JOIN на INNER, а в ON вона обмежує лише збіг.
Що IS NULL по колонці правої таблиці після LEFT JOIN є стандартним способом знайти рядки без відповідника, як і NOT EXISTS.
Що JOIN на звʼязок один-до-багатьох множить рядки, тому COUNT і SUM після нього потребують DISTINCT або підзапиту.
Що RIGHT JOIN це дзеркало LEFT, а FULL OUTER JOIN є у PostgreSQL і відсутній у MySQL.
Казати, що різниця лише у швидкості.
Писати умову на праву таблицю у WHERE й отримувати INNER JOIN, не розуміючи чому.
Рахувати COUNT(*) після JOIN один-до-багатьох і отримувати кількість замовлень замість кількості користувачів.
Використовувати NOT IN з підзапитом, який може повернути NULL: результат порожній, бо порівняння з NULL не є true.
Не знати, що для LEFT JOIN потрібен індекс на колонці звʼязку правої таблиці, інакше кожен рядок лівої сканує праву.
ПОРАДА

Саме приклад із WHERE проти ON відрізняє того, хто розуміє JOIN, від того, хто вивчив визначення. Намалюйте два кола або наведіть таблицю з трьома рядками.

Сторінка питання →
WP
WordPress·Junior ·хуки ·actions ·filters

Action виконує побічну дію в певній точці життєвого циклу й нічого не повертає; filter отримує значення, змінює його й обовʼязково повертає.

Що станеться, якщо фільтр нічого не поверне?
Як контролювати порядок виконання кількох хуків?
Чому після активації плагіна зник контент сторінок?

Хуки — це спосіб WordPress дозволити чужому коду втрутитись у свій без правки ядра. Action виконує побічну дію в певній точці: щось вивести, записати в лог, надіслати лист. Він нічого не повертає. Filter отримує значення, наприклад заголовок або контент, змінює його й повертає далі по ланцюжку. Внутрішньо обидва живуть в одному реєстрі WP_Hook, і add_action є обгорткою над add_filter; різниця лише в тому, чи використовується результат.

Найчастіша помилка новачків — фільтр без return. Колбек повертає null, null іде далі по ланцюжку і в код, який викликав apply_filters, і на сайті зникають заголовки, контент чи ціни. WordPress не перевіряє тип результату, тому помилка тиха.

Порядок задається пріоритетом: менше число виконується раніше, за замовчуванням 10. Якщо два плагіни фільтрують the_content з однаковим пріоритетом, результат залежить від порядку завантаження, і саме звідси більшість конфліктів. Четвертий аргумент accepted_args визначає, скільки параметрів отримає колбек; без нього другий аргумент фільтра не прийде.

Щоб зняти чужий хук, remove_filter потребує те саме імʼя, той самий колбек і той самий пріоритет. Тому у власних плагінах хуки краще реєструвати іменованими функціями або методами з доступним екземпляром, а не анонімними замиканнями.

// Action: побічна дія, нічого не повертає
add_action('wp_footer', function (): void {
    echo '<!-- rendered at '.date('c').' -->';
});

// Filter: отримує значення й ОБОВʼЯЗКОВО повертає
add_filter('the_title', function (string $title, int $postId): string {
    return is_admin() ? $title : trim($title).' ✦';
}, 10, 2); // пріоритет 10, колбек приймає 2 аргументи

// Класична помилка: контент зникає, бо повертається null
add_filter('the_content', function (string $content): void {
    str_replace('[year]', date('Y'), $content); // немає return
});

// Свій код стає розширюваним
function shop_shipping_cost(int $cents): int
{
    return (int) apply_filters('shop_shipping_cost', $cents);
}
do_action('shop_order_shipped', $orderId);

// Знімаємо чужий хук: імʼя + колбек + пріоритет мають збігатись
add_action('init', fn () => remove_filter('the_content', 'wpautop', 10), 20);
Що обидва механізми побудовані на одному реєстрі: add_filter і add_action це та сама функція, різниця в тому, чи використовується повернене значення.
Що забутий return у фільтрі повертає null, і саме так зникають контент, заголовки чи ціни після активації плагіна.
Що пріоритет визначає порядок: менше число виконується раніше, за замовчуванням 10, і конфлікти плагінів часто саме через однакові пріоритети.
Що четвертий аргумент accepted_args обмежує, скільки параметрів отримає колбек, і без нього другий аргумент фільтра просто не прийде.
Що remove_action і remove_filter потребують той самий колбек і пріоритет, тому анонімні функції в хуках не можна зняти.
Казати, що це синоніми або що різниця лише в назві.
Забувати return у фільтрі й ламати вивід усього сайту.
Використовувати action там, де треба змінити значення: писати в глобальну змінну замість повернення з фільтра.
Вішати хуки на анонімні функції або в неправильний момент, наприклад до plugins_loaded, коли потрібні класи ще не завантажені.
Не вказувати accepted_args і дивуватись, що $post чи $context у колбеку відсутній.
ПОРАДА

Додайте, що пріоритет хука визначає порядок, і конфлікти плагінів найчастіше саме через однакові пріоритети. Згадайте apply_filters і do_action як точки, де ваш код сам стає розширюваним.

Сторінка питання →
PHP
Core PHP·Junior ·ООП ·interface ·abstract class

Інтерфейс — це публічний контракт без стану, і їх можна реалізувати скільки завгодно; абстрактний клас — частково готова реалізація зі станом, конструктором і protected-методами, але успадкувати можна лише один.

Навіщо інтерфейс, якщо абстрактний клас теж уміє оголошувати методи без тіла?
Скільки інтерфейсів можна реалізувати і скільки класів успадкувати?
Чи можна в інтерфейсі оголосити властивість або protected-метод?
У вас два класи з однаковим кодом усередині — зробите спільного батька чи інтерфейс?

На рівні мови інтерфейс — це оголошення публічного API без жодної реалізації: список сигнатур методів, константи і (з PHP 8.4) вимоги до властивостей у формі хуків public string $name { get; }. Усі методи інтерфейсу автоматично публічні, спроба написати protected function дає фатальну помилку «Access type for interface method must be public». Абстрактний клас — це звичайний клас, якому заборонено new: у ньому вільно живуть властивості, конструктор, private/protected-методи, готові реалізації і поряд з ними методи з модифікатором abstract, тобто без тіла. Обидва не інстанціюються, обидва створюють повноцінний тип, який можна вказати в type hint і перевірити через instanceof.

Найважливіша практична різниця — не «є тіло чи немає», а кількість. Клас має рівно одного батька (extends), а інтерфейсів реалізує скільки завгодно (implements A, B, C); сам інтерфейс теж може успадкувати одразу кілька інтерфейсів. Тому наслідування — це ресурс, який витрачається один раз, і витрачати його варто на головну вісь класифікації. Саме звідси випливає критерій вибору: інтерфейс відповідає на питання «що обʼєкт уміє» і легко комбінується (Arrayable, JsonSerializable, PaymentGateway), абстрактний клас відповідає «як він це частково робить» і задає жорстке відношення «is-a».

Найчастіше їх не протиставляють, а поєднують, як у прикладі: інтерфейс PaymentGateway описує контракт, абстрактний HttpGateway implements PaymentGateway тримає спільний стан (HTTP-клієнт, ключ) і спільний алгоритм, а нащадок дописує лише те, що справді відрізняється — це шаблонний метод (Template Method). Абстрактний клас має право реалізувати інтерфейс частково: PHP вимагатиме повноти лише від першого конкретного нащадка. Типізувати в решті коду треба інтерфейс, а не абстрактний клас — тоді контейнер Laravel біндить PaymentGateway::class на потрібну реалізацію, а в тесті ви підставляєте фейк, не тягнучи за собою конструктор батька.

Обмеження абстрактного класу проявляються швидко. Він не може бути final одночасно з abstract, його private-методи не бачать нащадки, а якщо нащадок оголошує власний конструктор і забуває parent::__construct(), типізовані властивості батька лишаються неініціалізованими і код падає з «must not be accessed before initialization». Ще одна пастка — спокуса скласти в базовий клас усі спільні хелпери: чим більше туди потрапляє, тим менше шансів, що нащадок згодом переїде в іншу ієрархію, бо батько єдиний. Коли потрібне саме перевикористання коду без спільного типу, беруть трейт; коли потрібні дві незалежні осі — композицію і два інтерфейси, які з PHP 8.1 можна вимагати разом через intersection type A&B.

Компроміс на боці інтерфейсів теж є, і про нього рідко згадують на джуніорських співбесідах: інтерфейс — це публічна обіцянка, яку дорого міняти. Додавання нового методу ламає всі існуючі реалізації, зокрема чужі, тоді як новий метод із тілом в абстрактному класі просто успадковується. Тому інтерфейси тримають вузькими (принцип Interface Segregation: краще PaymentGateway і RefundsPayments окремо, ніж один роздутий), а розширення оформлюють новим інтерфейсом з перевіркою instanceof. З версійних деталей варто памʼятати: перевизначати константи інтерфейсу в класі дозволено з PHP 8.1, типізовані константи зʼявилися у 8.3, а оголошення властивостей в інтерфейсі — лише у 8.4 і лише через хуки.

interface PaymentGateway            // контракт: лише public-методи, жодного стану
{
    public function charge(int $amountUah, string $token): string;
}

interface RefundsPayments           // окремий вузький контракт: вміє не кожен шлюз
{
    public function refund(string $paymentId): void;
}

// Абстрактний клас реалізує контракт частково: спільний код тут, специфіка — у нащадках
abstract class HttpGateway implements PaymentGateway
{
    public function __construct(
        protected readonly HttpClient $http,   // стан, якого в інтерфейсі бути не може
        private readonly string $apiKey,
    ) {
    }

    abstract protected function endpoint(): string;  // нащадок зобовʼязаний дати URL

    public function charge(int $amountUah, string $token): string
    {
        $response = $this->post($this->endpoint(), ['amount' => $amountUah, 'token' => $token]);

        return $response['id'];
    }

    protected function post(string $url, array $payload): array  // protected в інтерфейсі неможливий
    {
        return $this->http->post($url, $payload + ['key' => $this->apiKey]);
    }
}

final class StripeGateway extends HttpGateway implements RefundsPayments
{
    protected function endpoint(): string
    {
        return 'https://api.stripe.com/v1/charges';
    }

    public function refund(string $paymentId): void
    {
        $this->post('https://api.stripe.com/v1/refunds', ['charge' => $paymentId]);
    }
}

new HttpGateway($http, 'key');   // Error: Cannot instantiate abstract class HttpGateway
new StripeGateway($http, 'key') instanceof PaymentGateway;  // true: інтерфейс успадкувався від батька

function payAndAllowRefund(PaymentGateway&RefundsPayments $gateway): void {}  // intersection type, PHP 8.1+
Ключове обмеження: `extends` — тільки один клас, `implements` — скільки завгодно інтерфейсів, а інтерфейс може успадковувати одразу кілька інтерфейсів.
Що інтерфейс не має стану: до PHP 8.4 властивості в ньому взагалі заборонені, а в 8.4 оголошення `public string $name { get; }` — це вимога до хука, а не поле.
Що методи інтерфейсу можуть бути лише `public`, а абстрактний клас вільно тримає `protected`/`private`, конструктор і готові методи.
Правило вибору: спільний код і відношення «is-a» → абстрактний клас; точка підміни, яку типізуємо й мокаємо → інтерфейс. Часто разом: інтерфейс як контракт, абстрактний клас як зручна база.
Що обидва не інстанціюються, обидва працюють у type hint та `instanceof`, а DI-контейнер біндять саме на інтерфейс.
Що додати метод у published-інтерфейс — це BC break для всіх реалізацій, а додати метод з тілом в абстрактний клас — ні.
Казати «інтерфейс — це просто клас без реалізації, різниці немає»: різниця в тому, що абстрактних батьків може бути лише один.
Оголошувати `protected function` чи `private function` в інтерфейсі — фатальна помилка: «Access type for interface method must be public».
Пробувати покласти в інтерфейс властивість `public array $items;` — до PHP 8.4 це фатальна помилка, у 8.4 працює тільки форма з хуками `{ get; }`.
Вважати, що абстрактний клас зобовʼязаний мати хоч один `abstract`-метод: клас без жодного абстрактного методу теж може бути `abstract`, просто його не можна створити через `new`.
Звужувати видимість або типи в нащадку (`protected` замість `public`, вужчий тип параметра) — PHP відмовиться завантажити клас.
Забути `parent::__construct()` у нащадка абстрактного класу — властивості батька лишаться неініціалізованими і впадуть на typed property.
Ліпити абстрактний клас-«помийку» зі спільними хелперами замість трейта або окремого сервіса — потім із нього неможливо вилізти, бо батько лише один.
ПОРАДА

Скажіть формулою: «інтерфейс відповідає на питання *що вміє* обʼєкт, абстрактний клас — *як він це частково робить*». І одразу додайте практичний критерій: якщо в спільній частині зʼявляється стан або protected-метод — це абстрактний клас; якщо потрібна лише точка підміни для контейнера і тестів — інтерфейс.

Сторінка питання →
PHP
Core PHP·Junior ·enum ·PHP 8.1 ·backed enum

Enum (PHP 8.1+) — тип із фіксованим переліком case-ів; pure enum не має скалярного значення, backed enum привʼязаний до int або string і вміє from()/tryFrom().

Навіщо enum, якщо є константи класу?
Що поверне tryFrom, якщо такого значення в enum немає?
Чому json_encode падає на вашому enum, а на сусідньому працює?
Можна в enum додати властивість, щоб зберігати стан кожного case?

Enum зʼявився в PHP 8.1 і закриває стару проблему «магічних рядків»: замість набору констант класу ви отримуєте окремий тип із фіксованим переліком значень. Кожен case — це обʼєкт-одинак, створений один раз на процес, тому порівняння через === завжди коректне, а new OrderStatus(...) заборонено. Найважливіший практичний наслідок — типізація: сигнатура public function markAs(OrderStatus $status): void фізично не дає передати 'payed' з одруківкою, і перевірку робить не ваш if, а сам PHP.

Різниця pure і backed — у наявності скалярного значення. Pure enum (enum Weekday { case Mon; }) реалізує інтерфейс UnitEnum і має лише властивість name — рядок з іменем case-у. Backed enum оголошується з типом (enum OrderStatus: string), реалізує BackedEnum, додає властивість value і два статичні методи: from() повертає case або кидає \ValueError, tryFrom() повертає case або null. Правило просте: from() — для значень, у яких ви впевнені (константи, дані з власної бази), tryFrom() — для всього, що прийшло від користувача чи зовнішнього API. Метод cases() є в обох видах і повертає масив усіх case-ів у порядку оголошення — з нього роблять селекти, сідери й перевірки.

Enum — це майже повноцінний тип: у ньому можна оголошувати методи, статичні методи, константи, підключати трейти й реалізовувати інтерфейси. Чого не можна — властивостей і будь-якого стану: enum не має конструктора, всі case-и незмінні. Тому «додаткові дані» до case-у оформлюють методом, найчастіше через match ($this), як у прикладі з label(). match тут природний партнер enum: він порівнює строго через ===, а якщо жодна гілка не підійшла, кидає \UnhandledMatchError — це краще за мовчазний default, бо новий case ламає код одразу, а не в проді.

У Laravel backed enum стає типом атрибута моделі: protected function casts(): array { return ['status' => OrderStatus::class]; } (enum-cast доступний з Laravel 9, метод casts() — з Laravel 10; раніше те саме писали у властивості $casts). Читання дає обʼєкт enum, запис приймає обʼєкт і кладе в колонку value. Pure enum так кастити не можна — усередині Eloquent викликає tryFrom(). Так само enum підтримується в implicit route binding і у валідації через Rule::enum(OrderStatus::class).

Межі варто назвати чесно. json_encode серіалізує backed enum у його value автоматично, а на pure enum кидає виняток, бо представлення за замовчуванням у нього немає. У базі колонку зазвичай лишають звичайним varchar замість нативного ENUM MySQL — тоді додавання нового case-у не вимагає ALTER TABLE, а PostgreSQL взагалі не має сумісного синтаксису. І пам'ятайте, що значення backed enum — це частина контракту з базою та API: перейменувати case Paid можна майже безкарно, а змінити 'paid' на 'payed' — уже міграція даних.

enum OrderStatus: string          // backed enum: у кожного case є рядкове значення
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Cancelled = 'cancelled';

    public const Default = self::Pending;   // константа-псевдонім, не новий case

    /** У методі $this — це сам case */
    public function label(): string
    {
        return match ($this) {              // match порівнює через ===
            self::Pending => 'Очікує оплати',
            self::Paid => 'Оплачено',
            self::Cancelled => 'Скасовано',
        };                                  // без default: новий case одразу зламає тест
    }
}

enum Weekday { case Mon; case Tue; }        // pure enum: значення немає

OrderStatus::from('paid');        // OrderStatus::Paid
OrderStatus::tryFrom('deleted');  // null — для даних ззовні
OrderStatus::from('deleted');     // \ValueError
OrderStatus::cases();             // [Pending, Paid, Cancelled]
OrderStatus::Paid->value;         // 'paid'   (лише в backed)
OrderStatus::Paid->name;          // 'Paid'   (є в обох)
Weekday::Mon->value;              // помилка: у pure enum немає value

OrderStatus::Paid === OrderStatus::from('paid'); // true: case — одинак
OrderStatus::Paid === 'paid';                    // false: обʼєкт != рядок

// Eloquent: у колонці лежить 'paid', у моделі — обʼєкт enum
final class Order extends Model
{
    protected function casts(): array
    {
        return ['status' => OrderStatus::class]; // працює тільки з backed enum
    }
}

$order->status->label();          // 'Оплачено'
$order->status = OrderStatus::Paid;
$order->save();                   // у базу піде 'paid'
Що enum зʼявився в PHP 8.1, а кожен case — це обʼєкт-одинак, тому порівнювати їх треба через === без жодних хитрощів.
Що pure enum реалізує UnitEnum і має лише властивість name, а backed enum реалізує BackedEnum, має ще value і методи from()/tryFrom().
Різницю from() і tryFrom(): from() кидає \ValueError на невідомому значенні, tryFrom() повертає null — саме його беруть для даних ззовні.
Що cases() повертає масив усіх case-ів по порядку оголошення і використовується для селектів, валідації та сідерів.
Що enum може мати методи, константи, статичні методи, трейти й реалізовувати інтерфейси, але не може мати власних властивостей і стану.
Практику Laravel: cast моделі на backed enum, match у методі label(), Rule::enum у валідації.
Казати, що enum — це просто набір констант: константи не дають типізації параметра, а enum дає (OrderStatus $status).
Використовувати from() для даних із запиту чи API: невідоме значення кине \ValueError і 500 замість валідаційної помилки.
Порівнювати case зі скаляром: OrderStatus::Paid === 'paid' завжди false, потрібне ->value.
Пробувати додати властивість у enum (public string $label) — це фатальна помилка, enum не має стану; значення дають через backed value або метод.
Кастити в Eloquent pure enum: cast працює лише з backed enum, бо всередині викликається tryFrom().
Очікувати, що json_encode серіалізує будь-який enum: pure enum кидає виняток, бо не має представлення за замовчуванням.
ПОРАДА

Сформулюйте одним реченням: pure enum — це іменований перелік, backed enum — той самий перелік плюс міст до бази й API через value. І одразу додайте, що з зовнішніми даними використовуєте tryFrom(), а не from().

Сторінка питання →
PHP
Core PHP·Junior ·readonly ·immutability ·PHP 8.1

readonly дозволяє записати типізовану властивість рівно один раз зі скоупу класу; з PHP 8.2 модифікатор можна поставити на весь клас.

Як зробити обʼєкт незмінним без приватних властивостей і геттерів?
Чому не можна змінити властивість, хоча вона public?
Чим readonly відрізняється від const і від private з геттером?
Що робити, якщо потрібна копія обʼєкта з іншим значенням readonly-поля?

Модифікатор readonly зʼявився в PHP 8.1 і застосовується до типізованої властивості екземпляра. Правило одне: у таку властивість можна записати значення рівно один раз і лише з коду класу, який її оголосив. Тому найчастіша форма — просування властивостей у конструкторі: public function __construct(public readonly int $amount) {}. Значення за замовчуванням заборонене (public readonly int $x = 5; не скомпілюється), тип обовʼязковий, static readonly не існує. Спроба присвоїти вдруге або ззовні дає Error: Cannot modify readonly property Money::$amount, а unset()Error: Cannot unset readonly property.

Важливо не плутати readonly з const і з «незмінним обʼєктом». const — константа класу, спільна для всіх екземплярів і відома на етапі компіляції; readonly — значення конкретного обʼєкта, обчислене в рантаймі під час створення. І захист тут поверхневий: заборонено перезаписати саму властивість, а не те, що всередині. Масив у readonly-властивості змінити не можна ($this->items[] = $x — це модифікація властивості), а от у вкладеного обʼєкта цілком можна викликати мутуючий метод. Глибока незмінність досягається лише тим, що всередину кладуть скаляри або теж незмінні обʼєкти.

PHP 8.2 додав readonly class: усі властивості екземпляра стають readonly автоматично, нетипізовані й статичні властивості заборонені, динамічні властивості теж (#[AllowDynamicProperties] на такий клас поставити не можна), а нащадок readonly-класу зобовʼязаний бути readonly. Це майже готове визначення value object: readonly class OrderId { public function __construct(public string $value) {} }.

Найболючіше місце readonly — зміна значення в копії. Сам clone працював завжди, але в PHP 8.1–8.2 навіть __clone() не міг перезаписати readonly-властивість, тому глибоке копіювання доводилося емулювати конструктором. PHP 8.3 це виправив: під час __clone() readonly-властивості вважаються неініціалізованими й приймають один новий запис. PHP 8.5 пішов далі й дав синтаксис clone $obj with { amount: 50 }: нові значення застосовуються одразу після копіювання і до виклику __clone(), а перевірки видимості й readonly виконуються за скоупом, у якому стоїть вираз — тобто readonly-поле так само можна змінити лише зсередини класу. Ручні withX()-методи від цього не зникають, але їхнє тіло стискається до одного рядка.

Практичний критерій вибору такий. readonly замінює приватний сеттер (і зв’язку «private-властивість плюс геттер») там, де значення задається під час створення й більше ніколи не змінюється: ідентифікатори, гроші, DTO вхідного запиту, налаштування сервісу. Якщо ж значення має оновлюватися пізніше або обчислюватися ліниво, readonly лише заважає — жодного кешу чи memoize всередині такого обʼєкта не зробити. Для випадку «читають усі, пише лише клас, але не один раз» у PHP 8.4 є асиметрична видимість public private(set) int $x, а для обчислюваних значень — property hooks.

final class Money
{
    public function __construct(
        public readonly int $amount,        // типізована, без default, пишеться раз
        public readonly string $currency,
    ) {}

    public function withAmount(int $amount): self
    {
        // PHP 8.5: копія зі зміненим полем; readonly перезаписується
        // лише зі скоупу класу, який його оголосив
        return clone $this with { amount: $amount };
        // до 8.5: return new self($amount, $this->currency);
    }
}

$price = new Money(100, 'UAH');
echo $price->amount;          // читати можна звідусіль
// $price->amount = 200;      // Error: Cannot modify readonly property
// unset($price->amount);     // Error: Cannot unset readonly property

// PHP 8.2: усі властивості класу readonly автоматично
readonly class OrderId
{
    public function __construct(public string $value) {}
}

final class Order
{
    public function __construct(public readonly Money $total) {}

    public function __clone(): void
    {
        // PHP 8.1–8.2: Error; з 8.3 readonly можна перезаписати в __clone
        $this->total = clone $this->total;
    }
}
Що readonly зʼявився в PHP 8.1 для властивостей, а readonly class — у PHP 8.2, і що readonly-клас робить readonly всі свої властивості.
Що властивість має бути типізованою, без значення за замовчуванням, не static, і що записати її можна лише зі скоупу класу, який її оголосив, — навіть якщо вона public.
Що readonly не означає глибокої незмінності: обʼєкт усередині readonly-властивості можна мутувати, забороняється лише перезапис самого посилання.
Що зміна значення дає Error: Cannot modify readonly property, а не warning, і що unset() readonly-властивості теж кидає Error.
Практику клонування: у 8.1–8.2 __clone не може перезаписати readonly, з 8.3 може, а з 8.5 є `clone ... with { ... }` замість ручних withX()-конструкторів.
Писати `public readonly int $x = 5;` — readonly-властивість не може мати значення за замовчуванням, це помилка компіляції.
Оголошувати `public readonly $x;` без типу — «Readonly property must have type».
Присвоювати readonly-властивість ззовні класу (`$obj->x = 1;`) і чекати, що public це дозволить: ініціалізація можлива лише зі скоупу оголошення.
Вважати readonly аналогом const: const — це константа класу, обчислена на етапі компіляції й спільна для всіх, readonly — значення конкретного екземпляра, задане в рантаймі.
Ставити readonly на властивість, яку треба лениво заповнити або перерахувати пізніше: кеш чи memoize всередині обʼєкта після цього неможливий.
ПОРАДА

Скажіть, що readonly — це не «незмінний обʼєкт», а «властивість, у яку можна записати один раз зі скоупу класу», і одразу назвіть шлях зміни: новий екземпляр, а з PHP 8.5 — `clone $this with { ... }`.

Сторінка питання →
LR
Laravel·Junior ·service container ·DI ·провайдери

Контейнер відповідає за створення обʼєктів і підстановку залежностей: читає типи параметрів конструктора через рефлексію і рекурсивно створює їх.

Як Laravel розуміє, що передати в конструктор контролера?
Чим bind відрізняється від singleton?
Навіщо потрібні service providers?

Service container — це реєстр того, як створювати обʼєкти. Коли Laravel потрібен контролер, listener, job чи будь-який клас, він просить контейнер, а той через рефлексію читає типи параметрів конструктора й рекурсивно створює кожну залежність. Для конкретних класів з типізованими конструкторами реєстрація не потрібна.

Привʼязки у service provider потрібні там, де рефлексії недостатньо: інтерфейс, який треба звʼязати з реалізацією, скалярний параметр із конфігурації, обʼєкт із дорогою ініціалізацією. Три основні методи відрізняються життєвим циклом: bind дає новий екземпляр щоразу, singleton один на весь час життя контейнера, scoped один на запит. Контекстна привʼязка через when()->needs()->give() дозволяє дати різним споживачам різні реалізації того самого інтерфейсу.

Головна практична користь контейнера в тестах. Клас, який оголошує залежності в конструкторі, можна зібрати з фейковим репозиторієм одним викликом instance(), не чіпаючи код. Фасад чи app() всередині методу ховають залежність, і підмінити її складніше. Довгий список параметрів у конструкторі теж корисний сигнал: клас робить забагато.

// 1. Конкретний клас: реєстрація не потрібна, контейнер читає типи конструктора
final class InvoiceService
{
    public function __construct(
        private readonly InvoiceRepository $invoices,   // інтерфейс: потрібна привʼязка
        private readonly PdfRenderer $pdf,               // клас: створиться сам
    ) {}
}

// 2. Привʼязки у AppServiceProvider::register()
$this->app->bind(InvoiceRepository::class, EloquentInvoiceRepository::class);
$this->app->singleton(PdfRenderer::class, fn () => new PdfRenderer(config('pdf.binary')));
$this->app->scoped(RequestContext::class); // один на запит, безпечно під Octane

// 3. Контекстна привʼязка: різні реалізації для різних споживачів
$this->app->when(ReportExporter::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('reports'));

// 4. У тесті інтерфейс підміняється без правки коду
$this->app->instance(InvoiceRepository::class, new InMemoryInvoiceRepository);
Що autowiring базується на type-hint: контейнер дивиться на клас параметра конструктора, а не на імʼя змінної.
Що інтерфейс сам себе створити не може, тому для нього потрібна привʼязка у service provider: bind або singleton.
Різницю bind, singleton і scoped: новий обʼєкт щоразу, один на весь процес, один на запит.
Що контекстна привʼязка (when-needs-give) дозволяє дати різні реалізації різним споживачам.
Чому залежність у конструкторі краща за фасад або app() всередині методу: явні залежності, простіші тести, видно, що клас робить забагато.
Казати, що контейнер створює лише зареєстровані класи: конкретні класи з типізованими конструкторами створюються без жодної реєстрації.
Плутати singleton контейнера з патерном Singleton у класі: перший керується ззовні і легко підміняється в тестах.
Використовувати singleton для обʼєкта, який зберігає дані запиту: під Octane такий стан протече між користувачами.
Не розуміти, що скалярний параметр без значення за замовчуванням контейнер сам не розвʼяже й кине BindingResolutionException.
ПОРАДА

Покажіть, що розумієте різницю між bind і singleton і чому інтерфейс у конструкторі кращий за фасад у тестах. Згадайте scoped як відповідь на Octane.

Сторінка питання →
SQL
SQL·Junior ·тризначна логіка ·UNIQUE ·агрегати

NULL — це «невідомо», а не значення: будь-яке порівняння з ним дає UNKNOWN і рядок не проходить WHERE, агрегати мовчки пропускають NULL замість того щоб рахувати їх нулями, а UNIQUE вважає різні NULL різними, тому дублікати проходять.

Чому `WHERE bonus = NULL` не повертає жодного рядка?
Чому після додавання умови `status <> 'paid'` зі звіту зникли рядки, у яких статус узагалі не заповнений?
Скільки рядків з однаковим NULL можна вставити в колонку з UNIQUE індексом?
Чому AVG по колонці дає більше число, ніж очікує бухгалтерія?

NULL у SQL означає не «порожньо» і не «нуль», а «значення невідоме». Через це логіка стає тризначною: вираз може бути TRUE, FALSE або UNKNOWN. bonus = NULL — це питання «чи дорівнює невідоме число тисячі», відповідь на яке невідома, тому результат UNKNOWN. WHERE пропускає далі лише рядки з TRUE, тож і = NULL, і <> NULL завжди дають порожній результат — без помилки й без попередження. Заперечення теж не допомагає: NOT UNKNOWN — це знову UNKNOWN. Саме звідси найпоширеніший баг у звітах: після додавання умови status <> 'paid' із вибірки тихо зникають усі рядки, де status взагалі не заповнений. Перевіряти наявність значення можна лише через IS NULL та IS NOT NULL, а порівнювати дві потенційно порожні колонки — через IS NOT DISTINCT FROM у PostgreSQL або <=> у MySQL.

Той самий механізм ламає NOT IN з підзапитом. Якщо підзапит повернув хоч один NULL, вираз id NOT IN (1, NULL) розкладається в id <> 1 AND id <> NULL, друга частина назавжди UNKNOWN, і результат ніколи не стане TRUE. Запит повертає нуль рядків, хоча дані на місці. Безпечна заміна — NOT EXISTS, який працює через звичайне зіставлення рядків і на NULL не спотикається; альтернатива — прибрати NULL прямо в підзапиті через WHERE col IS NOT NULL. Це одна з тих помилок, які проходять код-рев'ю і виявляються вже на проді, бо запит синтаксично коректний.

В агрегатах правило інше: SUM, AVG, MIN, MAX і COUNT(col) просто пропускають NULL, а не підставляють нуль. Тому COUNT(*) рахує рядки, COUNT(bonus) — лише заповнені бонуси, а AVG(bonus) ділить суму на кількість непорожніх значень. Для трьох співробітників із бонусами 1000, NULL і 0 середнє буде 500, а не 333.33 — і саме тут звіт розходиться з очікуваннями бізнесу. Якщо порожнє значення за змістом дорівнює нулю, це треба сказати явно: AVG(COALESCE(bonus, 0)). Окремо варто памʼятати, що SUM по порожній вибірці повертає NULL, а не 0, тому в коді така сума теж потребує COALESCE.

А от GROUP BY, DISTINCT, UNION і віконні PARTITION BY користуються третім правилом: там усі NULL вважаються однаковими й потрапляють в одну групу. Виходить, що в одному запиті NULL може бути одночасно «не рівним самому собі» у WHERE і «рівним самому собі» у GROUP BY. Унікальний індекс належить до першої категорії: NULL для нього різні, тому UNIQUE (email) спокійно пропускає скільки завгодно рядків із порожнім email — і в MySQL, і в PostgreSQL (для контрасту, SQL Server дозволяє лише один NULL). З PostgreSQL 15 зʼявився явний перемикач UNIQUE NULLS NOT DISTINCT; у MySQL 8.4 такого немає, там або NOT NULL, або перевірка на рівні застосунку, або згенерована колонка з підстановкою.

Практичний висновок: NOT NULL має бути станом за замовчуванням, а nullable-колонка — свідомим рішенням для випадків, де «невідомо» справді окремий стан (deleted_at, closed_at). Для сум і лічильників краще NOT NULL DEFAULT 0, бо будь-яка арифметика з NULL дає NULL і помилка розповзається далі по обчисленнях. Компроміс у зворотний бік теж є: заміна NULL на «магічні» значення на кшталт 0, порожнього рядка чи '1970-01-01' робить дані брехливими та псує агрегати, тож замінювати NULL заглушками лише щоб уникнути IS NULL — гірше, ніж навчитися з ним працювати.

-- Дані: employees
-- id | name  | manager_id | bonus | email
--  1 | Оля   |       NULL |  1000 | [email protected]
--  2 | Іван  |          1 |  NULL | NULL
--  3 | Петро |       NULL |     0 | NULL

-- 1. Порівняння з NULL дає UNKNOWN, а WHERE пропускає лише TRUE
SELECT * FROM employees WHERE bonus = NULL;   -- 0 рядків завжди
SELECT * FROM employees WHERE bonus IS NULL;  -- Іван

-- 2. Заперечення не рятує: NOT(UNKNOWN) теж UNKNOWN
SELECT count(*) FROM employees WHERE bonus <> 1000;  -- 1 (Петро), Іван мовчки зник

-- 3. NOT IN по nullable-колонці не поверне нічого
SELECT * FROM employees WHERE id NOT IN (SELECT manager_id FROM employees);  -- 0 рядків
-- Правильно: NOT EXISTS коректно працює з NULL
SELECT e.* FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees m WHERE m.manager_id = e.id);

-- 4. Агрегати пропускають NULL, а не рахують його за 0
SELECT count(*)     AS rows_total,  -- 3
       count(bonus) AS with_bonus,  -- 2
       sum(bonus)   AS total,       -- 1000
       avg(bonus)   AS avg_bonus    -- 500, а не 333.33
FROM employees;

-- 5. GROUP BY і DISTINCT, навпаки, збирають усі NULL в одну групу
SELECT bonus, count(*) FROM employees GROUP BY bonus;  -- є рядок bonus = NULL

-- 6. UNIQUE вважає NULL різними: обидва рядки з email = NULL пройдуть
CREATE UNIQUE INDEX employees_email_uniq ON employees (email);
-- PostgreSQL 15+: заборонити повтори NULL явно
CREATE UNIQUE INDEX employees_email_strict ON employees (email) NULLS NOT DISTINCT;
Що SQL має тризначну логіку: TRUE, FALSE, UNKNOWN, і WHERE пропускає лише TRUE, тому і `= NULL`, і `<> NULL` дають порожній результат.
Що перевіряти треба через `IS NULL` / `IS NOT NULL`, а для порівняння двох значень, з яких обидва можуть бути NULL, є `IS NOT DISTINCT FROM` у PostgreSQL і оператор `<=>` у MySQL.
Що `COUNT(*)` рахує рядки, а `COUNT(col)`, `SUM`, `AVG` пропускають NULL: AVG ділить на кількість непорожніх значень, а не на кількість рядків.
Що GROUP BY і DISTINCT, навпаки, вважають усі NULL однією групою — це інше правило, ніж у порівняннях.
Що UNIQUE індекс у MySQL і PostgreSQL дозволяє скільки завгодно NULL, і що в PostgreSQL 15+ це вимикається через `UNIQUE NULLS NOT DISTINCT`.
Що `NOT IN` з підзапитом, який може повернути NULL, не поверне нічого, і замінюється на `NOT EXISTS`.
Писати `WHERE deleted_at = NULL` або `WHERE deleted_at != NULL` замість `IS NULL` / `IS NOT NULL`.
Вважати, що умова `status <> 'paid'` включає рядки з NULL у status: вони мовчки випадають зі звіту.
Думати, що `SUM(bonus)` рахує NULL як 0, а `AVG(bonus)` ділить на загальну кількість рядків.
Розраховувати, що UNIQUE індекс на nullable-колонці не пустить два порожні значення — пустить, і в MySQL, і в PostgreSQL.
Використовувати `NOT IN (SELECT ...)` по nullable-колонці й дивуватись порожньому результату замість `NOT EXISTS`.
Плутати NULL з порожнім рядком `''` або з 0: у SQL це три різні значення (в Oracle порожній рядок і є NULL, у MySQL і PostgreSQL — ні).
ПОРАДА

Скажіть одним реченням: «NULL — це не значення, а відсутність знання, тому порівняння з ним дає не FALSE, а UNKNOWN». І одразу назвіть три різні правила: у WHERE усі NULL різні, у GROUP BY і DISTINCT — однакові, в агрегатах — просто пропускаються.

Сторінка питання →
SF
Symfony·Junior ·HttpKernel ·EventDispatcher ·lifecycle

HttpKernel перетворює Request на Response, дорогою розсилаючи події: kernel.request (маршрутизація), kernel.controller і kernel.controller_arguments (резолвінг контролера й аргументів), kernel.view (якщо контролер повернув не Response), kernel.response, kernel.terminate після відправлення відповіді.

Що відбувається між public/index.php і моїм контролером?
У якому порядку йдуть kernel.request, kernel.controller, kernel.view, kernel.response, kernel.terminate?
Мій listener не бачить назви маршруту в атрибутах запиту — чому?
Як повернути відповідь ще до того, як викличеться контролер?

Уся Symfony зводиться до одного контракту: HttpKernelInterface::handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response. Фронт-контролер public/index.php (з Symfony 5.3 це кілька рядків, які повертають замикання, а решту робить компонент Runtime через vendor/autoload_runtime.php) створює Request::createFromGlobals(), віддає його ядру, відправляє отриманий Response і викликає terminate(). Клас Kernel із src/Kernel.php спершу завантажується — реєструє бандли й піднімає скомпільований контейнер, — а потім передає запит сервісу http_kernel. Саме HttpKernel виконує прохід від запиту до відповіді й у ключових точках розсилає події через EventDispatcher. Фреймворк тут нічого не «знає» наперед: маршрутизацію, безпеку, профайлер додають звичайні слухачі.

Порядок такий. kernel.request — найбагатша подія: тут RouterListener (пріоритет 32) зіставляє URL із маршрутом і кладе результат у $request->attributes (_route, _controller, параметри шляху), тут же працює firewall. Якщо будь-який слухач викличе $event->setResponse(), поширення події зупиняється й HttpKernel одразу переходить до kernel.response — контролер не викликається взагалі. Далі ControllerResolver перетворює _controller на callable, диспатчиться kernel.controller (контролер ще можна підмінити), ArgumentResolver збирає аргументи, диспатчиться kernel.controller_arguments — і лише тоді викликається ваш метод.

Що станеться з результатом, залежить від його типу. Повернули Response — Symfony одразу диспатчить kernel.response, де слухачі правлять заголовки, кладуть cookie, вставляють toolbar профайлера. Повернули щось інше — масив, DTO, сутність — диспатчиться kernel.view, і хтось має перетворити це на Response: атрибут #[Template] віддасть дані у Twig, у API-проєктах це робить слухач, що серіалізує обʼєкт у JSON. Якщо після kernel.view відповіді досі немає, ви отримаєте LogicException про те, що контролер має повертати Response. Виняток у будь-якому місці циклу (за $catch = true) веде до kernel.exception: слухачі можуть підставити свою відповідь, інакше ErrorListener рендерить сторінку помилки, а код береться з HttpExceptionInterface, якщо виняток його реалізує.

Останні два кроки часто плутають. kernel.finish_request закриває обробку конкретного запиту (важливо для під-запитів: він відновлює контекст батьківського запиту, наприклад локаль), а kernel.terminate спрацьовує вже після $response->send() — відповідь у користувача, а процес доробляє «хвіст». Тому змінювати відповідь там безглуздо, зате доречно відправити лист чи дописати статистику; щось справді довге має йти в чергу. І весь цей цикл повторюється для кожного під-запиту з render(controller(...)) та ESI, тому будь-який слухач, дія якого має бути одноразовою, починається з if (!$event->isMainRequest()) { return; }.

Практичний спосіб побачити все це самому: php bin/console debug:event-dispatcher kernel.request покаже реальний список слухачів із пріоритетами, а вкладка Events у веб-профайлері — які з них відпрацювали на конкретному запиті. Це швидше за читання схеми й одразу пояснює, чому ваш listener бачить або не бачить _route.

// src/EventSubscriber/MaintenanceSubscriber.php
final class MaintenanceSubscriber implements EventSubscriberInterface
{
    public function __construct(private MaintenanceMode $maintenance) {}

    public static function getSubscribedEvents(): array
    {
        return [
            // 33 > 32 (пріоритет RouterListener): маршрут ще не визначено, _route буде null
            KernelEvents::REQUEST => ['onRequest', 33],
            KernelEvents::RESPONSE => 'onResponse',
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return; // під-запити з render()/ESI не чіпаємо
        }

        if ($this->maintenance->isOn()) {
            // setResponse зупиняє поширення події: контролер не викличеться взагалі,
            // далі одразу піде kernel.response
            $event->setResponse(new Response('Технічні роботи', 503));
        }
    }

    public function onResponse(ResponseEvent $event): void
    {
        // сюди потрапляє і звичайна відповідь, і сторінка помилки з kernel.exception
        $event->getResponse()->headers->set('X-App', 'phpukraine');
    }
}

// src/Controller/ArticleController.php
#[Route('/articles/{slug}', name: 'article_show')]
public function show(Article $article): Response // обʼєкт підставив EntityValueResolver
{
    return $this->render('article/show.html.twig', ['article' => $article]);
}
Що вся робота фреймворку — це один контракт `HttpKernelInterface::handle(Request): Response`, а події лише вбудовуються в цей прохід.
Що маршрут визначає `RouterListener` на `kernel.request`, тому до цієї події атрибута `_route` в запиті ще немає.
Що `$event->setResponse()` на `kernel.request` зупиняє поширення події й контролер не викликається взагалі — так працюють firewall, maintenance mode, кеш.
Що `kernel.view` спрацьовує лише тоді, коли контролер повернув не `Response` (масив, обʼєкт), і саме там працює `#[Template]` чи серіалізація в API.
Що `kernel.terminate` виконується після відправлення відповіді користувачу і придатний для повільної роботи, а `kernel.exception` перетворює виняток на `Response`.
Що є головний запит і під-запити (`MAIN_REQUEST` / `SUB_REQUEST`), і listener зобовʼязаний перевіряти `$event->isMainRequest()`.
Казати, що контролер викликається одразу після роутингу: між ними ще `kernel.controller`, резолвінг аргументів і `kernel.controller_arguments`.
Читати `$request->attributes->get('_route')` у listener з високим пріоритетом на `kernel.request` і дивуватися `null`: `RouterListener` має пріоритет 32 і ще не відпрацював.
Очікувати `kernel.view`, коли контролер уже повернув `Response` — подія в цьому разі не диспатчиться зовсім.
Модифікувати відповідь у `kernel.terminate`: її вже відправлено, зміни нікуди не потраплять.
Забути `isMainRequest()` і виконати логіку авторизації чи логування вдруге на кожному `render()`-фрагменті Twig.
Плутати `Kernel` (бандли, конфіг, контейнер, бутстрап) з `HttpKernel` (сервіс, що реально ганяє події й викликає контролер).
ПОРАДА

Проговоріть ланцюжок уголос у порядку подій і назвіть для кожної реального слухача: `kernel.request` → `RouterListener` і `FirewallListener`, `kernel.controller_arguments` → резолвери аргументів, `kernel.view` → `#[Template]`, `kernel.exception` → `ErrorListener`, `kernel.terminate` → відправлення листів. Це одразу показує, що ви бачили стек, а не читали схему.

Сторінка питання →
LR
Laravel·Junior ·validation ·FormRequest ·ValidationException

Обидва шляхи запускають один і той самий Validator: `$request->validate()` робить це всередині методу контролера, Form Request — під час резолву аргументів, ще до входу в метод, і додає authorize(), prepareForValidation() і власні повідомлення. Інлайн — для двох-трьох простих правил, Form Request — щойно зʼявляються авторизація, нормалізація вхідних даних або повторне використання правил.

У який момент відпрацьовує Form Request — до контролера чи всередині нього?
Що станеться, якщо authorize() поверне false: помилка валідації чи 403?
Чому наш API на невалідні дані повертає 302, а не 422?
Ви зробили Form Request, а в базу все одно потрапило зайве поле — як так?
Валідація не пройшла, користувача повернуло на форму, але помилок на сторінці немає. Де шукати?

Обидва підходи роблять одну й ту саму роботу одним і тим самим класом: Illuminate\Validation\Validator отримує масив вхідних даних і масив правил, і при першій невідповідності кидає ValidationException. Різниця лише в тому, де цей виклик стоїть. $request->validate([...]) — це метод на реквесті, який виконується всередині тіла контролера, коли ви туди вже зайшли. Form Request — це клас-параметр методу, і Laravel валідує його раніше: коли роутер резолвить аргументи через сервіс-контейнер, спрацьовує трейт ValidatesWhenResolved, який після створення обʼєкта викликає validateResolved(). Тому при провалі в тіло методу керування взагалі не потрапляє — це не «магія», а просто інший момент виклику того самого валідатора.

Порядок усередині Form Request важливий і його часто питають: спершу prepareForValidation() (тут нормалізують вхід — обрізають пробіли, приводять slug, розбивають рядок на масив), потім authorize(), і лише потім будуються правила з rules(). Тобто якщо authorize() повернув false, правила навіть не запускаються, а користувач отримує AuthorizationException403, а не 422 зі списком помилок. Форму відповіді при провалі валідації обирає не контролер, а глобальний обробник винятків: для звичайного браузерного запиту це редірект back() із помилками та старим інпутом у сесії, для запиту з заголовком Accept: application/json — статус 422 і тіло з ключами message та errors. Звідси два класичних симптоми: «API повертає 302 замість 422» означає, що клієнт не надіслав Accept: application/json, а «редіректить назад, але помилок на сторінці немає» — що маршрут не в групі web, тобто немає сесії й middleware ShareErrorsFromSession, яке кладе $errors у в'ю.

Головна практична пастка не в тому, де стоїть виклик, а в тому, що валідація нічого не прибирає з реквесту. Form Request не є фільтром: після нього $request->all() містить рівно те саме, що надіслав клієнт, включно з полями, для яких правил не було. Тому Post::create($request->all()) лишається діркою, від якої рятує тільки $fillable, і правильний рядок — $request->validated() або $request->safe()->only([...]). Тут же живе друга пастка: validated() віддає лише ті ключі, на які були правила, і для масивів це означає, що без окремого рядка 'tags.*' => ['integer'] поле tags пройде перевірку array, але у validated() його не буде — форма мовчки збережеться без тегів.

Практичний критерій вибору простий і його варто озвучити саме як критерій, а не як «Form Request завжди краще». Інлайн validate() доречний, коли правил два-три, вони тривіальні й ніде більше не потрібні: пошук, фільтр у списку, підписка на розсилку. Form Request забирає задачу, щойно зʼявляється хоч одне з чотирьох: перевірка прав (authorize() замість if у контролері), нормалізація даних до перевірки (prepareForValidation()), кастомні повідомлення й назви полів (messages(), attributes()), або потреба перевикористати правила у двох місцях — типово Store*Request і Update*Request, де другий успадковує перший і додає Rule::unique(...)->ignore($this->route('post')). Додатково Form Request дає withValidator() для крос-польових перевірок і stopOnFirstFailure = true, коли не потрібен повний список помилок. Третій варіант — Validator::make() з фасаду — лишається для випадків, коли дані прийшли не з HTTP-запиту або треба самому вирішити, що робити з fails().

Межа підходу: Form Request прибитий до HTTP-запиту й контейнера, тож у консольній команді, черговій job чи сідері він не працює взагалі, а Livewire не проганяє його автоматично — там правила описує сам компонент або Form-обʼєкт (перевикористати можна через (new StorePostRequest)->rules()). Це нормально, поки ви памʼятаєте, що валідація — це перевірка межі, тобто «чи придатне те, що надіслав клієнт», а не заміна доменним інваріантам. Правило «сума замовлення не менша за нуль» має триматися незалежно від того, чи прийшли дані з форми, з імпорту CSV чи з консолі, тому його місце — у моделі або сервісі; Form Request же залишається тонким шаром, який ловить сміття на вході й повертає користувачу зрозумілі повідомлення.

final class StorePostRequest extends FormRequest
{
    // Викликається ДО rules(); false → AuthorizationException (403), правила не запускаються
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    /** @return array<string, list<mixed>> */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'slug' => ['required', 'alpha_dash', Rule::unique('posts', 'slug')],
            'published_at' => ['nullable', 'date'],     // без nullable порожнє поле впаде на date
            'tags' => ['array', 'max:5'],
            'tags.*' => ['integer', 'exists:tags,id'],  // без цього рядка tags не буде у validated()
        ];
    }

    // Нормалізація ДО перевірки: у rules() і в базу піде вже приведений slug
    protected function prepareForValidation(): void
    {
        $this->merge(['slug' => Str::slug((string) $this->input('slug'))]);
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return ['slug.unique' => 'Стаття з таким посиланням уже існує.'];
    }
}

final class PostController
{
    // Валідація пройшла ще до входу сюди: у методі лише чисті дані
    public function store(StorePostRequest $request): RedirectResponse
    {
        $post = Post::create($request->safe()->only(['title', 'slug', 'published_at']));
        $post->tags()->sync($request->validated('tags', []));  // ключа може не бути

        return to_route('posts.show', $post);
    }

    // Інлайн-варіант для двох простих правил: той самий Validator, лише інше місце виклику
    public function search(Request $request): View
    {
        $data = $request->validate(['q' => ['required', 'string', 'min:3']]);

        return view('posts.search', ['posts' => Post::search($data['q'])->paginate()]);
    }
}
Що Form Request валідується не в контролері, а в момент резолву залежності контейнером (трейт ValidatesWhenResolved), тому в тіло методу керування взагалі не переходить при провалі.
Що обидва варіанти кидають один і той самий ValidationException, а форму відповіді обирає обробник винятків: редірект назад із помилками в сесії для звичайного запиту і 422 з JSON для запиту з `Accept: application/json`.
Що authorize() виконується перед rules() і при false дає AuthorizationException → 403, а не 422.
Що працювати треба з `validated()`/`safe()`, а не з `$request->all()`: сам факт валідації нічого з реквесту не викидає.
Що для елементів масиву потрібне окреме правило `tags.*`, інакше поле не потрапить у validated().
Що prepareForValidation() нормалізує дані до перевірки, а не після, тож у rules() і в базу йде вже приведене значення.
Думати, що Form Request «фільтрує» реквест: після нього `$request->all()` і `$request->input('is_admin')` як були, так і лишились, тому `Post::create($request->all())` пропускає незаявлені поля.
Класти перевірку прав у rules() (наприклад, кастомним правилом), замість authorize() чи політики — і отримувати 422 там, де мало бути 403.
Викликати `$request->validate()` в контролері API і дивуватись 302: без заголовка `Accept: application/json` Laravel вважає запит браузерним і робить редірект назад.
Забути `nullable` для необовʼязкових полів: порожній `published_at` приходить як `null` або `''` і валиться на правилі `date`.
Писати правило `'tags' => ['array']` без `'tags.*' => [...]` — масив проходить валідацію, але `validated()` його не віддає.
Використовувати `$this->validate()` у Form Request або дублювати правила: одні в FormRequest, інші в контролері «про всяк випадок».
Виносити маршрут із групи `web` і чекати, що `$errors` зʼявиться у в'ю: помилки живуть у сесії, їх шарить middleware ShareErrorsFromSession.
ПОРАДА

Скажіть, де саме проходить межа: «інлайн `validate()` — коли правил два-три і вони більше ніде не потрібні; Form Request — коли зʼявляється авторизація, нормалізація вхідних даних, кастомні повідомлення або те саме правило потрібне в другому місці». І одразу додайте головне практичне: беру `validated()`, а не `all()`, бо валідація нічого з реквесту не викидає.

Сторінка питання →
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`.

Сторінка питання →
PHP
Core PHP·Junior ·closure ·use ·arrow function

Замикання — це обʼєкт класу Closure; `function` захоплює зовнішні змінні лише явно через `use` (за значенням або за посиланням через `&`), а стрілочна функція `fn` (PHP 7.4+) захоплює їх автоматично й завжди за значенням.

Чому змінна, яку я змінив після оголошення closure, усередині нього стара?
Чим `fn` відрізняється від `function` — це просто коротший запис?
Що таке `use (&$total)` і навіщо там амперсанд?
Чому в замиканні всередині класу працює `$this`, хоча ви його нікуди не передавали?

Замикання в PHP — це не окрема мовна конструкція «функція всередині функції», а звичайний обʼєкт класу Closure. Тому його можна покласти в змінну, передати аргументом, повернути з методу, зберегти у властивості й викликати як $fn(...). Ключова властивість — воно памʼятає частину контексту, у якому було оголошене. Але «памʼятає» не автоматично: анонімна функція, оголошена через function, бачить лише свої параметри, глобальні суперглобали й те, що ви явно перелічили в use. Це принципова відмінність від JavaScript, де замикання затягує всю зовнішню область видимості; у PHP список захоплення пишеться руками.

Найважливіший момент, на якому валяться на співбесідах: use ($x) спрацьовує в момент оголошення замикання, а не в момент виклику. PHP копіює поточне значення $x у внутрішнє сховище обʼєкта Closure, і подальші зміни зовнішньої змінної на нього не впливають — саме це показує перший блок коду. Якщо потрібен звʼязок в обидва боки, пишуть use (&$x): тоді замикання і зовнішній код працюють з однією змінною, і присвоєння всередині видно назовні. Окремо варто розрізняти семантику типів: копіювання змінної, що тримає обʼєкт, копіює лише ідентифікатор, тому зміни властивостей обʼєкта видно й без &; масив же копіюється по-справжньому, і зовнішнє додавання елемента в замикання не потрапить.

Стрілочні функції зʼявилися в PHP 7.4 і закривають найчастіший випадок — коротке перетворення в array_map, usort, колекціях Laravel. Синтаксис fn (аргументи) => вираження має три жорсткі властивості. По-перше, захоплення неявне: усі зовнішні змінні, згадані в тілі, захоплюються автоматично, use для fn не існує і є синтаксичною помилкою. По-друге, захоплення завжди за значенням — накопичувач на fn не побудувати, для цього лишається function () use (&$total). По-третє, тіло — рівно одне вираження, яке автоматично і повертається: ні {}, ні return, ні кількох інструкцій там бути не може. Типи параметрів і повернення при цьому дозволені (fn (int $n): int => ...), і зловживати їх пропуском не варто.

Окрема тема — $this. Замикання, оголошене в методі, автоматично привʼязує поточний обʼєкт і область видимості класу, тому всередині доступні навіть private-властивості — це видно на $withTitle у прикладі. Слово static перед function або fn цю привʼязку знімає: $this стає недоступним, а спроба привʼязати обʼєкт через bindTo() дасть попередження і null. Практичний мотив писати static — час життя: замикання зі звʼязаним $this утримує обʼєкт у памʼяті, і для довгоживучих callback-ів (реєстрація в контейнері, слухачі подій, кеш замикань) це зайве утримання. У зворотний бік працюють Closure::bind() / bindTo() / Closure::call(), які підставляють інший $this і scope; на цьому побудовані макроси Laravel.

Межі варто назвати чесно. Замикання не бачить змінних зовнішньої функції без use, і global тут не заміна — це інший механізм із іншими проблемами. fn не є «кращим function»: щойно потрібні дві інструкції, try/catch або посилання, повертаються до повної форми. І в обох випадках залишається питання читабельності списку захоплення: якщо в use більше двох-трьох змінних, це майже завжди сигнал, що замість замикання просився окремий метод або невеликий клас із __invoke().

$multiplier = 3;

// use копіює ЗНАЧЕННЯ в момент оголошення замикання
$byValue = function (int $n) use ($multiplier): int {
    return $n * $multiplier;
};

// fn (PHP 7.4+) захоплює автоматично і теж лише за значенням
$arrow = fn (int $n): int => $n * $multiplier;

$multiplier = 10;                 // змінюємо ПІСЛЯ оголошення
echo $byValue(2);                 // 6  — захоплено ще трійку
echo $arrow(2);                   // 6  — так само

// use (&$x): замикання і зовнішній код ділять одну змінну
$total = 0;
$accumulate = function (int $price) use (&$total): void {
    $total += $price;             // пишемо назовні
};
$accumulate(100);
$accumulate(50);
echo $total;                      // 150

final class Report
{
    private string $title = 'Звіт';

    /** @return array<int, string> */
    public function render(array $rows): array
    {
        // звичайне замикання само привʼязує $this — приватне поле доступне
        $withTitle = fn (string $row): string => "{$this->title}: {$row}";

        // static прибирає привʼязку: $this всередині недоступний
        $trim = static fn (string $row): string => trim($row);

        return array_map($withTitle, array_map($trim, $rows));
    }
}

$counter = Closure::bind(
    function (): string { return $this->title; },  // $this підставимо ззовні
    new Report(),
    Report::class                 // scope дає доступ до private
);
echo $counter();                  // 'Звіт'
Що замикання — це не «просто функція», а обʼєкт класу `Closure`: його можна покласти в змінну, передати аргументом, повернути з методу і викликати через `$fn()` або `$fn->call()`.
Що `use ($x)` копіює значення в момент оголошення замикання, а не в момент виклику — тому подальші зміни `$x` зовні на замикання не впливають.
Що `use (&$x)` захоплює за посиланням: замикання і зовнішній код працюють з однією змінною, і саме так накопичують суму або збирають результат.
Що обʼєкт, захоплений за значенням, усе одно лишається тим самим обʼєктом — копіюється змінна, а не те, на що вона вказує.
Що `fn` (PHP 7.4+) — це одне-єдине вираження, без `{}`, без `return`, з автоматичним захопленням по значенню, і посилання в ньому неможливе взагалі.
Що звичайне замикання, оголошене в методі, автоматично привʼязує `$this`, а `static function () {}` / `static fn () =>` цю привʼязку прибирають.
Казати, що `use` «передає змінну в функцію»: параметри передають при виклику, `use` захоплює при оголошенні — між цими моментами може пройти будь-який час.
Очікувати, що `$sum` зміниться після `$fn()`, коли написано `use ($sum)` без `&` — зовнішня змінна лишиться незмінною.
Ставити `&` «про всяк випадок» у циклі: усі замикання почнуть ділити одну змінну й після циклу віддаватимуть її останнє значення.
Думати, що `fn` захоплює за посиланням або що в нього можна дописати `use` — синтаксис `fn ($x) use ($y) => ...` є помилкою парсингу.
Писати в `fn` кілька інструкцій через `;` — стрілочна функція має рівно одне вираження, яке і є результатом.
Дивуватись «Using $this when not in object context» у замиканні, яке оголосили через `static function`, або тому, що `$this` витік у замикання, збережене в контейнері й тримає обʼєкт від збирання.
ПОРАДА

Одна фраза, яка закриває питання: «`function () use ($x)` — явне захоплення, за значенням, або за посиланням через `&`; `fn () => $x` — неявне захоплення, тільки за значенням, тільки одне вираження». Далі додайте, що `$this` захоплюється в обох, і зняти його можна словом `static`.

Сторінка питання →
WP
WordPress·Junior ·ієрархія шаблонів ·child theme ·template-loader

Після резолву запиту wp-includes/template-loader.php перебирає умовні теги (is_404, is_search, is_front_page, is_home, is_singular, is_archive…) і для першого збігу будує список кандидатів від найконкретнішого до index.php; locate_template() шукає кожного кандидата спершу в child theme, потім у parent, тому перекриття — це файл із такою самою назвою в дочірній темі.

Я поклав у тему файл page-contacts.php, а сторінка «Контакти» все одно рендериться через page.php. Чому?
Який файл теми відповість за URL /category/news/, якщо в темі є index.php, archive.php і category.php?
Чому після оновлення батьківської теми зникли всі мої правки в single.php?
У чому різниця між get_template_directory() і get_stylesheet_directory()?

Коли WordPress відпрацював WP_Query і знає, що саме запитали, керування отримує wp-includes/template-loader.php. Він послідовно перевіряє умовні теги — is_embed(), is_404(), is_search(), is_front_page(), is_home(), is_post_type_archive(), is_tax(), is_attachment(), is_single(), is_page(), is_singular(), is_category(), is_tag(), is_author(), is_date(), is_archive() — і для першого збігу викликає відповідну get_*_template(). Ця функція не шукає файл сама: вона будує масив імен-кандидатів від найконкретнішого до найзагальнішого і передає його в get_query_template(), а той у locate_template(). Тобто «ієрархія» — це буквально впорядкований масив рядків, і будь-який запит завершується щонайпізніше на index.php, який тому й обовʼязковий для валідної теми.

Конкретність кандидатів рахується від даних поточного обʼєкта. Для одного запису це single-{post_type}-{post_name}.php, single-{post_type}.php, single.php, singular.php, index.php; для сторінки — спершу кастомний шаблон із метаполя _wp_page_template, далі page-{slug}.php, page-{id}.php, page.php, singular.php; для категорії — category-{slug}.php, category-{id}.php, category.php, archive.php. Звідси дві типові пастки джуна: слаг береться з post_name, а не з заголовка, тому сторінка «Контакти» з URL /kontakty/ шукає page-kontakty.php; і ID/слаг беруться саме поточного терміна, без успадкування від батьківської категорії. Окремо стоїть головна: front-page.php перекриває все, а якщо його немає, вибір залежить від налаштування «Головна сторінка відображає» — статична сторінка йде в page-гілку, стрічка постів у home.php.

Дочірня тема виграє не тому, що має якийсь пріоритет, а через порядок пошуку всередині locate_template(): спершу STYLESHEETPATH (активна, тобто дочірня, тема), потім TEMPLATEPATH (батьківська), потім wp-includes/theme-compat. Тому перекриття будь-якого шаблону — це просто файл з такою самою назвою в теці дочірньої теми, без жодної реєстрації. Сама дочірня тема — це тека з style.css, у заголовку якого є рядок Template: з іменем теки батьківської теми. Стилі батьківської теми дочірня має підключити сама через wp_enqueue_style на хуку wp_enqueue_scripts — застарілий @import у CSS сповільнює завантаження і його давно не використовують.

Виняток один, але важливий: functions.php через locate_template() не проходить. WordPress підключає обидва файли — спершу дочірній, потім батьківський, — тому копіювати батьківський цілком не можна, це фатальна помилка про повторне оголошення. Замінити функцію батьківської теми вдасться лише тоді, коли вона обгорнута в if (! function_exists()); інакше залишаються remove_action, remove_filter або передбачені темою фільтри. Так само не перекриється частина шаблону, яку батьківська тема підключає жорстким include get_template_directory().'/...' замість get_template_part() — це найчастіша причина «чому мій файл ігнорується».

Коли файлу створювати не хочеться або код живе в плагіні, у вибір можна втрутитися фільтрами: {$type}_template_hierarchy (зʼявився у WP 4.7) міняє список кандидатів до пошуку, {$type}_template — уже знайдений шлях, а template_include спрацьовує останнім і перекриває всіх. Обмеження теж варто памʼятати: у блокових темах (WP 5.9 і новіші) та сама логіка додатково шукає HTML-файли в теці templates/ і шаблони, збережені користувачем у базі як записи wp_template, і знайдений блоковий шаблон має перевагу над однойменним PHP-файлом. І практична порада замість зубріння схеми: повісьте на template_include логер або поставте Query Monitor — він показує і перелік кандидатів, і файл-переможець.

// wp-content/themes/parent-child/style.css має містити заголовок:
// Theme Name: Parent Child
// Template: parent      <- імʼя ТЕКИ батьківської теми, не її назва

// functions.php дочірньої теми: він не перекриває батьківський, а додається до нього
add_action('wp_enqueue_scripts', function (): void {
    // get_template_directory_uri() = батьківська тема, get_stylesheet_* = активна (дочірня)
    wp_enqueue_style('parent-style', get_template_directory_uri().'/style.css');

    wp_enqueue_style(
        'child-style',
        get_stylesheet_uri(),
        ['parent-style'], // вантажимо після батьківського, щоб правила перебивали
        wp_get_theme()->get('Version')
    );
});

// Перекриття файлом: wp-content/themes/parent-child/single-product.php
// Достатньо однакової назви — locate_template() перевіряє дочірню тему першою.

// Частини шаблону підключаємо так, щоб їх теж можна було перекрити з дочірньої теми
get_template_part('parts/product', 'card'); // шукає parts/product-card.php, потім parts/product.php
// а НЕ: include get_template_directory().'/parts/product-card.php'; — це жорстко батьківська тема

// Додати власного кандидата перед стандартними, без правки ядра теми
add_filter('single_template_hierarchy', function (array $templates): array {
    if (has_term('sale', 'product_cat')) {
        array_unshift($templates, 'single-product-sale.php');
    }

    return $templates; // фільтр мусить повернути масив
});

// Діагностика: який саме файл виграв
add_filter('template_include', function (string $template): string {
    error_log('template: '.$template); // спрацьовує останнім, після всіх *_template

    return $template;
}, PHP_INT_MAX);
Що ієрархія — це не магія, а список кандидатів: get_query_template() віддає масив імен від конкретного до загального, а locate_template() бере перший наявний файл.
Що child theme виграє не через «пріоритет теми», а через порядок пошуку в locate_template(): STYLESHEETPATH (дочірня), потім TEMPLATEPATH (батьківська), потім theme-compat.
Що functions.php — виняток: він не перекривається, а завантажується додатково, причому дочірній раніше за батьківський.
Що index.php обовʼязковий і є останнім кандидатом для будь-якого запиту, тому «білий екран» через ієрархію не буває — буває неправильний файл.
Що для front page і блогу правила різні: front-page.php перекриває обидва варіанти, а далі все залежить від налаштування «Головна сторінка відображає» — статична сторінка йде в page-ієрархію, стрічка постів у home.php.
Що перекрити вибір можна й без файлу: фільтрами {$type}_template_hierarchy (з WP 4.7), {$type}_template і template_include, який спрацьовує останнім.
Плутати page-{slug}.php і page-{id}.php з назвою заголовка: слаг береться з post_name, тому сторінка «Контакти» з URL /kontakty/ шукає page-kontakty.php, а не page-contacts.php.
Правити файли батьківської теми напряму й втрачати зміни при оновленні.
Копіювати в дочірню тему functions.php батьківської цілком — отримуєте фатальну помилку «Cannot redeclare function», бо обидва файли виконуються.
Підключати частини шаблону через include get_template_directory().'/parts/card.php' замість get_template_part('parts/card') — жорсткий шлях у батьківську тему вимикає перекриття з дочірньої.
Вважати, що category-5.php спрацює для дочірньої категорії: ієрархія бере слаг і ID саме поточного терміна, без успадкування від батьківського.
Забувати рядок Template: у style.css дочірньої теми або писати туди назву теми замість імені теки батьківської.
ПОРАДА

Скажіть, що будь-яку сторінку можна продіагностувати за пів хвилини: у дочірній тезі повісити на template_include функцію, яка логує обраний файл, або поставити Query Monitor — він показує і список кандидатів, і переможця. Це сильніше за перелік файлів напамʼять.

Сторінка питання →
LR
Laravel·Junior ·lifecycle ·middleware ·bootstrap/app.php

public/index.php підключає автолоадер і bootstrap/app.php, зібраний застосунок віддає запит HTTP-ядру: воно виконує бутстрапери (env, config, фасади, провайдери), проганяє запит через глобальні middleware, роутер знаходить маршрут, запускає його middleware й контролер, а відповідь повертається тим самим ланцюгом назад до send() і terminate().

Розкажіть життєвий цикл запиту в Laravel 11: що відбувається після того, як nginx віддав керування PHP?
Де у Laravel 11 подівся app/Http/Kernel.php і куди тепер додавати middleware?
У якому місці циклу спрацьовує middleware з групи web — до чи після того, як роутер знайшов маршрут?
Хто перетворює масив, який повернув контролер, на JSON-відповідь?

Точка входу — public/index.php. Він підключає автолоадер Composer і файл bootstrap/app.php, який повертає вже зібраний обʼєкт Illuminate\Foundation\Application, тобто service container застосунку. Далі викликається handleRequest(Request::capture()): Request::capture() будує обʼєкт запиту з суперглобальних масивів PHP, а handleRequest дістає з контейнера HTTP-ядро (Illuminate\Foundation\Http\Kernel) і виконує три кроки — handle($request), send() на отриманій відповіді та terminate(). Ядро першим ділом проганяє бутстрапери у фіксованому порядку: читання .env, завантаження конфігурації, встановлення обробників помилок, реєстрація фасадів, реєстрація провайдерів, boot() провайдерів. Саме тому в register() не можна нічого резолвити: на цьому кроці інші провайдери ще не зареєстровані.

У Laravel 11 і 12 усе налаштування зібрано в bootstrap/app.php через Application::configure(). Класів app/Http/Kernel.php і app/Console/Kernel.php у застосунку більше немає, як і масивів middleware та providers у config/app.php. Замість них — withRouting() (файли маршрутів, health-маршрут /up), withMiddleware() (глобальний стек, групи web і api, псевдоніми, пріоритети) та withExceptions() (звітування й рендеринг помилок). Провайдери застосунку перелічені окремо у bootstrap/providers.php, а провайдери пакетів фреймворк знаходить сам через package discovery і кешує у bootstrap/cache/packages.php. Група api існує лише після php artisan install:api — за замовчуванням у новому проєкті є тільки routes/web.php.

Коли застосунок піднятий, запит іде в Pipeline глобальних middleware. Вони бачать його ще до того, як хтось знає маршрут: тут місце для примусового HTTPS, request-id, обрізання пробілів. Потім dispatchToRouter передає запит роутеру, той шукає збіг за методом і URI, і вже для знайденого маршруту запускає його групові та маршрутні middleware — сесію, CSRF, auth, SubstituteBindings, який перетворює {post} в URL на модель. Тільки після цього контейнер створює контролер і резолвить залежності його методу за type hint. Повернене значення нормалізується роутером: рядок стає Response, масив або ArrayableJsonResponse, обʼєкт із Responsable віддає toResponse($request).

Далі відповідь рухається у зворотному напрямку — через ті самі middleware, але вже через код після $next($request). На цьому проході додаються заголовки й кукі, зберігається сесія. Це і є найкорисніша метафора для співбесіди: middleware — цибулина, а не список фільтрів. Будь-який шар може не викликати $next і повернути власну відповідь — так працюють редіректи гостя на форму входу й abort(403). Після цього send() віддає заголовки й тіло клієнту, а terminate() викликає terminate() у terminable middleware і відкладені через defer() колбеки. Під PHP-FPM користувач на цей момент відповідь уже отримав, але процес усе ще зайнятий: черга це не замінює.

Межі такої моделі варто знати навіть джуну. Увесь описаний цикл — це один запит на один процес: після terminate() PHP знищує обʼєкти й контейнер, тож стан між запитами не тече. Саме тому статичні змінні та властивості синглтонів здаються безпечними — під звичайним FPM вони живуть мілісекунди. Під Laravel Octane застосунок піднімається один раз і залишається в памʼяті, бутстрап не повторюється, і ті самі синглтони починають зберігати чужі дані. Кеш конфігурації (config:cache) і маршрутів (route:cache) також скорочують крок бутстрапу, але роблять env() поза config/ порожнім — це наслідок саме того, що LoadEnvironmentVariables при закешованому конфізі не робить нічого корисного для коду застосунку.

// public/index.php у Laravel 11/12 — по суті два рядки роботи
require __DIR__.'/../vendor/autoload.php';
(require_once __DIR__.'/../bootstrap/app.php')->handleRequest(Request::capture());
// handleRequest бере HTTP-ядро з контейнера, викликає handle(), send() і terminate()

// bootstrap/app.php — єдине місце налаштування замість двох Kernel-класів
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',      // маршрути з групою web
        commands: __DIR__.'/../routes/console.php',
        health: '/up',                          // готовий health-check маршрут
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->append(EnsureRequestId::class);   // глобальний: до роутера
        $middleware->web(append: [SetLocale::class]);  // лише для групи web
        $middleware->alias(['subscriber' => EnsureUserIsSubscribed::class]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->dontReport(PaymentDeclinedException::class);
    })->create();

// Middleware — «цибулина»: до $next іде запит, після $next — уже відповідь
final class SetLocale
{
    public function handle(Request $request, Closure $next): Response
    {
        App::setLocale($request->user()?->locale ?? 'uk'); // ще до контролера

        if ($request->hasHeader('X-Blocked')) {
            return response('Заборонено', 403); // ланцюг обірвано, контролера не буде
        }

        $response = $next($request); // тут уже відпрацювали роутер і контролер

        return $response->header('Content-Language', App::getLocale()); // зворотний прохід
    }
}
Послідовність без пропусків: index.php → autoload → bootstrap/app.php → HTTP-ядро → бутстрапери → глобальні middleware → роутер → middleware маршруту → контролер → відповідь назад через middleware → send() → terminate().
Що в Laravel 11 і 12 bootstrap/app.php замінив app/Http/Kernel.php, app/Console/Kernel.php і масиви middleware та providers з config/app.php.
Що провайдери підключаються у два проходи: спершу register() у всіх, потім boot() у всіх; список застосунку — у bootstrap/providers.php, пакетні знаходяться через package discovery.
Що middleware двонаправлений: код до $next($request) бачить запит, код після — уже готову відповідь, і будь-який middleware може обірвати ланцюг, не викликавши $next.
Що контролер не зобовʼязаний повертати об'єкт Response: рядок, масив, Arrayable чи Responsable роутер сам приводить до Response або JsonResponse.
Казати, що index.php підключає app/Http/Kernel.php: у Laravel 11+ цього класу в застосунку немає, ядро живе у фреймворку, а налаштування — у bootstrap/app.php.
Вважати, що middleware з групи web виконується до роботи роутера: групові й маршрутні middleware запускаються вже після того, як маршрут знайдено, до них ідуть тільки глобальні.
Плутати register() і boot(): резолвити в register() сервіси або читати конфіг, який ще міг не завантажитись іншим провайдером.
Думати, що відповідь формує лише контролер, і не згадувати зворотний прохід, де middleware додає заголовки, кукі й зберігає сесію.
Плутати terminate() з чергою: він виконується у тому ж процесі після відправки відповіді, без ретраїв, і важку роботу туди класти не можна.
ПОРАДА

Опишіть цикл як цибулину: запит іде всередину крізь шари middleware до контролера, а відповідь повертається тими самими шарами назовні. Окремо назвіть bootstrap/app.php як єдину точку налаштування в Laravel 11+ і два проходи провайдерів — register(), потім boot().

Сторінка питання →
LR
Laravel·Junior ·Eloquent ·звʼязки ·polymorphic

Тип звʼязку визначає не «логіка», а те, де лежить ключ: hasOne/hasMany — ключ у дочірній таблиці, belongsTo — у поточній, belongsToMany — у проміжній, morph* — пара колонок *_id і *_type, коли дитина належить кільком типам батьків.

Де фізично лежить зовнішній ключ у hasMany, а де в belongsTo?
У нас коментарі і до статей, і до відео — як це змоделювати?
Чому Laravel шукає таблицю post_tag, хоча в нас вона називається tags_posts?
Що поверне $post->tags, а що $post->tags()?

Усі звʼязки Eloquent — це одна й та сама річ: домовленість про те, де лежить зовнішній ключ і як за ним побудувати запит. belongsTo означає «ключ у моїй таблиці»: у posts є колонка author_id, і Laravel вгадує її з назви методу — метод author() дає author_id, метод user() дав би user_id. hasOne і hasMany — дзеркальний бік: ключ лежить у чужій таблиці, і його імʼя вгадується вже з назви батьківської моделі, тобто Postcomments.post_id. Різниця між hasOne і hasMany лише в тому, чи повертається один запис, чи колекція; схема бази в обох випадках однакова. Тому питання «що обрати» майже завжди зводиться до питання «де фізично живе колонка».

belongsToMany зʼявляється тоді, коли ключ не вміщається ні в одну з двох таблиць: пост має багато тегів і тег має багато постів, тож потрібна третя, проміжна таблиця. За замовчуванням Laravel шукає її під іменем з двох імен моделей в однині, snake_case і в алфавітному порядку: post_tag, role_user. Якщо таблиця називається інакше, її передають другим аргументом. Проміжна таблиця не має власної моделі: додаткові колонки треба явно перелічити в withPivot(), інакше $tag->pivot->sort буде порожнім, а withTimestamps() вмикає created_at/updated_at у самому pivot. Для запису використовують attach() (додати), detach() (зняти) і sync() (лишити рівно передані id) — саме sync(), а не цикл з attach(), бо attach() не перевіряє наявність і спокійно створює дублікати.

Поліморфні звʼязки (morphOne, morphMany, morphTo, morphToMany) потрібні в одному конкретному випадку: коли дочірня сутність має належати кільком різним типам батьків. Класика — коментарі, лайки, вкладення, які чіпляються і до постів, і до відео. Замість двох nullable-колонок post_id і video_id зберігається пара commentable_id + commentable_type, і commentable_type каже, у якій таблиці шукати. Значення типу за замовчуванням — повне імʼя класу, а це погана ідея: перейменували модель або перенесли її в інший неймспейс — і старі рядки перестали резолвитись. Тому в AppServiceProvider::boot() одразу оголошують Relation::enforceMorphMap() з короткими аліасами; enforceMorphMap на відміну від morphMap ще й кидає виняток, коли зберігають модель, якої в мапі немає.

Ціна polymorphic — цілісність. На колонку, яка вказує то в posts, то в videos, неможливо поставити foreign key constraint, отже база не захистить вас від «висячих» рядків і не зробить каскадного видалення — це доводиться робити руками або через deleting-хук. Друга плата — читання: with('commentable') на morphTo не дає один додатковий запит, як звичайний eager loading, а групує записи за типом і робить окремий запит на кожен тип (обмежити вибірку полів можна через morphWith()). Тому поліморфний звʼязок беруть тоді, коли типів справді багато й вони ростуть; для двох стабільних варіантів дві звичайні таблиці або окремі звʼязки часто виходять простішими й швидшими.

І останнє, на чому валяться на співбесіді: $post->tags і $post->tags() — різні речі. Без дужок це властивість, яка ліниво завантажує звʼязок і повертає Collection; з дужками — обʼєкт звʼязку, тобто query builder, на якому можна далі фільтрувати ($post->tags()->where('active', true)->get()) і рахувати на боці бази ($post->comments()->count()). Саме тому $post->comments->count() — помилка продуктивності: він тягне всі коментарі в память, щоб порахувати їх у PHP, тоді як для списків є withCount('comments'), а для вже завантаженої моделі — loadCount().

final class Post extends Model
{
    // FK author_id лежить у таблиці posts; ключ угадується з назви МЕТОДУ
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    // FK commentable_id + commentable_type лежать у comments:
    // той самий коментар може належати посту або відео
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    // Проміжна таблиця post_tag: два імені в однині, алфавітний порядок
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class)
            ->withPivot('sort')   // доступно як $tag->pivot->sort
            ->withTimestamps();   // created_at/updated_at у post_tag
    }
}

// AppServiceProvider::boot(): у commentable_type лягає 'post', а не FQCN
Relation::enforceMorphMap([
    'post' => Post::class,
    'video' => Video::class,
]);

$post->tags()->sync([3, 7, 11]);              // лишає рівно ці теги
$post->tags()->syncWithoutDetaching([12]);    // додає, нічого не знімаючи

// Один запит на пости, по одному на author і tags,
// а morphTo всередині comments дасть ще по запиту на КОЖЕН тип
$posts = Post::withCount('comments')->with(['author', 'tags'])->get();
Що вибір диктує розташування зовнішнього ключа: belongsTo — FK у моїй таблиці, hasMany — FK у чужій, це два боки одного звʼязку.
Що belongsTo вгадує ключ з назви методу (`author()` → `author_id`), а hasOne/hasMany — з назви батьківської моделі (`Post` → `post_id`).
Що belongsToMany за замовчуванням шукає таблицю з двох імен моделей в однині й алфавітному порядку (`post_tag`, `role_user`), а додаткові колонки треба явно оголосити через withPivot().
Що morph зберігає тип у колонці `*_type`, тому зовнішній ключ на рівні бази неможливий, а значення типу варто фіксувати через Relation::enforceMorphMap().
Що `$post->tags` — це властивість-колекція (ліниве завантаження), а `$post->tags()` — це query builder, на якому можна далі фільтрувати й рахувати.
Брати belongsToMany там, де вистачає hasMany: створюють pivot для звʼязку «один пост — багато коментарів», хоча ключ спокійно лежить у comments.post_id.
Робити все поліморфним «про запас»: втрачається FK-констрейнт і каскадне видалення, а замість двох чесних таблиць виходить одна смітникова.
Зберігати в `commentable_type` повне імʼя класу, а потім перенести модель в інший неймспейс — половина рядків стає непридатною.
Плутати attach() і sync(): attach() у циклі створює дублікати в pivot, бо не перевіряє наявність, а sync() лишає рівно передані id.
Рахувати через `$post->comments->count()`: це витягує всі рядки в память, замість `$post->comments()->count()` або withCount('comments').
Оголошувати belongsToMany і потім дивуватись, що `$tag->pivot->sort` порожній, бо колонку не додали в withPivot().
ПОРАДА

Не переказуйте список звʼязків — покажіть, що ви думаєте схемою бази: «зовнішній ключ у мене — belongsTo, у сусіда — hasMany, у третьої таблиці — belongsToMany, а якщо той самий коментар має належати посту й відео — morphMany з morphMap». І одразу згадайте, що morph коштує втрати foreign key constraint.

Сторінка питання →
PHP
Core PHP·Junior ·трейти ·ООП ·композиція

Трейт — механізм горизонтального повторного використання коду: набір методів і властивостей, який компілятор копіює в клас.

Чим трейт відрізняється від абстрактного класу й інтерфейсу?
Що буде, якщо два трейти мають метод з однаковою назвою?
Чи є трейти множинним наслідуванням?

Трейт — механізм горизонтального повторного використання коду. Компілятор бере методи й властивості трейта та вставляє їх у клас так, ніби вони були написані в ньому. Клас може підключити кілька трейтів, і саме цим PHP компенсує відсутність множинного наслідування.

Важлива різниця з класами й інтерфейсами: трейт не є типом. По ньому не можна зробити type-hint, instanceof завжди поверне false, а сам трейт неможливо створити. Якщо потрібен контракт, трейт доповнюють інтерфейсом: інтерфейс обіцяє метод, трейт постачає реалізацію за замовчуванням.

Конфлікти вирішуються явно. Якщо два трейти мають однаковий метод, PHP не вгадує, а вимагає вказати insteadof, і за потреби дає доступ до другої реалізації через as. Той самий as змінює видимість методу.

Межа застосування проста: трейт добре працює як технічна домішка без власного стану, наприклад логування, timestamps, серіалізація. Коли в трейт починають потрапляти бізнес-правила й залежності на кшталт $this->db, код стає непрозорим: клас отримує поведінку, походження якої не видно в його оголошенні. У таких випадках краще композиція через окремий сервіс у конструкторі.

trait Loggable
{
    // Трейт може вимагати від класу метод, не знаючи його реалізації
    abstract protected function logger(): LoggerInterface;

    public function log(string $message): void
    {
        $this->logger()->info(static::class.': '.$message);
    }
}

trait Timestamps
{
    public function touch(): void { $this->updatedAt = new DateTimeImmutable; }
}

final class Order
{
    use Loggable, Timestamps {
        Timestamps::touch as protected markUpdated; // псевдонім зі зміною видимості
    }

    private DateTimeImmutable $updatedAt;

    public function __construct(private LoggerInterface $logger) {}

    protected function logger(): LoggerInterface { return $this->logger; }
}

$order instanceof Loggable; // false: трейт не є типом
Що трейт не є типом: його не можна інстанціювати, по ньому не можна робити type-hint, він лише вставляє код у клас на етапі компіляції.
Що конфлікти методів вирішуються явно через insteadof і as, а не за порядком use.
Що трейт може оголошувати абстрактні методи та статичні властивості й тим самим вимагати від класу контракт.
Межу застосування: технічна домішка без стану (логування, timestamps, серіалізація) доречна, а доменну поведінку краще виносити в окремий сервіс через композицію.
Називати трейт множинним наслідуванням: у трейта немає власного місця в ієрархії, і instanceof по ньому не працює.
Ховати в трейти залежності: метод трейта звертається до $this->db, якого в класі може не бути, і помилка спливає лише в рантаймі.
Робити один величезний трейт HasEverything, який роздувають десятки класів, замість кількох невеликих.
Не знати, що властивість трейта і властивість класу з однаковою назвою мусять мати сумісне оголошення, інакше fatal error.
ПОРАДА

Хороша відповідь містить межу застосування: трейт доречний для технічної домішки без власного стану, а поведінку домену краще виносити в окремий сервіс. Наведіть приклад з Laravel: SoftDeletes або HasFactory.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти