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

Підготовка до співбесіди

77 питань з відповідями, флеш-картками й тестом. Фреймворки, SQL, суміжні технології — те, що реально питають на співбесідах в українських компаніях.

Тема
Рівень
77 питань
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.

Сторінка питання →
ARC
Архітектура·Middle ·репозиторій ·Eloquent ·Doctrine

Репозиторій виправданий, коли він виглядає як колекція агрегатів у мові домену й тримає доменний шар без Illuminate чи Doctrine; він шкодить, коли перетворюється на generic CRUD-обгортку, що повертає той самий Builder або модель і лише додає шар.

Навіщо репозиторій, якщо Eloquent-модель уже вміє where() і find()?
Ви пишете репозиторії, щоб потім замінити MySQL на MongoDB?
Doctrine вже дає EntityRepository — навіщо ще один шар поверх нього?
Чому у вашому репозиторії зʼявився метод findActiveByCityPaginatedSortedBySalary()?

Питання зводиться до того, що саме патерн обіцяє. Репозиторій — це колекціє-подібний інтерфейс над агрегатами: ти кладеш туди обʼєкт і дістаєш обʼєкт, не знаючи, чи за ним таблиця, чи HTTP-API. Ключове слово — «не знаючи»: якщо метод повертає Illuminate\Database\Eloquent\Builder або колекцію моделей, знання про ORM просто переїхало на рівень вище, і жодної інверсії залежності не сталося. Тому першою ознакою корисного репозиторію є його інтерфейс: ofId(), publishedIn(), save() читаються без згадки про ORM, а findAllWhere(array $criteria) — ні.

Друга половина відповіді — різниця в тому, поверх чого ви будуєте шар. Eloquent реалізує Active Record: модель сама вміє себе зберігати, save() летить у базу негайно, а $model->relation тягне ленивий запит. Doctrine реалізує Data Mapper: сутність — звичайний PHP-обʼєкт, зміни накопичує Unit of Work, а в базу вони йдуть на EntityManagerInterface::flush(). Через це в Doctrine репозиторій уже вбудований (EntityRepository, у Symfony — ServiceEntityRepository з doctrine/doctrine-bundle) і відповідає лише за читання: писати другу обгортку поверх нього безглуздо, достатньо, щоб цей клас реалізовував доменний інтерфейс. А ось для Eloquent репозиторій — це справжня робота: треба мапити модель у доменний обʼєкт і назад, бо інакше ви віддаєте назовні persistence-обʼєкт і абстракція існує лише на папері.

Коли шар справді потрібен. Перше — коли в проєкті є доменний і аплікаційний шари, яким заборонено бачити фреймворк (у цьому репозиторії це прямо перевіряють архітектурні тести). Друге — коли складна логіка вибірки має назву в мові бізнесу і повторюється в кількох місцях. Третє — тести: in-memory реалізація того самого інтерфейсу дає швидкі юніт-тести use case без бази, і це часто головна практична вигода. А от аргумент «підмінимо базу» на співбесіді працює проти вас: зміна MySQL на PostgreSQL чи документну базу міняє запити, типи й гарантії транзакцій, і одним новим класом не обійдеться.

Коли шкодить. Generic BaseRepository з find/all/create/update/delete для кожної моделі — це гірша копія Eloquent: ви втрачаєте скоупи, with(), chunk(), cursor(), paginate(), а натомість не отримуєте нічого. Так само шкодить репозиторій, що обростає методами під кожен екран: findActiveByCityPaginatedSortedBySalary — сигнал, що читання для UI треба винести в окремий query-сервіс, який віддає DTO або пагінатор проєкцій повз домен. У Laravel-застосунку без виділеного домену дешевша й чесніша альтернатива — тонкі скоупи на моделі (scopeActive() або атрибут #[Scope] у Laravel 12) плюс query-обʼєкти для важких вибірок.

Межа проходить по транзакціях і межах агрегату. Репозиторій не керує транзакцією: її відкриває use case через DB::transaction() або wrapInTransaction(), а в Doctrine flush() викликається один раз наприкінці сценарію, інакше два агрегати перестають комітитись атомарно. І репозиторій існує на агрегат, а не на таблицю: рядки vacancy_skills зберігаються разом із вакансією, а не мають власний VacancySkillRepository. Якщо в проєкті немає агрегатів і немає доменного шару — швидше за все, немає й потреби в репозиторії.

// Domain: інтерфейс живе поруч з агрегатом і не знає ані про Illuminate, ані про Doctrine
interface Vacancies
{
    public function ofId(VacancyId $id): ?Vacancy;          // один агрегат
    public function publishedIn(City $city): VacancyList;    // назва з мови бізнесу, не з SQL
    public function save(Vacancy $vacancy): void;            // без flush і без транзакції
}

// Infrastructure: єдине місце, де є ORM і мапінг у доменні обʼєкти
final class EloquentVacancies implements Vacancies
{
    public function ofId(VacancyId $id): ?Vacancy
    {
        $row = VacancyModel::query()->find($id->toString());

        return $row === null ? null : $this->toDomain($row);
    }

    public function publishedIn(City $city): VacancyList
    {
        // eager loading — деталь інфраструктури, назовні Builder не тече
        $rows = VacancyModel::query()
            ->with('company')
            ->where('status', 'published')
            ->where('city', $city->value)
            ->get();

        return new VacancyList($rows->map($this->toDomain(...))->all());
    }

    public function save(Vacancy $vacancy): void
    {
        // Eloquent пише одразу; межу транзакції відкриває use case, а не репозиторій
        VacancyModel::query()->updateOrCreate(
            ['id' => $vacancy->id()->toString()],
            ['title' => $vacancy->title(), 'salary_from' => $vacancy->salary()->from()],
        );
    }

    private function toDomain(VacancyModel $row): Vacancy
    {
        return Vacancy::restore(new VacancyId($row->id), $row->title, new City($row->city));
    }
}
Що репозиторій — це колекціє-подібна абстракція над агрегатами, а не обгортка над таблицею: назовні йдуть доменні обʼєкти, а не Builder і не Eloquent Collection.
Що мотивація «замінимо базу» майже ніколи не реалізується; реальні мотиви — тримати Domain/Application без залежності від ORM, називати запити мовою бізнесу й підставляти in-memory реалізацію в тестах.
Різницю Active Record і Data Mapper: Eloquent-модель сама себе зберігає (`save()`), Doctrine розділяє сутність і `EntityManagerInterface` з `persist()`/`flush()` та Unit of Work.
Що інтерфейс належить доменному шару, а реалізація — інфраструктурному; інакше залежність нікуди не інвертувалась.
Що читання для UI (списки з фільтрами, сортуванням і пагінацією) розумно виносити в окремий query-сервіс повз репозиторій, а не роздувати його методами під кожен екран.
Що межа транзакції — це application service (`DB::transaction()`, `EntityManagerInterface::wrapInTransaction()`), а не метод репозиторію.
Обґрунтовувати репозиторій тим, що «завтра підмінимо MySQL на Mongo»: різні бази означають різні запити й різні гарантії, підміна реалізації однаково не буде безболісною.
Повертати з репозиторію `Builder` або колекцію Eloquent-моделей: абстракція протікає, бо клієнтський код усе одно дописує `->where()` і `->with()`.
Писати `BaseRepository` з generic `find/all/create/update/delete` для всіх моделей — це другий Eloquent, тільки бідніший: без скоупів, eager loading, `chunk()` і `cursor()`.
Плодити методи під кожен екран (`findActiveByCityPaginatedSortedBySalary`): 30 методів в інтерфейсі — сигнал, що потрібні критерії або окремий read-модель.
Класти інтерфейс поруч із реалізацією в Infrastructure: формально інтерфейс є, а домен усе одно тягне ORM.
У Doctrine викликати `flush()` усередині `save()` репозиторію: це ламає Unit of Work і робить неможливою одну транзакцію на кілька агрегатів.
ПОРАДА

Скажіть коротко: «репозиторій виправданий тоді, коли його інтерфейс можна прочитати не знаючи, що там ORM». Якщо в сигнатурах зʼявляються Builder, масиви `['where' => ...]` чи пагінатор — це вже не репозиторій, а обгортка, і вона шкодить.

Сторінка питання →
ARC
Архітектура·Middle ·SOLID ·дизайн ·принципи

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

Наведіть приклад порушення LSP у реальному коді.
Коли DIP заважає, а не допомагає?
Який із принципів SOLID ви порушуєте свідомо?

SOLID — це не чекліст, а набір спостережень про те, де код починає опиратись змінам. На практиці найбільше працюють два з пʼяти. Єдина відповідальність: клас має одну причину для зміни. Не один метод, а один привід, з яким до нього приходять. Симптоми порушення видно без теорії: конструктор на десять залежностей, тести з величезним setup, клас, який правлять і бухгалтерія, і маркетинг. Інверсія залежностей: код залежить від абстракції там, де реалізація може змінитись або її треба підміняти в тестах, тобто на межах із базою, зовнішніми API, часом, файлами.

Решта принципів здебільшого наслідки. Відкритість до розширення означає точки розширення там, де варіанти справді додаються: способи оплати, формати експорту. Підстановка Лісков — це про контракти: підклас, який кидає виняток у методі батька або мовчки змінює його семантику, ламає весь код, написаний під батька. Розділення інтерфейсів — про те, щоб споживач не залежав від методів, які не використовує, і саме воно зазвичай виправляє порушення LSP.

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

// До: одна причина для зміни? Ні: платежі, лист, PDF, склад в одному класі
final class OrderService
{
    public function checkout(Order $order): void
    {
        $charge = $this->stripe->charge($order->total(), $order->card());   // зовнішній API
        $this->mailer->send(new ReceiptMail($order, $charge));            // формат листа
        file_put_contents("/invoices/{$order->id}.pdf", $this->pdf($order)); // інфраструктура
        $this->db->table('stock')->decrement('qty', $order->qty());          // складський облік
    }
}

// Після: SRP + DIP там, де є межа з зовнішнім світом. Усередині домену конкретні класи.
interface PaymentGateway { public function charge(Money $amount, CardToken $card): Charge; }

final class Checkout
{
    public function __construct(
        private PaymentGateway $payments,        // абстракція: буде FakeGateway у тестах
        private EventDispatcher $events,
    ) {}

    public function handle(Order $order): void
    {
        $charge = $this->payments->charge($order->total(), $order->card());
        $order->markPaid($charge->id());
        $this->events->dispatch(new OrderPaid($order->id)); // лист, PDF, склад — окремі слухачі
    }
}

// LSP порушено: підклас звужує контракт батька
class ReadOnlyOrders extends Orders
{
    public function save(Order $o): void { throw new LogicException('read only'); }
}
// Правильно: окремі інтерфейси OrdersReader і OrdersWriter (ISP), без наслідування
Не розшифровку абревіатури, а приклад: клас, який ви розділили, і що це дало в тестах чи при зміні вимог.
Що SRP це про одну причину для зміни, а не про один метод на клас, і що симптом порушення це клас, який редагують з різних приводів різні люди.
Що OCP на практиці означає точки розширення там, де зміни очікувані, наприклад стратегії оплати, а не інтерфейс на кожен клас.
Реальний приклад LSP: підклас кидає виняток у методі батька або звужує контракт, і код, який працює з батьком, ламається.
Що DIP це залежність від абстракції там, де реалізація може змінитись або її треба підміняти в тестах, а не інтерфейс для єдиної реалізації назавжди.
Переказувати визначення пʼяти принципів без жодного прикладу з власного коду.
Створювати інтерфейс для кожного класу з однією реалізацією й називати це DIP.
Трактувати SRP як один метод на клас і розсипати логіку по десятках класів без цілісності.
Не помічати порушення LSP у власному коді: ReadOnlyRepository extends Repository з save(), який кидає виняток.
Застосовувати принципи до CRUD-коду, де жодної складності немає, і роздувати проєкт шарами.
ПОРАДА

Не переказуйте абревіатуру. Наведіть приклад класу, який ви розділили, і що це дало в тестах. Скажіть, який принцип порушуєте свідомо і чому.

Сторінка питання →
PHP
Core PHP·Middle ·strict_types ·declare ·TypeError

`declare(strict_types=1)` — це директива рівня одного файлу, яка вимикає приведення скалярів для викликів, зроблених із цього файлу: без неї PHP у coercive-режимі намагається сконвертувати аргумент до оголошеного типу (з PHP 8.0 нечислові рядки — це `TypeError`, а числові з «хвостом» — `int` плюс `E_WARNING`), зі strict приймається лише точний тип, з єдиним винятком `int` → `float`.

Чому `strlen(42)` в одному файлі працює, а в сусідньому кидає TypeError?
Я поставив `declare(strict_types=1)` у `bootstrap.php` — чому це не вплинуло на решту файлів?
Функція оголошена з `int $id`, а в базу пішло `0` замість `'abc'` — як так вийшло?
Чому `declare(strict_types=1)` не завадив передати `'10'` у функцію з `int` — файл-виклик чи файл-оголошення тут головний?

declare(strict_types=1) — це не налаштування рантайму й не ini-опція, а директива часу компіляції з областю дії рівно в один файл. Вона зʼявилась у PHP 7.0 разом зі скалярними тайп-хінтами (RFC Andrea Faulds) і перемикає перевірку оголошених типів між двома режимами: coercive (за замовчуванням) і strict. Записувати її треба першою інструкцією файлу — після <?php не може бути ані пробільного виводу, ані namespace, ані use; аргумент — тільки літерал 0 або 1, змінна там дасть помилку компіляції. Директива не успадковується: include строгого файлу з нестрогого нічого не змінює, автозавантажений клас живе за режимом свого файлу, і саме тому найпоширеніша помилка — поставити рядок в один bootstrap.php і вважати проєкт строгим.

Найважливіша механіка: режим бере той файл, у якому знаходиться виклик, а не той, де функція оголошена. PHP на етапі компіляції позначає опкод виклику прапорцем поточного файлу, і перевірка аргументів у момент виконання йде за цим прапорцем. Практичний наслідок — ваш строгий код, викликаючи бібліотеку без директиви або взагалі внутрішню функцію ядра, отримає строгу перевірку: strlen(42) зі strict-файлу кине TypeError, хоча strlen() до вашого файлу не має жодного стосунку. І навпаки: якщо строга бібліотека викликає ваш нестрогий callback, перевірка буде строгою, бо виклик стався в її файлі.

У coercive-режимі PHP намагається сконвертувати аргумент до оголошеного типу, і з PHP 8.0 ці правила стали значно чеснішими. Повністю числовий рядок ('5', ' 5 ', '1e3') конвертується тихо. Leading-numeric рядок ('5 apples') конвертується у 5, але з E_WARNING. Повністю нечисловий рядок ('apples') з PHP 8.0 — це вже TypeError, тоді як у 7.x він мовчки ставав 0 і саме через це в базу потрапляли записи з id = 0. Окремо PHP 8.1 задеприкейтив неявне floatint із втратою дробової частини: 8.5 у параметр int дає deprecation-повідомлення. У strict-режимі всього цього немає взагалі — приймається лише точний тип, з єдиним винятком: int можна передати в параметр float (widening), бо в PHP немає окремого літерала для «цілого числа з плаваючою комою» і вимагати 100.0 замість 100 було б знущанням. Зворотний напрямок, floatint, заборонений завжди.

Дуже поширене перебільшення на співбесіді — сказати, що strict_types «вимикає type juggling». Не вимикає. Директива діє лише там, де тип оголошено явно: параметри, тип повернення, типізовані властивості (PHP 7.4+) і типізовані константи класів (PHP 8.3+). Усе інше поводиться як завжди: '5' + 5 дає 10, if ($string) приводить до bool, == порівнює за своїми правилами (які, до речі, самі змінились у PHP 8.0 у RFC «Saner string to number comparisons»), ключ масиву '5' збігається з 5, а array_sum() підсумує змішаний масив без питань. Не чіпає директива й нетипізовані параметри — там просто нема чого перевіряти. Тому strict_types корисно розуміти вузько: це заборона неявного приведення скалярів у типізованих сигнатурах, не більше.

Практично це означає дві речі. Перша — вмикати директиву треба автоматично в кожному файлі: правило declare_strict_types у Pint/PHP-CS-Fixer, шаблон файлу в IDE, перевірка в CI; ручна дисципліна тут не працює, а частково строгий проєкт дає найгірший варіант — поведінка залежить від того, з якого файлу прийшов виклик. Друга — межа з зовнішнім світом. HTTP-параметри, значення з CLI, дані з БД у деяких драйверах, JSON без JSON_BIGINT-нюансів — усе це приходить рядками, і в строгому коді конвертація має бути явним, видимим кроком: filter_var(..., FILTER_VALIDATE_INT), FormRequest з правилом integer, $request->integer('page'). Строгі типи не роблять валідацію за вас — вони гарантують, що після точки конвертації неправильний тип уже не протече глибше в домен. І варто памʼятати про верхній шар: PHPStan рівня 6+ ловить ті самі невідповідності статично, до запуску, тоді як strict_types спрацьовує вже в рантаймі — ці два інструменти доповнюють один одного, а не замінюють.

<?php

declare(strict_types=1);          // ПЕРША інструкція файлу, інакше fatal error

final class Cart
{
    public int $itemCount = 0;    // типізована властивість теж під strict

    public function addItem(int $productId, float $price): float
    {
        $this->itemCount++;

        return $price * $productId;
    }
}

$cart = new Cart();

// int → float: ЄДИНЕ послаблення, дозволене і в strict-режимі (widening)
$cart->addItem(7, 100);           // 100 стає 100.0, помилки немає

try {
    // рядок у параметр int: у strict-режимі приведення заборонене
    $cart->addItem('7', 100.0);   // TypeError: Argument #1 must be of type int, string given
} catch (TypeError $e) {
    echo $e->getMessage();
}

try {
    // float → int заборонено ЗАВЖДИ, навіть без strict_types
    $cart->addItem(7.0, 100.0);   // TypeError: Argument #1 must be of type int, float given
} catch (TypeError $e) {
    echo $e->getMessage();
}

try {
    $cart->itemCount = '10';      // типізована властивість: теж TypeError
} catch (TypeError $e) {
    echo $e->getMessage();
}

// Директива не чіпає type juggling поза оголошеними типами:
var_dump('5' + 5);                // int(10) — арифметика працює як раніше
var_dump('abc' == 0);             // false у PHP 8+, true у PHP 7 — це вже інші правила

// Межа з HTTP: вхід завжди рядок, конвертація має бути явною
$page = filter_var($_GET['page'] ?? '1', FILTER_VALIDATE_INT) ?: 1;
$cart->addItem($page, 9.99);      // сюди приходить справжній int
Що `declare(strict_types=1)` діє на файл, у якому написаний, і не успадковується ані підключеними файлами, ані автозавантаженими класами — це не налаштування рантайму й не ini-опція.
Що вирішує файл **виклику**, а не файл оголошення функції: `strlen(42)` зі strict-файлу кинеться, хоча `strlen()` оголошена в ядрі.
Що директива стосується лише скалярних типів (`int`, `float`, `string`, `bool`) у параметрах, поверненнях і присвоєннях типізованим властивостям; `array`, обʼєкти, `iterable`, `callable` ніколи не приводяться і без неї.
Що єдине послаблення в strict-режимі — widening `int` → `float` (передати `5` у параметр `float` можна), а `float` → `int` заборонено завжди.
Що з PHP 8.0 coercive-режим став суворішим: `'abc'` в `int` — це `TypeError`, `'5 apples'` — `5` з `E_WARNING`, `'5'` — тихо `5`; до 8.0 `'abc'` мовчки ставало `0`.
Що `declare(strict_types=1)` має стояти першою інструкцією файлу (після `<?php`, до всього іншого, крім опційного блоку `declare` у фігурних дужках) — інакше фатальна помилка компіляції.
Ставити `declare(strict_types=1)` в один `index.php`/`bootstrap.php` і вважати, що весь проєкт тепер строгий: директива не поширюється на `include`, `require` й автозавантажені класи.
Казати, що strict_types вимикає «type juggling» узагалі: `'5' + 5`, `if ($str)`, `==` і `array_sum()` працюють точно так само — директива стосується лише перевірки оголошених типів.
Вважати, що директива діє там, де функція оголошена. Насправді режим бере з файлу, у якому стоїть виклик; бібліотека без strict, викликана зі strict-файлу, отримає строгу перевірку.
Думати, що в strict-режимі `int` не пройде в параметр `float` — widening дозволений і це навмисно, бо в PHP немає літерала для «цілого float».
Розраховувати на strict_types у даних із HTTP: `$_GET['page']` — завжди рядок, і в strict-режимі його треба явно кастити (`(int)`) або валідувати `filter_var`, а не сподіватись, що PHP «сам зрозуміє».
Плутати `declare(strict_types=1)` з `1` як «увімкнено для всіх» — допустимі лише літерали `0` і `1`, змінна чи константа там викличе помилку компіляції.
ПОРАДА

Формулювання, яке закриває питання: «strict_types — це директива компіляції на один файл, і вирішує вона файл виклику; вмикає не заборону type juggling, а заборону неявного приведення скалярів у типізованих сигнатурах — плюс один виняток, int → float». Далі додайте, що в проєкті це ставлять у кожен PHP-файл автоматично (Pint/PHP-CS-Fixer, правило `declare_strict_types`), а на межі з HTTP усе одно потрібен явний каст.

Сторінка питання →
PHP
Що таке property hooks у PHP 8.4+? ЧАСТО ПИТАЮТЬ
Core PHP·Middle ·PHP 8.4 ·property hooks ·інваріанти

Це можливість оголосити get і set логіку прямо на властивості, без окремих методів і без магії __get/__set.

Чим property hooks кращі за геттери й сеттери?
Що нового в PHP 8.4 для класів?
Чи можна оголосити hook в інтерфейсі?

Property hooks дозволяють оголосити логіку читання й запису прямо на властивості. Замість пари getName()/setName() клас має public string $name { get => ...; set => ...; }, а зовнішній код працює зі звичайним доступом до поля. Обійти сеттер неможливо: будь-яке присвоєння, зокрема в промоутнутому конструкторі, проходить через хук, тому інваріант виконується завжди.

На відміну від __get/__set хуки статично типізовані й привʼязані до конкретної властивості. IDE підказує тип, PHPStan перевіряє тіло хука, а рантайм не платить за виклик магічного методу на кожне звернення. Якщо хук get не читає саму властивість, вона стає віртуальною й не займає памʼяті, як обчислюване fullName.

Інтерфейси теж отримали цю можливість: public string $label { get; } у контракті замінює метод getLabel(). Клас може реалізувати вимогу і хуком, і звичайною публічною властивістю.

Практична порада: не переписувати всі геттери на хуки заради стилю. Найбільшу користь дають місця, де інваріант уже порушувався через прямий запис, і value objects, де валідація має жити поруч із даними.

final class Product
{
    public function __construct(
        public string $name {
            set(string $value) {
                if (trim($value) === '') {
                    throw new InvalidArgumentException('Name cannot be empty.');
                }
                $this->name = trim($value);
            }
        },
        public int $priceCents { set => max(0, $value); },
    ) {}

    // Віртуальна властивість: памʼять не виділяється, значення обчислюється
    public string $label { get => $this->name.' — '.number_format($this->priceCents / 100, 2).' грн'; }
}

interface HasLabel
{
    public string $label { get; }   // інтерфейс вимагає властивість, а не метод getLabel()
}

$p = new Product('  Кава ', 1990);
$p->name = '';      // InvalidArgumentException: сеттер неможливо обійти
echo $p->label;     // Кава — 19.90 грн
Що hook оголошується на самій властивості, тому обійти set неможливо: інваріант виконується завжди, навіть при прямому присвоєнні.
Що на відміну від __get/__set це статично типізовано: IDE й PHPStan бачать тип, а аналізатор перевіряє тіло хука.
Що властивість із хуком get без backing value стає віртуальною й не займає памʼяті.
Що інтерфейси можуть вимагати властивість з get або set, і це замінює пари getX/setX у контрактах.
Тверезу оцінку: масова міграція геттерів на hooks заради стилю не окупається, переписують те, де вже болять інваріанти.
Плутати з readonly: readonly забороняє зміну після ініціалізації, hooks додають логіку на читання й запис.
Вважати hooks заміною __get і __set: магічні методи працюють для неоголошених властивостей, hooks лише для оголошених.
Не знати, що властивість з хуками не можна зробити readonly, а всередині set треба писати $this->prop = ..., щоб зберегти значення.
Змішувати з asymmetric visibility public private(set), яка теж зʼявилась у 8.4, але вирішує іншу задачу.
ПОРАДА

Згадайте, що масова міграція геттерів на hooks заради стилю не окупається — переписують те, де вже болять інваріанти. І назвіть другу фічу 8.4 поруч: asymmetric visibility.

Сторінка питання →
ARC
Архітектура·Middle ·value object ·immutability ·DDD

Value object — незмінний обʼєкт без ідентичності, який описується лише своїм значенням: він валідує себе в конструкторі, тому в системі не існує невалідного екземпляра, і порівнюється за вмістом, а не за посиланням.

Чим `Money` кращий за `float $amount` і `string $currency` поруч?
У нас email валідується у трьох місцях по-різному — як це лікувати?
Value object і entity: у чому різниця, крім слова «immutable»?
Як порівняти два value object і чому `===` тут не працює?

Скалярний тип описує форму даних, але нічого не каже про їхній зміст. string $email — це будь-який рядок, зокрема порожній, з пробілом або з двома собаками; float $price — будь-яке число, зокрема відʼємне й з похибкою округлення. Тому валідація розповзається: перевірка у формі, ще одна в сервісі, третя перед відправкою в платіжний шлюз, і всі трохи різні. Value object згортає це в одну точку: інваріант перевіряється в конструкторі, а якщо конструктор відпрацював — невалідного екземпляра в системі не існує. Далі тип у сигнатурі працює як доказ: побачивши Email $to, ви вже знаєте, що всередині не сміття, і повторна перевірка зайва.

Друга властивість — відсутність ідентичності. Value object повністю визначається своїм вмістом: двісті гривень нічим не відрізняються від інших двохсот гривень, тому їх можна вільно замінювати одне одним. Через це VO роблять незмінним: у PHP це readonly-властивості з 8.1 або readonly class з 8.2, а «зміна» повертає новий екземпляр (add(), withCurrency()). Незмінність прибирає цілий клас багів із розділюваним станом: обʼєкт, переданий у три сервіси, не може бути зіпсований одним із них. Памʼятайте про межу readonly: воно захищає саме посилання, тож обʼєкт усередині VO теж має бути незмінним, інакше гарантія фіктивна.

Третя властивість — рівність за значенням, і саме тут найчастіше помиляються. === для обʼєктів порівнює ідентичність, тому два однакові Money будуть нерівні. == порівнює клас і всі властивості рекурсивно, що для простого VO спрацює, але залежить від внутрішніх деталей і вкладених обʼєктів. Тому пишуть явний equals(), який ще й фіксує правило: наприклад, для Email домен зазвичай нечутливий до регістру, і це рішення має бути в коді, а не в чиїйсь голові. Окремо: PHP не дозволяє обʼєкт як ключ масиву, тож для дедуплікації VO беруть його рядкове представлення.

Практичний виграш крім валідації — поведінка, якій нарешті є куди переїхати. Форматування суми, конвертація валют, порівняння цін, нормалізація телефону — усе це раніше жило статичними хелперами й дублювалося; у VO воно лежить поруч із даними, які описує, і покривається тестами без бази. Типовий кандидат: гроші, email, телефон, слаг, період дат, координати, ідентифікатор із форматом (наприклад, IBAN або ЄДРПОУ). Ознака, що VO потрібен: значення мандрує через кілька шарів і в кожному його перевіряють чи форматують заново.

Ціна теж реальна. VO треба мапити на сховище: у Doctrine ORM це #[Embeddable] плюс #[Embedded] у сутності або власний DBAL-тип, в Eloquent — кастомний каст на CastsAttributes, чий set() може повернути масив і розкласти обʼєкт у кілька колонок. Його треба серіалізувати на межах системи: у JSON-відповіді, у payload черги, у форму — тож toString()/fromString() пишуться одразу. І VO не безкоштовний за памʼяттю, тому в гарячих циклах на сотні тисяч ітерацій скаляр із перевіркою на вході чесніший. Правило межі просте: обгортка без інваріанта — це шум, а обгортка з інваріантом окупається з першого ж місця, де ви змогли викинути валідацію.

/**
 * Гроші: сума в мінорних одиницях (копійки), валюта — enum.
 * float заборонений: 0.1 + 0.2 !== 0.3, а гроші мусять сходитись до копійки.
 */
final readonly class Money                       // readonly class — PHP 8.2+
{
    private function __construct(
        public int $amount,                      // копійки, не гривні
        public Currency $currency,               // enum, PHP 8.1+
    ) {
        // Єдине місце, де перевіряється інваріант: далі тип сам є гарантією
        if ($amount < 0) {
            throw new InvalidArgumentException('Сума не може бути відʼємною');
        }
    }

    /** Іменований конструктор: назва пояснює одиниці виміру */
    public static function fromMinorUnits(int $amount, Currency $currency): self
    {
        return new self($amount, $currency);
    }

    /** «Зміна» — це новий екземпляр, старий лишається недоторканим */
    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->amount + $other->amount, $this->currency);
    }

    /** === порівняв би ідентичність, тому рівність описуємо явно */
    public function equals(self $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;   // enum-кейси — синглтони
    }

    public function __toString(): string
    {
        return sprintf('%d.%02d %s', intdiv($this->amount, 100), $this->amount % 100, $this->currency->value);
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new DomainException('Не можна додавати різні валюти');
        }
    }
}

// Сигнатура сама себе документує і не дає переплутати аргументи місцями
$total = Money::fromMinorUnits(19900, Currency::UAH)->add(Money::fromMinorUnits(5000, Currency::UAH));
Що VO не має ідентичності: два `Money(100, 'UAH')` взаємозамінні, а два `User` з однаковим імʼям — ні.
Що інваріант перевіряється один раз у конструкторі, тому тип `Email` у сигнатурі вже є гарантією, і повторна валідація в сервісах зайва.
Що незмінність досягається `readonly`-властивостями (PHP 8.1) або `readonly class` (8.2), а «зміна» — це новий екземпляр, а не мутація.
Що порівняння йде через власний `equals()`, бо `===` для обʼєктів порівнює ідентичність, а не вміст.
Що гроші зберігаються цілим числом у мінорних одиницях, а не `float`, і що VO — природне місце для цього правила.
Що VO треба мапити на сховище: Doctrine `#[Embedded]`, Laravel custom cast через `CastsAttributes`.
Називати VO будь-який DTO: DTO переносить дані без інваріантів, VO гарантує їх і порівнюється за значенням.
Валідувати в сеттері чи в статичному методі `isValid()`, залишаючи можливість створити невалідний обʼєкт напряму через конструктор.
Порівнювати через `===` (завжди false для різних екземплярів) або через `==` без розуміння, що воно рекурсивно порівнює всі властивості, зокрема вкладені обʼєкти.
Робити VO з `float` для грошей: `0.1 + 0.2 !== 0.3`, і VO лише ховає помилку за фасадом.
Загортати в VO геть усе, включно з булевими прапорцями й лічильниками, від чого код розбухає без жодної нової гарантії.
Додавати VO ідентифікатор чи `updated_at` — це вже entity, і зберігати його треба інакше.
ПОРАДА

Сформулюйте вигоду через сигнатуру: `charge(Money $amount, Email $to)` неможливо викликати з переплутаними аргументами й неможливо викликати з невалідними даними, а `charge(float $amount, string $currency, string $email)` — можна, і компілятор мовчатиме. Далі згадайте `equals()` і мапінг у базу — це відрізняє того, хто VO писав, від того, хто про них читав.

Сторінка питання →
SQL
SQL·Middle ·віконні функції ·ROW_NUMBER ·LAG

Віконна функція рахує агрегат або позицію по групі рядків (вікні), не згортаючи їх у один рядок: ROW_NUMBER і RANK дають нумерацію всередині PARTITION BY, LAG бере значення попереднього рядка, SUM() OVER (ORDER BY …) дає running total — усе це замінює корельовані підзапити й самоджойни одним проходом.

Як вибрати три найдорожчі замовлення для кожного користувача одним запитом?
Чим ROW_NUMBER відрізняється від RANK і DENSE_RANK?
Як порахувати наростаючий підсумок без коду на PHP і без корельованого підзапиту?
Чому запит із ROW_NUMBER() у WHERE падає з помилкою?

Віконна функція обчислює значення по набору рядків, повʼязаних із поточним, і при цьому не згортає їх: на виході стільки ж рядків, скільки на вході, просто зʼявляється додаткова колонка. Набір задає речення OVER: PARTITION BY ділить результат на незалежні розділи (нумерація починається заново для кожного user_id), ORDER BY задає порядок усередині розділу, а рамка (ROWS/RANGE) визначає, які саме сусідні рядки потрапляють у розрахунок. Це і є принципова відмінність від GROUP BY, який із десяти замовлень користувача робить один рядок: вікно лишає всі десять і кожному дописує ранг, суму або значення сусіда.

Три функції покривають більшість практичних задач. ROW_NUMBER() дає суцільні унікальні номери 1, 2, 3 — навіть якщо значення однакові, порядок між ними база обере довільно. RANK() при ничиїй ставить однаковий ранг і пропускає наступні (1, 1, 3), DENSE_RANK() не пропускає (1, 1, 2); різниця важлива, коли треба «топ-3 з урахуванням рівних результатів». LAG(col, 1, default) бере значення з попереднього рядка розділу, LEAD — з наступного, і третій аргумент рятує від NULL на межі розділу. Раніше те саме писали корельованим підзапитом (SELECT count(*) FROM orders o2 WHERE o2.user_id = o.user_id AND o2.total > o.total) або самоджойном по n - 1: обидва читають таблицю багато разів, вікно — один раз із сортуванням.

Ключовий момент, на якому валяться на співбесіді, — момент обчислення. Віконні функції рахуються після FROM, WHERE, GROUP BY і HAVING, але до ORDER BY і LIMIT. Тому у WHERE їх писати не можна: WHERE row_number() OVER (...) <= 3 дає помилку і в MySQL, і в PostgreSQL. Канонічний шаблон top-N у групі — CTE або похідна таблиця, де вікно живе в SELECT, і зовнішній запит із WHERE rn <= 3. Наслідок із того самого правила корисний і в інший бік: усе, що відсіює WHERE, до вікна взагалі не доходить, тому WHERE status = 'paid' всередині CTE звужує розділ, а не фільтрує вже пронумеровані рядки.

Running total — це агрегат із рамкою: sum(total) OVER (PARTITION BY user_id ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Явний ROWS тут не прикраса. Якщо рамку не вказати, стандарт наказує RANGE UNBOUNDED PRECEDING AND CURRENT ROW, а RANGE працює зі значеннями ORDER BY: усі рядки з однаковим created_at вважаються однією позицією й отримують однакову суму, що включає їх усіх. На секундній точності часу це майже непомітно, на даті — ламає звіт. Без ORDER BY рамка охоплює весь розділ, і sum(total) OVER (PARTITION BY user_id) дає загальну суму користувача в кожному його рядку — зручно, щоб порахувати частку замовлення від обороту без другого запиту.

Межі теж варто назвати. Віконні функції є в PostgreSQL із 8.4, у MySQL з 8.0, MariaDB з 10.2, SQLite з 3.25 — на легасі MySQL 5.7 доведеться повертатись до підзапитів. Продуктивність не безкоштовна: PARTITION BY user_id ORDER BY total DESC без індексу (user_id, total) дає в плані Sort над повним скануванням, і на великій таблиці це дорожче за альтернативу. Для «одного найкращого рядка на групу» майже завжди виграє DISTINCT ON у PostgreSQL або LATERAL із LIMIT 1 (MySQL 8.0.14+), бо вони читають по кілька рядків з індексу замість сортування всього розділу. Тож правило просте: вікно — коли потрібні всі рядки з додатковим контекстом, GROUP BY — коли потрібен один рядок на групу, а вибір між вікном і LATERAL вирішує EXPLAIN.

-- Топ-3 замовлення кожного користувача + наростаючий підсумок + дельта до попереднього
WITH ranked AS (
    SELECT
        o.id,
        o.user_id,
        o.total,
        o.created_at,
        -- унікальні номери 1,2,3… всередині кожного user_id
        row_number() OVER (PARTITION BY o.user_id ORDER BY o.total DESC) AS rn,
        -- при однакових сумах дає однаковий ранг і розрив: 1,1,3
        rank()       OVER (PARTITION BY o.user_id ORDER BY o.total DESC) AS rnk,
        -- сума всіх попередніх замовлень користувача; ROWS, а не дефолтний RANGE,
        -- інакше рядки з однаковим created_at отримають однакову суму
        sum(o.total) OVER (
            PARTITION BY o.user_id ORDER BY o.created_at
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS running_total,
        -- сума попереднього замовлення; 0 для першого рядка користувача
        lag(o.total, 1, 0) OVER (PARTITION BY o.user_id ORDER BY o.created_at) AS prev_total
    FROM orders o
    WHERE o.status = 'paid'          -- WHERE відпрацьовує ДО обчислення вікна
)
SELECT id, user_id, total, running_total, total - prev_total AS diff
FROM ranked
WHERE rn <= 3                        -- фільтр по вікну можливий лише в зовнішньому запиті
ORDER BY user_id, rn;

-- Індекс, який прибирає Sort із плану WindowAgg
CREATE INDEX orders_user_total ON orders (user_id, total DESC);

-- Для топ-1 у групі дешевше: PostgreSQL читає по одному рядку на користувача
SELECT DISTINCT ON (user_id) user_id, id, total
FROM orders
WHERE status = 'paid'
ORDER BY user_id, total DESC;
Що віконна функція не зменшує кількість рядків, на відміну від GROUP BY: кожен вхідний рядок лишається і отримує додаткову колонку.
Різницю ROW_NUMBER (завжди унікальні 1, 2, 3), RANK (при ничиїй однакове число і розрив: 1, 1, 3) і DENSE_RANK (1, 1, 2).
Що вікно рахується після WHERE, GROUP BY і HAVING, тому фільтрувати по ROW_NUMBER можна лише в зовнішньому запиті чи CTE — це і є типовий шаблон top-N у групі.
Що SUM() OVER (ORDER BY …) дає running total, і що за замовчуванням рамка RANGE UNBOUNDED PRECEDING включає всі рядки-однолітки з тим самим значенням ORDER BY, тому при дублікатах треба явно писати ROWS.
Що LAG/LEAD зі значеннями попереднього й наступного рядка замінюють самоджойн по `n - 1` і мають третій аргумент — значення за замовчуванням.
Версії: MySQL 8.0, MariaDB 10.2, PostgreSQL 8.4+, SQLite 3.25 — у MySQL 5.7 віконних функцій немає, і це впливає на вибір рішення для легасі-проєкту.
Писати `WHERE row_number() OVER (...) <= 3` — віконні функції у WHERE і HAVING заборонені, потрібен підзапит або CTE.
Плутати ROW_NUMBER і RANK: брати RANK для пагінації й отримувати дірки в нумерації, або ROW_NUMBER для «топ-3 з урахуванням ничиїх» і випадково відрізати один із рівних результатів.
Забувати PARTITION BY і отримувати наскрізну нумерацію по всій таблиці замість нумерації всередині користувача.
Писати `SUM(amount) OVER (ORDER BY created_at)` для running total на даних із однаковими датами й дивуватися, що кілька рядків мають однакову суму: дефолтна рамка RANGE, а не ROWS.
Вважати, що віконна функція завжди швидша: PARTITION BY і ORDER BY без відповідного індексу дають Sort у плані, і для top-1 у групі LATERAL або DISTINCT ON часто дешевші.
Замінювати GROUP BY віконним агрегатом і дивуватися дублікатам: вікно рядки не згортає, потрібен ще DISTINCT або зовнішній фільтр.
ПОРАДА

Скажіть одним реченням: «GROUP BY згортає рядки, вікно лишає їх на місці й додає колонку». Далі назвіть три робочі шаблони — ROW_NUMBER + PARTITION BY для top-N, SUM() OVER для наростаючого підсумку, LAG для дельти між сусідніми рядками — і одразу згадайте, що фільтрувати по вікну можна тільки зовні.

Сторінка питання →
PHP
Core PHP·Middle ·Generator ·yield ·yield from

Генератор — це функція з `yield`, яка при виклику не виконує жодного рядка тіла, а повертає обʼєкт `Generator` (реалізує `Iterator`): значення обчислюються по одному на вимогу, тому пікова памʼять не залежить від обсягу даних; ціна — один-єдиний прохід без перемотки, без `count()` і без доступу за індексом.

Експорт падає з Allowed memory size exhausted на 2 млн рядків — як переписати, не піднімаючи memory_limit?
Чим `yield` відрізняється від `return` і що взагалі повертає функція, у тілі якої є `yield`?
Чому `count()` на результаті такої функції падає, а `foreach` працює?
Чому другий `foreach` по тому самому генератору кидає «Cannot rewind»?
Навіщо `yield from`, якщо можна написати вкладений `foreach` з `yield` усередині?

Генератори зʼявилися в PHP 5.5 і працюють не так, як здається з синтаксису. Наявність yield будь-де в тілі функції змінює саму природу виклику: PHP не виконує жодного рядка тіла, а створює й повертає обʼєкт класу Generator, який реалізує інтерфейс Iterator. Тіло стартує лише тоді, коли хтось уперше запитає значення — foreach, current(), send() або iterator_to_array(). Дійшовши до yield, функція заморожується: її локальні змінні, позиція виконання й навіть напівпройдений try живуть у власному стек-фреймі генератора. Наступна ітерація не починає функцію спочатку, а розморожує її рівно там, де зупинила. Саме тому в прикладі RuntimeException з fopen() вилітає не на рядку allLines(...), а вже всередині foreach — класична пастка, на якій ловлять на співбесіді.

Практичний сенс цієї ліні — памʼять. Функція, що повертає масив, зобовʼязана дорахувати всі елементи до return, тому пік споживання пропорційний обсягу даних: мільйон рядків по 200 байт — це не 200 МБ, а помітно більше, бо кожен елемент хеш-таблиці коштує ще десятки байтів службових даних. Генератор тримає одночасно один елемент, і memory_limit перестає бути функцією розміру файла чи вибірки. Друга, менш очевидна вигода — конвеєр: onlyErrors(allLines(...)) не створює жодного проміжного масиву, кожен рядок проходить крізь усі ланки й одразу звільняється. Третя — час до першого результату: споживач отримує перший рядок майже миттєво, а не після повного читання. Тому генератори — природний інструмент для парсингу великих файлів, ітерації по вибірці з БД (Model::cursor(), LazyCollection), потокової віддачі відповіді через StreamedResponse і для нескінченних послідовностей, які просто не існують як масив.

Протокол генератора ширший за foreach. Ключі — його повноцінна частина: без явного ключа PHP нумерує елементи 0, 1, 2…, а yield $key => $value дозволяє віддавати свої (номер рядка, ідентифікатор запису). yield from, доданий у PHP 7.0, делегує іншому генератору, масиву чи будь-якому Traversable і повертає його return-значення як результат виразу — у прикладі це дозволяє порахувати сумарну кількість рядків, не заводячи зайвого стану. Важливий нюанс: yield from зберігає ключі внутрішнього джерела, тому при склеюванні кількох файлів нумерація кілька разів починається з нуля, і iterator_to_array() без другого аргументу false мовчки перетре частину даних. return у генераторі теж не те, чим здається: він не потрапляє в foreach, а забирається окремо через getReturn() — і лише після того, як генератор дійшов до кінця. Нарешті, send() робить обмін двонаправленим: вираз yield усередині обчислюється в значення, передане ззовні, — на цьому побудовані корутини асинхронних рушіїв.

Головне обмеження генератора — одноразовість. Він не має курсора, який можна повернути назад: Generator::rewind() дозволений лише доки виконання стоїть на першому yield, інакше кидає Exception: Cannot rewind a generator that was already run. Через це другий foreach по тому самому обʼєкту падає, а не повертає порожнечу. Так само немає count() (генератор не знає, скільки в нього елементів, поки не дорахує), немає доступу за індексом, і його не приймають array_map(), array_filter(), sort() — усі вони працюють з масивами. Якщо дані потрібні двічі, зберігайте не генератор, а спосіб його створити: фабрику-замикання або клас з IteratorAggregate; саме так влаштований LazyCollection, який приймає замикання й тому ітерується скільки завгодно разів. Матеріалізація через iterator_to_array() теж законна — просто памʼятайте, що вона повертає ту саму памʼять, заради якої все й починалось.

Межі варто називати чесно, бо на них ловлять найчастіше. Генератор не прискорює обчислення — він лише розтягує їх у часі; на маленькій колекції з десятків елементів звичайний масив простіший і швидший, а зайва ланка генераторів робить стек-трейси менш читабельними. Генератор не зменшує памʼять поза PHP: Model::cursor() на MySQL з увімкненою буферизацією PDO (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, за замовчуванням true у mysqlnd) все одно затягне весь результат у клієнтський буфер — тут або небуферизований запит зі своїми обмеженнями, або chunkById(), який ріже вибірку на окремі запити. І генератор — погане місце для тримання ресурсу з довгим життям: якщо споживач вийде через break, генератор просто зависне на yield до знищення, тож звільнення файла чи відкат транзакції мають бути у finally, а сама транзакція — краще в коді-споживачі, а не всередині ітератора.

/** Читає лог рядок за рядком: у памʼяті живе один рядок, а не весь файл. */
function lines(string $path): Generator
{
    // ця перевірка спрацює НЕ на виклику lines(), а на першій ітерації
    $handle = fopen($path, 'rb') ?: throw new RuntimeException("Немає {$path}");
    $n = 0;
    try {
        while (($line = fgets($handle)) !== false) {
            yield $n++ => rtrim($line, "\r\n");   // ключ задаємо явно
        }
    } finally {
        fclose($handle);          // виконається і при break у споживача
    }
    return $n;                    // видно лише через getReturn() після кінця
}

/** yield from делегує іншому генератору й повертає його return-значення. */
function allLines(string ...$paths): Generator
{
    $total = 0;
    foreach ($paths as $path) {
        $total += (yield from lines($path));   // ключі 0,1,2… стартують заново!
    }
    return $total;
}

/** Ланка конвеєра: фільтр нічого не матеріалізує. */
function onlyErrors(iterable $lines): Generator
{
    foreach ($lines as $key => $line) {
        if (str_contains($line, ' ERROR ')) {
            yield $key => $line;
        }
    }
}

$stream = allLines('app-09-01.log', 'app-09-02.log'); // тіло ще не виконувалось
foreach (onlyErrors($stream) as $key => $line) {
    echo "{$key}: {$line}\n";     // пікова памʼять не залежить від розміру логів
}
echo $stream->getReturn();        // усього рядків; до завершення — Exception
// повторний foreach ($stream) → Cannot rewind a generator that was already run
Що виклик функції з `yield` не виконує її тіло: повертається обʼєкт `Generator`, і код стартує лише на першій ітерації (тому валідація аргументів усередині генератора «мовчить» до `foreach`).
Що памʼять стає сталою замість лінійної: масив на мільйон рядків — це сотні мегабайтів (рядок плюс десятки байтів на елемент хеш-таблиці), генератор тримає один рядок і власний стек-фрейм.
Що `Generator` реалізує `Iterator`, тому працюють `foreach` та `iterator_to_array()`, але не працюють `count()`, `$gen[0]`, `array_map()` і будь-який другий прохід.
Що ключі є повноцінною частиною протоколу: без явного ключа PHP нумерує 0, 1, 2…, а `yield $key => $value` задає свій — і `yield from` (PHP 7.0+) ключі внутрішнього генератора **не** переномеровує.
Що `send()` робить генератор двонаправленим — значенням виразу `yield` усередині стає те, що передали ззовні; це основа корутин у ReactPHP/Amp.
Що межа економії — не лише PHP: `Model::cursor()` не врятує памʼять на MySQL, поки PDO буферизує весь результат на боці клієнта.
Класти перевірку аргументів на початок генератора і чекати винятку одразу після виклику: тіло не виконається, поки хтось не почне ітерацію.
«Для зручності» обгортати результат в `iterator_to_array()` — це повертає рівно ту памʼять, заради якої генератор і писався.
Забувати про дублікати ключів після `yield from`: `iterator_to_array($gen)` тихо перетирає рядки, потрібен другий аргумент `false`.
Викликати `getReturn()` до того, як генератор дійшов до кінця (або після `break`), і отримувати `Exception: Cannot get return value of a generator that hasn't returned`.
Писати `return $value;` у генераторі й чекати, що `foreach` віддасть це значення як останній елемент — воно доступне лише через `getReturn()`.
Вважати `Model::cursor()` або `LazyCollection` автоматичною гарантією низької памʼяті, не подивившись на буферизацію запиту в драйвері БД.
ПОРАДА

Формула, яка закриває питання: «функція з `yield` повертає не дані, а обʼєкт `Generator`; він рахує значення по одному, памʼять стала, але прохід рівно один — перемотати не можна, лише викликати функцію ще раз». Далі одразу назвіть трійку `yield from` / `send()` / `getReturn()` — це показує, що ви бачили генератори не лише в `foreach`.

Сторінка питання →
LR
Laravel·Middle ·Pest ·тестування ·фабрики

У Laravel «unit» і «feature» — це не «клас проти роуту», а «без завантаженого застосунку проти з ним»: у стандартному `tests/Pest.php` `Tests\TestCase` підключається лише до теки `Feature`, тому в `Unit` немає ні контейнера, ні фасадів, ні бази. Логіку без Illuminate тестують швидкими unit-тестами, все інше — feature-тестами через HTTP-межу з фабриками для даних і фейками (`Queue`, `Mail`, `Http`, `Storage`) для зовнішніх меж; фейк доводить лише факт виклику, тому job чи mailable потребують власного тесту.

У вас 400 тестів — скільки з них unit, скільки feature і чому саме так?
Тест із `Mail::fake()` зелений, а на проді лист не пішов. Як таке можливо?
Ви додали `Event::fake()` — і обсервер перестав проставляти slug. Що сталося?
Ви написали тест сервісу, підмінивши мокапом власний репозиторій. Що саме цей тест довів?

Почніть відповідь із того, що в Laravel «unit» і «feature» — це технічна, а не філософська межа. У згенерованому tests/Pest.php стоїть pest()->extend(Tests\TestCase::class)->in('Feature'), і саме Tests\TestCase тягне за собою CreatesApplication: піднімає контейнер, реєструє провайдери, вмикає фасади й зʼєднання з базою. Тести з теки Unit успадковують голий PHPUnit\Framework\TestCase, тому будь-який Cache::get() чи config() там впаде з «A facade root has not been set», а Model::factory() — з відсутнім зʼєднанням. Звідси й практичне правило: unit-тест — це тест коду, який не потребує застосунку (калькулятори, value objects, доменні правила, форматери), і він коштує мілісекунди; feature-тест піднімає застосунок, б'є в роут через $this->post(...) й перевіряє все, що між HTTP-запитом і рядком у базі: middleware, Form Request, авторизацію, контролер, модель, редірект. У проєкті з розділенням на Domain/Application/Infrastructure межа збігається з архітектурною: Domain і Application не імпортують Illuminate, тому покриваються unit-тестами природно, а все, що є Laravel-склейкою, — feature-тестами.

Пропорція між ними — це не «піраміда з підручника», а наслідок того, де у вашому коді живуть баги. У типовому CRUD-застосунку більшість помилок — не в арифметиці, а на стиках: забутий middleware('auth'), правило валідації, яке пропускає порожній рядок, політика, що дозволяє чуже редагувати, звʼязок, який повертає не те. Усе це ловиться лише feature-тестом, тому в Laravel-проєктах їх зазвичай більшість, і це нормально. Unit-тести виправдані там, де є справжня логіка з багатьма гілками: розрахунок вилки зарплати, парсер, стейт-машина статусів, — там прогнати 20 випадків через HTTP-цикл просто марнотратно.

База даних у feature-тестах тримається на трейтах, і їх варто розрізняти. RefreshDatabase мігрує схему один раз на весь прогін, а далі загортає кожен тест у транзакцію й відкочує її — це найшвидший і дефолтний варіант, але він не працює, якщо тестований код сам робить TRUNCATE або керує транзакціями так, що зовнішня «ламається»; тоді беруть DatabaseTruncation зі списком $tablesToTruncate. DatabaseMigrations мігрує заново перед кожним тестом — чесно, але повільно, і на великій сюїті це головна причина шестихвилинних прогонів. Окремо памʼятайте про драйвер: SQLite :memory: спокусливо швидкий, але це інша СУБД — lockForUpdate() там фактично no-op, JSON-функції, строгість типів, поведінка дат і повідомлення про порушення унікальності відрізняються від MySQL і PostgreSQL. Якщо код покладається на блокування чи специфічний SQL, тести мають ходити в ту саму СУБД, що й прод, а швидкість добирається через --parallel (Laravel створює по базі на процес: ..._test_1, ..._test_2) і BCRYPT_ROUNDS=4 у phpunit.xml.

Дані для тестів створюють фабриками, і тут більшість зупиняється на Model::factory()->create(), не використавши й половини можливостей. make() будує модель без запису в базу — цього достатньо, коли перевіряється серіалізація чи метод моделі. Стани (->published(), ->expired()) описують не поля, а бізнес-ситуацію, і роблять тест читабельним. for() і has() будують дерево звʼязків одним виразом, а recycle($company) вирішує типову проблему такого дерева: без нього кожна вкладена фабрика створює собі нову компанію, і тест на «вакансії однієї компанії» тихо перевіряє не те. sequence() дає різні значення на кожну наступну модель, afterCreating() довішує побічні сутності. Дві пастки: фабрика, викликана в Pest-датасеті, впаде, бо датасети резолвляться до підняття застосунку (створюйте дані замиканням усередині тесту), і фабрика, яка через has() генерує сотні рядків заради одного асерту, — саме такі тести потім показує pest --profile.

Фейки — це підміна біндінгу в контейнері на записувач викликів, і з них випливає все їхнє правильне й неправильне вживання. Ставити фейк треба до дії: Queue::fake() після dispatch() не побачить нічого. Queue::fake() перехоплює чергу, але не dispatchSync() — така job виконається по-справжньому; повний перехоплювач — Bus::fake(), який ще й уміє assertChained() та assertBatched(). Event::fake() без списку класів глушить і події моделей, тому обсервер, що проставляє slug чи uuid, мовчить, і тест валиться в несподіваному місці — тому майже завжди пишуть Event::fake([VacancyPublished::class]) або Event::fakeExcept(). Mail::fake() розрізняє відправлені й поставлені в чергу листи: mailable із ShouldQueue перевіряється через assertQueued(), а не assertSent(). Http::fake() без Http::preventStrayRequests() перетворює будь-який незбіжний запит на порожню 200 — інтеграція зламана, тест зелений. Storage::fake('public') підміняє диск тимчасовою текою, після чого працює Storage::disk('public')->assertExists(...). І головне обмеження, яке відрізняє сильну відповідь: фейк доводить лише те, що ви перетнули межу, а не те, що по той бік усе правильно. На кожен Queue::assertPushed(IndexVacancy::class) має існувати другий тест, який створює job і кличе handle() із підставленою залежністю; інакше клас, який ви «покрили», не виконувався жодного разу.

// tests/Unit/SalaryRangeTest.php — без контейнера й БД: чиста логіка домену.
it('нормалізує перевернуту вилку зарплати', function () {
    $range = new SalaryRange(from: 4000, to: 3000, currency: 'USD');

    expect($range->from())->toBe(3000)          // межі міняються місцями
        ->and($range->to())->toBe(4000);
});

// tests/Feature/PublishVacancyTest.php — справжній роут, контейнер і база.
it('публікує вакансію та ставить її в чергу на індексацію', function () {
    Queue::fake([IndexVacancy::class]);         // решта job виконуються як завжди
    Http::preventStrayRequests();               // незамокане не поверне тихо 200
    Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
    $this->freezeTime();

    $company = Company::factory()->create();
    // recycle(): і користувач, і вакансії посилаються на ту саму компанію,
    // інакше кожна вкладена фабрика створила б собі нову.
    $user = User::factory()->recycle($company)->create();

    $this->actingAs($user)
        ->post(route('vacancies.store'), ['title' => 'Senior PHP', 'salary_from' => 4000])
        ->assertRedirect();

    $this->assertDatabaseHas('vacancies', [
        'company_id' => $company->id,
        'published_at' => now(),                // детерміновано лише через freezeTime()
    ]);
    Queue::assertPushed(IndexVacancy::class);
    Http::assertSentCount(1);
});

// Фейк довів тільки те, що job поставили в чергу. Її поведінка — окремий тест.
it('надсилає вакансію в пошуковий індекс', function () {
    $vacancy = Vacancy::factory()->published()->create(); // стан фабрики
    $index = Mockery::spy(SearchIndex::class);

    (new IndexVacancy($vacancy->id))->handle($index);     // handle() кличемо руками

    $index->shouldHaveReceived('put')->once();
});
Що поділ на unit/feature у Laravel визначається не розміром об'єкта тестування, а тим, чи піднято застосунок: у дефолтному `tests/Pest.php` стоїть `pest()->extend(Tests\TestCase::class)->in('Feature')`, тож у `Unit` фасад кине «A facade root has not been set».
Що `Queue::fake()` не перехоплює `dispatchSync()` (job виконається по-справжньому), а `Bus::fake()` перехоплює і його — звідси `Bus::assertDispatchedSync()`, `assertChained()`, `assertBatched()`.
Що `Event::fake()` без аргументів глушить і події моделей (`creating`, `saved`, `deleted`), тому обсервери й `booted()`-хуки перестають працювати; рятує `Event::fake([OrderShipped::class])` або `Event::fakeExcept()`.
Що `RefreshDatabase` мігрує базу один раз на прогін і загортає кожен тест у транзакцію з відкотом, а не мігрує заново перед кожним (це `DatabaseMigrations`).
Що фабрики вміють більше за `create()`: стани, `for()`/`has()`, `recycle()` для переви­користання тієї самої повʼязаної моделі, `sequence()`, і що `make()` не пише в базу.
Що фейк перевіряє межу, а не поведінку: після `Queue::assertPushed(Job::class)` сама job лишається непокритою, доки її `handle()` не викликано в окремому тесті.
Мокати те, що написали самі: `$this->mock(VacancyRepository::class)->shouldReceive('find')->andReturn($vacancy)` — такий тест перевіряє власну ж заглушку й ламається від будь-якого рефакторингу, не ловлячи жодного бага.
`Mail::fake()` + `assertSent()` для mailable з `ShouldQueue` або відправленого через `Mail::queue()`: він реєструється як queued, тож потрібен `assertQueued()`, інакше тест валиться (або, гірше, дає хибну впевненість у зворотному напрямку).
`Http::fake()` без `Http::preventStrayRequests()`: щойно зареєстровано хоч один фейк, усі незбіжні запити тихо отримують порожню відповідь 200 — URL змінили, інтеграція зламалась, тест зелений.
Викликати фабрику в Pest-датасеті: датасети резолвляться до того, як підніметься застосунок, тому `dataset('users', [User::factory()->create()])` падає; дані створюють замиканням усередині тесту.
Ганяти сюїту на SQLite `:memory:`, а прод тримати на MySQL/PostgreSQL: `lockForUpdate()` і `sharedLock()` там фактично no-op, JSON-функції й строгість типів інші, помилки унікальності та дати поводяться інакше.
Ставити `Event::fake()` чи `Queue::fake()` після дії, яку перевіряють: фейк підміняє біндінг у контейнері в момент виклику, тож усе, що відбулося раніше, він не бачить і асерт покаже «нічого не відправлено».
Гнатися за відсотком покриття unit-тестами гетерів, ресурсів і фасадних обгорток, залишивши без жодного feature-тесту авторизацію й валідацію — саме там ламається продакшн.
ПОРАДА

Скажіть уголос розділову лінію: «unit — для коду без Illuminate, feature — для всього, що торкається контейнера, БД чи HTTP», і одразу додайте, що в Laravel це технічна межа, а не філософська: у `Unit` застосунок не піднято. Далі — правило про фейки: «фейк перевіряє, що ми перетнули межу, а не що по той бік усе правильно», тому на кожен `Queue::assertPushed()` має бути другий тест, який кличе `handle()`.

Сторінка питання →
SF
Symfony·Middle ·Security ·Voter ·IsGranted

Будь-яка перевірка проходить через `isGranted($attribute, $subject)`: AccessDecisionManager опитує всі voter'и й зводить їхні голоси стратегією (за замовчуванням affirmative). Ролі перевіряє RoleHierarchyVoter, який розгортає `role_hierarchy`, а права на конкретний обʼєкт — власний Voter, підключений атрибутом `#[IsGranted('EDIT', subject: 'post')]`.

Чому `#[IsGranted('ROLE_ADMIN')]` пропускає користувача, у якого в базі записано лише `ROLE_SUPER_ADMIN`, а перевірка `in_array('ROLE_ADMIN', $token->getRoleNames())` у власному voter'і — ні?
Де перевіряти право «редагувати цей пост»: у контролері, в entity чи у voter'і?
Що відбувається, коли всі voter'и повернули ABSTAIN?
Чим `access_control` у security.yaml відрізняється від `#[IsGranted]` на контролері?

Уся авторизація в Symfony зводиться до одного виклику: isGranted($attribute, $subject). Ні контролер, ні Twig не знають, що таке «роль» чи «власник поста» — вони лише передають рядок-атрибут і необовʼязковий обʼєкт у AccessDecisionManager, а той опитує кожен сервіс, тегований security.voter (autoconfigure навішує тег автоматично за VoterInterface). Кожен voter повертає GRANTED, DENIED або ABSTAIN, і менеджер зводить голоси стратегією: за замовчуванням affirmative — достатньо одного GRANTED. Якщо всі утрималися, працює allow_if_all_abstain: false, тобто доступу немає. Через це друкарка в назві атрибута ніколи не відкриває доступ, але й не кидає помилки — саме тому атрибути тримають константами voter'а.

Ролі — не окремий механізм, а такі самі атрибути. Рядок, що починається з ROLE_, підбирає RoleHierarchyVoter: він бере ролі токена, розгортає їх через RoleHierarchy::getReachableRoleNames() за конфігом role_hierarchy і порівнює з потрібною. Ключова деталь, на якій валяться: розгортання живе всередині цього voter'а, а не в токені. $token->getRoleNames() і $user->getRoles() повертають сирі ролі, тому перевірка in_array('ROLE_ADMIN', $token->getRoleNames(), true) у власному voter'і не побачить ROLE_SUPER_ADMIN, який успадковує ROLE_ADMIN. Правильно — інʼєктувати Symfony\Bundle\SecurityBundle\Security і викликати isGranted('ROLE_ADMIN'). Поруч із ролями є службові атрибути: IS_AUTHENTICATED_FULLY, IS_AUTHENTICATED_REMEMBERED, IS_IMPERSONATOR, PUBLIC_ACCESS (замість прибраного в Symfony 6 IS_AUTHENTICATED_ANONYMOUSLY).

Точок входу в перевірку дві, і вони працюють на різних етапах запиту. access_control у security.yaml — це AccessListener на firewall: він бачить лише URL, IP, метод і хост, спрацьовує до контролера і добре закриває цілі розділи (/admin). Атрибут #[IsGranted] (у ядрі з Symfony 6.2) обробляється слухачем на події kernel.controller_arguments, тобто вже після того, як ParamConverter або MapEntity завантажили Post — тому subject: 'post' посилається на аргумент контролера й дає перевірку на конкретному обʼєкті. Той самий voter викликається з Twig через is_granted('POST_EDIT', post), коли треба сховати кнопку, і з сервісу через denyAccessUnlessGranted(). Це і є головна цінність voter'а: правило описане один раз, а не тричі.

Продуктивність упирається в те, що voter — звичайний сервіс, який викликається на кожен isGranted() і нічого не memoize. Абстрактний Voter у Symfony 6/7 реалізує CacheableVoterInterface, і його supportsAttribute()/supportsType() дозволяють менеджеру раз і назавжди зрозуміти, що цей voter не цікавиться атрибутом ROLE_USER чи типом Comment, і більше його не смикати. Але всередині свого voter'а за кеш відповідаєте ви: якщо voteOnAttribute() ходить у базу по правах, то список із 200 постів дасть 200 запитів. Лікується це або завантаженням прав одним запитом у приватну властивість voter'а, або перевіркою на рівні запиту (фільтрувати вибірку по власнику), а не поштучним isGranted() у шаблоні.

Межі підходу варто назвати самому. #[IsGranted] захищає дію контролера, а не сутність: виклик того самого сервісу з консольної команди чи з Messenger-хендлера пройде повз перевірку, тому критичні правила дублюють у домені. Voter'и погано підходять для «показати лише свої записи» — це завдання запиту, а не перевірки постфактум. І якщо прав стає багато й вони налаштовуються адміністратором у рантаймі, конфіг role_hierarchy перестає бути відповіддю: тоді ролі перетворюють на permissions у базі, а voter стає тонким шаром, який їх читає, лишаючи isGranted() єдиним публічним API авторизації.

// src/Security/Voter/PostVoter.php
final class PostVoter extends Voter   // абстрактний Voter реалізує CacheableVoterInterface
{
    public const EDIT = 'POST_EDIT';
    public const PUBLISH = 'POST_PUBLISH';

    public function __construct(private Security $security) {}

    protected function supports(string $attribute, mixed $subject): bool
    {
        // false тут = ABSTAIN, а не DENY: інші voter'и голосують далі
        return in_array($attribute, [self::EDIT, self::PUBLISH], true)
            && $subject instanceof Post;
    }

    /** @param Post $subject */
    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;                     // не залогінений
        }

        // ROLE_ADMIN перевіряємо через Security, щоб спрацювала role_hierarchy;
        // $token->getRoleNames() поверне сирі ролі без успадкування
        if ($this->security->isGranted('ROLE_ADMIN')) {
            return true;
        }

        return match ($attribute) {
            self::EDIT => $subject->getAuthor() === $user && !$subject->isLocked(),
            self::PUBLISH => $subject->getAuthor() === $user
                && $this->security->isGranted('ROLE_EDITOR'),
        };
    }
}

// src/Controller/PostController.php — subject бере значення аргументу $post
#[Route('/posts/{id}/edit', name: 'post_edit')]
#[IsGranted(PostVoter::EDIT, subject: 'post')]
public function edit(Post $post): Response { /* ... */ }

// config/packages/security.yaml
// role_hierarchy:
//     ROLE_EDITOR: [ROLE_USER]
//     ROLE_ADMIN:  [ROLE_EDITOR]
//     ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
Що `isGranted()` не знає нічого про ролі: він передає атрибут і subject у AccessDecisionManager, а той опитує всі теговані `security.voter` сервіси.
Що стратегія за замовчуванням affirmative — достатньо одного GRANTED; при повному ABSTAIN доступ забороняється, бо `allow_if_all_abstain: false`.
Що `role_hierarchy` розгортає RoleHierarchyVoter, тому в кастомному voter'і не можна порівнювати `$token->getRoleNames()` напряму — треба `Security::isGranted('ROLE_X')`.
Що абстрактний `Voter` у Symfony 6/7 реалізує `CacheableVoterInterface`: `supportsAttribute()` і `supportsType()` дозволяють менеджеру взагалі не викликати voter для чужих атрибутів.
Що `access_control` спрацьовує на firewall до контролера і працює з URL, а `#[IsGranted]` — на `kernel.controller_arguments`, тому має доступ до вже завантаженого обʼєкта.
Перевіряти роль усередині voter'а через `in_array('ROLE_ADMIN', $token->getRoleNames(), true)`: токен містить сирі ролі, ієрархія там не розгорнута, і `ROLE_SUPER_ADMIN` не спрацює.
Писати в voter'і `if (!$user instanceof User) { return false; }` замість `return false` у `supports()` — voter повертає DENY замість ABSTAIN і глушить решту voter'ів при стратегії unanimous.
Ставити `IS_AUTHENTICATED_ANONYMOUSLY` у security.yaml: атрибут прибрано в Symfony 6, для відкритих маршрутів є `PUBLIC_ACCESS`.
Класти бізнес-логіку доступу в контролер (`if ($post->getAuthor() === $this->getUser())`) і дублювати ту саму умову в Twig — правило розповзається у трьох місцях замість одного voter'а.
Викликати `isGranted()` у циклі по 200 сутностях: voter не кешує результат між викликами, і кожен його запит до БД перетворюється на N+1.
Вважати, що `#[IsGranted]` захищає entity: він захищає лише дію контролера, прямий виклик сервісу з іншого місця пройде без перевірки.
ПОРАДА

Порада: скажіть, що voter — це єдине місце правди для правила доступу, бо той самий `isGranted('EDIT', $post)` викликається з контролера, з Twig (`is_granted`) і з сервісу. І одразу назвіть пастку з `role_hierarchy`: у voter'і ролі перевіряють через `Security`, а не через `$token->getRoleNames()`.

Сторінка питання →
LR
Laravel·Middle ·Eloquent ·N+1 ·eager loading

N+1 виникає, коли для колекції з N записів виконується ще N запитів на звʼязки; виправляється eager loading, а ловиться через preventLazyLoading, Debugbar або Telescope.

Чому сторінка зі списком робить 200 запитів до бази?
Що таке eager loading і коли він шкодить?
Як зловити N+1 до того, як він потрапить у продакшн?

N+1 — це ситуація, коли один запит повертає N записів, а далі для кожного з них виконується ще один запит по звʼязку. Кожен окремий запит швидкий, тому профайлер повільних запитів нічого не покаже. Проблему видно лише за кількістю: сторінка на 50 постів робить 51 запит, на 500 постів 501.

В Eloquent причина завжди одна: доступ до незавантаженого звʼязку всередині циклу, у PHP чи в Blade. Виправлення теж одне: завантажити звʼязок заздалегідь через with() на запиті або load() на готовій колекції. Замість N запитів Eloquent зробить один із WHERE id IN (...) і розкладе результат по моделях.

Eager loading має свою ціну. Якщо потрібен лише лічильник або сума, withCount і withSum дешевші, ніж завантаження всіх звʼязаних рядків. Глибокі звʼязки на великих колекціях завантажують у памʼять десятки тисяч моделей, тому в таких місцях краще обмежений with через closure або окрема пагінація.

Найважливіша частина відповіді — як не допустити N+1 знову. Model::preventLazyLoading() у не-продакшн середовищах кидає виняток на кожен лінивий доступ, і проблема падає в тестах. У продакшені порушення варто логувати через handleLazyLoadingViolationUsing, а кількість запитів на HTTP-запит виводити в метрики.

// Було: 1 запит на пости + N запитів на авторів
foreach (Post::all() as $post) {
    echo $post->author->name;
}

// Стало: 2 запити (пости, потім автори через WHERE id IN (...))
$posts = Post::with('author')->get();

// Лише лічильник: не тягнемо самі коментарі
$posts = Post::withCount('comments')->get();
$posts->first()->comments_count;

// Обмежений eager loading через closure
$posts = Post::with(['comments' => fn ($q) => $q->latest()->limit(3)])->get();

// AppServiceProvider::boot(): ловимо N+1 ще в розробці та тестах
Model::preventLazyLoading(! $this->app->isProduction());

// У продакшені не падаємо, а логуємо
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    Log::warning("Lazy loading [{$relation}] on ".$model::class);
});
Розуміння механіки: lazy loading звʼязку всередині циклу породжує окремий запит на кожну ітерацію.
Що with() перетворює N запитів на один додатковий з WHERE IN, а load() робить те саме для вже отриманої колекції.
Що Model::preventLazyLoading(! app()->isProduction()) кидає виняток на кожен лінивий доступ і ловить проблему ще в тестах.
Що withCount, withSum і підзапити через addSelect вирішують випадки, коли потрібні лише агрегати, а не самі звʼязані моделі.
Що eager loading не безкоштовний: завантажити 10 000 коментарів заради лічильника гірше, ніж withCount, а глибокі with на великих колекціях зʼїдають памʼять.
Плутати N+1 з повільним запитом: тут кожен запит швидкий, проблема в їхній кількості.
Вважати, що with() всередині циклу щось вирішує: eager loading має бути на запиті, який формує колекцію.
Додавати with('comments') щоб порахувати коментарі замість withCount('comments').
Не знати, що N+1 буває і в Blade: доступ до $post->author у шаблоні всередині @foreach те саме, що в PHP-циклі.
Лікувати індексом на зовнішній ключ: індекс прискорює кожен запит, але не зменшує їхню кількість.
ПОРАДА

Найкраща відповідь згадує Model::preventLazyLoading() у AppServiceProvider — тоді N+1 падає з помилкою ще на етапі розробки. Додайте, як логуєте кількість запитів на запит у продакшені.

Сторінка питання →
WP
WordPress·Middle ·безпека ·nonce ·capabilities

Nonce (wp_nonce_field + check_admin_referer) захищає від CSRF, current_user_can з конкретною здатністю — від перевищення прав, esc_* на виводі — від XSS, $wpdb->prepare — від SQL-інʼєкції. Це чотири різні шари, і жоден не замінює інший.

Nonce ви перевірили — навіщо тоді ще current_user_can?
У формі є wp_nonce_field, а дію все одно виконує передплатник. Як таке можливо?
Ми санітизуємо все на вході через sanitize_text_field. Чи потрібно щось робити на виводі?
У $wpdb->prepare підставляємо назву таблиці змінною — де тут проблема?
Форма на фронтенді під повносторінковим кешем: чому nonce «протухає» і що з цим робити?

Безпека плагіна — це чотири окремі шари, які закривають чотири різні атаки, і на співбесіді перевіряють саме те, чи людина їх не змішує. Nonce закриває CSRF: wp_nonce_field('crm_save_city') кладе у форму токен, обчислений з імені дії, id користувача, його токена сесії та поточного тіку часу, а check_admin_referer цей токен звіряє й у разі невдачі завершує запит через wp_die(-1) зі статусом 403. Capability закриває перевищення прав: current_user_can('manage_options') питає про конкретну здатність, а для окремого обʼєкта — мета-здатність з ідентифікатором, current_user_can('edit_post', $id), яка проходить через map_meta_cap і враховує авторство та статус запису. Ці дві перевірки не взаємозамінні: валідний nonce отримає будь-який залогінений користувач, що відкрив сторінку, а сама лише перевірка прав лишає адміністратора вразливим до запиту, ініційованого чужим сайтом його ж кукою.

Найпоширеніша діра в цьому місці — довіра до UI. Аргумент capability в add_menu_page лише вирішує, показувати пункт меню чи ні; функція-колбек, обробник admin_post_{action} і wp_ajax_{action} викликаються за прямим URL і мусять перевіряти права самі. Так само wp_ajax_nopriv_, доданий «щоб працювало для всіх», перетворює адмінську дію на публічну. Nonce для розлогінених користувачів рахується з user_id 0 і порожнього токена сесії, тобто однаковий для всіх гостей і CSRF-захисту не дає — це варто памʼятати перед тим, як будувати на ньому безпеку публічної форми.

Робота з даними ділиться на дві незалежні операції. На вході — санітизація: wp_unslash (WordPress історично додає слеші в $_POST і $_GET), потім sanitize_text_field, absint, sanitize_email, wp_kses_post — залежно від того, що це за поле. На виході — екранування, і воно має бути пізнім: esc_html у тексті, esc_attr в атрибуті, esc_url у href і src, wp_kses_post там, де розмітка дозволена. Контекст відомий лише в точці виводу, тому екранувати перед записом у базу — помилка: у полях накопичується &amp;#039;, а при зміні контексту захист усе одно не спрацює. Дані з бази, з опцій чи від іншого плагіна теж екрануються — «ми ж санітизували на вході» не аргумент, бо значення могло потрапити туди в обхід вашого коду.

SQL закривається $wpdb->prepare з плейсхолдерами %s, %d, %f, а з WP 6.2 — ще й %i для ідентифікаторів. Найтиповіша напівміра — підставити значення плейсхолдером, а назву таблиці або ORDER BY вклеїти в рядок конкатенацією. До 6.2 імена таблиць збирали з $wpdb->prefix, напрямок сортування й колонки звіряли з білим списком, і цей підхід лишається робочим для сумісності зі старими версіями. Для IN (...) генерують рядок плейсхолдерів потрібної довжини, для LIKE — спершу $wpdb->esc_like, і лише потім обгортають у %. Прості вставки й оновлення краще робити через $wpdb->insert, $wpdb->update і $wpdb->delete: вони екранують значення за переданим форматом і не дають забути про prepare.

Межа цих інструментів у тому, що вони працюють тільки там, де їх викликали. Прохід власного коду через phpcs з набором WordPress-Coding-Standards ловить неекранований вивід і запити без prepare автоматично й коштує дешевше за ручний рев'ю. Для REST-маршрутів роль nonce і capability бере на себе permission_callback разом із заголовком X-WP-Nonce, а для завантаження файлів жодна з чотирьох перевірок не допомагає — там потрібні wp_check_filetype_and_ext і wp_handle_upload, які перевіряють реальний тип, а не розширення.

// Форма в адмінці: nonce іде прихованим полем, дія має власне імʼя
function crm_city_form(): void
{
    ?>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="crm_save_city">
        <?php wp_nonce_field('crm_save_city'); // додає _wpnonce і _wp_http_referer ?>
        <input type="text" name="city"
               value="<?php echo esc_attr(get_option('crm_city', '')); ?>">
        <?php submit_button(); ?>
    </form>
    <?php
}

add_action('admin_post_crm_save_city', function (): void {
    // 1. CSRF: невірний або протухлий nonce -> wp_die(-1) зі статусом 403
    check_admin_referer('crm_save_city');

    // 2. Права: nonce каже «форма наша», а не «цьому користувачу можна»
    if (! current_user_can('manage_options')) {
        wp_die(esc_html__('Недостатньо прав', 'crm'), 403);
    }

    // 3. Вхід: WP слешить суперглобали, тому спершу wp_unslash, потім санітизація
    $city = sanitize_text_field(wp_unslash($_POST['city'] ?? ''));
    update_option('crm_city', $city);

    global $wpdb;
    $rows = $wpdb->get_results($wpdb->prepare(
        // %i — імʼя таблиці (WP 6.2+), %s — значення, %d — число
        'SELECT id, name FROM %i WHERE city = %s AND name LIKE %s LIMIT %d',
        $wpdb->prefix.'crm_leads',
        $city,
        '%'.$wpdb->esc_like($city).'%', // esc_like екранує _ і % у шаблоні
        20
    ));

    // 4. Екранування на виводі й під контекст: url у href, html у тексті
    foreach ($rows as $row) {
        printf('<a href="%s">%s</a>',
            esc_url(admin_url('admin.php?page=crm&lead='.(int) $row->id)),
            esc_html($row->name));
    }
});
Що nonce і capability відповідають на різні питання: nonce — «чи цей запит справді з нашої форми», capability — «чи цьому користувачу взагалі можна»; перевіряти треба обидва.
Що права перевіряють через конкретну здатність (manage_options, edit_others_posts), а для окремого обʼєкта — через мета-здатність з id: current_user_can('edit_post', $id), а не через is_user_logged_in() чи перевірку ролі.
Що аргумент capability в add_menu_page лише ховає пункт меню — сама функція-колбек лишається доступною за прямим URL і мусить перевіряти права самостійно.
Що екранують пізно, на самому виводі, і під контекст: esc_html у тексті, esc_attr в атрибуті, esc_url у href/src, wp_kses_post там, де HTML дозволений.
Що санітизація на вході (sanitize_text_field, absint) і екранування на виводі — різні речі; одне не скасовує іншого, бо дані можуть прийти з бази, з опції чи з іншого плагіна.
Що $wpdb->prepare підставляє лише значення (%s, %d, %f) та ідентифікатори (%i з WP 6.2), а $wpdb->insert/update/delete екранують самі за форматом.
Що перед санітизацією даних із $_POST/$_GET потрібен wp_unslash, бо WordPress історично додає слеші в суперглобали.
Вважати nonce перевіркою прав: «check_admin_referer пройшов, значить користувач має право» — nonce валідний у будь-якого залогіненого користувача, який відкрив сторінку.
Перевіряти роль замість здатності: current_user_can('administrator') працює випадково, ламається на кастомних ролях і не враховує map_meta_cap.
Покладатися на capability в add_menu_page чи на приховану кнопку в UI, лишивши обробник admin_post_ / wp_ajax_ без власної перевірки.
Реєструвати дію на wp_ajax_nopriv_ «щоб працювало у всіх» і відкрити запис у базу анонімам.
Екранувати на вході (esc_html перед update_post_meta) і зберігати в базі вже екранований текст: у полях зʼявляються &amp;#039; і подвійне екранування.
Ставити esc_html у href або esc_attr в URL: esc_html не відріже javascript:, а esc_url ще й фільтрує дозволені протоколи.
Писати $wpdb->query("SELECT ... WHERE id = $id") і виправдовувати це тим, що «$id все одно int» — без явного приведення чи %d це інʼєкція.
Використовувати prepare лише для частини аргументів: інтерполювати назву таблиці або ORDER BY у рядок запиту, а значення підставляти плейсхолдером.
Забувати $wpdb->esc_like перед LIKE: символи % і _ у введенні перетворюють пошук на повний перебір і міняють логіку умови.
ПОРАДА

Скажіть одним реченням, що це чотири незалежні шари: nonce — від CSRF, capability — від перевищення прав, escaping — від XSS, prepare — від SQL-інʼєкції, і що падіння будь-якого з них не компенсується іншими. Додайте, що екранування має бути «пізнім» — просто перед echo, бо тільки там відомий контекст виводу.

Сторінка питання →
LR
Laravel·Middle ·Queue ·Horizon ·retries

Кількість спроб задають `$tries` (або `$maxExceptions`), паузу між ними — `$backoff` чи метод `backoff()`, дедлайн — `retryUntil()`, а фінальний обробник — `failed(Throwable $e)`. Ключова пастка не в цих властивостях, а в тому, що `retry_after` у config/queue.php має бути більшим за `$timeout` job, інакше воркер підхопить ще працюючий job і виконає його вдруге. Horizon дає ті самі налаштування на рівні супервізора, дашборд і теги; `ShouldBeUnique`, `Bus::batch()` і `Bus::chain()` керують не повторами, а тим, що взагалі потрапить у чергу і в якому порядку.

Лист клієнту прийшов тричі, хоча job виконався успішно — як таке можливо?
У вас `public $tries = 3`, а в `failed_jobs` порожньо, хоча job явно падає — куди дівся запис?
Job кидає ModelNotFoundException одразу після `Order::create()` в транзакції — чому?
Скільки разів повториться job, у якого є і `$tries = 5`, і `retryUntil()` на годину вперед?
Зовнішнє API повернуло 429 — як зробити паузу для всієї черги, а не для одного job?

Черга в Laravel — це два незалежні механізми, які початківці зливають в один. Перший: драйвер зберігає серіалізований payload і при pop() резервує його — ставить позначку reserved_at (database) або переносить у sorted set :reserved (redis). Другий: воркер queue:work бере job, викликає handle() і, якщо винятку не було, видаляє його з черги. Між резервуванням і видаленням є вікно, у якому процес може померти, і саме тому черга дає гарантію at-least-once, а не exactly-once. Параметр retry_after у config/queue.php каже, через скільки секунд вважати зарезервований job загубленим і повернути його в роботу; $timeout job (і --timeout воркера) — через скільки секунд убити процес обробки. Якщо retry_after менший за $timeout, черга віддасть job другому воркеру, поки перший ще працює, і ви отримаєте дубль, який не пояснюється жодним $tries. Це найчастіша реальна причина «листа тричі», і перше, що варто назвати на співбесіді.

Кількість повторів задають на трьох рівнях, і вони комбінуються. public int $tries (або --tries воркера, або tries супервізора Horizon) — це ліміт спроб: кожне взяття job з черги, включно з поверненням через $this->release(30) і з таймаутом. public int $maxExceptions — ліміт саме необроблених винятків; він потрібен там, де job легально повертається в чергу десятки разів (middleware WithoutOverlapping, RateLimited, ThrottlesExceptions), і без нього щедрий $tries перетворює три реальні помилки на двадцять п'ять. retryUntil(): DateTimeInterface задає дедлайн замість лічильника: після цього моменту job більше не повторюється незалежно від спроб. Важлива деталь механіки — значення retryUntil() обчислюється один раз при диспатчі й лягає в payload разом із job, тому повтори його не зсувають; якщо задано і $tries, і retryUntil(), спрацює те, що настане раніше. Паузу між спробами описує public $backoff або метод backoff(): число дає однакову затримку, масив [10, 60, 300] — прогресію (останнє значення повторюється для всіх подальших спроб). Для зовнішніх API майже завжди треба експоненційна прогресія плюс джиттер, інакше сотня job, що впала на одному збої, синхронно повернеться в те саме API.

Коли спроби вичерпано, воркер викликає failed(Throwable $e) на job і пише рядок у failed_jobs (провайдер database-uuids за замовчуванням). Тут ховається пастка, яку перевіряють на middle-рівні: failed() виконується на новому екземплярі, відновленому з payload, а не на тому, що працював у handle(). Усе, що ви присвоїли властивостям під час обробки, там уже недоступне — у failed() є лише конструкторські дані та виняток. Друга пастка симетрична: якщо ви обгорнули тіло handle() у try/catch і мовчки залогували помилку, job вважається успішним, failed() не викличеться і в failed_jobs не буде нічого — «падає, але нічого не пишеться» майже завжди означає саме це. Явно провалити job можна через $this->fail($e). Глобально фейли ловлять хуком Queue::failing() у сервіс-провайдері; далі — queue:failed, queue:retry {uuid}, queue:forget, а щоб таблиця не росла вічно — queue:prune-failed --hours=168 у планувальнику. Окремий випадок: job із SerializesModels, чия модель видалена, падає з ModelNotFoundException на кожному повторі — правильна реакція не ретрай, а public bool $deleteWhenMissingModels = true.

Horizon — це не інший механізм черг, а надбудова над тим самим воркером, і працює вона тільки з Redis. Конфіг воркерів переїжджає з supervisor у config/horizon.php, де на кожен супервізор описані черги, processes, tries, timeout, memory і стратегія balance: simple ділить процеси між чергами порівну, auto перерозподіляє їх динамічно між minProcesses і maxProcesses (за часом розгрібання або за розміром черги), false обробляє черги строго за пріоритетом. Взамін ви отримуєте дашборд із пропускною здатністю, часом виконання і стектрейсами падінь, теги через метод tags() (щоб знайти всі job однієї сутності) та нотифікації про довге очікування. Дві операційні деталі, які люблять питати: horizon:snapshot має стояти в планувальнику, бо без нього метрики порожні, а на деплої викликають horizon:terminate (аналог queue:restart) — інакше воркери продовжать виконувати старий код, який вони тримають у памʼяті ще з моменту старту.

Останній шар — керування тим, що взагалі потрапляє в чергу і в якому порядку. ShouldBeUnique не сканує чергу: при диспатчі він бере атомарний лок у кеші за uniqueId() на $uniqueFor секунд і, якщо лок зайнятий, просто не диспатчить job — тихо, без винятку. Потрібен стор із локами (redis, memcached, dynamodb, database), спільний для всіх воркерів; ShouldBeUniqueUntilProcessing знімає лок на початку handle(), а від одночасного виконання захищає інший інструмент — middleware WithoutOverlapping. Bus::chain([...]) виконує job послідовно, диспатчачи наступний лише після успіху попереднього; падіння обриває ланцюжок і викликає catch(), замикання якого серіалізується, тому $this в нього затягувати не можна. Bus::batch([...]) виконує job паралельно, рахує прогрес у job_batches і дає then(), catch() (лише для першого падіння), finally(); за замовчуванням перше падіння скасовує батч, що вимикається через allowFailures(), а вже поставлені в чергу job усе одно будуть узяті — тому в них перевіряють $this->batch()->cancelled() або вішають SkipIfBatchCancelled. І наскрізне правило, яке важливіше за всі ці властивості: dispatch()->afterCommit() (або after_commit => true у конфізі зʼєднання), бо job, відправлений усередині транзакції, регулярно виграє гонку в COMMIT і не знаходить у базі рядок, заради якого його створили.

final class ChargeInvoice implements ShouldQueue, ShouldBeUnique
{
    use Batchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 25;          // спроб разом із release() від middleware
    public int $maxExceptions = 3;   // а реальних винятків — лише три
    public int $timeout = 60;        // менший за retry_after у config/queue.php!
    public bool $failOnTimeout = true;
    public bool $deleteWhenMissingModels = true;
    public int $uniqueFor = 300;     // страховка, якщо воркера вбили

    public function __construct(public Invoice $invoice) {}

    public function uniqueId(): string
    {
        return (string) $this->invoice->id;
    }

    /** Пауза між спробами: 10 с, 60 с, 5 хв. */
    public function backoff(): array
    {
        return [10, 60, 300];
    }

    /** Обчислюється один раз при диспатчі й лягає в payload. */
    public function retryUntil(): DateTimeInterface
    {
        return now()->addHours(2);
    }

    public function middleware(): array
    {
        // 429 від провайдера: перші 5 винятків за 10 хв — не спамимо API.
        return [new ThrottlesExceptions(5, 10 * 60), new SkipIfBatchCancelled];
    }

    public function handle(PaymentGateway $gateway): void
    {
        if ($this->invoice->isPaid()) {
            return; // ідемпотентність: at-least-once означає можливий дубль
        }

        $gateway->charge($this->invoice, idempotencyKey: "inv-{$this->invoice->id}");
    }

    /** Новий екземпляр із payload: властивостей із handle() тут уже немає. */
    public function failed(Throwable $e): void
    {
        $this->invoice->markChargeFailed($e->getMessage());
    }
}

// Диспатч після COMMIT, інакше воркер не побачить щойно створений рядок.
ChargeInvoice::dispatch($invoice)->afterCommit()->onQueue('payments');
Що `retry_after` у конфізі зʼєднання і `$timeout` job — різні речі: перший каже, через скільки секунд чергу вважати job загубленим і віддати іншому воркеру, і він мусить бути більшим за timeout, інакше job виконається двічі паралельно.
Що `failed()` викликається на новому екземплярі, відновленому з payload: усе, що ви записали у властивості під час `handle()`, там уже втрачене.
Що `retryUntil()` обчислюється один раз при диспатчі й кладеться в payload, тому повтори його не зсувають; а якщо є і `$tries`, і `retryUntil()`, спрацює те, що настане раніше.
Що job має бути ідемпотентним, бо at-least-once доставка гарантована, а exactly-once — ні: воркера можуть вбити після сайд-ефекту, але до видалення job з черги.
Що `ShouldBeUnique` тримає лок у кеші (потрібен стор з атомарними локами), знімає його після завершення або падіння, а `ShouldBeUniqueUntilProcessing` — на початку `handle()`.
Що за замовчуванням перший фейл у батчі скасовує весь батч, і що `allowFailures()` це вимикає, а `catch()` викликається лише для першого падіння.
Ставити `$timeout = 300` при дефолтному `retry_after = 90` і потім дивуватися дублям: job ще працює, а черга вже віддала його другому воркеру.
Вважати, що `$tries = 3` рахує винятки. Воно рахує спроби: кожен `release()` і кожен таймаут теж збільшують `attempts()`. Рахує саме винятки `$maxExceptions`.
Диспатчити job усередині `DB::transaction()` без `->afterCommit()` — воркер підхоплює його швидше, ніж відбувся COMMIT, і отримує `ModelNotFoundException`.
Писати логіку компенсації у `failed()` і покладатися на `$this->результат`, порахований у `handle()`: об'єкт інший, властивості з payload.
Не перезапускати воркери після деплою: `queue:work` тримає завантажений код у памʼяті, тож потрібен `queue:restart` або `horizon:terminate`.
Ловити виняток у `handle()` через `try/catch` і мовчки логувати — job вважається успішним, `failed()` не викличеться, у `failed_jobs` нічого не буде.
Плутати `$backoff` з паузою після падіння всієї черги: для 429 і 503 потрібні middleware `RateLimited` або `ThrottlesExceptions`, які тримають лок для всіх job класу.
ПОРАДА

Почніть не з `$tries`, а з фрази «черга гарантує at-least-once, тому job має бути ідемпотентним» — і одразу назвіть два джерела дублів: `retry_after` менший за `$timeout` і сайд-ефект без ключа ідемпотентності. Далі складіть картину з трьох рівнів: скільки разів (`$tries`/`$maxExceptions`/`retryUntil`), з якою паузою (`$backoff`, exponential + jitter), що робити наприкінці (`failed()`, `queue:retry`, `queue:prune-failed`). Horizon згадуйте як те, що дає ті самі опції на рівні супервізора плюс метрики й теги, а не як заміну розуміння.

Сторінка питання →
OPS
DevOps·Middle ·Docker ·шари образу ·multi-stage

Multi-stage: у білдерах Composer і збірка ассетів, у фінальному образі лише runtime, vendor без dev-залежностей і код; шари впорядковані так, щоб зміна коду не інвалідувала шар із залежностями.

Чому composer install має йти після копіювання лише composer.json і lock?
Навіщо multi-stage build для PHP-проєкту з фронтендом?
Що має бути в продакшн-образі, а чого не має?

Правильний образ починається з розуміння кешу шарів. Docker перевикористовує шар, поки його вхідні дані не змінились, і перезбирає все після першого зміненого. Код змінюється щодня, composer.lock рідко, тому спершу копіюють лише composer.json і composer.lock, ставлять залежності, і тільки потім копіюють решту. Так зміна одного файлу не перевстановлює vendor.

Multi-stage розділяє збірку й рантайм. Composer живе у своєму стейджі, Node зі збіркою Vite у своєму, а фінальний образ на php:8.4-fpm-alpine отримує лише результат через COPY --from: vendor без dev-залежностей із оптимізованим автолоадером і зібраний public/build. Фінальний образ менший у рази, не містить інструментів збірки й має меншу поверхню атаки.

Продакшн-налаштування PHP входять в образ: php.ini-production як база, opcache з validate_timestamps=0, бо в immutable-образі файли не змінюються, а деплой це заміна контейнера. Версія базового образу пінується до мінорної, процес запускається від www-data, секрети приходять через змінні середовища, а не через .env у шарах. Healthcheck, логи в stdout і graceful shutdown FPM завершують картину. Для нового проєкту альтернативою FPM з nginx є FrankenPHP: один бінарник із вбудованим сервером і worker mode.

# syntax=docker/dockerfile:1

# 1. Залежності PHP: шар кешується, доки не зміниться composer.lock
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative

# 2. Фронтенд: Node живе лише тут
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources
COPY vite.config.js ./
RUN npm run build

# 3. Рантайм: пінована версія, без Composer, Node і dev-залежностей
FROM php:8.4-fpm-alpine AS runtime
RUN docker-php-ext-install pdo_pgsql opcache \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/opcache.ini $PHP_INI_DIR/conf.d/   # validate_timestamps=0, jit, memory
WORKDIR /var/www
COPY --chown=www-data:www-data --from=vendor /app ./
COPY --chown=www-data:www-data --from=assets /app/public/build ./public/build
USER www-data
HEALTHCHECK CMD php-fpm-healthcheck || exit 1
CMD ["php-fpm"]
Що кеш шарів працює зверху вниз: усе після зміненого шару перезбирається, тому залежності ставляться до копіювання коду.
Що multi-stage дає малий фінальний образ без Node, Composer і dev-залежностей, а також єдину точку правди для збірки в CI і локально.
Що продакшн-налаштування PHP задаються в образі: opcache з validate_timestamps=0, preload за потреби, memory_limit, realpath cache, і php.ini-production як база.
Що образ пінується до конкретної версії PHP і базового образу, запускається від non-root, а секрети не потрапляють у шари.
Що для FPM потрібен окремий nginx або єдиний образ на FrankenPHP, і що healthcheck, логи в stdout і graceful shutdown є частиною правильного образу.
Копіювати весь проєкт першим шаром: кожна зміна коду перевстановлює всі залежності.
Тягнути dev-залежності, Node і node_modules у продакшн-образ.
Використовувати тег latest або php:8 без мінорної версії й отримувати несподіване оновлення.
Запускати composer install або npm ci на старті контейнера замість на етапі збірки.
Лишати opcache.validate_timestamps=1 у продакшені й платити stat-викликами на кожен запит.
Запускати процес від root і класти .env з секретами в образ.
ПОРАДА

Додайте, що opcache.validate_timestamps=0 у продакшн-образі дає відчутний приріст, і поясніть, чому це безпечно саме в immutable-образі. Згадайте FrankenPHP як варіант без окремого nginx.

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

Middleware — це шари Pipeline навколо контролера: код до `$next($request)` бачить запит, код після — уже готову відповідь. Спершу йдуть глобальні (у порядку реєстрації, ще до роутера), потім групові й маршрутні — але ці фреймворк переставляє за списком пріоритетів, а `terminate()` викликається окремо, вже після відправки відповіді.

Ми додали свій middleware у групу web, а всередині `$request->user()` завжди null — чому так виходить?
Middleware у групі виконуються рівно в тому порядку, в якому я їх записав? Якщо ні, хто цей порядок змінює?
Чим terminable middleware відрізняється від коду після `$next($request)` — і те, і те ж після контролера?
Як виключити один middleware для одного маршруту всередині групи web?

Middleware в Laravel — це не «перевірка перед контролером», а шар навколо нього. Ядро складає масив класів і віддає його в Illuminate\Pipeline\Pipeline, який згортає їх у вкладені замикання: кожен handle(Request $request, Closure $next) викликає наступний шар і отримує назад Response. Звідси два проходи в одному методі: усе до $next($request) бачить лише запит і виконується зверху вниз, усе після — вже готову відповідь і виконується знизу вгору. Саме тому «before» і «after» у Laravel не окремі хуки, як у старих фреймворках, а позиція рядка коду відносно $next. Якщо $next не викликати взагалі й повернути власну відповідь, ланцюг обривається — так працюють abort(403), редірект гостя на форму входу й PreventRequestsDuringMaintenance.

Реєструються middleware у чотирьох місцях, і від місця залежить момент виконання. Глобальні ($middleware->append() / prepend() у bootstrap/app.php) працюють на кожен запит ще до того, як роутер знає маршрут: там живуть TrustProxies, HandleCors, ValidatePostSize, TrimStrings. Групи — web(), api(), довільна group('admin', [...]) — застосовуються до всіх маршрутів групи, але вже після пошуку маршруту. Псевдоніми з alias() дають короткі імена для навішування на конкретний маршрут (Route::middleware('team:owner')), а контролер може оголосити свої через статичний метод middleware() інтерфейсу Illuminate\Routing\Controllers\HasMiddleware, зокрема з ->only() і ->except() для окремих екшенів. У Laravel 11+ усе це налаштовується виключно в bootstrap/app.php: класу app/Http/Kernel.php у застосунку більше немає, а стандартні списки лежать у Illuminate\Foundation\Configuration\Middleware.

Порядок — найчастіше місце, де плутаються. Глобальний стек виконується рівно так, як записаний у масиві, і жодного сортування там немає. А от стек маршруту роутер спершу збирає (групові, потім маршрутні, потім контролерні, з дедуплікацією), а вже потім проганяє через SortedMiddleware. Той піднімає вгору лише ті елементи, які знайшов у списку $middlewarePriority з HTTP-ядра: EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, AuthenticatesRequests, ThrottleRequests, AuthenticatesSessions, SubstituteBindings, Authorize. Усі інші лишаються на своїх позиціях. Тому симптом «мій middleware у групі web, а $request->user() порожній» майже завжди означає, що він опинився перед StartSession; лікується не перестановкою рядка в масиві, а appendToPriorityList() / prependToPriorityList(). Повністю переписувати список через priority([...]) заради одного класу небезпечно: легко зламати ланцюг StartSessionSubstituteBindingsAuthorize.

Terminable middleware стоїть осторонь від Pipeline. Якщо в класі є метод terminate($request, $response), ядро викличе його в Kernel::terminate() — після того, як send() уже віддав відповідь клієнту (під PHP-FPM Symfony на цьому місці робить fastcgi_finish_request()). Спочатку обходяться middleware маршруту, потім глобальні; реалізовувати спеціальний інтерфейс не потрібно, перевіряється просто method_exists. Дві пастки. Перша: ядро резолвить клас із контейнера заново, тому властивість, записана в handle(), у terminate() буде порожня — потрібен $this->app->singleton(MyMiddleware::class). Друга: це не черга. Процес FPM усе ще зайнятий, помилку ніхто не повторить, тож туди годяться логи й дрібні метрики, а не відправка листа чи виклик зовнішнього API.

Межі й компроміси варто назвати самому. Middleware добре працює як фільтр запиту й декоратор відповіді — автентифікація, локаль, заголовки безпеки, rate limit; погано — як місце для бізнес-логіки, бо його важко тестувати ізольовано й неможливо перевикористати поза HTTP (черги, консольні команди й Livewire-запити ходять іншими шляхами). Виключення теж асиметричні: withoutMiddleware() знімає лише групові й маршрутні, глобальні прибираються тільки через remove() чи replace() у bootstrap/app.php, а для CSRF і maintenance є точковий except: за URI. І останнє, що варто перевіряти руками, а не в голові: php artisan route:list -v показує фактичний стек для кожного маршруту вже після сортування — це швидший спосіб виграти суперечку про порядок, ніж читати bootstrap/app.php.

// app/Http/Middleware/AuditRequest.php — before, after і terminate в одному класі
final class AuditRequest
{
    private ?float $startedAt = null;

    // 'audit:billing' → у $channel прилетить 'billing'
    public function handle(Request $request, Closure $next, string $channel = 'web'): Response
    {
        $this->startedAt = microtime(true);       // BEFORE: контролера ще не було

        if ($request->user()?->isBanned()) {
            // $next не викликано — ланцюг обірвано, контролер не запуститься
            return response()->view('banned', status: 403);
        }

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

        return $response->header('X-Audit-Channel', $channel); // AFTER: відповідь ще не відправлена
    }

    // Викликає Kernel::terminate() після send(): клієнт відповідь уже отримав
    public function terminate(Request $request, Response $response): void
    {
        Log::channel('audit')->info($request->path(), [
            'status' => $response->getStatusCode(),
            // без singleton нижче тут буде null: ядро робить app->make()
            // і в terminate() потрапляє НОВИЙ екземпляр класу
            'ms' => $this->startedAt ? (microtime(true) - $this->startedAt) * 1000 : null,
        ]);
    }
}

// AppServiceProvider::register() — щоб terminate() побачив той самий $startedAt
$this->app->singleton(AuditRequest::class);

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->append(SecurityHeaders::class);              // глобальний: до роутера, на кожен запит
    $middleware->web(append: [AuditRequest::class]);          // уся група web
    $middleware->alias(['team' => EnsureTeamMatches::class]); // Route::middleware('team:owner')
    // без цього рядка team може стати ДО SubstituteBindings і не побачити модель
    $middleware->appendToPriorityList(SubstituteBindings::class, EnsureTeamMatches::class);
})
Що middleware — не «фільтр перед контролером», а шар цибулини: один метод `handle()` містить і before-код (до `$next`), і after-код (після `$next`), який працює вже з `Response`.
Чотири рівні реєстрації в Laravel 11+: глобальний стек (`append`/`prepend`), групи (`web()`, `api()`, `group()`), псевдоніми (`alias()`) для маршрутів і `HasMiddleware` на контролері.
Що порядок усередині маршруту не дорівнює порядку запису: `Router::sortMiddleware()` через `SortedMiddleware` переставляє ті middleware, що є в списку пріоритетів (`StartSession`, `SubstituteBindings`, `Authorize` і т. д.), а решта лишається на своїх місцях.
Що `terminate($request, $response)` викликає ядро після `send()`, і ядро резолвить клас із контейнера заново — без `singleton()` це інший екземпляр, ніж той, що виконував `handle()`.
Що глобальні middleware пріоритетами не сортуються взагалі: для них діє лише порядок у масиві, тому `prepend()` і `append()` тут — єдиний важіль.
Ставити свій middleware у групу web і чекати на `$request->user()`: без пріоритету він може опинитися перед `StartSession`, і сесії ще не існує.
Читати `$request->route('post')` як модель у middleware, який стоїть до `SubstituteBindings`: там ще рядок з URL, а не Eloquent-модель.
Класти важку роботу в `terminate()`, вважаючи його «майже чергою»: він виконується в тому ж процесі FPM, без ретраїв, і тримає воркер зайнятим.
Зберігати стан у властивості middleware й читати його в `terminate()`, не зареєструвавши клас через `$this->app->singleton()` — властивість буде порожня.
Замінювати весь список пріоритетів через `$middleware->priority([...])` заради одного свого класу: так легко втратити пару `StartSession` → `SubstituteBindings` → `Authorize`, замість цього є `appendToPriorityList()` і `prependToPriorityList()`.
Думати, що `withoutMiddleware()` знімає й глобальні middleware: він працює лише зі стеком маршруту (групові й маршрутні), глобальні виключаються тільки на рівні `bootstrap/app.php` через `remove()`.
ПОРАДА

Дайте дві осі одразу: вертикаль — чотири місця реєстрації (глобально → група → alias на маршруті → контролер), горизонталь — два проходи всередині кожного шару (до `$next` і після `$next`), плюс `terminate()` окремо після відправки. І одразу додайте, що всередині маршруту порядок вирішує не ваш масив, а `$middlewarePriority`.

Сторінка питання →
SF
Symfony·Middle ·DI ·compiler passes ·autowiring

Контейнер компілюється в PHP-клас на етапі cache warmup: autowiring читає типи в конструкторах, compiler passes змінюють визначення сервісів, а результат — статичний код без рефлексії в рантаймі.

Чим контейнер Symfony відрізняється від контейнера Laravel?
Навіщо потрібні compiler passes?
Як зібрати всі сервіси з певним тегом?

Контейнер Symfony живе у двох фазах. Перша — побудова визначень: services.yaml, атрибути на класах, автоматична реєстрація всього з src/, autowiring по типах конструкторів і autoconfigure, який навішує теги за інтерфейсами. Друга — компіляція: всі визначення перетворюються на один згенерований PHP-клас у var/cache, де кожен сервіс створюється звичайним new з уже відомими аргументами. У рантаймі немає ні YAML, ні рефлексії, а помилки залежностей видно ще на cache:warmup.

Між фазами працюють compiler passes. Це код, який бачить усі визначення й може їх змінювати: знайти сервіси з тегом і передати їх у реєстр, підмінити реалізацію, додати декоратор. Теги — головний механізм розширення: бандл оголошує тег, ваш код додає сервіс із цим тегом, і нічого чужого правити не треба. У типових випадках compiler pass не потрібен, достатньо #[AutowireIterator] або !tagged_iterator.

Autowiring розвʼязує залежності по типу й не вгадує. Якщо інтерфейс має дві реалізації, потрібен alias за замовчуванням або явний вибір через #[Target] чи #[Autowire]. Сервіси приватні: їх не дістати через $container->get(), і це навмисно, бо залежності мають бути оголошені в конструкторі, а не витягнуті з контейнера в довільному місці.

// config/services.yaml
// services:
//   _defaults: { autowire: true, autoconfigure: true }
//   App\: { resource: '../src/' }
//   App\Export\Exporter: '@App\Export\CsvExporter'   # alias для інтерфейсу з кількома реалізаціями

interface Exporter { public function supports(string $format): bool; }

#[AutoconfigureTag('app.exporter')]           // кожна реалізація отримує тег автоматично
final class CsvExporter implements Exporter { /* ... */ }
final class XlsxExporter implements Exporter { /* ... */ }

final class ExporterRegistry
{
    /** @param iterable<Exporter> $exporters */
    public function __construct(
        #[AutowireIterator('app.exporter')] private iterable $exporters,
    ) {}

    public function for(string $format): Exporter
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->supports($format)) {
                return $exporter;
            }
        }
        throw new UnsupportedFormat($format);
    }
}

// Той самий результат через compiler pass, коли потрібна складніша логіка
final class ExporterPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $refs = array_map(fn ($id) => new Reference($id), array_keys($container->findTaggedServiceIds('app.exporter')));
        $container->getDefinition(ExporterRegistry::class)->setArgument('$exporters', $refs);
    }
}
Що контейнер має дві фази: побудова визначень (services.yaml, атрибути, autoconfigure) і компіляція в один PHP-клас, який у рантаймі просто викликає new.
Що autowiring працює по типу параметра, а для кількох реалізацій одного інтерфейсу потрібен alias, атрибут #[Autowire] або #[Target].
Що compiler pass це хук у момент компіляції: він бачить усі визначення й може їх змінювати, наприклад зібрати теговані сервіси в один реєстр.
Що autoconfigure автоматично навішує теги за інтерфейсом чи атрибутом, тому EventSubscriber або Command реєструються без конфігурації.
Що приватні сервіси не дістати через $container->get(), і це навмисно: залежності оголошуються в конструкторі, а не витягуються з контейнера.
Казати, що контейнер парсить YAML на кожен запит: у prod він скомпільований у var/cache і YAML не читається взагалі.
Плутати autowiring і autoconfigure: перший про підстановку залежностей, другий про автоматичні теги за інтерфейсами.
Не розуміти, що бінарний вибір між двома реалізаціями інтерфейсу autowiring не робить і кидає помилку, поки не додано alias.
Робити сервіси публічними, щоб діставати їх через контейнер у контролері: це ховає залежності й ламає ідею DI.
Реалізовувати логіку збирання плагінів через рефлексію в рантаймі замість тегів і compiler pass чи tagged_iterator.
ПОРАДА

Плюс до відповіді — згадка про compiler passes і теговані сервіси як механізм розширення без правки чужого коду. Наведіть приклад: набір експортерів, які підключаються тегом.

Сторінка питання →
WP
WordPress·Middle ·CPT ·таксономії ·wp_postmeta

CPT — це рядок у wp_posts із власним post_type, таксономія — нормалізована багато-до-багатьох звʼязка через term_relationships; таксономія годиться для фільтрів і категоризації, meta — для атрибутів запису, а власна таблиця потрібна, коли даних мільйони або потрібні складні фільтри й свої індекси.

Чому після реєстрації CPT сторінки записів віддають 404?
У нас 300 тисяч записів з двадцятьма meta-полями і фільтр по них вішає базу — що не так із моделлю даних?
Чим таксономія відрізняється від meta-поля, і як обрати між ними?
Коли ви б відмовились від post type і зробили окрему таблицю?

Custom post type не створює жодної таблиці. register_post_type('property', …) лише каже ядру, що в спільній wp_posts бувають рядки зі значенням post_type = 'property', і що для них треба показати пункт меню, застосувати правила перезапису й підключити потрібні шаблони. Тому питання «скільки типів реєструвати» майже не має ціни: у стандартній схемі є індекс type_status_date по (post_type, post_status, post_date, ID), і вибірка по типу лишається індексованою. Ціна ховається в іншому — усі типи ділять одну wp_posts і одну wp_postmeta, тож роздутий meta одного типу гальмує запити до решти.

Таксономія — це нормалізована звʼязка багато-до-багатьох через три таблиці: terms (назва й слаг), term_taxonomy (термін у контексті конкретної таксономії, з parent і count) і term_relationships (object_id, term_taxonomy_id). Остання має первинний ключ по обох колонках і окремий індекс по term_taxonomy_id, тобто запит «дай усі записи цього терміна» — це звичайний індексований JOIN. Meta влаштована інакше: wp_postmeta — це EAV з індексами лише по post_id і по meta_key (перші 191 символ), а по meta_value індексу немає взагалі. Кожна умова в meta_query додає ще один JOIN до тієї самої таблиці, тому фільтр каталогу з пʼяти meta-умов на 300 тисячах записів — це пʼять самозʼєднань по колонці без індексу.

Звідси практичне правило проєктування. Якщо за значенням фільтрують, воно повторюється між записами й для нього доречний архів з власним URL — це таксономія (місто, бренд, тип матеріалу). Якщо значення описує один конкретний запис і читається лише разом із ним — це meta (SKU, вага, координати). Ознака помилки очевидна: якщо термінів буде приблизно стільки ж, скільки записів, таксономія вироджується й тільки роздуває term_taxonomy. Реєструвати обидва треба на init — раніше ядро ще не готове, пізніше типу вже не побачить частина функцій — і робити це в плагіні, а не в темі: після зміни теми записи лишаться в базі, але без реєстрації типу стануть недоступними. Правила перезапису WordPress кешує в опції rewrite_rules, тому після появи нового типу з has_archive або власним rewrite потрібен один flush_rewrite_rules() у register_activation_hook — саме його відсутність дає 404 на сторінках записів. Викликати flush на кожному init не можна: це запис в options на кожен запит.

Власну таблицю варто робити за чотирма ознаками, не за однією. Обсяг: сотні тисяч рядків, що ростуть, особливо коли це не контент, а події — перегляди, ціни постачальників, лог доставок. Інтенсивність запису: wp_posts тягне за собою revisions, autosave, save_post і чужі плагіни на кожен UPDATE. Потреба у власних складених індексах під конкретні фільтри й сортування, чого над EAV зробити неможливо. І відсутність потреби в інфраструктурі поста: ні редактора, ні статусів, ні коментарів, ні прав. Схему створюють через dbDelta() у хуку активації, зберігаючи версію схеми в опції для наступних міграцій, а запити пишуть через $wpdb->prepare().

Компроміс тут прямий і його варто назвати вголос: із власною таблицею ви втрачаєте WP_Query, кеш обʼєктів, адмінку, REST, права, ревізії, багатомовність і сумісність з плагінами, що працюють з постами. Усе це доведеться писати руками — список через WP_List_Table, ендпоїнти через register_rest_route. Тому в реальних проєктах частіше виграє гібрид: дані й фільтрація живуть у власній таблиці, а CPT лишається вітриною зі шаблоном, URL і SEO, звʼязаною по post_id. А перед тим, як іти в цей бік, дешевший крок — перевести фільтровані атрибути з meta в таксономії й перевірити, чи цього вже досить.

add_action('init', function (): void {
    // Службовий тип: без фронтенду, керується лише з адмінки
    register_post_type('property', [
        'labels'       => ['name' => 'Обʼєкти', 'singular_name' => 'Обʼєкт'],
        'public'       => true,
        'has_archive'  => true,          // потребує flush після реєстрації
        'rewrite'      => ['slug' => 'nerukhomist', 'with_front' => false],
        'supports'     => ['title', 'editor', 'thumbnail', 'custom-fields'],
        'show_in_rest' => true,          // без цього не працює Gutenberg і REST
        'menu_icon'    => 'dashicons-building',
    ]);

    // Фільтрований атрибут -> таксономія, бо значення повторюються
    register_taxonomy('property_city', ['property'], [
        'hierarchical' => true,          // як категорії, а не як теги
        'public'       => true,
        'show_in_rest' => true,
        'rewrite'      => ['slug' => 'misto'],
    ]);
});

// Flush лише при активації плагіна, ніколи не на кожному запиті
register_activation_hook(__FILE__, function (): void {
    do_action('init');                   // типи мають бути вже зареєстровані
    flush_rewrite_rules();
});

// Так робити не треба: три JOIN до wp_postmeta без індексу по meta_value
$slow = new WP_Query(['post_type' => 'property', 'meta_query' => [
    ['key' => 'city', 'value' => 'Львів'],
    ['key' => 'rooms', 'value' => 3, 'type' => 'NUMERIC'],
    ['key' => 'floor', 'value' => 5, 'compare' => '<=', 'type' => 'NUMERIC'],
]]);

// Те саме через таксономію + одну meta: звʼязка йде по term_relationships
$fast = new WP_Query([
    'post_type' => 'property',
    'tax_query' => [['taxonomy' => 'property_city', 'terms' => 'lviv', 'field' => 'slug']],
    'meta_query' => [['key' => 'rooms', 'value' => 3, 'type' => 'NUMERIC']],
]);
Що CPT не створює таблицю: всі записи лежать в одному wp_posts, а post_type це просто колонка, тому кількість типів на продуктивність не впливає, а кількість рядків впливає.
Що таксономія вже індексована під запит «дай усі записи терміна» (term_relationships з ключем по term_taxonomy_id), а meta — ні, бо wp_postmeta має індекс лише по meta_key(191) і post_id.
Що register_post_type і register_taxonomy треба викликати на init, а не раніше й не пізніше, і після зміни rewrite-правил потрібен flush_rewrite_rules — саме звідси 404 на сторінках записів.
Що meta_query по кількох ключах перетворюється на кілька JOIN до wp_postmeta по одній таблиці, і на сотнях тисяч записів це головна причина повільного каталогу.
Що критерій для власної таблиці — це обсяг, частота запису, потреба у власних складених індексах і в тому, щоб дані не тягли за собою revisions, autosave й адмінку wp_posts.
Реєструвати post type у файлі теми поза хуком init або на after_setup_theme і потім дивуватись, що частина функцій ядра типу не бачить.
Викликати flush_rewrite_rules() на кожному завантаженні сторінки замість register_activation_hook — це перезапис опції rewrite_rules на кожен запит.
Тримати CPT у темі: після зміни теми записи лишаються в базі, але стають недоступними, бо тип більше ніде не зареєстрований.
Робити таксономію з даних, які не групують записи: ціна, дата, SKU, рейтинг — це meta, бо термінів буде стільки ж, скільки записів.
Будувати фільтр каталогу на meta_query з п'яти умов і лікувати повільність кешем сторінки, замість того щоб винести атрибути в таксономії або власну таблицю.
Забувати 'public' => false для службових типів і отримувати сторінки-порожняки в sitemap та у видачі.
ПОРАДА

Скажіть просте правило: якщо за значенням будуть фільтрувати або потрібен архів і URL — це таксономія; якщо значення описує один конкретний запис і його лише читають разом із записом — це meta; якщо рядків мільйони або потрібен свій складений індекс — це власна таблиця з $wpdb, а CPT лишається лише вітриною.

Сторінка питання →
SQL
SQL·Middle ·EXPLAIN ·план запиту

Дивіться на тип доступу до таблиці, оцінку рядків проти фактичної кількості і найдорожчі вузли плану; розбіжність оцінки й факту вказує на застарілу статистику.

Чим EXPLAIN ANALYZE відрізняється від EXPLAIN?
Що означає Seq Scan на великій таблиці?
Запит швидкий локально й повільний у продакшені: як зрозуміти чому?

EXPLAIN показує, як оптимізатор збирається виконати запит: дерево вузлів, для кожного оцінка вартості й очікувана кількість рядків. EXPLAIN ANALYZE виконує запит насправді й додає фактичний час і рядки на кожному вузлі. Саме порівняння оцінки з фактом дає найбільше: якщо оптимізатор очікував тисячу рядків, а отримав двісті тисяч, він побудував план під неправильні припущення, і лікується це ANALYZE або розширеною статистикою.

План читається від найглибших вузлів до кореня, і шукати треба той вузол, де витрачається час, а не загальний cost. Перше, на що дивляться, — тип доступу. Seq Scan або ALL у MySQL означає повне читання таблиці; на малій таблиці це нормально, на великій із селективною умовою сигнал, що індекс відсутній або незастосовний через функцію в умові. Index Scan читає індекс і таблицю, Index Only Scan лише індекс. Далі шукають сортування без індексу: Sort із великою кількістю рядків або Using filesort і Using temporary у MySQL. У PostgreSQL важливо множити на loops: вузол із rows=1 і loops=100000 виконався сто тисяч разів.

План залежить від даних. На порожній локальній базі оптимізатор обирає Seq Scan і Nested Loop, які в продакшені стають катастрофою, тому аналізувати треба на реалістичному обсязі, а повільні запити в продакшені ловити через auto_explain або slow query log.

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid' AND o.created_at > now() - interval '7 days'
ORDER BY o.created_at DESC
LIMIT 50;

-- Проблемний план (PostgreSQL): читаємо всю таблицю й сортуємо в памʼяті
-- Limit (actual time=812.4..812.5 rows=50 loops=1)
--   -> Sort (actual time=812.3..812.4 rows=50 loops=1)
--        Sort Method: top-N heapsort
--        -> Hash Join (actual rows=184213 loops=1)
--             -> Seq Scan on orders o (rows=184213 loops=1)        <- повний скан
--                  Filter: (status = 'paid' AND created_at > ...)
--                  Rows Removed by Filter: 4815787                  <- 96 % рядків відкинуто
--             -> Hash -> Seq Scan on users u

-- Ознаки проблеми: Seq Scan з великим Rows Removed by Filter, Sort без індексу,
-- estimated rows=1200 проти actual rows=184213 (стара статистика)

CREATE INDEX orders_status_created ON orders (status, created_at DESC);
ANALYZE orders;

-- Після: Index Scan по orders_status_created, Sort зник, actual time ~2 ms
Що EXPLAIN показує план оптимізатора з оцінками, а EXPLAIN ANALYZE реально виконує запит і додає фактичний час і кількість рядків на кожному вузлі.
Що план читається від найглибших вузлів до кореня, і шукати треба вузол, де витрачається час, а не загальний cost.
Типи доступу: Seq Scan або ALL це повне читання таблиці, Index Scan пошук по індексу з читанням таблиці, Index Only Scan без таблиці, Bitmap Scan для великих діапазонів.
Що велика різниця між rows estimated і rows actual означає застарілу статистику або скорельовані колонки, і тоді оптимізатор обирає неправильний JOIN або порядок.
Що план залежить від даних: на порожній локальній базі Seq Scan нормальний, тому аналізувати треба на копії продакшн-обсягів.
Дивитись лише на загальний cost і вважати, що EXPLAIN показує час.
Панікувати від Seq Scan на таблиці з тисячею рядків: для малих таблиць це найшвидший спосіб.
Запускати EXPLAIN ANALYZE на UPDATE або DELETE у продакшені без транзакції з ROLLBACK.
Не знати, що loops у PostgreSQL множить час і рядки вузла: actual rows=1 loops=100000 це сто тисяч виконань.
Ігнорувати Using filesort і Using temporary у MySQL або Sort з Disk у PostgreSQL, які показують сортування без індексу.
ПОРАДА

Скажіть, що читаєте EXPLAIN ANALYZE на копії продакшн-даних: на порожній локальній базі план буде іншим. Згадайте BUFFERS і auto_explain для пошуку повільних запитів у продакшені.

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

Сторінка питання →
PHP
Core PHP·Middle ·named arguments ·constructor promotion ·PHP 8.0

Named arguments (PHP 8.0) дозволяють передавати аргументи за іменем параметра й пропускати необовʼязкові, а constructor property promotion (PHP 8.0) оголошує властивість прямо в сигнатурі конструктора; ціна — імена параметрів стають частиною публічного API, а промотована властивість ініціалізується ще до тіла конструктора.

У нас конструктор на вісім параметрів — як зробити виклик читабельним, не роблячи білдер?
Чому після перейменування параметра `$str` на `$string` у сервісі впав чужий код, хоча сигнатура сумісна?
Можна передати масив з рядковими ключами як іменовані аргументи — і з якої версії PHP?
Чому `$this->name = trim($name)` у тілі конструктора падає з «Cannot modify readonly property», якщо параметр промотований?

Обидві фічі приїхали в PHP 8.0, але лікують різні болі. Constructor property promotion прибирає дублювання в оголошенні: замість трьох рядків на кожну залежність (властивість, параметр конструктора, присвоєння) модифікатор видимості просто ставиться перед параметром — public function __construct(private LoggerInterface $logger) {} — і PHP сам створює властивість із тим самим типом та імʼям. Named arguments лікують місце виклику: аргумент передається за іменем параметра, а не за позицією, тому необовʼязкові параметри можна пропускати вибірково, а не «дотягувати» дефолтами до потрібного. new SearchQuery(text: 'php', city: 'Львів') читається без заглядання в сигнатуру, тоді як new SearchQuery('php', 1, 20, 'Львів') — ні.

Механіка named arguments має кілька жорстких правил, і саме на них ловлять. Позиційні аргументи мають іти першими: f(text: 'php', 3) — це помилка парсингу, а не рантайму. Передати той самий параметр двічі (позиційно й за іменем) не можна — буде Error: Named parameter $x overwrites previous argument. Неіснуюче імʼя дає Error: Unknown named parameter $x. Іменовані аргументи, що не збіглися з жодним параметром, збираються у варіадик із рядковими ключами — саме тому прозорі декоратори виду handle(...$args) продовжують працювати. Розпакування масиву з рядковими ключами в іменовані аргументи (f(...['page' => 2])) дозволене з PHP 8.1; у 8.0 воно кидало Cannot unpack array with string keys. Окремо варто памʼятати, що func_get_args() бачить виклик так, ніби все передали позиційно: пропущені необовʼязкові параметри підставляються своїми дефолтами, тому старий код на func_num_args() починає рахувати інакше.

Головна ціна named arguments — імена параметрів стають публічним API. PHP звіряє сумісність сигнатур за типами й кількістю параметрів, але не за іменами: клас, що реалізує інтерфейс, може назвати параметр як завгодно і завантажиться без жодної помилки. Тому виклик $gateway->charge(amount: 100) через тип-інтерфейс впаде в рантаймі на тій єдиній реалізації, де параметр названо $sum. Так само перейменування $str на $string у власній бібліотеці — це BC break, навіть якщо тип і позиція незмінні. Практичне правило: іменовані аргументи безпечні для конструкторів конкретних класів, DTO, атрибутів і вбудованих функцій (їх імена якраз і причесали в PHP 8.0 саме заради цього), а для поліморфних викликів через абстракцію надійніше лишатися позиційним.

У промоції своя пастка, і вона про порядок. Присвоєння промотованих властивостей відбувається до тіла конструктора, тому тіло вже працює з $this->…. Для звичайних властивостей це нічого не змінює, а для readonly (PHP 8.1+) означає, що властивість уже ініціалізована, і $this->text = trim($text) у тілі дасть Cannot modify readonly property. Трансформацію значень доводиться виносити або на рівень виклику — приватний конструктор плюс статичний fromRequest(), який чистить дані, — або в окремі value-обʼєкти, що нормалізують себе самі. Валідація ж без зміни значення (кинути InvalidArgumentException, прочитавши $this->perPage) у тілі конструктора цілком легальна.

Межі промоції варто називати списком: тільки __construct не-абстрактного класу, не варіадичний параметр, не тип callable (замість нього — Closure), не можна дублювати ту саму властивість у тілі класу, модифікатор видимості обовʼязковий. readonly доступний з 8.1, асиметрична видимість public private(set) — з 8.4. Атрибут перед промотованим параметром вішається і на параметр, і на властивість, а ReflectionProperty::isPromoted() дозволяє відрізнити такі властивості в рантаймі. Останнє — документація типів: узагальнені типи промотованих властивостей описують @param list<string> $tags у docblock конструктора, бо окремого місця для @var більше немає; при міграції старих DTO на promotion це найчастіша тиха втрата типів для PHPStan.

final class SearchQuery
{
    /** @param list<string> $tags */
    public function __construct(
        public readonly string $text,
        public readonly int $page = 1,
        public readonly int $perPage = 20,
        public readonly ?string $city = null,
        public readonly array $tags = [],
    ) {
        // тіло виконується ПІСЛЯ присвоєння — властивості вже заповнені
        if ($this->perPage > 100) {
            throw new InvalidArgumentException('perPage максимум 100');
        }
        // $this->text = trim($text);  // Error: readonly вже ініціалізовано промоцією
    }
}

// називаємо лише те, що відрізняється від типового; page і perPage пропускаємо
$q = new SearchQuery(text: 'php', city: 'Львів', tags: ['remote']);

// new SearchQuery(text: 'php', 3);   // Parse error: позиційний після іменованого
// new SearchQuery('php', text: 'x'); // Error: Named parameter $text overwrites previous argument

// розпакування масиву з РЯДКОВИМИ ключами як іменованих — PHP 8.1+
$filters = ['text' => 'php', 'perPage' => 50];
$q2 = new SearchQuery(...$filters);

// зайвий ключ ламає виклик, тому вхідні дані фільтруємо явно
// new SearchQuery(...['text' => 'php', 'sort' => 'new']); // Unknown named parameter $sort

function collect(...$args): array
{
    return $args;                     // іменовані аргументи стають рядковими ключами
}

var_dump(collect(1, 2));              // [0 => 1, 1 => 2]
var_dump(collect(a: 1, b: 2));        // ['a' => 1, 'b' => 2]
Що обидві фічі зʼявилися в PHP 8.0 і вирішують різні задачі: named arguments — про місце виклику, promotion — про місце оголошення.
Що після переходу на іменовані аргументи назва параметра стає частиною контракту: перейменування `$str` → `$string` — це BC break, хоч типи й порядок незмінні.
Що позиційний аргумент після іменованого — синтаксична помилка, а повторна передача того самого параметра позиційно й за іменем дає `Error: Named parameter $x overwrites previous argument`.
Що розпакування масиву з рядковими ключами (`f(...['page' => 2])`) як іменованих аргументів працює з PHP 8.1, а в 8.0 давало `Cannot unpack array with string keys`.
Що промоція присвоює властивості ДО виконання тіла конструктора, тому для `readonly` повторний запис у тілі вже неможливий — трансформацію треба робити в аргументі або у статичному конструкторі.
Що промотувати не можна все підряд: тільки в конструкторі не-абстрактного класу, не варіадичний параметр, не тип `callable`.
Вважати named arguments «просто цукром» і вільно перейменовувати параметри в публічних класах і в реалізаціях інтерфейсів — PHP не перевіряє сумісність імен при успадкуванні, і виклик за іменем впаде вже в рантаймі.
Писати `f(text: 'php', 3)` — позиційний аргумент після іменованого не парситься взагалі, це не рантайм-помилка.
Розраховувати, що `new Dto(...$request->all())` безпечний: зайвий ключ дає `Error: Unknown named parameter $sort`, тому масив треба явно фільтрувати або валідувати.
Дублювати промотовану властивість ще й у тілі класу (`private string $name;` + `private string $name` у конструкторі) — фатальна помилка «Cannot redeclare property».
Робити `$this->name = trim($name)` у тілі конструктора для промотованого `readonly`-параметра й дивуватись `Cannot modify readonly property`.
Промотувати параметри в класі з великою логікою ініціалізації й вважати, що це «звільняє» від валідації: перевірки в тілі конструктора нікуди не діваються, просто працюють уже з `$this->…`.
ПОРАДА

Формула на дві фрази: «promotion скорочує оголошення, named arguments — виклик; разом вони роблять DTO читабельним без білдера». І одразу назвіть ціну: «але імена параметрів після цього — публічний API, перейменування = BC break».

Сторінка питання →
OPS
DevOps·Middle ·.env ·секрети ·конфігурація

Конфігурація приходить у процес через змінні оточення й відрізняється по середовищах, код лишається однаковим; .env — це локальна зручність розробника, яку не комітять, а на продакшені значення дає pool php-fpm, оркестратор або менеджер секретів. У Laravel env() дозволений лише у файлах config/, бо після config:cache .env не завантажується взагалі.

Виправили значення в .env на сервері, перезапустили — застосунок далі бачить старе. Що сталося?
Що з цього комітять у git: .env, .env.example, .env.local, config/secrets?
Розробник випадково запушив ключ від платіжного шлюзу й одразу зробив revert. Цього достатньо?
Де тримати пароль до бази в контейнері, якщо змінні оточення видно в `docker inspect` і в `/proc/1/environ`?

Базовий принцип формулюється в одному реченні: конфігурація — це те, що відрізняється між середовищами, і вона має приходити ззовні, а не з коду. Це третій пункт 12-factor, і практично він означає, що один і той самий артефакт (образ, теґ, архів релізу) виїжджає на staging і на prod без перезбирання, а різниця між ними — виключно у значеннях змінних оточення процесу. Перевірка на зрілість тут проста: якщо у коді є if (app()->environment('production')) навколо адреси сервісу чи ліміту, конфігурація живе в коді, а не зовні.

Технічно PHP отримує ці значення трьома різними каналами, і їх плутають частіше, ніж здається. getenv() читає справжнє оточення процесу — те, що передав php-fpm, systemd чи контейнер. $_ENV наповнюється лише якщо в variables_order є буква E, а і php.ini-development, і php.ini-production постачаються зі значенням "GPCS", тобто без неї — тому на чистому продакшн-конфізі $_ENV порожній, хоча getenv() працює. Третій канал — бібліотека dotenv, яка на старті читає .env і сама наповнює $_ENV та $_SERVER. У Symfony компонент Dotenv за замовчуванням не викликає putenv() (це поведінка з 5.0), тож там значення беруть із $_ENV/$_SERVER, а не через getenv(). Висновок для коду: не звертайтеся до оточення напряму, ходіть через config() або параметри контейнера — тоді джерело можна змінити, не чіпаючи класи.

Конвенції двох головних фреймворків протилежні, і на співбесіді це люблять питати. У Laravel .env завжди в .gitignore, у репозиторії лежить .env.example зі списком ключів і безпечними значеннями; env() дозволений тільки у файлах config/, бо на деплої виконується php artisan config:cache, після чого .env не читається взагалі — Laravel бачить закешований bootstrap/cache/config.php і пропускає завантаження оточення. Звідси й класичний симптом «змінив .env, а нічого не змінилося»: треба config:clear або повторний config:cache. У Symfony ж .env комітять — це файл дефолтів, а локальні відхилення йдуть у .env.local, який у .gitignore; для продакшену composer dump-env prod (з symfony/flex) згортає все у скомпільований .env.local.php, щоб не парсити файли на кожному запиті.

Для справжніх секретів обидві екосистеми мають шифроване сховище, а індустріальний варіант — зовнішній менеджер. Symfony від 4.4 має vault на libsodium: bin/console secrets:set DATABASE_PASSWORD кладе sealed box у config/secrets/prod/, публічний ключ шифрування комітять, приватний prod.decrypt.private.php — ні (він доставляється окремо або передається через SYMFONY_DECRYPTION_SECRET). Laravel від 9.32 має php artisan env:encrypt --env=production, що робить .env.production.encrypted під AES-256-CBC, а розшифрування на деплої бере ключ зі змінної LARAVEL_ENV_ENCRYPTION_KEY. У хмарі ці файли часто не потрібні зовсім: AWS Secrets Manager, GCP Secret Manager чи HashiCorp Vault віддають значення інстансу, який автентифікувався роллю, — і тоді кореневого пароля на диску немає взагалі, а є короткоживучий токен. Окремо: секрет ніколи не потрапляє в ENV/ARG Dockerfile, бо лишається в шарі образу й читається через docker history; для збірки є RUN --mount=type=secret, для рантайму — файл у /run/secrets і патерн *_FILE.

Межі підходу варто назвати самому. Змінні оточення — не броня: їх успадковують дочірні процеси, вони читаються з /proc/<pid>/environ, потрапляють у краш-дампи й у сторінку помилки, якщо на проді лишили APP_DEBUG=true. Тому найчутливіше (приватні ключі, сертифікати) віддають файлом з правами 0400, а не змінною. Друга межа — час життя: значення, що не змінювалося рік, поводиться як пароль, який знають усі, хто колись мав доступ; тому ротація має бути плановою, і кожен секрет має пару «поточний + попередній», щоб її пережити без даунтайму — у Laravel це APP_PREVIOUS_KEYS для APP_KEY, у баз — окремий користувач на застосунок замість спільного. І третє, найважливіше правило інциденту: щойно секрет потрапив у пуш, він скомпрометований, і першою дією є відкликання ключа в провайдера, а не git filter-repo — переписана історія не забирає значення з форків, кешу CI, локальних клонів і логів вебхуків.

<?php
// config/services.php — єдине місце, де дозволено env().
return [
    'stripe' => [
        // Значення без дефолту: на проді має бути задане ззовні.
        'secret' => env('STRIPE_SECRET'),
        // Несекретна конфігурація: дефолт прямо тут, у .env лише відхилення.
        'webhook_tolerance' => (int) env('STRIPE_WEBHOOK_TOLERANCE', 300),
        // Патерн *_FILE: секрет змонтований файлом (Docker/K8s), не змінною.
        'signing_key' => ($p = env('STRIPE_SIGNING_KEY_FILE'))
            ? trim(file_get_contents($p))
            : env('STRIPE_SIGNING_KEY'),
    ],
];

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(StripeGateway::class, fn ($app) => new StripeGateway(
        // config() читає закешований масив і працює після config:cache.
        $app['config']->get('services.stripe.secret')
            ?? throw new RuntimeException('STRIPE_SECRET не заданий'),
    ));
}

// app/Billing/StripeGateway.php
final class StripeGateway
{
    public function __construct(private readonly string $secret) {}

    // ПОМИЛКА, на якій валяться:
    // public function __construct()
    // {
    //     // Після `php artisan config:cache` файли config/ не виконуються,
    //     // .env не завантажується — env() поверне null мовчки, без винятку,
    //     // і впаде вже HTTP-запит до Stripe із 401.
    //     $this->secret = env('STRIPE_SECRET');
    // }
}
Розділення конфігурації (адреси, ліміти, фіче-флаги) і секретів (паролі, ключі API): перше може лежати у репозиторії з дефолтами, друге — ніколи.
Що один артефакт (образ, теґ) деплоїться в усі середовища без перезбирання, а різниця між dev/staging/prod — тільки в значеннях змінних оточення.
Що `.env` — це файл для локальної розробки, який читає бібліотека dotenv на старті; у продакшені змінні краще віддавати процесу зовні: `env[...]` у пулі php-fpm, systemd, secret у Kubernetes, AWS Secrets Manager чи HashiCorp Vault.
Laravel: `.env` у .gitignore, `.env.example` у git, `env()` тільки в `config/`, на деплої `php artisan config:cache`. Symfony навпаки: `.env` комітять із безпечними дефолтами, секрети йдуть у `.env.local` або в sodium-сховище `secrets:set`.
Що витік секрету лікується ротацією, а не переписуванням історії git: щойно значення потрапило в пуш, воно скомпрометоване назавжди.
Викликати `env('STRIPE_SECRET')` у контролері чи сервісі: після `php artisan config:cache` .env не читається взагалі й виклик тихо поверне `null` — без винятку, з падінням уже на боці API.
Закомітити `.env` «тимчасово, щоб колега підняв стейджинг» і залишити його в історії репозиторію назавжди.
Прибрати секрет через `git revert` або `commit --amend` і вважати інцидент закритим, не відкликавши ключ.
Класти секрет у `ENV`/`ARG` в Dockerfile: значення лишається в шарі образу й видно в `docker history` та `docker inspect` кожному, хто має доступ до реєстру.
Використовувати одні й ті самі креденшели для dev і prod або тягнути дамп продакшену на ноутбук «щоб відтворити баг».
Змінити `.env` на сервері з увімкненим кешем конфігурації й не запустити `config:clear`/`config:cache` — застосунок працює зі старим масивом.
ПОРАДА

Сформулюйте так: «код однаковий у всіх середовищах, різні лише значення змінних оточення, і жодне з них не живе в репозиторії». Далі додайте фразу, за якою чути досвід: «витік секрету закривається ротацією, а не переписуванням історії» — і поясніть, що на продакшені `.env` взагалі може не бути, бо змінні віддає pool php-fpm або оркестратор.

Сторінка питання →
PHP
Core PHP·Middle ·match ·switch ·PHP 8.0

`match` (PHP 8.0+) — це вираз, який повертає значення й порівнює строго через `===` без провалювання між гілками, а якщо жодна умова не збіглася і немає `default` — кидає `UnhandledMatchError`; `switch` — оператор, який порівнює нежорстко через `==`, потребує `break` і при відсутності збігу мовчки нічого не робить.

`match` — це просто коротший `switch`, чи різниця глибша?
Чому `match ($_GET['page'])` з гілкою `1 => ...` не спрацьовує, хоча в URL стоїть `page=1`?
Що станеться, якщо жодна гілка `match` не збіглася і `default` немає?
Чому в `switch` треба писати `break`, а в `match` — ні? І що буде, якщо все-таки написати?

Головна відмінність не в синтаксисі, а в тому, що це різні мовні категорії. switch — оператор: він передає керування в гілку, а результат ви мусите самі кудись покласти, тому кожен case закінчується присвоєнням у тимчасову змінну і break. match, що зʼявився в PHP 8.0, — вираз: він обчислюється у значення, тому пишеться там, де очікується значення — праворуч від =, у return, в аргументі виклику, у тілі стрілочної функції, у рядковій інтерполяції через {}. Звідси й дрібна деталь синтаксису, на якій спотикаються: після закриваючої фігурної дужки match ставиться крапка з комою, бо це закінчення виразу, а не блоку.

Друга відмінність — семантика порівняння. switch порівнює тему з кожним case нежорстко, через ==, з усіма приведеннями типів; match — строго, через ===, тобто збіг має бути і за значенням, і за типом. PHP 8.0 прибрав найгидкішу пастку switch, змінивши порівняння числа з нечисловим рядком (0 == 'foo' тепер false), але сам == нікуди не подівся: '1' == 1, '1e2' == '100', '0' == false, null == false — усе це в switch досі дає збіг. Практичний наслідок видно в прикладі коду: значення '1', що прийшло з query-рядка, потрапляє в case 1 у switch і не потрапляє в гілку 1 => у match. Це не баг match, а причина №1 регресій при механічній заміні однієї конструкції на іншу — тип теми треба нормалізувати явно ((int) $page, Status::from($raw)).

Третя відмінність — поведінка на невідомому значенні. switch без відповідного case і без default мовчки не робить нічого; це зручно рівно доти, доки на цьому «нічого» випадково не почала триматися логіка. match зобовʼязаний повернути значення, тому відсутність збігу для нього — помилковий стан: інтерпретатор кидає \UnhandledMatchError. Важлива для співбесіди деталь: цей клас успадковує \Error, а не \Exception, тож catch (Exception $e) його не перехопить — потрібні catch (\UnhandledMatchError), catch (\Error) або \Throwable. Найцінніше це на enum: match по кейсах enum без default дає вичерпність, яку перевіряє статичний аналізатор на етапі CI, а рантайм страхує помилкою. Дописаний «щоб не падало» default => null вимикає обидва рівні захисту — і забутий новий кейс перетворюється з голосної помилки на тихий null, який спливе за три екрани звідси.

Решта відмінностей дрібніші, але їх теж питають. Провалювання (fallthrough) в match немає взагалі: кілька значень в одній гілці перелічуються комою (200, 201, 204 =>), а break там не пишеться і не парситься — гілка є виразом, а не блоком інструкцій. Через це ж обмеження гілка не може містити кілька інструкцій: багатокрокова логіка або витягується в метод, або лишається в switch. Для діапазонів і складених умов є ідіома match (true), де кожна гілка — булевий вираз; вона читається краще за драбину if/elseif, бо повертає значення, але порядок гілок стає критичним, а умови після першого збігу не обчислюються взагалі. Щодо продуктивності: коли всі умови — літерали одного типу (int або string), обидві конструкції компілюються в таблицю переходів (ZEND_SWITCH_LONG/ZEND_SWITCH_STRING для switch, ZEND_MATCH для match), тож обирати між ними за швидкістю немає сенсу.

Межі варто назвати чесно. match — не патерн-матчинг: у PHP немає ні деструктуризації, ні guard-умов, ні матчингу за типом (instanceof доводиться писати руками всередині match (true)). Порівняння через === для звичайних обʼєктів означає ідентичність екземпляра, а не рівність вмісту, тому два еквівалентні DateTimeImmutable у match не збігатимуться — з enum це працює лише тому, що його кейси є синглтонами. І якщо match розростається до пари десятків гілок, це вже не питання вибору між ним і switch: там просилася або мапа array<string, callable>, або поліморфізм із окремим класом на кожен випадок.

declare(strict_types=1);

$page = '1';                          // усе, що приходить з HTTP, — рядок

switch ($page) {                      // switch порівнює через ==
    case 1:                           // '1' == 1 → true, гілка спрацює
        $bySwitch = 'перша сторінка';
        break;                        // без break провалиться в default
    default:
        $bySwitch = 'інша';
}
echo $bySwitch;                       // 'перша сторінка'

$byMatch = match ($page) {            // match порівнює через ===
    1 => 'перша сторінка',            // '1' !== 1 → гілка НЕ спрацює
    '1' => 'перша сторінка (рядок)',
    default => 'інша',
};                                    // match — вираз, тому крапка з комою
echo $byMatch;                        // 'перша сторінка (рядок)'

$status = 503;

// match повертає значення: присвоюємо одразу, тимчасова змінна не потрібна
$label = match (true) {               // умови перевіряються згори вниз
    $status >= 500 => 'помилка сервера',
    $status >= 400 => 'помилка клієнта',
    $status >= 200 => 'успіх',
    default => 'невідомо',
};
echo $label;                          // 'помилка сервера'

try {
    // default немає; кілька значень в одній гілці — через кому, без fallthrough
    echo match ($status) {
        200, 201, 204 => 'тіло можна кешувати',
        301, 302 => 'редірект',
    };
} catch (\UnhandledMatchError $e) {   // це Error, а не Exception
    echo $e->getMessage();            // у повідомленні — незіставлене значення
}
Що `match` порівнює через `===` (тип і значення), а `switch` — через `==` з приведенням типів; це не стилістична, а семантична різниця.
Що `match` — вираз: його результат можна присвоїти, повернути з `return`, передати аргументом, покласти в тіло стрілочної функції; `switch` — оператор, тому в кожній гілці доводиться писати присвоєння у тимчасову змінну.
Що провалювання (fallthrough) в `match` немає взагалі: кілька значень групуються комою `200, 201, 204 =>`, а `break` там не потрібен і навіть неможливий.
Що при відсутності збігу `match` без `default` кидає `\UnhandledMatchError`, який успадковує `Error`, а не `Exception` — тобто `catch (Exception)` його не спіймає.
Що тіло гілки `match` — рівно одне вираження, тому багатокрокова логіка з кількома інструкціями лишається за `switch`, окремим методом або `if`.
Що `match` зʼявився в PHP 8.0 і що на enum він дає перевірювану вичерпність: без `default` статичний аналізатор бачить пропущений case, а рантайм ловить його `UnhandledMatchError`.
Називати `match` «синтаксичним цукром над switch»: цукор не змінює семантику, а тут змінюються і порівняння, і поведінка при відсутності збігу.
Механічно замінювати `switch` на `match` у коді, що працює з даними з HTTP, БД чи CSV: там значення — рядки, і гілка `1 =>` після заміни перестає спрацьовувати, бо `'1' !== 1`.
Вважати, що в PHP 8 `switch` став строгим: змінилося лише порівняння числа з нечисловим рядком (`0 == 'foo'` тепер `false`), а `==` з усіма іншими приведеннями (`'1' == 1`, `'1e2' == '100'`, `'0' == false`) у `switch` лишився.
Писати `break` або кілька інструкцій через `;` усередині гілки `match` — це помилка парсингу, бо гілка є виразом, а не блоком.
Ловити `UnhandledMatchError` через `catch (Exception $e)` — не спрацює; потрібен `catch (\UnhandledMatchError)`, `catch (\Error)` або `\Throwable`.
Дописувати `default => null` «щоб не падало» в `match` по enum: це вимикає перевірку вичерпності, і новий case мовчки почне повертати `null` замість того, щоб зламатися голосно.
ПОРАДА

Одна фраза, яка закриває питання: «`switch` — оператор із `==` і провалюванням, `match` — вираз із `===` і без провалювання, а на невідоме значення `switch` мовчить, `match` кидає `UnhandledMatchError`». Далі додайте головний практичний наслідок: `match` по enum без `default` перетворює забутий новий case із тихого бага на помилку.

Сторінка питання →
LR
Laravel·Middle ·черги ·події ·ідемпотентність

Event описує факт, що щось сталося; listener реагує на нього; job — це одиниця відкладеної роботи в черзі, яку хтось явно поставив.

Коли робити job, а коли event із listener?
Що станеться, якщо queued listener впаде?
Як правильно відправити лист після реєстрації користувача?

Різниця перш за все семантична. Event описує факт, який уже стався: OrderPaid, UserRegistered. Він не знає, хто на нього відреагує, і скільки буде слухачів. Listener підписується на подію й виконує реакцію. Job — це команда: конкретна одиниця роботи, яку хтось явно поставив у чергу, наприклад GenerateInvoicePdf.

Технічно межа розмита. Listener, який реалізує ShouldQueue, стає окремим job у черзі, зі своїми tries, backoff і failed(). Тому питання «job чи listener» зводиться до того, чи потрібна розвʼязка через подію. Якщо на факт реагує кілька незалежних дій, подія дає їм незалежні спроби й незалежні падіння. Якщо потрібна одна важка операція з ланцюжком або батчем, це job.

Все, що йде в чергу, виконується щонайменше один раз. Retry гарантований, воркер може впасти після виконання дії, але до підтвердження. Тому обробник мусить бути ідемпотентним: перевіряти стан агрегату перед дією, а не вірити, що його викликали вперше.

Друга типова пастка — транзакції. Подія, відправлена всередині DB::transaction, може дістатись воркера раніше, ніж транзакція закомітилась, і слухач не знайде запис. Рішення: ShouldDispatchAfterCommit на події, afterCommit() на job або глобальна опція after_commit у конфігурації черги.

// Подія: факт у минулому часі, без знання про споживачів
final class OrderPaid implements ShouldDispatchAfterCommit
{
    public function __construct(public readonly int $orderId) {}
}

// Слухач у черзі: окремий job на кожен listener, незалежні падіння
final class SendReceipt implements ShouldQueue
{
    public int $tries = 3;
    public array $backoff = [10, 60, 300];

    public function handle(OrderPaid $event): void
    {
        $order = Order::findOrFail($event->orderId);

        if ($order->receipt_sent_at !== null) {
            return; // ідемпотентність: повтор не шле другий лист
        }

        Mail::to($order->email)->send(new ReceiptMail($order));
        $order->update(['receipt_sent_at' => now()]);
    }

    public function failed(OrderPaid $event, Throwable $e): void
    {
        Log::error('Receipt failed', ['order' => $event->orderId, 'error' => $e->getMessage()]);
    }
}

// Job: явна команда зробити конкретну роботу
GenerateInvoicePdf::dispatch($order->id)->onQueue('pdf')->afterCommit();

DB::transaction(function () use ($order) {
    $order->markPaid();
    OrderPaid::dispatch($order->id); // піде після коміту
});
Семантику: event названий у минулому часі й нічого не знає про споживачів, job це команда зробити конкретну роботу.
Що listener може реалізувати ShouldQueue і тоді виконується у черзі як окремий job на кожен listener.
Що все, що йде в чергу, мусить бути ідемпотентним, бо retry гарантований, а доставка щонайменше один раз.
Що job серіалізується: моделі передаються через SerializesModels як id і перечитуються при виконанні, тому стан може змінитись.
Розуміння, що event з кількома queued listeners дає незалежні спроби й незалежні падіння, а один job із трьома діями впаде цілком.
Казати, що event це відкладена робота, а job синхронна: listener може бути в черзі, а job може виконатись синхронно через dispatchSync.
Диспатчити event всередині транзакції без afterCommit: queued listener стартує до коміту й не знайде запис.
Робити job, який не переживає повтор: другий retry створює другий платіж або відправляє другий лист.
Передавати в job великі масиви або обʼєкти замість id: payload роздувається, а дані застарівають.
Не задавати tries, backoff і failed(): job тихо зникає після першої помилки або повторюється нескінченно.
ПОРАДА

Скажіть, що ключове питання — ідемпотентність: усе, що йде в чергу, мусить безпечно виконуватись повторно після retry. І згадайте afterCommit для подій усередині транзакцій.

Сторінка питання →
SQL
SQL·Middle ·пагінація ·keyset ·OFFSET

OFFSET не вміє «стрибати»: база читає й відкидає всі пропущені рядки, тому час зростає лінійно з номером сторінки. Keyset (seek method) замість номера сторінки передає ключ останнього прочитаного рядка й читає рівно LIMIT записів за постійний час.

Чому 1-ша сторінка списку відкривається миттєво, а 5000-та — три секунди?
Що таке seek method або курсорна пагінація і чим вона краща за LIMIT/OFFSET?
Чому користувач бачить той самий запис двічі, гортаючи сторінки?
Індекс на created_at є, EXPLAIN показує Index Scan — чому запит із OFFSET 200000 усе одно повільний?

OFFSET — це не пошук, а відлік. Індекс дає базі впорядкований список записів, але не дає адресації «дай мені сотий тисячний елемент»: щоб дійти до потрібної позиції, планувальник читає й викидає всі рядки до неї. Тому ORDER BY created_at DESC LIMIT 20 OFFSET 100000 коштує 100 020 прочитаних записів заради двадцяти віддданих, і час зростає лінійно з номером сторінки. У плані це видно як Limit над Index Scan із великим rows removed, і саме тому перша сторінка списку відкривається за одиниці мілісекунд, а п'ятитисячна — за секунди. Якщо ще й індексу під сортування немає, до відліку додається повне сортування всієї вибірки.

Keyset-пагінація (вона ж seek method, вона ж курсорна) прибирає відлік: замість номера сторінки клієнт повертає значення ключа останнього прочитаного рядка, а запит формулюється як діапазон — «усе, що йде строго після цієї позиції». Для сортування created_at DESC, id DESC умова записується рядковим порівнянням (created_at, id) < (?, ?), і база через індекс (user_id, created_at, id) одразу стрибає в потрібну точку дерева та читає рівно двадцять записів. Вартість сторінки перестає залежати від її глибини. Ключовий момент — унікальність ключа сортування: created_at сам по собі не унікальний, тож без tie-breaker у вигляді id рядки з однаковою міткою часу розподіляються між сторінками недетерміновано. Так само не можна розписувати умову як created_at <= ? AND id < ?: це відкидає всі рядки з меншим created_at, але більшим id. Або рядкове порівняння, або явне a < ? OR (a = ? AND b < ?) — у MySQL 8.0 обидві форми оптимізуються в range scan, у старіших версіях надійніша друга.

Другий, часто важливіший за швидкість, аргумент — консистентність. Між запитом сторінки 3 і сторінки 4 хтось вставив запис на початок списку: вікно OFFSET зсувається, і користувач бачить той самий елемент двічі; при видаленні — навпаки, пропускає. Keyset прив'язаний до значення, а не до позиції, тому вставки та видалення на попередніх сторінках його не зсувають. Той самий принцип лежить в основі chunkById() і lazyById() в Laravel — вони обходять таблицю через WHERE id > ? саме тому, що chunk() з OFFSET ламається, коли обробка змінює рядки. Для HTTP-пагінації Laravel з версії 8.27 дає cursorPaginate(): курсор — це base64-JSON зі значеннями колонок з orderBy, який фреймворк розгортає в keyset-умову.

Ціна в keyset теж є, і на співбесіді її треба назвати першим. Немає переходу на довільну сторінку — позиція описується ключем, а не номером. Немає загальної кількості сторінок (COUNT(*) на великій таблиці й сам по собі коштує сканування, тому його або кешують, або замінюють на оцінку з reltuples у PostgreSQL чи rows з EXPLAIN у MySQL). Сортування має бути детермінованим, а колонки сортування — без NULL або з явним NULLS LAST і окремою гілкою курсора. Зміна сортування користувачем означає новий індекс під кожен варіант. Звідси практичний поділ: стрічки, нескінченний скрол, експорт і API з next_cursor — keyset; адмінка з номерами сторінок і глибиною в десятки сторінок — звичайний paginate(), він там ніколи не стане вузьким місцем. І в обох випадках рішення підтверджують через EXPLAIN (ANALYZE, BUFFERS), а не на око.

-- OFFSET: щоб віддати 20 рядків, база читає й відкидає 100 000
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

-- Keyset: передаємо не номер сторінки, а ключ останнього прочитаного рядка.
-- Рядкове порівняння читається як «усе, що йде строго після цієї позиції»
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
  AND (created_at, id) < ('2026-02-01 10:15:00', 918273)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Індекс під keyset: рівність попереду, обидві колонки сортування далі
CREATE INDEX orders_user_created_id
    ON orders (user_id, created_at DESC, id DESC);

-- ПОМИЛКА: так пропадуть рядки з іншим created_at, але меншим id
-- WHERE created_at <= '2026-02-01 10:15:00' AND id < 918273

-- Еквівалент рядкового порівняння вручну:
-- для MySQL до 8.0 і для змішаних напрямків сортування
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
  AND (created_at < '2026-02-01 10:15:00'
       OR (created_at = '2026-02-01 10:15:00' AND id < 918273))
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Перевірка: у плані має бути range/Index Scan і ~20 прочитаних рядків,
-- а не сотні тисяч, як у варіанті з OFFSET
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders
WHERE user_id = 42 AND (created_at, id) < ('2026-02-01 10:15:00', 918273)
ORDER BY created_at DESC, id DESC LIMIT 20;
Що OFFSET не є операцією пошуку: індекс дає впорядкованість, але позицію N база отримує лише прочитавши N рядків, тому OFFSET 100000 LIMIT 20 коштує 100020 прочитаних рядків замість 20.
Що keyset формулює умову через значення останнього рядка попередньої сторінки, а не через його порядковий номер, і завдяки цьому кожна сторінка коштує однаково.
Що ключ сортування має бути унікальним: до created_at додають id як tie-breaker, інакше рядки з однаковим часом губляться або дублюються на межі сторінок.
Що умову треба писати рядковим порівнянням `(created_at, id) < (?, ?)`, а не `created_at <= ? AND id < ?` — друге відкидає валідні рядки.
Що OFFSET дає ще й неконсистентність: вставка або видалення між запитами зсуває вікно, і користувач бачить дублі або пропуски, а keyset до цього стійкий.
Що керуються компроміси усвідомлено: keyset не вміє переходу на довільну сторінку й не дає загальної кількості, тому підходить для стрічок і нескінченного скролу, а не для адмінки з номерами сторінок.
Вважати, що індекс на колонці сортування «лікує» OFFSET: індекс прибирає Sort, але пропущені рядки все одно читаються один за одним.
Писати keyset-умову як `created_at <= ? AND id < ?` — рядки з іншим created_at і більшим id зникнуть зі стрічки.
Сортувати лише за created_at без унікального tie-breaker і дивуватися дублям на межі сторінок при однакових мітках часу.
Плутати `simplePaginate()` з keyset: він лише прибирає `COUNT(*)`, але запит лишається `LIMIT ... OFFSET ...`.
Робити keyset по колонці, яка може бути NULL, без явного `NULLS LAST` і окремої гілки умови — порівняння з NULL дає UNKNOWN і рядок не потрапляє в жодну сторінку.
Лишати `SELECT COUNT(*)` на кожен запит: на великій таблиці він сам по собі коштує повного сканування й з'їдає весь виграш.
ПОРАДА

Скажіть коротко: «OFFSET — це не пошук, це відлік; keyset перетворює пагінацію на звичайний range scan по індексу». І одразу назвіть ціну: немає стрибка на сторінку 500 і немає загальної кількості — тому в адмінці лишаємо OFFSET, у стрічці й API ставимо keyset.

Сторінка питання →
OPS
DevOps·Middle ·CI ·тести ·статичний аналіз

Встановлення залежностей з кешем, перевірка стилю, статичний аналіз, тести на реальній базі й збірка артефакту; швидкі кроки першими, щоб пайплайн падав рано.

Як пришвидшити CI, що йде 20 хвилин?
Чи потрібен PHPStan у CI, якщо є тести?
У якому порядку запускати кроки пайплайну?

Мінімальний CI для PHP-проєкту складається з чотирьох кроків. Встановлення залежностей: composer validate, composer install строго за lock-файлом з кешем vendor по хешу composer.lock, composer audit для відомих вразливостей. Перевірка стилю: Pint або PHP-CS-Fixer у режимі перевірки без правок. Статичний аналіз: PHPStan або Psalm на зафіксованому рівні, для легасі з baseline. Тести: PHPUnit або Pest на тій самій базі, що в продакшені, піднятій як service-контейнер, бо SQLite відрізняється в типах, JSON і ALTER TABLE.

Порядок визначає принцип fail fast. Лінтер і аналіз відпрацьовують за секунди, тому вони йдуть першими або паралельно, а хвилинні тести після. Збірка Docker-образу відбувається лише після зелених перевірок і лише з гілки, з якої деплоять. Образ тегується SHA коміту, щоб будь-який деплой можна було відкотити до конкретної збірки.

Коли пайплайн росте до 20 хвилин, лікують не вимкненням кроків, а вимірюванням. Зазвичай час іде на залежності без кешу й на послідовні тести: кеш шарів, паралельні джоби, шарди тестів через --parallel, транзакції замість міграцій з нуля в кожному тесті, і винесення важких інтеграційних сьютів на етап merge. Статичний аналіз при цьому не прибирають: він ловить помилки типів у коді, до якого тести не дійшли, і робить це за секунди.

# .github/workflows/ci.yml — мінімальний, але повний пайплайн
name: CI
on: [push, pull_request]

jobs:
  static:                      # секунди: падаємо рано
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', coverage: none }
      - uses: actions/cache@v4
        with: { path: vendor, key: composer-${{ hashFiles('composer.lock') }} }
      - run: composer validate --strict && composer install --no-interaction --prefer-dist
      - run: composer audit
      - run: vendor/bin/pint --test
      - run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G

  tests:                       # хвилини: реальна база, як у продакшені
    runs-on: ubuntu-latest
    needs: static
    services:
      postgres:
        image: postgres:17
        env: { POSTGRES_DB: app_test, POSTGRES_USER: app, POSTGRES_PASSWORD: secret }
        options: --health-cmd pg_isready --health-interval 5s
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', extensions: pdo_pgsql }
      - uses: actions/cache@v4
        with: { path: vendor, key: composer-${{ hashFiles('composer.lock') }} }
      - run: composer install --no-interaction --prefer-dist
      - run: vendor/bin/pest --parallel
        env: { DB_CONNECTION: pgsql, DB_HOST: localhost, DB_DATABASE: app_test, DB_USERNAME: app, DB_PASSWORD: secret }

  build:                       # лише після зелених перевірок
    runs-on: ubuntu-latest
    needs: tests
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t registry.example.com/app:${{ github.sha }} .
Конкретний набір: composer validate і install з кешем, Pint або PHP-CS-Fixer у режимі перевірки, PHPStan або Psalm на фіксованому рівні, PHPUnit або Pest, збірка Docker-образу лише після зелених перевірок.
Принцип fail fast: лінтер і статичний аналіз за секунди, тести за хвилини, тому спершу швидке, а незалежні кроки паралельно.
Що тести в CI ганяються на тій самій базі, що в продакшені, через service container, а не на SQLite, бо відмінності в SQL реальні.
Що composer.lock у репозиторії й install без update, composer audit для вразливостей, а версія PHP у CI збігається з образом продакшену.
Розуміння, який рівень PHPStan тримається і чому, і що baseline дозволяє впровадити аналіз у легасі без зупинки розробки.
Обмежуватись лише запуском тестів і вважати CI готовим.
Запускати тести на SQLite заради швидкості й ловити відмінності в SQL уже в продакшені.
Не кешувати vendor і залежності Docker-шарів, через що кожен запуск качає все з нуля.
Ставити збірку образу перед тестами або деплоїти з гілки без перевірок.
Використовувати composer update у CI замість install і отримувати різні залежності в кожному запуску.
ПОРАДА

Скажіть, який рівень PHPStan тримаєте і чому саме такий — це показує реальний досвід, а не список інструментів. Додайте, як розбили довгі тести на паралельні джоби.

Сторінка питання →
ARC
Архітектура·Middle ·CQRS ·команди ·запити

CQRS — це розділення операцій зміни стану (команди) і операцій читання (запити) на різні моделі: команди йдуть через доменні агрегати з інваріантами, читання — окремими DTO або навіть сирим SQL. Event sourcing і окрема база для читання — необовʼязкові додатки, а не частина визначення.

У чому різниця між CQRS і звичайним сервісним шаром з методами save() і find()?
Чи обовʼязково для CQRS мати дві бази і event sourcing?
У нас одна модель обслуговує і форму редагування, і звіт із десятьма JOIN. Що тут не так?
Навіщо команді повертати void, якщо мені потрібен id створеної сутності?

CQRS означає рівно одне: операції, що змінюють стан, і операції, що повертають дані, працюють через різні моделі. Причина в тому, що вимоги до цих двох сторін розходяться. Запису потрібні інваріанти, транзакційні межі й мінімальний обсяг завантажених даних — рівно стільки, щоб перевірити правило. Читанню потрібні денормалізовані плоскі рядки під конкретний екран, часто з кількох таблиць, без жодних правил. Коли обидві потреби обслуговує одна модель (сутність Doctrine чи Eloquent-модель), вона програє обом: у неї додають поля заради звітів і навантажують звʼязками заради списків, а інваріанти тонуть у геттерах.

Мінімальна реалізація не вимагає ніякої нової інфраструктури. Запис: команда як незмінний DTO з наміром і handler, який завантажує агрегат, викликає доменний метод і зберігає. Читання: окремий клас, що виконує SELECT і повертає readonly-DTO під конкретний екран — у Laravel через DB::table(), у Doctrine через DBAL або NativeQuery з ResultSetMapping. Ключове тут те, що читання не проходить через ORM-сутність: сутність потрапляє в Unit of Work, отримує dirty checking і гідрейтинг звʼязків, які на сторінці списку не потрібні. Одна база, одна транзакція, звичайні міграції — і це вже повноцінний CQRS.

Далі йдуть три незалежні кроки, які часто помилково вважають частиною визначення. Перший — окрема схема для читання в тій самій базі: SQL view або денормалізована таблиця, яку оновлює той самий код, що й пише, у тій самій транзакції. Другий — окреме сховище: репліка, Redis, Elasticsearch. Третій — event sourcing, тобто зберігання стану як послідовності подій, з яких проєкція будує read model. Кожен крок вирішує свою проблему (складність запиту, профіль навантаження, потреба в історії) і має свою ціну. Брати їх разом «бо так у статтях про CQRS» — найдорожча помилка в цій темі.

Ціна самого розділення теж не нульова: класів стає більше, і те, що раніше було одним методом сервісу, тепер команда, handler і читач. Тому CQRS вводять точково, а не по всьому проєкту: у модулі, де правила запису нетривіальні, або де екрани читання вимагають агрегацій, яких немає у формі редагування. У простому CRUD-модулі — довідник, налаштування, теги — розділення дає лише зайві файли, і чесна відповідь на співбесіді включає цю межу.

Останнє, про що варто сказати самому: узгодженість. Поки read model оновлюється в одній транзакції із записом, її немає про що обговорювати. Щойно проєкція стає асинхронною (черга, реплікація, зовнішній індекс), зʼявляється вікно, у якому користувач бачить старі дані одразу після власної дії. Це не баг CQRS, а свідомий обмін швидкості читання на затримку узгодження, і його треба закладати в сценарій: читати після запису з primary, показувати нову версію оптимістично або прямо повідомляти, що зміни зʼявляться за кілька секунд.

// Запис: команда + handler. Модель запису — агрегат з інваріантами.
final readonly class PublishArticle
{
    public function __construct(
        public string $articleId,      // ULID генерує клієнт: команда ідемпотентна
        public string $editorId,
    ) {}
}

final readonly class PublishArticleHandler
{
    public function __construct(private ArticleRepository $articles) {}

    // void: результат читання беруть окремим запитом, а не з команди
    public function __invoke(PublishArticle $command): void
    {
        $article = $this->articles->get(ArticleId::fromString($command->articleId));
        $article->publish(EditorId::fromString($command->editorId)); // інваріанти всередині агрегату
        $this->articles->save($article);
    }
}

// Читання: жодного агрегату й жодної ORM-сутності — плоский SELECT у DTO.
final readonly class PublishedArticleRow
{
    public function __construct(
        public string $id,
        public string $title,
        public string $authorName,
        public int $viewCount,
    ) {}
}

final readonly class PublishedArticles
{
    public function __construct(private ConnectionInterface $db) {}

    /** @return list<PublishedArticleRow> */
    public function latest(int $limit = 20): array
    {
        // Та сама база й та сама транзакційна модель — окрема лише модель читання
        $rows = $this->db->table('articles as a')
            ->join('users as u', 'u.id', '=', 'a.author_id')
            ->where('a.status', 'published')
            ->orderByDesc('a.published_at')
            ->limit($limit)
            ->get(['a.id', 'a.title', 'u.name as author_name', 'a.view_count']);

        return $rows->map(fn ($r) => new PublishedArticleRow($r->id, $r->title, $r->author_name, (int) $r->view_count))->all();
    }
}
Що CQRS розділяє саме моделі, а не обовʼязково бази: одна таблиця й одна транзакція цілком сумісні з CQRS.
Що записом керує агрегат з інваріантами, а читання не зобовʼязане проходити через сутності ORM і може бути звичайним SELECT у DTO.
Що event sourcing, окрема read-база й асинхронна проєкція — це три незалежні рішення, кожне зі своєю ціною, і жодне не входить у мінімальний CQRS.
Що ціна CQRS — дублювання моделей і більше класів, тому його вводять там, де форми читання і запису реально розійшлися: звіти, списки з фільтрами, експорти.
Що як тільки читання йде з окремого сховища або проєкції, зʼявляється eventual consistency, і це треба свідомо показати в UI, а не ховати.
Ставити знак рівності між CQRS і event sourcing і відмовлятися від CQRS через складність ES.
Заводити CommandBus і QueryBus, але всередині обох ходити тими самими Eloquent-моделями: розділення на пакети є, розділення моделей немає.
Робити read model через ті самі сутності Doctrine з fetch-join і вважати, що це окрема модель читання: сутність тягне за собою Unit of Work і зайвий гідрейтинг.
Стверджувати, що команда не може повертати нічого: правило «void» стосується даних для читання, а ідентифікатор створеної сутності віддавати нормально, надто якщо його генерує клієнт.
Вводити асинхронну проєкцію заради «швидкості» і отримати скаргу «я зберіг і не бачу змін», не передбачивши на це відповіді в UI.
ПОРАДА

Скажіть, що CQRS — це розділення моделей, а не інфраструктури, і назвіть три незалежні кроки: окремі DTO для читання, окрема схема (view чи денормалізована таблиця), окреме сховище з асинхронною синхронізацією. Більшість проєктів зупиняється на першому.

Сторінка питання →
PHP
Core PHP·Middle ·Fiber ·PHP 8.1 ·корутини

`Fiber` (PHP 8.1+) — це низькорівневий примітив кооперативної багатозадачності: блок коду з власним стеком, який можна призупинити з будь-якої глибини викликів через `Fiber::suspend()` і відновити через `resume()`. Планувальника й циклу подій у ядрі немає — їх дають revolt/event-loop та AMPHP v3.

Fibers — це нарешті багатопоточність у PHP?
Чим `Fiber` відрізняється від генератора, якщо обидва вміють призупинятися й віддавати значення?
Я обгорнув запити через PDO у `Fiber`, а сторінка не пришвидшилась — чому?
Ви колись писали `new Fiber()` руками? Якщо ні, то навіщо воно взагалі в мові?

Fiber — це клас у глобальному просторі імен, доданий у PHP 8.1 разом із FiberError. Обʼєкт створюють від будь-якого callable (new Fiber($callback)), запускають через start(...$args), а всередині коду волокна викликають статичний Fiber::suspend($value) — виконання завмирає, а start() повертає передане значення тому, хто волокно запустив. Далі resume($value) продовжує роботу з тієї ж точки, причому аргумент resume() стає значенням, яке поверне Fiber::suspend(). Стан читають через isStarted(), isSuspended(), isRunning(), isTerminated(), результат callable — через getReturn() (до завершення волокна це FiberError), а «розбудити з винятком» можна через throw(). Усе разом це один примітив: перемикання стеків, і нічого більше.

Порівняння з генераторами — головна змістовна частина відповіді. Обидва механізми кооперативні й обидва вміють двобічний обмін (Generator::send() проти Fiber::resume()), але генератор призупиняє тільки власне тіло: yield має стояти в тій самій функції, а функція з yield перестає бути звичайною — вона повертає Generator, і викликач мусить її ітерувати. Щоб призупинитися з глибини, кожну функцію в ланцюжку доводиться робити генератором і прокидати yield from — це та сама «розфарбованість функцій», через яку синхронний і корутинний код не змішувалися. Волокно має власний стек, тому Fiber::suspend() спрацьовує на будь-якій глибині, а проміжний код лишається звичайним і взагалі не знає про своє оточення; за потреби бібліотека перевіряє контекст через Fiber::getCurrent(). Зворотний бік: волокно не є Iterator, у нього немає ключів і foreach, тож для лінивого перебору даних генератор нікуди не подівся.

Руками new Fiber() майже ніхто не пише, і це нормальна відповідь на питання «навіщо воно тоді». RFC свідомо лишив у ядрі лише примітив, без планувальника й циклу подій, бо його місце — у користувацькому просторі. Планувальником став revolt/event-loop — спільний цикл подій, який використовують AMPHP v3 і ReactPHP; поверх нього amphp/amp v3 дає Amp\async() (запускає волокно) та Future::await() (усередині кличе suspend()). Найкраща ілюстрація зиску — сама історія AMPHP: у v2 корутини будувалися на генераторах і кожен асинхронний виклик писався як yield $promise, у v3 бібліотеку переписали на волокна, і yield із користувацького коду зник — асинхронний виклик виглядає як звичайний. У ReactPHP той самий підхід дає пакет react/async зі своїми async()/await().

Чому це не async у розумінні JavaScript. По-перше, у PHP немає вбудованого рантайму з циклом подій: у Node цикл є завжди й усі I/O-API неблокуючі за замовчуванням, у PHP цикл треба принести бібліотекою і явно запустити. По-друге, у ядрі немає ані Promise, ані ключових слів async/await — є один клас Fiber. По-третє й найважливіше практично: волокно не перетворює блокуючий виклик на неблокуючий. PDO::query(), file_get_contents(), curl_exec(), sleep() зупиняють увесь процес разом з усіма волокнами, тож конкурентність зʼявляється лише тоді, коли ви користуєтесь неблокуючими клієнтами (amphp/http-client, amphp/mysql, amphp/postgres, amphp/socket). Це відрізняє підхід ядра PHP від Swoole, який підміняє блокуючі функції власними реалізаціями через runtime hooks.

Межі варто назвати одразу, щоб відповідь звучала як досвід, а не як переказ документації. Волокна — це конкурентність, а не паралелізм: у кожен момент виконується рівно одне волокно, і задачу, що впирається в CPU, вони не пришвидшать — там потрібні amphp/parallel, ext-parallel або кілька воркерів. У класичній моделі PHP-FPM виграш обмежений одним запитом: розпаралелити три звернення до зовнішніх API можна, а тримати цикл подій між запитами — ні (для цього потрібен довгоживучий воркер). Памʼять теж не безкоштовна: кожне призупинене волокно тримає власний стек, тому «мільйон волокон» — не той дизайн, який варто пропонувати. І нарешті, ресурси всередині волокна закривають у finally: коли призупинене волокно втрачає останнє посилання, PHP розкручує його стек саме заради finally, а призупинитися ще раз у цей момент уже не дасть.

// Функції нижче — звичайні: ні yield, ні Generator у сигнатурах
function fetchBody(string $url): string
{
    return readResponse($url);        // виклик на рівень глибше
}

function readResponse(string $url): string
{
    // тут був би неблокуючий сокет; призупиняємось із глибини стека
    $answer = Fiber::suspend("чекаю на {$url}");

    return "тіло {$url} ({$answer})";  // suspend() повертає те, що дали в resume()
}

$fibers = [
    new Fiber(fn (): string => fetchBody('/api/jobs')),
    new Fiber(fn (): string => fetchBody('/api/companies')),
];

// start() повертає значення, передане у Fiber::suspend()
foreach ($fibers as $fiber) {
    echo $fiber->start(), PHP_EOL;    // «чекаю на /api/jobs», «чекаю на /api/companies»
}

// Примітивний планувальник: обидва волокна вже «в польоті» одночасно
foreach ($fibers as $fiber) {
    if ($fiber->isSuspended()) {
        $fiber->resume('200 OK');     // значення повертається з suspend()
    }
}

foreach ($fibers as $fiber) {
    // getReturn() дійсний лише після завершення волокна
    echo $fiber->isTerminated() ? $fiber->getReturn() : 'ще виконується', PHP_EOL;
}

try {
    Fiber::suspend('з {main}');       // поза волокном це заборонено
} catch (FiberError $e) {
    echo $e->getMessage(), PHP_EOL;   // Cannot suspend outside of fiber
}
Що `Fiber` — це не «async у PHP», а лише механізм призупинення: RFC свідомо не додав ані планувальника, ані циклу подій, ані `Promise` в ядро.
Що головна відмінність від генератора — власний стек: `Fiber::suspend()` викликається з будь-якої глибини вкладених функцій, і проміжні функції не треба переписувати на `yield from`.
Що волокна кооперативні й однопотокові: у кожен момент виконується рівно одне, паралельності на кількох ядрах вони не дають.
Що реальні споживачі — `revolt/event-loop` (спільний цикл подій для AMPHP v3 і ReactPHP) та `amphp/amp` v3; AMPHP v2 будував корутини на генераторах і `yield`, v3 переписали на волокна й `yield` з користувацького коду зник.
Що блокуючий виклик (`PDO::query`, `file_get_contents`, `sleep`, `curl_exec`) усередині волокна блокує весь процес — волокно саме собою нічого не робить неблокуючим.
Казати «Fibers — це потоки» або «тепер PHP паралелить запити на ядрах»: це один процес, один потік, кооперативне перемикання за явним `suspend()`.
Обгортати у волокно звичайний `PDO`/`file_get_contents` і чекати прискорення: потрібні неблокуючі клієнти (`amphp/http-client`, `amphp/mysql`, `amphp/postgres`), інакше виграшу нуль.
Викликати `Fiber::suspend()` із `{main}` або з коду поза волокном — буде `FiberError: Cannot suspend outside of fiber`.
Читати `$fiber->getReturn()`, поки волокно ще призупинене — `FiberError`; спочатку `isTerminated()`.
Плутати ролі: брати `Fiber` там, де потрібна лінива послідовність даних — для ітерації по мільйону рядків правильний інструмент і далі генератор.
Стверджувати, що в PHP 8.1 зʼявилися `async`/`await` і `Promise`: у ядрі є лише клас `Fiber`, решта — бібліотеки.
ПОРАДА

Формула, яка закриває питання: «генератор — це корутина, видима в сигнатурі; волокно — корутина, невидима для викликаного коду». Далі одне речення про межі: «PHP отримав перемикач стеків, а планувальник і неблокуючий I/O довелося взяти з Revolt і AMPHP».

Сторінка питання →
SF
Symfony·Middle ·Doctrine ·Unit of Work ·flush

Doctrine відслідковує всі керовані обʼєкти й накопичує зміни в памʼяті; на flush() вона обчислює change set і виконує запити в правильному порядку.

Чому Doctrine не пише в базу після persist?
Чому flush у циклі це погано?
Що таке identity map і чим вона небезпечна при масовій обробці?

Unit of Work — це реєстр усього, що Doctrine завантажила або отримала через persist у межах одного EntityManager. Для кожної managed-сутності він зберігає копію початкових значень, а на flush() порівнює її з поточним станом, обчислює change set і генерує INSERT, UPDATE, DELETE у порядку, який враховує звʼязки між сутностями. Усе це виконується в одній транзакції.

Звідси два наслідки, які часто дивують. По-перше, persist не робить запиту, а update не існує: зміна властивості через сеттер достатня. По-друге, identity map гарантує один PHP-обʼєкт на один рядок бази, тому повторний find того самого id не йде в базу, а звʼязки завжди вказують на той самий екземпляр.

Ціна цієї моделі — памʼять і вартість flush. Кожен flush обходить усі managed-обʼєкти, тому виклик у циклі дає квадратичну складність і по транзакції на ітерацію. Identity map тримає обʼєкти до clear(), тому імпорт на мільйон рядків без очищення закінчується OOM. Стандартний рецепт — батчі: flush і clear через кожні кількасот записів, toIterable() замість getResult(), а для простих масових оновлень без доменної логіки — DQL або SQL UPDATE одним запитом.

// persist лише реєструє обʼєкт; SQL немає до flush()
$user = new User('[email protected]');
$em->persist($user);        // стан: managed, INSERT ще не виконано

$existing = $em->find(User::class, 42);
$existing->rename('Олена'); // change set порахується на flush, update() не потрібен

$em->flush();               // одна транзакція: INSERT + UPDATE у правильному порядку

// Погано: N обходів identity map і N транзакцій
foreach ($users as $u) {
    $u->activate();
    $em->flush();
}

// Добре для масової обробки: батчі з flush + clear
$batch = 500;
foreach ($query->toIterable() as $i => $u) {
    $u->activate();
    if (($i + 1) % $batch === 0) {
        $em->flush();
        $em->clear();       // identity map звільняється, памʼять не росте
    }
}
$em->flush();

// Для сотень тисяч рядків без логіки в сутностях краще один DQL UPDATE
$em->createQuery('UPDATE App\Entity\User u SET u.active = true WHERE u.invitedAt < :d')
    ->setParameter('d', $threshold)->execute();
Що persist не робить INSERT, а лише переводить обʼєкт у стан managed: реальні запити відбуваються на flush.
Що для managed-сутностей Doctrine зберігає копію оригінальних даних і на flush порівнює її з поточним станом, тому явний update не потрібен.
Що identity map гарантує один PHP-обʼєкт на один рядок у межах EntityManager, звідси й економія запитів, і ріст памʼяті.
Що flush упорядковує запити за залежностями сутностей і загортає їх в одну транзакцію.
Практику масової обробки: батчі з flush і clear через кожні N записів, або взагалі DQL/SQL для UPDATE великих обсягів.
Казати, що Unit of Work це транзакція БД або кеш запитів: це патерн відстеження змін обʼєктів у памʼяті.
Викликати flush після кожного persist у циклі: кожен flush проходить по всій identity map і відкриває транзакцію.
Забувати clear() при обробці сотень тисяч записів: identity map тримає всі обʼєкти й процес падає з OOM.
Викликати clear() і далі використовувати старі обʼєкти: вони стали detached, і зміни в них Doctrine не побачить.
Не знати, що після виключення в flush EntityManager закривається й потребує нового екземпляра.
ПОРАДА

Згадайте clear() під час обробки великих наборів — інакше identity map зʼїдає памʼять. І поясніть, чому один flush у кінці транзакції ефективніший за flush у циклі.

Сторінка питання →
SQL
SQL·Middle ·індекси ·EXPLAIN

Складений індекс працює за принципом лівого префікса: індекс (a, b, c) допомагає умовам по a, по a і b, по a, b і c, але не по b чи c окремо.

Чи спрацює індекс (a, b) для умови лише по b?
Яку колонку ставити першою в індексі?
Коли краще два окремих індекси замість одного складеного?

Складений індекс — це B-tree, у якому записи відсортовані спершу по першій колонці, всередині рівних значень по другій, і так далі. Звідси правило лівого префікса: індекс (a, b, c) дає швидкий пошук по a, по a, b і по a, b, c, але не по b окремо, бо значення b розкидані по всьому дереву. Телефонний довідник відсортований за прізвищем, потім за імʼям; знайти всіх Олен у ньому неможливо без повного перегляду.

Порядок колонок визначається запитами, які індекс має обслуговувати. Колонки з умовою рівності йдуть першими, колонка з діапазоном або та, по якій потрібне сортування, останньою. Після діапазону решта індексу для звуження пошуку вже не працює. Для типового WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT 20 правильний індекс (user_id, status, created_at): база стрибає в потрібне місце й читає двадцять уже відсортованих записів.

Індекс, який містить усі колонки запиту, стає покривним: таблиця не читається взагалі, у PostgreSQL це Index Only Scan із INCLUDE для додаткових колонок. Ціна кожного індексу — місце й повільніший запис, тому один складений індекс, спроєктований під кілька запитів, зазвичай кращий за набір вузьких. Після створення індексу його використання перевіряють через EXPLAIN, а не припускають.

-- Запит, під який проєктуємо індекс
SELECT id, total
FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

-- Правильно: рівності попереду, сортування останнім
CREATE INDEX orders_user_status_created ON orders (user_id, status, created_at DESC);

-- Неправильно: після діапазону або сортування решта індексу не використовується для пошуку
CREATE INDEX orders_created_user ON orders (created_at, user_id, status);

-- Покривний: total у INCLUDE, таблицю читати не треба (PostgreSQL)
CREATE INDEX orders_user_status_created_cov
    ON orders (user_id, status, created_at DESC) INCLUDE (total);

-- Перевірка: має бути Index Scan / Index Only Scan, а не Seq Scan + Sort
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;
Правило лівого префікса з поясненням через структуру B-tree: записи відсортовані спершу по a, всередині рівних a по b, тому без a стрибнути до потрібного b неможливо.
Що колонки з умовою рівності йдуть першими, а колонка з діапазоном або сортуванням останньою: після діапазону решта індексу для пошуку не використовується.
Що селективність важлива, але порядок визначає перш за все набір запитів, які індекс має покривати.
Що покривний індекс, який містить усі потрібні колонки, дозволяє не читати таблицю взагалі: Index Only Scan у PostgreSQL, Using index у MySQL.
Що кожен індекс коштує на запис і памʼять, тому один складений індекс під кілька запитів часто кращий за кілька вузьких.
Казати, що порядок не має значення й індекс працює для будь-якої комбінації.
Створювати індекс (created_at, user_id) для запиту WHERE user_id = ? ORDER BY created_at: діапазон або сортування мають бути в кінці.
Створювати окремі індекси по кожній колонці й очікувати, що база обʼєднає їх так само ефективно, як складений.
Ставити першою колонку з двома значеннями на кшталт is_active лише тому, що вона є в кожному запиті, не перевіривши альтернативи.
Не дивитись EXPLAIN після створення індексу й вірити, що він використовується.
ПОРАДА

Хороша ілюстрація — телефонний довідник: за прізвищем шукати легко, за одним лише імʼям — ні. І назвіть правило: рівність, потім діапазон або сортування.

Сторінка питання →
LR
Laravel·Middle ·API ·JsonResource ·пагінація

Модель серіалізується через `toArray()`, тож форма відповіді — це набір колонок таблиці плюс випадково завантажені звʼязки й `$appends`: нова колонка публікується сама, а `$hidden` захищає лише те, що ви згадали. `JsonResource` — це білий список полів у явному класі, де `whenLoaded()` прибирає ключ замість ліниво тягнути звʼязок, а `Resource::collection($paginator)` сам додає `links` і `meta`.

У контролері `return $user;` — що з цим не так, крім стилю?
Додали в таблицю `posts` колонку `internal_note` — чому вона наступного дня зʼявилась у мобільному застосунку?
Чому `PostResource::collection(Post::paginate(20))` дає `links` і `meta`, а `PostResource::collection(Post::all())` — лише `data`?
У ресурсі написано `whenLoaded('author')`, звʼязок не завантажили — у JSON буде `"author": null` чи ключа не буде взагалі?

Коли з контролера повертають return $post;, Laravel бачить обʼєкт, що реалізує JsonSerializable, і викликає toJson()toArray(). Усередині — attributesToArray() плюс relationsToArray(): у відповідь іде кожна колонка з $attributes, кожен акцесор із $appends і кожен звʼязок, який на цей момент виявився завантаженим. Наслідків три, і всі неприємні. Перший: контракт API стає дзеркалом схеми БД — ALTER TABLE ADD COLUMN internal_note мовчки публікує нове поле, а перейменування колонки ламає мобільний застосунок. Другий: $hidden — це чорний список, він рятує від password і remember_token, бо їх туди вписали в скелеті, і не рятує від колонки, доданої іншою людиною через півроку. Третій, найпідступніший: форма відповіді залежить від того, які звʼязки випадково завантажилися раніше по коду — додали $post->load('comments') заради перевірки в middleware, і відповідь виросла на кілограм JSON, якого ніхто не просив.

JsonResource перевертає логіку: замість «віддаємо все, крім забороненого» — «віддаємо рівно те, що перелічено в toArray(Request $request)». Клас створюють через php artisan make:resource PostResource, усередині доступний $this->resource (модель), а звертання $this->title проксіюється до неї трейтом DelegatesToResource. Далі результат toArray() проходить через filter(): ключі, значення яких є MissingValue, викидаються; вкладений ресурс, чий resource дорівнює null, перетворюється на null. Готовий масив загортається в data — це public static $wrap = 'data' у JsonResource, який знімається глобально викликом JsonResource::withoutWrapping(). Одиничний ресурс можна повернути прямо з контролера (Responsable), а якщо потрібен код 201 чи заголовок — через ->response()->setStatusCode(201).

Ключова для продуктивності частина — умовні поля. whenLoaded('author') перевіряє relationLoaded() і сам у базу не ходить: якщо звʼязку немає в памʼяті, повертається MissingValue і ключ просто зникає з JSON. Це принципово відрізняється від new AuthorResource($this->author), яке для кожного елемента колекції зробить окремий select — тобто ресурс, написаний наївно, сам стає генератором N+1. Тому пара завжди така: with('author') у запиті плюс whenLoaded('author') у ресурсі; забули перше — поле тихо зникне, і краще зловити це тестом assertJsonStructure(), ніж клієнтом. Поруч живуть whenCounted('comments') (працює після withCount()), whenAggregated() (після withSum()/withAvg()), when() для прав і mergeWhen() для вливання блоку полів на верхній рівень. Разом із Model::preventLazyLoading() у dev/test це дає жорстку гарантію: скільки запитів у контролері написано, стільки їх і буде, скільки б полів ресурс не описував.

Пагінація вбудована в ту саму механіку. PostResource::collection($posts) повертає AnonymousResourceCollection, і якщо $posts — пагінатор, відповідь формує PaginatedResourceResponse: поруч із data зʼявляються links (first, last, prev, next) і meta — усе, що є в $paginator->toArray(), крім data і чотирьох url: current_page, from, to, last_page, per_page, path, total і масив links для нумерації. Власні поля з ->additional(['meta' => [...]]) зливаються з цим через array_merge_recursive, тому нічого не затирають. Два практичні моменти: ->withQueryString() на пагінаторі, щоб next_page_url зберіг фільтри, і обовʼязковий унікальний тайбрейкер у сортуванні — orderByDesc('published_at')->orderByDesc('id'), бо LIMIT/OFFSET без стабільного порядку дає дублі на одній сторінці й пропуски на іншій. Памʼятайте також, що paginate() — це два запити: спершу select count(*) (при total = 0 другий взагалі не виконується), і саме цей count(*) разом із глибоким офсетом стає вузьким місцем на мільйонах рядків; тоді беруть simplePaginate() або keyset-пагінацію cursorPaginate(), свідомо відмовляючись від total і номерів сторінок.

Межі й компроміси теж варто назвати вголос. Ресурс — не механізм авторизації: він вирішує, які поля показати, а право на сам обʼєкт перевіряють policy й authorize(), інакше «приховане» поле легко дістається сусіднім ендпоїнтом. Усередині toArray() не місце запитам і виклику зовнішніх сервісів — цей код виконується для кожного елемента колекції; усе потрібне має прийти з with()/withCount(). Шар справді додає файлів, і для внутрішнього ендпоїнта на два поля дешевше повернути явний масив, ніж заводити клас; у Laravel 12+ рутину скорочують $post->toResource() і Post::all()->toResourceCollection(), які знаходять клас за неймспейсом або за атрибутом #[UseResource]. І нарешті, JsonResource — це трансформація, а не типізований DTO: він не описує схему для OpenAPI й не дає гарантій типів, тому проєкти, де контракт API важливіший за швидкість написання, або доповнюють ресурси генератором специфікації, або замінюють їх на явні DTO з spatie/laravel-data. Але в кожному з цих варіантів залишається та сама межа, за яку й ставлять плюс на співбесіді: модель описує таблицю, окремий клас описує відповідь, і зміна першого не повинна автоматично змінювати друге.

final class PostResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        return [
            // Білий список: нова колонка в таблиці не потрапить у відповідь сама.
            'id' => $this->id,
            'slug' => $this->slug,
            'title' => $this->title,
            'published_at' => $this->published_at?->toIso8601String(),
            // Звʼязок не завантажений -> MissingValue -> ключа в JSON не буде.
            // Жодного лінивого запиту з циклу по колекції.
            'author' => AuthorResource::make($this->whenLoaded('author')),
            // Зʼявиться, лише якщо в запиті був withCount('comments').
            'comments_count' => $this->whenCounted('comments'),
            // Замикання, а не значення: інакше вираз рахується й тоді, коли умова false.
            'internal_note' => $this->when(
                (bool) $request->user()?->can('update', $this->resource),
                fn () => $this->internal_note,
            ),
        ];
    }
}

final class PostController
{
    public function index(): ResourceCollection
    {
        $posts = Post::query()
            ->with('author')      // один запит на всіх авторів замість N
            ->withCount('comments')
            ->where('is_published', true)
            ->orderByDesc('published_at')
            ->orderByDesc('id')   // тайбрейкер: інакше рядки стрибають між сторінками
            ->paginate(20)        // +1 запит select count(*) заради total
            ->withQueryString();  // фільтри лишаються в next/prev

        return PostResource::collection($posts); // links і meta додасть сам
    }
}
Що `return $post;` — це `toJson()` → `toArray()`: усі колонки з `$attributes`, усі завантажені звʼязки й усе з `$appends`; контракт API стає дзеркалом схеми БД, а міграція — публічною зміною.
Що `$hidden` — чорний список: він ховає перелічене, а нову колонку публікує; `JsonResource::toArray()` — білий список, де за замовчуванням не віддається нічого.
Що `whenLoaded('author')` повертає `MissingValue`, і `removeMissingValues()` видаляє ключ із масиву — це не `null`, а відсутність поля, і саме тому ресурс не породжує N+1 при `Model::preventLazyLoading()`.
Що `Resource::collection($paginator)` віддає відповідь через `PaginatedResourceResponse`: `links` (first/last/prev/next) і `meta` (`current_page`, `per_page`, `total`, `last_page`, `from`, `to`, `path`), а звичайна колекція — лише `data`.
Що `paginate()` — це два запити (спершу `select count(*)`, потім вибірка) і що на глибоких офсетах та великих таблицях відповіддю є `simplePaginate()` або `cursorPaginate()`, а не індекс.
Покладатися на `$hidden = ['password']` як на захист: колонку `salary` чи `internal_note`, додану через півроку, ніхто в `$hidden` не допише.
Писати в ресурсі `'author' => new AuthorResource($this->author)` замість `whenLoaded('author')`: на колекції з 50 елементів це 50 додаткових запитів, і ресурс сам стає джерелом N+1.
Ставити `whenLoaded('author')` і забути `with('author')` у запиті — ключ мовчки зникає з відповіді, клієнт бачить не помилку, а «поля немає».
Обгортати вручну: `return ['data' => PostResource::collection($posts)]` — виходить `data.data`, бо ресурс уже загорнутий у `data` (`static $wrap`).
Пагінувати з `orderByDesc('created_at')` без унікального тайбрейкера: рядки з однаковою секундою стрибають між сторінками — щось видно двічі, щось не видно взагалі.
Втрачати фільтри в посиланнях: без `withQueryString()` у `next_page_url` не буде ні `?status=`, ні `?q=`, і друга сторінка покаже інший набір.
Чекати від `cursorPaginate()` полів `total` і `last_page`: у `meta` там лише `path`, `per_page`, `next_cursor`, `prev_cursor` — намалювати «сторінка 7 з 340» неможливо.
Робити запити всередині `toArray()` (`$this->comments()->count()`, `Cache::get(...)`) — код виконається для кожного елемента колекції.
ПОРАДА

Сформулюйте це як межу: модель — це схема БД, ресурс — це контракт із клієнтом, і між ними має бути явний клас, інакше `ALTER TABLE` автоматично стає зміною публічного API. Далі назвіть три речі, за які ресурс і любили: білий список полів, `whenLoaded()` (ключ зникає, а не тягнеться зайвий запит) і `links`/`meta`, які `collection($paginator)` додає сам.

Сторінка питання →
WP
WordPress·Middle ·REST API ·register_rest_route ·permission_callback

register_rest_route на хуку rest_api_init: namespace/версія, methods, callback, обовʼязковий permission_callback з current_user_can і args зі sanitize_callback та validate_callback; помилки повертаються як WP_Error зі status.

Куди вішати register_rest_route і чому виклик у плагіні «просто так» не працює?
Ендпоінт віддає 401 з браузера, хоча користувач залогінений — у чому річ?
Ми написали type: integer в args, але приходить рядок і код падає. Чому WordPress не перевірив?
Чим permission_callback відрізняється від перевірки прав усередині callback?

REST API в ядрі з WP 4.7, і власний маршрут реєструється однією функцією — register_rest_route($namespace, $route, $args). Ключове обмеження: викликати її можна лише на хуку rest_api_init, бо саме там ядро створює WP_REST_Server і збирає таблицю маршрутів. Виклик у файлі плагіна або на init мовчки нічого не дасть — маршрут просто не зʼявиться у /wp-json/. Namespace має вигляд vendor/v1: версія живе в namespace, а не в шляху, щоб згодом можна було випустити vendor/v2 поруч зі старим. Динамічні сегменти описуються іменованою групою регулярного виразу, /leads/(?P<id>\d+), і потрапляють у $request['id']. Якщо на сайті plain-перміалінки, /wp-json/ не працює й потрібен запасний вигляд ?rest_route=/crm/v1/leads — про це варто памʼятати, коли ендпоінт «не існує» лише на одному стенді.

Права перевіряє permission_callback, і з WP 5.5 це обовʼязковий аргумент: без нього ядро пише _doing_it_wrong, але маршрут усе одно реєструється і лишається публічним. Тому забутий permission_callback — це не помилка розробки, а відкритий ендпоінт у продакшені; для навмисно публічного маршруту пишуть явне 'permission_callback' => '__return_true', щоб намір було видно з коду. Усередині перевіряють здатність через current_user_can з конкретною capability, а для дії над конкретним записом — мета-здатність з id: current_user_can('edit_post', (int) $request['id']), бо вона проходить через map_meta_cap і враховує авторство та статус. is_user_logged_in() як перевірка прав означає, що будь-який передплатник дістає доступ до адмінської дії. Повертати з колбеку можна true, false або WP_Error; зручний хелпер rest_authorization_required_code() дає 401 для гостя й 403 для залогіненого.

Валідація описується в масиві args. Тут є пастка, на якій валяться на співбесіді: у рукописному args ключі type, enum, format, minimum самі по собі нічого не перевіряють — вони лише потрапляють у схему, яку віддає запит OPTIONS. Реальну перевірку робить validate_callback, тому в кожен параметр підставляють 'validate_callback' => 'rest_validate_request_arg', і лише тоді enum чи minimum починають відхиляти запит з кодом rest_invalid_param і статусом 400. Контролери, успадковані від WP_REST_Controller, отримують це безкоштовно: rest_get_endpoint_args_for_schema() будує args з get_item_schema() і сам додає rest_validate_request_arg та rest_sanitize_request_arg. Порядок теж важливий: ядро спершу валідує всі параметри (has_valid_params), потім санітизує (sanitize_params), тож у валідатор приходить сире значення. Санітизація без валідації небезпечна тим, що absint('abc') тихо перетворить сміття на 0 і запит виконається не з тими даними.

Автентифікація — окремий шар від авторизації. Запит із браузера на тому ж домені йде під cookie, але cookie в REST довіряють лише разом із nonce дії wp_rest: його передають у заголовку X-WP-Nonce або параметром _wpnonce, інакше ядро повертає rest_cookie_invalid_nonce, і current_user_can у вашому колбеку бачить гостя. Класичний антипатерн — «полікувати» цей 401 заміною перевірки на __return_true. Для зовнішніх клієнтів cookie не підходять взагалі: з WP 5.6 у ядрі є Application Passwords, які працюють як Basic Auth поверх HTTPS і не дають доступу до адмінки.

Відповідь формують поверненням масиву, WP_REST_Response або WP_Error — жодних echo і wp_die(), які ламають JSON і віддають 200 замість потрібного коду. WP_Error з ['status' => 4xx] ядро саме серіалізує у {code, message, data}. Для колекцій варто повторити ядрову поведінку: заголовки X-WP-Total і X-WP-TotalPages, параметри page і per_page з minimum/maximum, інакше per_page=100000 стане найдешевшим способом покласти базу. І межа розумності: власний маршрут виправданий там, де ресурс не мапиться на наявний, — агрегації, дії, інтеграції. Якщо треба лише додати поле до поста, дешевше register_rest_field() або register_post_meta() з 'show_in_rest' => true, ніж дублювати половину WP_REST_Posts_Controller.

// Маршрути реєструються ЛИШЕ на rest_api_init: раніше WP_REST_Server ще не існує
add_action('rest_api_init', function (): void {
    register_rest_route('crm/v1', '/leads', [
        'methods'  => WP_REST_Server::CREATABLE, // POST
        'callback' => 'crm_create_lead',
        // Обовʼязковий з WP 5.5; для публічного ендпоінта пишуть явне '__return_true'
        'permission_callback' => static function (WP_REST_Request $request): bool|WP_Error {
            if (! current_user_can('edit_others_posts')) {
                return new WP_Error(
                    'crm_forbidden',
                    'Недостатньо прав для створення ліда',
                    ['status' => rest_authorization_required_code()] // 401 гостю, 403 залогіненому
                );
            }

            return true;
        },
        'args' => [
            'email' => [
                'required'          => true,
                'type'              => 'string',
                'format'            => 'email',
                // Без validate_callback ключі type і format лишаються лише документацією схеми
                'validate_callback' => 'rest_validate_request_arg',
                'sanitize_callback' => 'sanitize_email',
            ],
            'source' => [
                'type'              => 'string',
                'enum'              => ['form', 'phone', 'import'],
                'default'           => 'form',
                'validate_callback' => 'rest_validate_request_arg',
            ],
        ],
    ]);
});

function crm_create_lead(WP_REST_Request $request): WP_REST_Response|WP_Error
{
    $postId = wp_insert_post([
        'post_type'   => 'crm_lead',
        'post_status' => 'private',
        'post_title'  => $request['email'],       // вже санітизоване
        'meta_input'  => ['source' => $request['source']],
    ], true); // true — повертати WP_Error замість 0

    if (is_wp_error($postId)) {
        $postId->add_data(['status' => 500]);

        return $postId; // ядро саме перетворить WP_Error на JSON з потрібним кодом
    }

    return new WP_REST_Response(['id' => $postId], 201);
}
Що маршрути реєструються лише на хуку rest_api_init, і namespace має вигляд vendor/v1 — версія в namespace, а не в шляху.
Що permission_callback обовʼязковий з WP 5.5: без нього ядро кидає _doing_it_wrong, а ендпоінт лишається публічним; для справді публічного треба явне '__return_true'.
Що права перевіряють через current_user_can з конкретною здатністю, а для конкретного обʼєкта — з його id: current_user_can('edit_post', $id), а не is_user_logged_in().
Що в args працюють sanitize_callback і validate_callback, а сам по собі ключ type у рукописному масиві args нічого не перевіряє — потрібен явний rest_validate_request_arg.
Що помилку повертають як WP_Error з ['status' => 4xx], а не echo/wp_die, і що rest_authorization_required_code() дає 401 для гостя й 403 для залогіненого.
Що cookie-автентифікація в REST вимагає nonce wp_rest у заголовку X-WP-Nonce, а для зовнішніх клієнтів є Application Passwords (WP 5.6+).
Викликати register_rest_route одразу при завантаженні плагіна, а не на rest_api_init — маршрут не зареєструється, бо WP_REST_Server ще не створений.
Ставити permission_callback => '__return_true' на ендпоінт, що пише в базу, «щоб не заважало», і отримати відкритий запис для анонімів.
Перевіряти is_user_logged_in() замість здатності: будь-який передплатник отримує доступ до адмінських дій.
Описати 'type' => 'integer' в args без validate_callback і вважати, що ядро перевірить тип; насправді валідація запускається лише за наявності validate_callback.
Повертати з callback echo json_encode(...) або wp_die() — відповідь ламає JSON і виходить 200 замість коректного статусу.
Забувати X-WP-Nonce у fetch з фронтенду й лікувати 401/403 тим, що ставлять '__return_true'.
Тестувати ендпоінт лише на /wp-json/ і дивуватись 404 на сайті з plain-перміалінками, де працює ?rest_route=.
ПОРАДА

Скажіть, що permission_callback — це не «додаткова опція», а обовʼязковий аргумент з WP 5.5, і що ключ type в args — документація для схеми, а не валідація: перевірку вмикає rest_validate_request_arg. Додайте, що для складніших ресурсів варто успадкувати WP_REST_Controller, бо там args генеруються з get_item_schema через rest_get_endpoint_args_for_schema і валідатори підставляються автоматично.

Сторінка питання →
SQL
SQL·Middle ·JSON ·jsonb ·GIN

У PostgreSQL зберігайте jsonb і індексуйте його GIN для пошуку по довільних ключах або B-tree на виразі `(data->>'key')` для конкретного поля; у MySQL індекс на JSON можливий лише через згенеровану колонку, функціональний індекс (8.0.13+) або multi-valued index (8.0.17+). Помилка — тримати в JSON поля, які є в кожному рядку і за якими фільтрують чи джойнять: там потрібні звичайні колонки з типом, NOT NULL і зовнішнім ключем.

Чим json відрізняється від jsonb і що брати за замовчуванням?
Як прискорити `WHERE data->>'status' = 'paid'`, якщо в таблиці мільйон рядків?
Чому в MySQL не можна просто повісити індекс на колонку типу JSON?
Ми тримаємо всі атрибути товару в JSON-полі — що з цим не так?

У PostgreSQL є два типи: json зберігає текст документа дослівно — з пробілами, порядком і навіть дублікатами ключів, — і парсить його наново при кожному зверненні; jsonb розбирає документ один раз на запис у бінарне подання, де ключі відсортовані й унікальні. Практично завжди потрібен jsonb: тільки він підтримує оператори @>, ?, ?|, ?& і тільки його можна проіндексувати GIN. Тип json виправданий хіба що для сирого логування, де важливо зберегти байти як прийшли. У MySQL тип один — JSON (з 5.7.8), він теж бінарний і теж нормалізує документ, а перевірка валідності відбувається на вставці.

Індексація йде двома різними шляхами, і сильна відповідь називає обидва. GIN по jsonb індексує вміст документа й відповідає на питання «чи містить документ ось цей фрагмент» (meta @> '{"source":"webhook"}') та «чи є такий ключ» (meta ? 'source'); з PG 12 туди ж потрапляють jsonpath-оператори @? і @@. Клас операторів jsonb_path_ops індексує хеші повних шляхів: індекс менший і швидший, але вміє лише containment. Якщо ж запит завжди звертається до одного відомого поля, GIN зайвий — потрібен звичайний B-tree на виразі: CREATE INDEX ON orders ((meta->>'utm_source')). Такий індекс, на відміну від GIN, дає ще й статистику по виразу після ANALYZE, тому планувальник перестає вгадувати кардинальність.

MySQL прямий індекс на JSON-колонці забороняє взагалі. Класичний шлях — згенерована колонка: ADD COLUMN utm_source VARCHAR(64) AS (meta->>'$.utm_source') STORED плюс індекс на ній; VIRTUAL теж індексується і не займає місця в рядку. З 8.0.13 те саме можна записати функціональним індексом з обовʼязковим CAST, але всередині MySQL усе одно створює приховану віртуальну колонку. Для масивів з 8.0.17 є multi-valued index — єдиний випадок, коли один рядок дає кілька записів в індексі; він працює з MEMBER OF, JSON_CONTAINS і JSON_OVERLAPS, але не годиться для сортування, унікальності й первинного ключа. Головна пастка в обох варіантах — типи й колація: вираз в індексі має збігатися з виразом у WHERE посимвольно, інакше EXPLAIN мовчки покаже ALL.

Ціна JSON платиться на записі й на читанні великих документів. У PostgreSQL будь-який UPDATE через MVCC створює нову версію рядка цілком — часткового оновлення поля всередині jsonb не існує; документ більший за пару кілобайтів їде в TOAST у стиснутому вигляді, і щоб дістати один ключ, його треба прочитати й розтиснути повністю. GIN додає помітну вартість вставки й має pending list, через який щойно записані рядки шукаються повільніше, доки не відпрацює чистка. У MySQL оновлення на місці можливе, але лише для JSON_SET, JSON_REPLACE і JSON_REMOVE і лише якщо документ не зростає; будь-яка інша зміна переписує значення повністю.

Помилка починається там, де JSON заміняє схему. Якщо поле є в кожному рядку, має тип, за ним фільтрують, сортують чи джойнять — це колонка, а не ключ у документі: інакше ви втрачаєте NOT NULL, зовнішній ключ, нормальну статистику, а помилка в назві ключа не викликає помилки взагалі, meta->>'statuss' тихо повертає NULL. Обмеження частково рятують — CHECK (jsonb_typeof(meta->'items') = 'array'), CHECK (meta ? 'version'), унікальний індекс на виразі, у MySQL CHECK на JSON-функціях з 8.0.16, — але вони не замінять зовнішній ключ. Розумна межа проста: у колонки виносимо все обовʼязкове й запитуване, у JSON лишаємо розріджені атрибути, payload зовнішніх систем, снапшоти й налаштування, форма яких змінюється швидше, ніж ви готові писати міграції.

-- PostgreSQL: тільки jsonb, json індексувати не можна
CREATE TABLE orders (
    id      bigserial PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users(id),  -- завжди є → звичайна колонка
    status  text   NOT NULL,                       -- фільтруємо → звичайна колонка
    meta    jsonb  NOT NULL DEFAULT '{}'           -- сюди тільки змінне
);

-- GIN: пошук по довільному ключу через containment @> і наявність ключа ?
CREATE INDEX orders_meta_gin ON orders USING gin (meta);
SELECT id FROM orders WHERE meta @> '{"source":"webhook"}';

-- jsonb_path_ops: менший і швидший, але лише @> (без оператора ?)
CREATE INDEX orders_meta_path ON orders USING gin (meta jsonb_path_ops);

-- B-tree на виразі: під рівність/діапазон/сортування по одному полю
-- (дає ще й статистику для планувальника після ANALYZE)
CREATE INDEX orders_meta_utm ON orders ((meta->>'utm_source'));
SELECT id FROM orders WHERE meta->>'utm_source' = 'google';

-- Число з JSON порівнюємо після приведення, не як рядок
CREATE INDEX orders_meta_amount ON orders (((meta->>'amount')::numeric));

-- MySQL 8: прямий індекс на JSON заборонений, потрібна згенерована колонка
ALTER TABLE orders
    ADD COLUMN utm_source VARCHAR(64)
        AS (meta->>'$.utm_source') STORED,
    ADD INDEX idx_utm (utm_source);

-- Або функціональний індекс (8.0.13+): CAST обовʼязковий
CREATE INDEX idx_utm_fn ON orders ((CAST(meta->>'$.utm_source' AS CHAR(64))));

-- Multi-valued index для масиву (8.0.17+): працює з MEMBER OF / JSON_CONTAINS
ALTER TABLE orders ADD INDEX idx_tags ((CAST(meta->'$.tags' AS CHAR(32) ARRAY)));
SELECT id FROM orders WHERE 'urgent' MEMBER OF(meta->'$.tags');
Різницю json і jsonb: json зберігає текст як є (пробіли, порядок і дублікати ключів), jsonb — розібране бінарне подання, ключі відсортовані й унікальні, парсинг на запис, а не на читання; індексувати можна лише jsonb.
Що GIN індексує вміст документа й обслуговує оператори `@>`, `?`, `?|`, `?&` (і `@?`/`@@` з jsonpath у PG 12+), а B-tree на виразі `(data->>'key')` — це звичайний індекс під рівність, діапазон і сортування по одному полю.
Що в MySQL індекс на колонці JSON створити не можна: потрібна STORED/VIRTUAL generated column з індексом, функціональний індекс з обовʼязковим CAST (8.0.13+) або multi-valued index для масивів під `MEMBER OF`, `JSON_CONTAINS`, `JSON_OVERLAPS` (8.0.17+).
Що JSON коштує на запис: у PostgreSQL UPDATE переписує весь рядок через MVCC, великий jsonb їде в TOAST і читання одного ключа розтискає весь документ; у MySQL часткове оновлення на місці працює лише для JSON_SET/JSON_REPLACE/JSON_REMOVE і лише якщо документ не зростає.
Критерій вибору: JSON — для розріджених, різнорідних або зовнішніх даних (payload вебхука, налаштування, снапшот); звичайні колонки — для того, що є завжди, має тип, обмеження, зовнішній ключ і бере участь у фільтрах та джойнах.
Що планувальник погано оцінює селективність по JSON: для виразу `data->>'key'` статистики немає, поки не створено індекс на цьому ж виразі (PG) або згенеровану колонку (MySQL), тому оцінка кардинальності буває на порядки хибною.
Обрати тип `json` замість `jsonb` у PostgreSQL «бо коротша назва»: по `json` не можна побудувати GIN і кожне читання ключа заново парсить текст.
У MySQL написати `CREATE INDEX ... ON t (data)` для JSON-колонки й здивуватися помилці: прямий індекс на JSON заборонений (як і на BLOB/TEXT без довжини префікса).
Створити GIN-індекс і чекати, що він прискорить `data->>'status' = 'paid'`: GIN обслуговує `@>` і `?`, а не `->>`; або запит переписують на `data @> '{"status":"paid"}'`, або будують B-tree на виразі.
У MySQL зробити функціональний індекс без CAST і без узгодженої колації: `((data->>'$.email'))` не приймається, потрібен `CAST(data->>'$.email' AS CHAR(191))` і той самий COLLATE, що й у запиті, інакше індекс мовчки не використається.
Класти в JSON `user_id`, `status`, `created_at` — поля, які є в кожному рядку: втрачаються NOT NULL, зовнішній ключ, тип і нормальна статистика, а кожен запит обростає кастами.
Порівнювати числа з JSON як рядки: `data->>'price' > '100'` — це лексикографічне порівняння, потрібен явний `(data->>'price')::numeric` у PG або CAST у MySQL.
ПОРАДА

Сформулюйте правило одним реченням: «JSON — для того, чого ми не знаємо заздалегідь; колонки — для того, за чим фільтруємо». А далі покажіть, що знаєте обидва шляхи індексації: GIN для пошуку по довільному ключу, B-tree на виразі — для конкретного, і що в MySQL це завжди generated column під капотом.

Сторінка питання →
LR
Laravel·Middle ·Cache ·Redis ·теги

Кеш стає застарілим з двох причин: ключ не враховує всього, від чого залежить результат, або інвалідація не спрацювала (масовий update без подій моделі, forget усередині ще не закомміченої транзакції). Лікується не меншим TTL, а тегами й обсерверами з `$afterCommit`, а одночасний промах гарячого ключа — `Cache::lock()` або `Cache::flexible()`.

Редактор виправив заголовок, а на сторінці ще годину висить старий — де шукати причину?
У вас ключ живе 10 хвилин; що станеться о 10:00:01, коли він протух, а на сайті 500 rps?
Чому `Cache::tags()` працює в тестах і падає з BadMethodCallException на проді?
Ви чистите кеш в обсервері після оновлення моделі — чому дані все одно інколи старі?

Кеш у Laravel — це тонкий шар над key-value сховищем, і вся його механіка вміщається в чотири рядки Cache::remember(): прочитати ключ, якщо значення не null — повернути, інакше викликати замикання, записати результат із TTL і повернути його. Звідси одразу два наслідки, на яких валяться найчастіше. Перший: null для remember() — це промах, а не значення, тому кешування «нічого не знайдено» не працює й кожен запит за неіснуючим id іде в базу (потрібне значення-заглушка на кшталт false або порожнього масиву). Другий: TTL задається в секундах (так із Laravel 5.8; у 12+ його можна передати замиканням, яке отримує обчислене значення), і саме TTL — єдиний механізм, який працює сам. Усе інше — інвалідація — це ваш код, і якщо його немає, «застарілі дані» просто означають «TTL ще не минув».

Друга причина застарілості тонша: ключ не описує все, від чого залежить результат. Якщо сторінка залежить від локалі, ролі, номера сторінки й набору фільтрів, а в ключі лише posts:list, то ви кешуєте не сторінку, а першу з її версій і показуєте її всім. Робоче правило: усе, що входить у запит, входить і в ключ; довгі набори фільтрів згортаються в хеш; версія коду або схеми серіалізації — у суфікс (posts:popular:v2), щоб деплой не почав читати старий формат новим кодом. Окремо памʼятайте про префікс стора (CACHE_PREFIX): якщо кілька застосунків дивляться в одну базу Redis без різних префіксів, вони бачать ключі одне одного, а php artisan cache:clear виносить усе одразу.

Явна інвалідація має два інструменти. Точковий — Cache::forget("post:{$id}"), коли ви точно знаєте, який ключ зіпсувався. Груповий — теги: Cache::tags(['posts'])->remember(...) і Cache::tags(['posts'])->flush(), коли одна зміна псує десятки похідних ключів (списки, фасети, сайдбари). Теги реалізовані в сторі, а не в Repository, тому доступні лише на таггабельних драйверах — redis, memcached, array, apc; на file, database і dynamodb виклик впаде з BadMethodCallException. Це особливо неприємно тому, що дефолтний стор у Laravel 11+ — саме database, а в тестах тут стоїть CACHE_STORE=array, де теги є: код зелений локально й падає на проді. У Redis теги коштують додаткового запису: кожен ключ реєструється в sorted set свого тега, flush() проходить по цих посиланнях і видаляє ключі пачками, а посилання на ключі, що протухли самі, лишаються в сеті, доки хтось не викличе flushStale().

Інвалідацію по подіях моделі роблять обсервером на saved/deleted, і тут три пастки. Перша — транзакції: якщо Cache::forget() виконався до COMMIT, паралельний процес встигне перечитати ще старий рядок і покласти його в кеш заново, і застарілим він лишиться до кінця TTL. Ліки — public bool $afterCommit = true; на обсервері (диспетчер подій перевіряє цю властивість і відкладає виклик до коміту) або явний DB::afterCommit(fn () => Cache::forget($key)), який поза транзакцією просто виконується одразу. Друга — масові операції: Post::where(...)->update() і ->delete() йдуть повз моделі, тому подій не породжують взагалі; те саме стосується saveQuietly(), withoutEvents(), truncate() і attach()/detach() на pivot. Третя — зміни поза застосунком: імпорт, SQL із консолі, репліка з лагом. Якщо джерел запису кілька, чесніше жити на короткому TTL, ніж вірити в обсервер, який бачить лише половину змін.

Окремий клас проблем — не застарілість, а стемпіда: гарячий ключ протух, і всі паралельні запити одночасно бачать промах та йдуть у базу. Зменшення TTL робить це частіше, а не рідше. Правильні відповіді: Cache::lock("{$key}:lock", 10)->block(5, ...) — рахує один, решта чекають і влучають у вже прогрітий ключ (block() кидає LockTimeoutException, а TTL лока страхує від процесу, що помер, не відпустивши його); Cache::flexible($key, [60, 600], ...) — до 60 с значення свіже, далі віддається старе, а оновлення йде в defer() після відповіді; плюс джиттер у TTL, щоб ключі, прогріті одним деплоєм, не протухали в одну секунду. Нарешті, чого кешувати не варто: даних, які мають бути точними в момент читання (баланси, залишки, ліміти — там блокування в базі, а не кеш), персональних даних у спільному ключі, колекцій Eloquent-моделей (серіалізується модель разом із завантаженими звʼязками, і розпакування буває дорожчим за сам запит — кладіть масиви або DTO) і того, що дешевше порахувати: вибірка по первинному ключу з індексом часто швидша за round-trip у Redis, а повторні читання в межах одного запиту закриває Cache::memo().

final class PopularPosts
{
    // Гарячий ключ: 60 с свіжий, до 600 с віддаємо старе й освіжаємо
    // у defer() після відповіді — під локом, тож рахує лише один процес.
    public function list(): array
    {
        return Cache::flexible('posts:popular:v2', [60, 600], fn () => Post::query()
            ->where('is_published', true)
            ->orderByDesc('views')
            ->limit(10)
            ->get(['id', 'slug', 'title'])
            ->toArray()); // масив, а не моделі: без звʼязків і дешевша серіалізація
    }

    // Важкий звіт: перший бере лок, решта чекають до 5 с і влучають у кеш.
    public function stats(int $companyId): array
    {
        $key = "company:{$companyId}:stats";

        return Cache::get($key) ?? Cache::lock("{$key}:lock", 10)->block(5, fn () => Cache::remember(
            $key, 300, fn () => $this->calculate($companyId)
        ));
    }
}

final class PostObserver
{
    // Без цього forget станеться до COMMIT: сусідній процес перечитає
    // старий рядок і закешує його заново — і так назавжди.
    public bool $afterCommit = true;

    public function saved(Post $post): void
    {
        Cache::forget("post:{$post->id}");
        Cache::tags(['posts'])->flush(); // redis/memcached; на file і database — BadMethodCallException
    }

    public function deleted(Post $post): void
    {
        $this->saved($post);
    }
}

// Пастка: масове оновлення не викликає saved(), інвалідуємо руками.
Post::where('published_at', '<', now())->update(['is_published' => false]);
Cache::tags(['posts'])->flush();
Що `Cache::remember()` не вважає `null` попаданням: якщо callback повернув `null`, значення запишеться, але кожне наступне читання буде промахом і піде в базу.
Що теги підтримують лише таггабельні стори (redis, memcached, array, apc), а `file`, `database` і `dynamodb` кидають BadMethodCallException — і що дефолтний стор у Laravel 11+ саме `database`.
Що від одночасного промаху гарячого ключа рятує `Cache::lock()->block()` або `Cache::flexible()` зі stale-while-revalidate, а не зменшення TTL.
Що `Post::where(...)->update()` і `->delete()` не викликають подій моделі, тому обсервер такої зміни не побачить.
Що інвалідація всередині транзакції — це гонка: сусідній процес перечитає ще не закомічені дані й закешує старе назавжди; звідси `public $afterCommit = true` на обсервері або `DB::afterCommit()`.
Лікувати застарілі дані зменшенням TTL: з 60 хв до 5 хв — це та сама помилка, тільки в 12 разів частіше, плюс у 12 разів більше промахів.
Кешувати «нічого не знайдено»: `Cache::remember($k, 600, fn () => User::find($id))` для неіснуючого id щоразу б'є в базу, бо `null` для `remember()` — це промах.
Класти в ключ лише id сутності й забути про локаль, роль, номер сторінки чи фільтри — і показати одному користувачеві сторінку іншого.
Викликати `Cache::flush()` або `php artisan cache:clear` замість точкової інвалідації: якщо сесії, rate limiter і кеш живуть в одному сторі, зносить і їх.
Класти в кеш колекції Eloquent-моделей: серіалізується модель разом із завантаженими звʼязками, і `unserialize` часом дорожчий за сам SQL-запит.
Перевіряти наявність через `Cache::has()` для значення, яке легально може бути `false` або `null` — `has()` під капотом читає значення й вважає `null` відсутністю.
Писати теги в коді, ганяти тести на `CACHE_STORE=array` (де теги є) і викочувати це на `database`-стор, де їх немає.
ПОРАДА

Скажіть уголос дві речі, які інтервʼюер чекає: «ключ має містити все, від чого залежить відповідь» і «інвалідацію робимо після COMMIT». А далі назвіть три рівні: TTL з джиттером — базова гігієна, теги — інвалідація по сутності, `Cache::lock()`/`Cache::flexible()` — захист від того, що на протухлий ключ одночасно прийдуть сотні запитів.

Сторінка питання →
OPS
DevOps·Middle ·логи ·моніторинг ·observability

Логи пишемо структурованим JSON у stdout одним рядком на подію й віддаємо збирачу, помилки дублюємо в Sentry, де вони групуються за fingerprint і мають реліз та контекст, а сигналом «щось не так» служать не логи, а метрики RED (rate, errors, duration) з алертами на симптоми, які бачить користувач.

Користувач каже «сайт лежав п'ять хвилин учора ввечері» — як ви це перевірите заднім числом?
Куди має писати логи PHP-застосунок у Docker і чому не у storage/logs?
Навіщо Sentry, якщо всі винятки й так є в лог-файлі?
Як ви дізнаєтесь про аварію раніше, ніж про неї напише клієнт?

Почніть з того, що застосунок у контейнері не володіє своїми логами. Дванадцятифакторний підхід трактує лог як потік подій: процес пише в stdout/stderr і більше ні за що не відповідає — ані за ротацію, ані за доставку, ані за retention. У Docker цей потік підбирає логовий драйвер, у Kubernetes — збирач на ноді (Fluent Bit, Vector, Promtail), який складає записи в Loki, OpenSearch чи хмарне сховище. Файл storage/logs/laravel.log у поді помирає разом із подом, а канал daily у трьох репліках дає три розірвані стрічки, які ніхто не звірить. Технічно в Laravel це канал з driver => monolog, StreamHandler на php://stdout і JsonFormatter; найпростіший варіант того ж — вбудований канал stderr із LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter. Окремо не забудьте про сам PHP-FPM: без catch_workers_output = yesdecorate_workers_output = no, доступного з PHP 7.3) фатальні помилки воркера просто зникнуть, не дійшовши до stdout контейнера.

Структурованість важливіша за сам факт логування. Текстовий рядок «User 42 paid 1500 UAH» читається людиною і не читається машиною: за ним не побудувати ані фільтр, ані графік. PSR-3 навмисно розділяє message і масив context, і саме другий аргумент Log::info('checkout.paid', ['order_id' => …]) перетворюється на індексовані поля JSON. Практичне правило: повідомлення — це стабільний ідентифікатор події (checkout.paid, payment.gateway_timeout), усе змінне — у контекст. Тоді запит «усі таймаути платіжного шлюзу за останню годину, згруповані за провайдером» — це один рядок у мові запитів сховища, а не регулярний вираз по мегабайтах тексту.

Другий обов'язковий елемент — наскрізний ідентифікатор. Один HTTP-запит породжує записи у nginx, у застосунку, у кількох чергових джобах і, можливо, у сусідньому сервісі; без спільного ключа склеїти їх неможливо. Nginx уміє генерувати $request_id, застосунок приймає його заголовком X-Request-Id, кладе у Context (з Laravel 11 дані Context автоматично потрапляють у кожен запис лога і серіалізуються разом із джобою в чергу — на відміну від Log::withContext(), який межу черги не переживає), ставить тегом у Sentry і повертає у відповіді. Далі за одним рядком з тікета користувача піднімається вся історія запиту.

Sentry вирішує іншу задачу, ніж сховище логів, і тому не замінюється ним. Він рахує fingerprint події за типом винятку і стеком, тож мільйон однакових падінь — це одна проблема з лічильниками подій і унікальних користувачів. До кожної події прикріплені release і environment (звідси відповідь на головне питання чергового: «це з'явилося в останньому деплої?»), breadcrumbs із запитами і SQL перед падінням, значення змінних у кадрах стеку, призначення відповідальному і статус «regressed», якщо закрита помилка повернулася. У Laravel 11/12 інтеграція sentry/sentry-laravel підключається у bootstrap/app.php через Integration::handles($exceptions) у withExceptions(), а traces_sample_rate для трасування ставлять часткою на кшталт 0.1, бо повне трасування продакшену коштує грошей. Із тих самих міркувань send_default_pii лишають вимкненим, а в before_send вирізають ключі password, token, authorization.

І головне розмежування, яке очікує почути інтерв'юер: логи й Sentry — інструменти розслідування, а не сповіщення. Дізнаватися про аварію треба з метрик. Мінімум — модель RED на HTTP-шарі: частота запитів, частка 5xx і гістограма латентності (дивіться p95/p99, середнє ховає хвіст), плюс специфіка PHP — зайняті воркери FPM зі сторінки pm.status_path, яку знімає php-fpm_exporter, глибина черги і вік найстарішої джоби, кількість failed_jobs. Збирає це Prometheus, алертить Alertmanager. Алерти вішають на симптоми, які відчуває користувач, і бажано через бюджет помилок SLO з двома порогами burn rate: швидкий (умовно 14x за 5 хвилин) будить людину, повільний (3x за 6 годин) створює тікет. Алерт «на кожен ERROR у логах» гарантовано вимкнуть через тиждень; алерт «CPU > 80%» будить без причини, бо висока утилізація сама по собі не є проблемою.

Межі цієї схеми — вартість і сліпі зони. Логи на рівні debug у продакшені коштують дорожче за сервери й тягнуть персональні дані у сховище з довгим retention, тому рівень тримають на info, а деталізацію вмикають точково і тимчасово. Метрики ламаються на високій кардинальності: мітка user_id у Prometheus створює мільйони серій і кладе сховище — ідентифікаторам місце в логах і трейсах, не в мітках. Трейси (open-telemetry/opentelemetry-php разом із розширенням opentelemetry) закривають третій кут спостережуваності — де саме всередині запиту витрачений час, — але їх завжди семплюють. Нарешті, найпідступніший випадок — тиша: застосунок, який перестав слати метрики, на графіку виглядає так само, як ідеально здоровий, тому в схемі обов'язково має бути dead man's switch на потік метрик і на cron.

// config/logging.php — усе в stdout структурованим JSON, помилки додатково в Sentry
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['stdout', 'sentry'],
        'ignore_exceptions' => false,
    ],

    // Один рядок = один JSON-обʼєкт. Файлів немає: збирач читає stdout контейнера.
    'stdout' => [
        'driver' => 'monolog',
        'level' => env('LOG_LEVEL', 'info'),          // на проді info, не debug
        'handler' => Monolog\Handler\StreamHandler::class,
        'handler_with' => ['stream' => 'php://stdout'],
        'formatter' => Monolog\Formatter\JsonFormatter::class,
        'processors' => [Monolog\Processor\PsrLogMessageProcessor::class],
    ],

    'sentry' => [
        'driver' => 'sentry',
        'level' => 'error',                            // у Sentry — лише те, що вимагає дії
        'bubble' => true,
    ],
],

// app/Http/Middleware/TrackRequestContext.php — наскрізний ідентифікатор
public function handle(Request $request, Closure $next): Response
{
    $requestId = $request->header('X-Request-Id') ?: (string) Str::uuid();

    // Context (Laravel 11+) сам додається в кожен запис лога і їде разом із джобою в чергу
    Context::add('request_id', $requestId);
    Context::add('user_id', $request->user()?->id);
    \Sentry\configureScope(fn ($scope) => $scope->setTag('request_id', $requestId));

    // Дані — окремим масивом-контекстом, а не всередині тексту повідомлення
    Log::info('checkout.paid', ['order_id' => $order->id, 'amount_uah' => $order->total]);

    return tap($next($request), fn ($response) => $response->headers->set('X-Request-Id', $requestId));
}
Що в контейнері застосунок не володіє файлами логів: пише в stdout/stderr одним рядком JSON, а ротацією, доставкою і зберіганням займається платформа (Docker driver, Fluent Bit, Vector, Promtail).
Різницю між текстовим і структурованим записом: у JSON-лога поля `level`, `message`, `context.request_id`, `user_id` індексуються і шукаються, у `sprintf`-рядку — ні.
Наскрізний `request_id`, який іде з заголовка `X-Request-Id` у кожен запис лога і в чергові джоби, — без нього логи розподіленої системи не склеюються.
Що Sentry — це не «ще один лог», а агрегатор: дедуплікація за fingerprint, кількість подій і користувачів, реліз, у якому проблема з'явилася, breadcrumbs і стек зі значеннями змінних.
Що алерти вішають на метрики й SLO (частка 5xx, p95 латентності, глибина черги, вік найстарішої джоби), а логи читають уже після спрацювання алерту, щоб зрозуміти причину.
Обізнаність про вартість і PII: рівні логування, семплінг трейсів, `send_default_pii=false` і скрабінг токенів перед відправкою.
Писати в `storage/logs/laravel.log` всередині контейнера: після рестарту подів логи зникають, а `daily` канал у кількох репліках дає розірвану картину.
Ставити `LOG_LEVEL=debug` на продакшені й отримати рахунок за зберігання більший, ніж за самі сервери, плюс SQL-запити з персональними даними в логах.
Форматувати дані в текст повідомлення (`Log::info("User {$id} paid {$sum}")`) замість другого аргументу-контексту — потім по такому логу неможливо агрегувати.
Вважати Sentry заміною моніторингу: він ловить винятки, але не побачить, що сервіс просто перестав відповідати, або що черга росте без жодної помилки.
Алертити на кожен `ERROR` у логах — за тиждень команда вимикає сповіщення й аварію знову помічає клієнт.
Заводити алерт на «CPU > 80%» замість симптому: висока утилізація сама по собі не є проблемою, а падіння p95 і зростання 5xx — є.
Забути про stderr самого PHP-FPM: без `catch_workers_output = yes` фатальні помилки воркера не потрапляють у stdout контейнера взагалі.
ПОРАДА

Скажіть так: «лог — це те, що читають, коли вже відомо, що зламалось; дізнаємось ми про поломку з метрик». Далі назвіть три рівні — структурований JSON у stdout для розслідування, Sentry для винятків із дедуплікацією і релізом, метрики RED з алертами на SLO для сповіщення. Це показує, що ви розрізняли ці інструменти на чергуванні, а не просто перелічили модні назви.

Сторінка питання →
WP
WordPress·Middle ·Gutenberg ·блоки ·block.json

Сучасний шлях — block.json і register_block_type з шляхом до директорії блоку: метадані, скрипти й стилі описуються декларативно, а PHP-рендер задається через render або render_callback.

Динамічний чи статичний блок: у чому різниця?
Як передати дані з PHP у редактор блоку?
Чому блок є в редакторі, але не рендериться на фронтенді?

Сучасна реєстрація блоку починається з block.json. У ньому описано все: назва й категорія, схема атрибутів, supports, скрипт редактора, стилі, скрипт фронтенду і, з WordPress 6.1, файл render.php для серверного рендеру. PHP-код зводиться до register_block_type зі шляхом до директорії: WordPress сам прочитає метадані, зареєструє ассети й підключить їх лише там, де блок реально використаний.

Ключове рішення при проєктуванні — статичний чи динамічний блок. Статичний зберігає готовий HTML у post_content через save(): швидко, не залежить від плагіна, але контент застигає. Динамічний повертає з save() null, а розмітку будує PHP на кожен показ: правильний вибір для списків, цін, будь-чого, що змінюється або залежить від користувача.

Атрибути статичних блоків живуть у коментарях розмітки, тому зміна схеми чи save() без масиву deprecated ламає всі вже вставлені блоки. Дані з PHP у редактор передають через REST API або inline-скрипт до editorScript, а вивід у render.php обовʼязково екранують. Для інтерактивності на фронтенді замість власного React-бандла зараз використовують Interactivity API з viewScriptModule.

// build/pricing/block.json (генерується з src через @wordpress/scripts)
// {
//   "apiVersion": 3, "name": "shop/pricing", "title": "Тарифи",
//   "attributes": { "plan": { "type": "string", "default": "pro" } },
//   "supports": { "align": ["wide"], "color": { "background": true } },
//   "editorScript": "file:./index.js", "style": "file:./style-index.css",
//   "viewScriptModule": "file:./view.js",
//   "render": "file:./render.php"
// }

// plugin.php: реєструємо директорію, WordPress читає block.json сам
add_action('init', function (): void {
    register_block_type(__DIR__.'/build/pricing');
});

// build/pricing/render.php: динамічний рендер, $attributes і $content доступні
$plan = sanitize_key($attributes['plan'] ?? 'pro');
$price = shop_plan_price($plan);
?>
<div <?php echo get_block_wrapper_attributes(['class' => 'pricing pricing--'.$plan]); ?>>
    <strong><?php echo esc_html(shop_plan_title($plan)); ?></strong>
    <span><?php echo esc_html(number_format_i18n($price / 100, 2)); ?> грн</span>
</div>
Що block.json є єдиним джерелом метаданих: назва, атрибути, supports, editorScript, style, viewScript, а PHP лише реєструє директорію.
Різницю між статичним блоком, у якого HTML зберігається в post_content із save(), і динамічним, який рендериться PHP на кожен показ.
Що атрибути блоку зберігаються в коментарі-розмітці, тому зміна їхньої схеми потребує deprecations у JS, інакше редактор покаже block validation error.
Що дані з PHP у редактор передаються через REST API, wp_localize_script або wp_add_inline_script на editorScript, а не через глобальні змінні в шаблоні.
Що збірка йде через @wordpress/scripts, а render.php у block.json з WP 6.1 замінює render_callback.
Реєструвати блок лише в JS через registerBlockType без block.json: WordPress не знає про ассети й не може лениво їх завантажити.
Робити статичний блок для контенту, який змінюється: список останніх постів, курси, ціни, бо збережений HTML застаріває.
Змінювати атрибути або розмітку save() без deprecated-версій і ламати всі вже вставлені блоки.
Виводити через render_callback необроблений HTML з атрибутів без esc_html і wp_kses_post.
Підключати editorScript на фронтенді або viewScript в адмінці, роздуваючи обидва бандли.
ПОРАДА

Уточніть, що туторіали з wp.blocks.registerBlockType у JS без block.json уже вважаються застарілими. Згадайте render.php і Interactivity API як актуальний напрям.

Сторінка питання →
LR
Laravel·Middle ·транзакції ·lockForUpdate ·deadlock

DB::transaction($callback, $attempts) обгортає замикання в транзакцію, відкочує її на будь-якому Throwable і повторює лише при deadlock чи lock wait timeout; lockForUpdate() потрібен там, де ви читаєте значення, щоб на його основі писати, і блокування тримається до COMMIT — тому має сенс тільки всередині транзакції.

У нас двічі списався товар зі складу, хоча код перевіряє залишок перед списанням — де помилка?
Чим DB::transaction відрізняється від beginTransaction/commit і навіщо другий аргумент?
Job усередині транзакції падає з ModelNotFoundException, хоча модель точно створена. Чому?
Коли sharedLock, а коли lockForUpdate?

DB::transaction(Closure $callback, int $attempts = 1) — це тонка обгортка: beginTransaction(), виклик замикання, commit(), а на будь-якому ThrowablerollBack() і проброс винятку далі. Звідси перша практична порада: ніколи не ковтайте виняток усередині замикання. Якщо ви обгорнули частину коду в try/catch і нічого не кинули, Laravel дійде до commit() і збереже половину роботи — база не знає про вашу логіку, вона бачить лише успішне завершення. Ручні DB::beginTransaction()/DB::commit() потрібні рідко: коли транзакція має пережити межу одного методу або коли ви керуєте нею з тесту. У всіх інших випадках замикання надійніше, бо забути rollBack() у ньому неможливо.

Другий аргумент — це кількість спроб, і ретрай спрацьовує вибірково. Laravel перевіряє помилку через causedByConcurrencyError(), який ловить характерні повідомлення драйверів: Deadlock found when trying to get lock і Lock wait timeout exceeded у MySQL, deadlock detected та serialization failure у PostgreSQL, database is locked у SQLite. Звичайний ValidationException чи порушення NOT NULL не повторюються — вони просто відкочують транзакцію. Є ще одне обмеження: у handleTransactionException Laravel дивиться на transactionLevel(), і якщо ви всередині вкладеної транзакції (тобто фактично всередині SAVEPOINT), повтор не робиться взагалі. Головна ж вимога до ретраю — ідемпотентність: замикання виконається з нуля вдруге і втретє, тому все, що не можна зробити двічі, всередині йому не місце.

Саме тут з'являється afterCommit. Транзакція, яка ще не закомітилась, невидима для інших з'єднань — а воркер черги працює на окремому з'єднанні. Тому SendOrderReceipt::dispatch($order) всередині транзакції — класична гонка: Redis отримує job миттєво, воркер підхоплює його за мілісекунди й падає з ModelNotFoundException, бо orders.id ще не існує. Лікується трьома способами: ->afterCommit() на конкретному диспатчі, public bool $afterCommit = true; у класі job'а або 'after_commit' => true у конфігурації з'єднання черги — тоді правило діє глобально, а виняток робиться через ->beforeCommit(). Для подій і слухачів у Laravel 10+ є контракти ShouldDispatchAfterCommit (на самій події) і ShouldHandleEventsAfterCommit (на слухачі), а для довільного коду — DB::afterCommit(fn () => ...), який поза транзакцією просто виконується негайно. Модельні події created/updated за замовчуванням спрацьовують усередині транзакції, тож обсервер, який щось надсилає назовні, треба позначати явно.

Транзакція гарантує атомарність, але не гарантує, що між вашим SELECT і вашим UPDATE ніхто не втрутився. Класична дірка — read-modify-write: прочитали stock, порівняли з $qty у PHP, зменшили, зберегли. Два паралельних запити прочитають однакове значення й обидва вважатимуть, що товару вистачає. lockForUpdate() додає FOR UPDATE і перетворює читання на ексклюзивне: другий процес зупиняється на самому SELECT і продовжить лише після вашого COMMIT, причому побачить уже нове значення (у MySQL на REPEATABLE READ звичайний SELECT читає знімок, а блокувальний — останню закомічену версію). sharedLock() дає слабше блокування — lock in share mode у MySQL, for share у PostgreSQL: кілька процесів можуть читати паралельно, і жоден не змінить рядок, доки ви не завершите. Він доречний, коли ви читаєте довідник, від якого залежить запис в іншу таблицю, і категорично недоречний як «легша версія» lockForUpdate: два процеси з S-lock, які потім спробують зробити UPDATE, чекатимуть одне одного і дадуть deadlock замість черги.

Межі й ціна. Блокування живе рівно стільки, скільки транзакція, тому поза DB::transaction lockForUpdate() не робить нічого корисного, а всередині — тримає рядок увесь час, доки ви робите будь-що інше; HTTP-виклик до платіжного шлюзу під блокуванням гарантує, що сусідні запити впруться в innodb_lock_wait_timeout (50 секунд за замовчуванням у MySQL) або в lock_timeout PostgreSQL. Блокувати можна лише те, що існує: у сценарії «створити, якщо немає» рятує унікальний індекс, а не FOR UPDATE. І окрема пастка тестування: SQLite-грамотка Laravel просто ігнорує блокування — compileLock() повертає порожній рядок, — тому feature-тест на in-memory SQLite не доведе, що ваш lockForUpdate() узагалі потрапляє в SQL. Нарешті, часто блокування взагалі не потрібне: там, де все зводиться до одного оператора, атомарний UPDATE ... WHERE stock >= ? із перевіркою кількості змінених рядків дешевший, коротший і не створює жодного шансу на deadlock.

use Illuminate\Support\Facades\DB;

// Другий аргумент — кількість СПРОБ, а не таймаут: Laravel повторить
// замикання цілком, якщо драйвер повернув deadlock або lock wait timeout
$order = DB::transaction(function () use ($user, $productId, $qty) {
    // FOR UPDATE: рядок заблоковано до COMMIT.
    // Паралельний запит зупиниться саме тут, а не прочитає старий stock.
    $product = Product::whereKey($productId)->lockForUpdate()->firstOrFail();

    if ($product->stock < $qty) {
        // Будь-який Throwable = автоматичний ROLLBACK і проброс далі
        throw new OutOfStockException($product->id);
    }

    $product->decrement('stock', $qty);

    $order = Order::create([
        'user_id' => $user->id,
        'product_id' => $product->id,
        'quantity' => $qty,
    ]);

    // Без afterCommit() воркер може взяти job раніше за COMMIT
    // і впасти з ModelNotFoundException на свіжому $order->id
    SendOrderReceipt::dispatch($order)->afterCommit();

    // Довільний побічний ефект після успішного COMMIT;
    // поза транзакцією замикання виконається негайно
    DB::afterCommit(fn () => Cache::forget("stock:{$product->id}"));

    return $order;
}, attempts: 3);

// Той самий сценарій без блокування взагалі: одна атомарна операція,
// 0 змінених рядків означає «залишку не вистачило»
$affected = Product::whereKey($productId)
    ->where('stock', '>=', $qty)
    ->decrement('stock', $qty);
Що `DB::transaction($cb, 3)` повторює замикання не на будь-якій помилці, а лише на конкурентних (deadlock, «Lock wait timeout exceeded», serialization failure), і лише на верхньому рівні вкладеності.
Що повтор означає вимогу ідемпотентності: замикання виконається вдруге цілком, тому листи, HTTP-виклики і платіжні запити всередині нього неприпустимі.
Що `lockForUpdate()` тримає рядок до `COMMIT`/`ROLLBACK`, отже поза транзакцією (в autocommit) блокування знімається одразу і не захищає нічого.
Що job, надісланий усередині транзакції, воркер може взяти раніше за COMMIT — тому `->afterCommit()`, `public $afterCommit = true` або `'after_commit' => true` у конфізі черги.
Що `sharedLock()` дозволяє паралельні читання, і саме тому два процеси, які потім роблять UPDATE, надійно ловлять deadlock: обидва тримають S-lock і чекають на X-lock.
Що альтернатива блокуванню — атомарний `UPDATE ... WHERE stock >= ?` з перевіркою кількості змінених рядків або унікальний індекс замість перевірки «чи існує».
Ловити виняток усередині замикання `DB::transaction` і не кидати його далі: Laravel вважає, що все добре, і комітить транзакцію з половиною змін.
Викликати `Product::lockForUpdate()->first()` поза транзакцією і вважати, що рядок заблоковано: в autocommit блокування знімається наступним же тиком.
Робити read-modify-write без блокування: прочитали `stock`, порахували в PHP, зберегли — два паралельних запити спокійно спишуть той самий залишок двічі.
Ставити `$attempts = 5` і залишати всередині `Mail::send()` або запит до платіжного шлюзу: після ретраю клієнт отримає два листи і два списання.
Перевіряти конкурентність тестами на SQLite: у SQLiteGrammar `compileLock()` повертає порожній рядок, тобто `lockForUpdate()` просто зникає з SQL і тест «зелений» на неробочому коді.
Тримати транзакцію відкритою навколо HTTP-виклику до зовнішнього API: рядки заблоковані на весь час мережевого таймауту, і `innodb_lock_wait_timeout` (50 с за замовчуванням) починає валити сусідні запити.
Розраховувати, що вкладений `DB::transaction` — це справжня транзакція: це SAVEPOINT, і повтор при deadlock на вкладеному рівні не працює.
ПОРАДА

Скажіть двома реченнями: «Транзакція гарантує атомарність, але не захищає від того, що хтось прочитав те саме значення, що і я — для цього потрібен lockForUpdate або атомарний UPDATE з умовою». І одразу додайте про повтори: «`DB::transaction($cb, 3)` виконає замикання вдруге цілком, тому все, що не можна зробити двічі, виноситься в `afterCommit`».

Сторінка питання →
SF
Symfony·Middle ·Forms ·Validator ·DTO

Форма — це двонаправлений маппер HTTP-даних на обʼєкт: submit прогонить дані через трансформери, покладе їх у модель і лише потім викличе Validator, який валідує обʼєкт, а не поля; в JSON-API цей шар зайвий, бо `handleRequest` читає `$request->request`, а не тіло запиту, і краще брати `#[MapRequestPayload]`.

Чому `$form->isValid()` повертає false, хоча жодної помилки на екрані немає?
Ми шлемо JSON у контролер із формою — форма каже, що всі поля порожні. Чому?
Де саме спрацьовують констрейнти: у формі чи в сутності?
Чим `#[MapRequestPayload]` кращий за форму для REST-ендпоінта?

Форма в Symfony — це двонаправлений маппер між HTTP-даними й обʼєктом, а не валідатор. Коли викликається submit(), кожне поле проганяє вхідний рядок через ланцюжок view- і model-трансформерів (getViewData()getNormData()getData()), після чого DataMapper записує результат у властивості обʼєкта з data_class через PropertyAccess. Валідація починається лише після цього і виконується окремим сервісом: ValidatorExtension додає до кореневої форми констрейнт Form, а його FormValidator просить ValidatorInterface перевірити вже змаплений обʼєкт за метаданими класу — тими самими атрибутами #[Assert\...], які працюють і без форм. Тому фраза «констрейнти у формі» майже завжди помилкова: у формі лежить хіба що опція constraints для полів без data_class, решта живе на DTO чи сутності.

З цього порядку випливають дві класичні загадки. Перша: помилка є, а на екрані порожньо. Порушення приходить із property path, який ViolationMapper шукає в дереві форм; якщо шляху немає (наприклад, констрейнт на рівні класу через #[Assert\Callback]), помилка залишається на кореневій формі, і побачити її можна тільки якщо шаблон рендерить form_errors(form). Лікується опцією error_mapping, яка явно каже, на яке поле повісити конкретний шлях. Друга: «This value is not valid» замість вашого повідомлення. Це TransformationFailedException із трансформера — рядок не перетворився на DateTimeImmutable чи int, дані в модель не потрапили, і Validator для цього поля просто не запускався; текст береться з опції invalid_message.

Джерело даних для форми — $request->request і $request->files, а не тіло запиту. Symfony не декодує JSON у $request->request автоматично, тому handleRequest() на JSON-ендпоінті чесно бачить порожньо і повідомляє, що обовʼязкові поля не заповнені. Далі накладається все інше, що форма тягне з собою у stateless-контекст: CSRF-токен, якого в клієнта немає (для класичних форм у Symfony 7.2 зʼявився ще й stateless-варіант із double-submit cookie); іменування полів як form_name[email], тобто чужий для API формат; помилка extra_fields на будь-який зайвий ключ, поки не виставлено allow_extra_fields; і структура помилок у вигляді дерева FormErrorIterator, яку доводиться вручну складати в плаский JSON через $form->getErrors(true, false).

Практичний висновок: у JSON-API форму варто замінити на DTO плюс #[MapRequestPayload] (Symfony 6.3+). Резолвер бере тіло, віддає його Serializer, валідує результат Validator і у разі порушень кидає виняток із кодом 422 — контролер отримує вже коректний обʼєкт. Поруч живуть #[MapQueryString] для query-параметрів (у нього дефолтний код відмови 404, бо невалідний query частіше означає неіснуючий ресурс) і #[MapUploadedFile] для файлів із Symfony 6.4. Формат відповіді при цьому не треба вигадувати: ProblemNormalizer серіалізує ValidationFailedException у RFC 7807 із масивом violations.

Межа проста. Форма виграє там, де сервер рендерить HTML і потрібна двонаправленість: адмінки, CRUD, майстри, CollectionType з allow_add, EntityType з вибіркою з Doctrine. Форма програє там, де половина її роботи не потрібна, а друга половина дублює Serializer. Окремо варто памʼятати про PATCH: HttpFoundationRequestHandler викликає submit($data, false) тільки тому, що метод запиту PATCH, і якщо ви подаєте дані у форму вручну, цей clearMissing доведеться передавати самому — інакше частковий апдейт занулить поля, яких клієнт не надсилав. І в будь-якому підході Validator не замінює обмежень бази: UniqueEntity робить окремий SELECT, тому без унікального індексу гонка двох запитів усе одно пройде.

// 1. Класична форма: валідація живе на класі, а не у формі
final class RegistrationData
{
    #[Assert\NotBlank(groups: ['registration'])]
    #[Assert\Email(mode: 'strict', groups: ['registration'])]
    public ?string $email = null;

    #[Assert\Length(min: 12, groups: ['registration'])]
    public ?string $password = null;
}

$form = $this->createForm(RegistrationType::class, new RegistrationData(), [
    'validation_groups' => ['registration'],  // саме ці групи піде перевіряти FormValidator
    'error_mapping' => ['emailAlreadyTaken' => 'email'], // порушення без свого поля не загубиться
]);

$form->handleRequest($request);           // читає $request->request[form_name] і $request->files
if ($form->isSubmitted() && $form->isValid()) {
    // isValid() без isSubmitted() кине LogicException
}

// 2. Той самий контракт для JSON-API: форма не потрібна взагалі
final class RegisterRequest
{
    public function __construct(
        #[Assert\NotBlank] #[Assert\Email(mode: 'strict')]
        public readonly string $email,
        #[Assert\Length(min: 12)]
        public readonly string $password,
    ) {}
}

#[Route('/api/register', methods: ['POST'])]
public function register(#[MapRequestPayload] RegisterRequest $data): JsonResponse
{
    // тіло вже десеріалізоване Serializer і провалідоване Validator;
    // при порушеннях сюди не зайдемо — резолвер кине 422 з ConstraintViolationList
    return new JsonResponse(['id' => $this->users->register($data)], 201);
}

// 3. Якщо форму все ж треба нагодувати JSON — руками, з урахуванням PATCH
$form->submit(json_decode($request->getContent(), true), $request->getMethod() !== 'PATCH');
Що валідація живе не у формі: форма лише додає констрейнт `Valid` на кореневий обʼєкт, а перевіряє його `ValidatorInterface` за метаданими класу (атрибути `#[Assert\...]`), і порушення потім розкладаються по полях через `error_mapping`.
Що `handleRequest()` бере дані з `$request->request` і `$request->files` за іменем форми, тому сире JSON-тіло туди не потрапляє — його треба декодувати самому й викликати `$form->submit()`.
Що для PATCH `HttpFoundationRequestHandler` викликає `submit($data, false)`, тобто не затирає відсутні поля, а для POST/PUT — затирає; це і є різниця часткового й повного оновлення.
Що помилка трансформера (`TransformationFailedException`) не долітає до Validator і показується як загальне повідомлення з опції `invalid_message`.
Що для stateless JSON-API форму зазвичай замінюють на DTO + `#[MapRequestPayload]` (Symfony 6.3+), який десеріалізує тіло, валідує і дає 422 без CSRF, теми й рендерингу.
Вішати констрейнти лише в `constraints` опції поля й дивуватися, що при `$form->submit()` з `validation_groups` іншої групи вони мовчать.
Вимкнути `csrf_protection` для API, але залишити форму — проблема була не в CSRF, а в тому, що дані не потрапляють у `$request->request` з JSON.
Вважати, що `isValid()` перевіряє форму: без `isSubmitted()` він кине `LogicException`, а перевіряє він змаплений обʼєкт.
Ловити «зайві» ключі як помилку валідації: невідоме поле дає окрему помилку форми `extra_fields`, і вимикається вона опцією `allow_extra_fields`, а не констрейнтом.
Валідувати сутність Doctrine напряму й покладатися на це як на захист БД: Validator не знає про унікальність без `UniqueEntity`, а той робить окремий запит і не рятує від гонки без унікального індексу.
Рендерити помилки форми в JSON через `$form->getErrors()` без `true` як першого аргументу — вкладені помилки дочірніх полів просто зникають.
ПОРАДА

Скажіть коротко: «Form — це UI-шар для HTML, Validator — окремий сервіс, який працює з обʼєктом». Далі покажіть, що в API ви залишаєте другий і викидаєте перший: DTO з `#[Assert]` + `#[MapRequestPayload]`, а `ConstraintViolationList` нормалізуєте у відповідь за RFC 7807.

Сторінка питання →
WP
WordPress·Middle ·продуктивність ·object cache ·WP_Query

Спершу профілювання через Query Monitor, потім persistent object cache у Redis, правильні аргументи WP_Query, індекси або власні таблиці замість meta_query і повне кешування сторінок на рівні nginx.

Чому сторінка каталогу з фільтрами відкривається 6 секунд?
Чим transients відрізняються від object cache?
З чого почати оптимізацію WooCommerce на 50 000 товарів?

Оптимізація починається з вимірювання. Query Monitor показує кожен SQL-запит сторінки з часом і стеком викликів, і в каталозі майже завжди винен один тип запиту: WP_Query з meta_query по кількох ключах. Таблиця wp_postmeta має EAV-структуру, кожен ключ у фільтрі додає JOIN на її копію, а індексу по meta_value немає. На 50 000 товарів такий запит триває секунди.

Далі йдуть три рівні. Перший — persistent object cache у Redis: без нього WordPress на кожен запит перечитує опції, мету й терми, а transients живуть у wp_options і самі стають повільними запитами. Другий — правильні аргументи WP_Query: no_found_rows там, де не потрібна пагінація, fields => ids для списків, вимкнені update_post_meta_cache і update_post_term_cache, коли мета не читається. Третій — структура даних: атрибути з обмеженим набором значень переносяться в таксономії, числові діапазони й багатовимірні фільтри у власну таблицю з індексами або в зовнішній пошуковий рушій.

Останній шар — повне кешування сторінок на nginx або Varnish для всього, що не персоналізоване, з інвалідацією по save_post. Кошик, чекаут і кабінет із цього кешу виключаються, а їхні дорогі фрагменти кешуються через wp_cache з контекстом у ключі.

// Повільно: meta_query по трьох ключах = три JOIN на wp_postmeta без індексу по meta_value
$q = new WP_Query([
    'post_type'  => 'product',
    'meta_query' => [
        ['key' => '_color', 'value' => 'red'],
        ['key' => '_size',  'value' => 'M'],
        ['key' => '_price', 'value' => [100, 500], 'compare' => 'BETWEEN', 'type' => 'NUMERIC'],
    ],
]);

// Швидше: атрибути в таксономіях, лише id, без підрахунку found_rows і зайвих кешів
$ids = (new WP_Query([
    'post_type'              => 'product',
    'fields'                 => 'ids',
    'posts_per_page'         => 24,
    'no_found_rows'          => true,   // без SQL_CALC_FOUND_ROWS
    'update_post_meta_cache' => false,
    'update_post_term_cache' => false,
    'tax_query'              => [
        ['taxonomy' => 'pa_color', 'field' => 'slug', 'terms' => 'red'],
        ['taxonomy' => 'pa_size',  'field' => 'slug', 'terms' => 'm'],
    ],
]))->posts;

// Дорогий результат у object cache (Redis), а не в wp_options
$facets = wp_cache_get('catalog_facets', 'shop');
if ($facets === false) {
    $facets = shop_build_facets();
    wp_cache_set('catalog_facets', $facets, 'shop', 10 * MINUTE_IN_SECONDS);
}
Що починаєте з вимірювання: Query Monitor або New Relic показують найповільніший запит, а не здогадки про кеш-плагіни.
Що головний ворог каталогу це meta_query по кількох ключах: wp_postmeta з EAV-структурою робить JOIN на кожен ключ і не має індексу по значенню.
Що persistent object cache у Redis чи Memcached кешує результати wp_cache і get_option між запитами, без нього WordPress перечитує опції та мету щоразу.
Що аргументи WP_Query мають значення: no_found_rows, fields => ids, update_post_meta_cache і update_post_term_cache вимкнені там, де не потрібні.
Що для фільтрів каталогу масштабується лише власна таблиця з індексами або зовнішній пошуковий рушій, а повне сторінкове кешування знімає навантаження з усього, що не персоналізоване.
Ставити ще один кеш-плагін замість пошуку повільного запиту.
Плутати transients і object cache: transient без persistent cache живе в wp_options і сам стає повільним запитом.
Використовувати posts_per_page => -1 і потім фільтрувати в PHP.
Робити фільтри каталогу через meta_query з пʼятьма ключами й дивуватись JOIN на пʼять копій wp_postmeta.
Кешувати сторінку цілком разом із кошиком чи іменем користувача й показувати чужі дані.
ПОРАДА

Скажіть, що починаєте з профілювання Query Monitor, а не з встановлення кеш-плагіна навмання. І назвіть, який саме запит зазвичай виявляється винним.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Сторінка питання →
SF
Symfony·Middle ·Doctrine ·N+1 ·fetch join

Lazy-проксі та PersistentCollection довантажують дані окремим запитом на кожну сутність; лікується fetch join (`addSelect` приєднаної асоціації), а для пагінації — Doctrine Paginator, який ріже сутності, а не рядки.

Чому сторінка з 20 постами робить 21 запит, хоча ми нічого не додавали?
Чим `join` у DQL відрізняється від fetch join?
Чому після `setMaxResults(20)` із JOIN на колекцію повертається 7 записів?
Що дає `fetch: EXTRA_LAZY` і чи рятує воно від N+1?

N+1 у Doctrine — прямий наслідок лінивого завантаження за замовчуванням. Коли гідратор будує сутність Post, для to-one асоціації він підставляє проксі-обʼєкт із заповненим лише ідентифікатором, а для to-many — PersistentCollection у неініціалізованому стані. Обидва виглядають як звичайні обʼєкти, аж поки хтось не викличе геттер: тоді проксі йде в базу за своїм рядком, а колекція — за всім своїм вмістом. Один SELECT на список плюс по одному на кожен елемент у циклі шаблону і дає ті самі «21 запит на 20 постів». Помітно це не в коді репозиторію, а в панелі Doctrine у Symfony Profiler, яка групує ідентичні запити й підписує, скільки разів кожен виконався.

Базовий інструмент — fetch join. У DQL він відрізняється від звичайного JOIN лише тим, що приєднаний аліас потрапляє в SELECT: ->select('p', 'a')->leftJoin('p.author', 'a') або еквівалентний ->addSelect('a'). Тоді гідратор бачить колонки автора, створює повну сутність, кладе її в identity map і привʼязує до поста — жодних додаткових запитів. Саме тут ламаються найчастіше: JOIN без addSelect дає ті самі N+1, бо він служить лише для WHERE й ORDER BY. leftJoin замість innerJoin варто брати свідомо: inner join мовчки викине з видачі всі пости без автора.

З колекціями fetch join має свою ціну — множення рядків. JOIN на p.tags перетворює один пост на стільки рядків, скільки в нього тегів, і setMaxResults(20) після цього обмежує рядки, а не сутності: сторінка отримає 7 постів замість 20. Для цього й існує Doctrine\ORM\Tools\Pagination\Paginator з fetchJoinCollection: true — він спершу вибирає DISTINCT ідентифікатори з LIMIT, а потім виконує основний запит із WHERE id IN (...) уже без обмеження. Із тієї ж причини два fetch join на дві різні колекції в одному запиті дають декартів добуток: другу колекцію дешевше догрузити окремим запитом.

fetch: 'EXTRA_LAZY' в мапінгу розвʼязує іншу задачу і його регулярно плутають із лікуванням N+1. Він змушує PersistentCollection виконувати count(), contains(), containsKey() і slice() цільовим SQL, не ініціалізуючи колекцію: замість гідрації 5000 коментарів — один SELECT COUNT(*). Кількість запитів при цьому не змінюється, змінюється їхня вартість і памʼять. Протилежність, fetch: 'EAGER', теж рідко буває доброю ідеєю як глобальне налаштування: асоціація тягнеться при кожному завантаженні сутності, зокрема в тих сценаріях, де вона не потрібна взагалі, тож рішення про eager краще ухвалювати на рівні конкретного запиту.

Для фонової обробки картина інша: там проблема не в кількості запитів, а в памʼяті, і відповіддю є Query::toIterable() (замінив задепрекейчений iterate()) із flush() і clear() кожні кількасот записів. Обмеження варто назвати самому: toIterable() не працює з fetch join колекцій, бо не може зібрати сутність із кількох рядків. Якщо ж дані потрібні лише для читання, найдешевший шлях — узагалі не гідрувати сутності: DQL-оператор NEW віддає готові DTO, getArrayResult() — масиви, і в обох випадках Unit of Work не тримає копій оригінальних даних. Партіальні обʼєкти для цього більше не варіант — вони задепрекейчені в ORM 2.x і прибрані в 3.0.

// Погано: 1 запит на список + по одному на автора кожного поста
foreach ($repo->findBy(['status' => 'published']) as $post) {
    echo $post->getAuthor()->getName();   // ініціалізація проксі => SELECT ... WHERE id = ?
}

// Добре: fetch join. Ключове тут addSelect — без нього JOIN лише фільтрує
$qb = $em->createQueryBuilder()
    ->select('p', 'a')                    // 'a' у SELECT: автор гідрується разом із постом
    ->from(Post::class, 'p')
    ->leftJoin('p.author', 'a')           // leftJoin, щоб пости без автора не зникли
    ->where('p.status = :status')
    ->setParameter('status', 'published');

// Пагінація з fetch join колекції: LIMIT ріже РЯДКИ, а один пост дає рядок на кожен тег
$qb->leftJoin('p.tags', 't')->addSelect('t')
   ->setFirstResult(0)
   ->setMaxResults(20);

// Paginator робить SELECT DISTINCT p.id ... LIMIT 20, потім основний запит з WHERE p.id IN (...)
$paginator = new Paginator($qb->getQuery(), fetchJoinCollection: true);
foreach ($paginator as $post) {           // рівно 20 сутностей, теги вже в памʼяті
    echo $post->getTitle(), count($post->getTags());
}

// EXTRA_LAZY: count() не завантажує 5000 коментарів, але це все одно запит на кожен пост
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'post', fetch: 'EXTRA_LAZY')]
private Collection $comments;             // $post->getComments()->count() => SELECT COUNT(*)

// Масова обробка: toIterable() не тримає весь результат, clear() чистить identity map
$query = $em->createQuery('SELECT p FROM App\Entity\Post p');  // без fetch join колекцій!
foreach ($query->toIterable() as $i => $post) {
    $post->recalculateStats();
    if (($i + 1) % 500 === 0) {
        $em->flush();
        $em->clear();
    }
}
$em->flush();
Що N+1 у Doctrine породжує ліниве завантаження: to-one асоціація підмінюється проксі, to-many — PersistentCollection, і перший же геттер робить SELECT.
Що звичайний `leftJoin('p.author', 'a')` не рятує: без `addSelect('a')` асоціація не гідрується, JOIN лише фільтрує й сортує.
Що fetch join колекції ламає `setFirstResult`/`setMaxResults`, бо LIMIT застосовується до рядків після JOIN, і саме тому існує `Doctrine\ORM\Tools\Pagination\Paginator`.
Що EXTRA_LAZY це не ліки від N+1, а здешевлення одного звернення: `count()`, `slice()`, `contains()` перестають завантажувати всю колекцію.
Що для масової обробки правильна відповідь — `toIterable()` з `flush()`/`clear()` батчами, і що з fetch join колекцій `toIterable()` не працює.
Писати `->join('p.author', 'a')` без `->addSelect('a')` і дивуватись, що кількість запитів не змінилась.
Ставити `fetch: 'EAGER'` в мапінгу як глобальний фікс: асоціація тягнеться при кожному завантаженні сутності, зокрема там, де вона не потрібна.
Вважати EXTRA_LAZY розвʼязанням N+1: запитів залишається стільки ж, вони просто легші.
Пагінувати fetch join на колекцію через `setMaxResults` без `Paginator` і отримувати неповну сторінку через дублікати рядків.
Лікувати N+1 індексом на зовнішньому ключі або кешем на рівні HTTP: кількість round-trip до бази від цього не змінюється.
Робити три-чотири fetch join колекцій в одному запиті й отримувати декартів добуток рядків замість пришвидшення.
ПОРАДА

Назвіть діагностику до рецепта: панель Doctrine у Symfony Profiler групує однакові запити й показує «executed N times» — саме там N+1 видно за секунду. І окремо згадайте, що fetch join на дві колекції одночасно множить рядки, тому другу колекцію краще догрузити другим запитом.

Сторінка питання →
LR
Laravel·Senior ·Livewire ·SPA ·Inertia

Вибір визначають три речі, а не смак: чи є другий клієнт (мобілка, партнери) — тоді API все одно доведеться зробити, і SPA стає майже безкоштовним; яка зернистість взаємодій — Livewire платить мережевим round-trip за кожен крок, тому годиться для форм, таблиць і фільтрів, а не для перетягування, канви чи офлайну; і хто це супроводжуватиме. Livewire тримає стан на сервері й ганяє снапшот компонента туди-сюди, SPA тримає стан у браузері й вимагає версіонованого контракту; Inertia — середина, де є клієнтські компоненти, але немає публічного API.

Адмінка на 60 CRUD-екранів, команда — три бекендери й пів фронтендера. Livewire чи Vue з REST?
Кажуть, Livewire повільний, бо кожен клік іде на сервер. Це так, і в чому саме ціна?
Що конкретно летить у браузер, коли в компонента є `public User $user`, і чи можу я це підмінити з devtools?
Livewire чи Inertia — якщо і там, і там немає окремого API, у чому тоді різниця?
У нас уже є мобільний застосунок. Це щось змінює у виборі?

Спершу механіка, бо без неї суперечка зводиться до смаків. Livewire — це серверні компоненти зі станом, який подорожує разом із клієнтом. При першому рендері в HTML вкладається снапшот: JSON з усіма публічними властивостями, іменем класу, ідентифікатором компонента й memo-даними, підписаний HMAC-чексумою від APP_KEY. Кожна взаємодія — це POST на /livewire/update з парою snapshot плюс calls/updates; кілька компонентів на сторінці за замовчуванням батчаться в один запит. Сервер не памʼятає ваш обʼєкт — він створює новий і гідратує його зі снапшота (властивості-моделі зберігаються як клас плюс первинний ключ і перезапитуються з БД), виконує метод, повністю перерендерює Blade і повертає HTML, який клієнтський морфер накладає на наявний DOM. Звідси випливає все інше: застосунок лишається stateless і масштабується звичайними інстансами, але ціна кожної взаємодії — RTT плюс бутстрап фреймворку плюс рендер, а розмір публічних властивостей — це трафік, помножений на кількість кліків.

Протилежний варіант — API плюс SPA — переносить стан у браузер. Сервер віддає JSON через контролери й JsonResource, клієнт має власний роутер, власний кеш і власну модель даних; взаємодії всередині екрана нічого не коштують серверу, зате зʼявляється другий контракт, який треба версіонувати, документувати, тестувати й окремо авторизувати. Автентифікація роздвоюється: laravel/sanctum у SPA-режимі на тому самому домені працює на сесійних куках із CSRF, а мобільний клієнт ходить з токенами. Третій варіант — Inertia — навмисно не є API: контролер повертає Inertia::render('Orders/Index', $props), роутинг і авторизація лишаються на сервері, а Vue чи React рендерять сторінку з props як зі звичайного JSON. Це дає справжні клієнтські компоненти без публічного контракту, але вимагає фронтенд-стека й окремого SSR-процесу (php artisan inertia:start-ssr), якщо потрібен індексований HTML.

Тепер критерії, за якими насправді ухвалюють рішення. Перший і найважливіший: чи існує другий клієнт. Якщо є мобільний застосунок, партнерські інтеграції або публічний API — контракт у кошторисі вже стоїть, і SPA поверх нього коштує додатково небагато; будувати ж API «на майбутнє», якого немає в роадмапі, — це подвоєння роботи без споживача. Другий: зернистість взаємодій. Форми, таблиці, фільтри, майстри, адмінки — усе, де користувач робить дію й чекає результату, — чудово живуть на round-trip. Перетягування, канва, редактор, зум графіка, офлайн і оптимістичні оновлення — не живуть: там кожен рух миші перетворився б на HTTP-запит. Третій: команда й чергування. Три бекендери без фронтенд-компетенції на SPA платитимуть щодня — збіркою, дублюванням типів, розсинхроном контракту; для них Livewire з wire:navigate дає SPA-подібну навігацію без другого стека.

Практика зводиться до кількох дисциплін, які й відрізняють senior-відповідь. У Livewire публічні властивості — це клієнтський контракт: їх видно в devtools і їх можна виставляти через updates, тому ідентифікатори власника позначають #[Locked], секрети туди не кладуть узагалі, а права перевіряють у самій дії (або через #[Authorize]), бо mount() виконується лише при першому рендері. Похідні дані — це #[Computed], а не public: результат кешується в межах запиту й не роздуває снапшот. Кількість запитів зменшують wire:model без .live там, де вистачає синхронізації з сабмітом, .debounce.300ms там, де потрібен живий пошук, #[Renderless] для дій без змін у розмітці, #[Lazy] для важких блоків і острівці Livewire 4 (@island), що перерендерюють частину компонента замість усього дерева. У @foreach обовʼязковий wire:key з реальним ідентифікатором, інакше після сортування морфер зіставить не ті вузли. У SPA-варіанті дисципліни інші: білий список полів у ресурсах, стабільна пагінація, версія в шляху й тести, які ловлять зміну контракту, — деталі цього розібрані в картці про JsonResource.

Межі й компроміси варто назвати вголос, бо на них перевіряють зрілість. Livewire не усуває JavaScript — на сторінці є його рантайм і Alpine, і будь-який складний віджет усе одно доведеться писати руками, загорнувши сторонню бібліотеку в wire:ignore. Livewire множить кількість PHP-запитів на кількість кліків, тож планувати потужність треба від взаємодій, а не від сторінок; персоналізовані POST-відповіді на CDN не кешуються, а Octane прибирає бутстрап, але вимагає чистих статичних станів і синглтонів. SPA, у свою чергу, платить за SEO рендером на сервері, за автентифікацію — двома різними схемами, а за кожну зміну поля — координацією двох релізів. І головне: вибір не бінарний і не остаточний. Типова здорова архітектура — Livewire на CRUD і адмінці, два-три ізольовані клієнтські віджети на Alpine або на власних компонентах із синхронізацією через $wire.entangle, і API рівно для тих споживачів, які справді існують. Якщо на співбесіді ви покажете саме цей шлях міграції — від сторінки до острівця, а не від фреймворку до фреймворку, — питання закрите.

use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Livewire\Attributes\{Computed, Locked, Url};
use Livewire\{Component, WithPagination};

final class OrderList extends Component
{
    use WithPagination;

    // Публічна властивість = частина снапшота: їде в браузер і назад на кожну дію.
    // Без #[Locked] клієнт легально виставляє її через updates у payload —
    // чексума від цього не рятує, вона захищає лише те, що надіслав сервер.
    #[Locked]
    public int $merchantId;

    #[Url(as: 'q', history: true)]
    public string $search = '';

    public function mount(int $merchantId): void
    {
        // Виконується лише при першому рендері: наступні запити гідратують
        // снапшот і одразу йдуть у дію, тому авторизація тут нічого не захищає.
        $this->merchantId = $merchantId;
    }

    public function updatedSearch(): void
    {
        $this->resetPage(); // інакше новий фільтр застосується до сторінки 7
    }

    public function markShipped(int $orderId): void
    {
        $order = Order::where('merchant_id', $this->merchantId)->findOrFail($orderId);
        $this->authorize('ship', $order); // права перевіряємо в дії, а не в mount()
        $order->markShipped();
    }

    // Не властивість: не серіалізується у снапшот і не їде клієнту.
    #[Computed]
    public function orders(): LengthAwarePaginator
    {
        return Order::query()
            ->where('merchant_id', $this->merchantId)
            ->when($this->search !== '', fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
            ->with('customer') // рендер Blade так само породжує N+1
            ->paginate(20);
    }
}

// Blade: <input wire:model.live.debounce.300ms="search">
// .live обовʼязково: з Livewire 3 біндинг відкладений і синхронізується з дією.
Що рішення виводиться з вимог (другий клієнт, зернистість взаємодій, склад команди, вимоги до офлайну й SEO), а не з тези «Livewire простіший».
Розуміння протоколу Livewire: снапшот із публічних властивостей їде в браузер і назад на кожну дію, підписаний HMAC від `APP_KEY`, сервер перерендерює Blade і повертає HTML, який морфиться в DOM.
Що публічні властивості — це клієнтський контракт: їх видно й можна змінювати з payload, тому `#[Locked]`, жодних секретів і авторизація в дії, а не в `mount()`.
Що Inertia — це третій, а не проміжний варіант: серверний роутинг і контролери, клієнтські компоненти, props як JSON і жодного публічного API для підтримки.
Розрахунок навантаження: у Livewire кожна взаємодія — це PHP-запит із бутстрапом фреймворку, тоді як у SPA більшість взаємодій не доходить до сервера; для авторизованих сторінок CDN не рятує ні там, ні там.
Що гібрид — нормальний результат: Livewire на CRUD, Alpine/JS-острівці на важкі віджети, API — лише там, де є справжній споживач.
Продавати Livewire як «фронтенд без JavaScript»: на сторінці все одно є рантайм Livewire і Alpine, а `wire:key` у циклах треба ставити руками, інакше морфінг переплутає рядки після сортування.
Класти в публічну властивість колекцію на тисячі рядків або результат запиту: він серіалізується у снапшот і їде по мережі двічі на кожен клік. Похідні дані — це `#[Computed]`, а не `public`.
Вважати, що чексума снапшота захищає значення: вона захищає від підміни того, що надіслав сервер, але `updates` у payload легально виставляє будь-яку публічну властивість — без `#[Locked]` `merchantId` переписується з консолі.
Перевіряти права в `mount()`: він виконується лише при першому рендері, наступні запити гідратують снапшот і одразу викликають дію — авторизація має бути в самій дії або в `#[Authorize]`.
Будувати повноцінний REST/GraphQL «на випадок майбутньої мобілки», якої немає в роадмапі: це другий контракт, другий набір тестів і другий шар авторизації без жодного споживача.
Обирати SPA заради SEO, а потім місяць прикручувати SSR: Livewire і Inertia з SSR віддають готовий HTML, чистий CSR — ні.
Дивуватися, що `wire:model="search"` не оновлює список: з Livewire 3 біндинг за замовчуванням відкладений, потрібен `wire:model.live` (і `.debounce`, щоб не слати запит на кожну літеру).
Ставити Livewire на високочастотні взаємодії — drag-and-drop, редактор, графік із зумом: кожен рух миші перетворюється на HTTP-запит замість локального стану.
ПОРАДА

Не відповідайте «залежить». Назвіть три критерії вголос і пройдіться по них: (1) чи існує другий клієнт — якщо так, API вже в кошторисі, і SPA дешевшає; (2) чи є взаємодії частіші за клік — якщо так, вони не переживуть round-trip; (3) хто чергує вночі — команда без фронтенд-компетенції платитиме за SPA щодня. І одразу додайте, що вибір не бінарний: Livewire на CRUD плюс два ізольовані JS-віджети — типова, а не компромісна архітектура.

Сторінка питання →
SF
Symfony·Senior ·API Platform ·DTO ·state provider

API Platform виправданий, коли потрібен машиночитний контракт (OpenAPI/JSON-LD), фільтри, пагінація й HTTP-кеш «з коробки»; головна архітектурна зміна — контролер зникає, а його місце займають state provider і processor, тому ресурс має бути окремим DTO, а не Doctrine-сутністю.

У нас 40 CRUD-ендпоінтів на Doctrine-сутностях — навіщо тягнути ще один шар?
Чому в API Platform немає контролерів і де тоді жити бізнес-логіці?
Ми поставили #[ApiResource] на сутність, а тепер не можемо змінити колонку в БД без ламання клієнтів. Що зробили не так?
Чим state provider і state processor відрізняються від DataProvider і DataPersister?

API Platform (4.x, вимагає PHP 8.2+ і Symfony 6.4/7.x) — це не «генератор CRUD», а машина, що з одних метаданих виводить кілька представлень одразу: специфікацію OpenAPI, гіпермедіа-формат JSON-LD/Hydra за замовчуванням, за потреби JSON:API, HAL і GraphQL, а разом з ними — узгоджену пагінацію, фільтри, обробку помилок валідації у форматі RFC 7807 і заголовки кешу. Виправданий він тоді, коли ця машиночитність комусь потрібна: є фронтенд або мобільний клієнт, що генерує собі SDK з OpenAPI; є десятки read-моделей із фільтрами й сортуванням, які інакше довелося б писати руками; є вимога до стабільного, задокументованого контракту. Якщо ж API — це десяток викликів-команд («створити», «підтвердити», «скасувати») і жодної колекції з фасетним пошуком, звичайні контролери плюс Messenger дадуть менше магії й менше шарів.

Головна архітектурна зміна — зникнення контролера як точки розширення. У 3.x і 4.x кожна операція має свій provider (клас із ApiPlatform\State\ProviderInterface::provide()) і processor (ProcessorInterface::process()); вони замінили DataProviderInterface і DataPersisterInterface з 2.x разом із їхнім supports(), бо тепер прив'язка явна, прямо в атрибуті операції. Дефолтні реалізації для Doctrine ORM — ItemProvider, CollectionProvider, PersistProcessor, RemoveProcessor — можна декорувати, коли треба лише додати крок до стандартного шляху. Далі йдуть Doctrine-розширення (QueryCollectionExtensionInterface, QueryItemExtensionInterface), які втручаються у сам QueryBuilder і тому підходять для наскрізних умов на кшталт multi-tenancy. Кастомний controller: в операції теж існує, але він обходить денормалізацію, валідацію й security-експресії, тому це шлях для виняткових відповідей, а не для бізнес-логіки.

Друга зміна — де проходить межа контракту. Найшвидший старт — повісити #[ApiResource] на Doctrine-сутність: фільтри SearchFilter, OrderFilter, RangeFilter, DateFilter через #[ApiFilter] працюють одразу, eager-loading-розширення саме будує join-и за групами серіалізації. Ціна — схема бази стає публічним контрактом: перейменування колонки ламає клієнтів, а групи серіалізації й #[Assert]-обмеження для API розповзаються по доменних класах. Дисципліноване рішення — ресурс як окремий клас у src/Api/Resource, свій provider для читання (аж до простого DBAL-запиту з проєкцією рівно потрібних полів) і processor, що викликає команду домену. Тоді сутність вільно змінюється, а API — ні.

Безпека й продуктивність теж переїжджають у метадані, і це місце типових помилок. security: "is_granted('ROLE_CUSTOMER')" виконується до денормалізації, тому бачить лише користувача й request; усе, що залежить від надісланого тіла, перевіряється в securityPostDenormalize, де доступні object і previous_object. Обмеження видимості колекції краще робити не security-виразом, а розширенням запиту: воно дає 404 замість 403 і не витягує з бази чужий рядок, щоб потім його відкинути. З продуктивністю тримайте в голові дві речі: maxJoins (за замовчуванням 30) обмежує автоматичний eager loading, а COUNT для пагінації на великих таблицях знімається pagination_partial: true або курсорною пагінацією через paginationViaCursor.

Межі варто називати чесно. API Platform не має вбудованого версіонування API — його роблять окремими ресурсами під різними uriTemplate або через content negotiation, і це ручна робота. Дії-команди лягають на HTTP-семантику погано: POST /orders/{id}/confirm з окремим input-DTO працює, але виглядає як вправа на обхід моделі ресурсів. Серіалізація нормалізатором коштує помітного CPU, тому широкі групи на гарячих ендпоінтах доводиться замінювати вузькими read-моделями й HTTP-кешем (cacheHeaders плюс інвалідація через Varnish або Souin). І, нарешті, команді потрібно розуміти сам фреймворк: помилку в конфігурації операції видно не в стектрейсі контролера, а у відповіді, якої ніхто не очікував.

// Ресурс API — окремий DTO, а не Doctrine-сутність: схема БД лишається приватною
#[ApiResource(
    shortName: 'Order',
    operations: [
        new GetCollection(provider: OrderCollectionProvider::class),
        new Get(provider: OrderItemProvider::class, security: "is_granted('ORDER_VIEW', object)"),
        new Post(
            processor: PlaceOrderProcessor::class,
            security: "is_granted('ROLE_CUSTOMER')",       // до денормалізації: перевірка ролі
            validationContext: ['groups' => ['order:create']],
        ),
    ],
    normalizationContext: ['groups' => ['order:read']],
    denormalizationContext: ['groups' => ['order:write']],
    paginationItemsPerPage: 30,
)]
final class OrderResource
{
    #[Groups(['order:read'])]
    public ?string $id = null;

    #[Assert\NotBlank(groups: ['order:create'])]
    #[Groups(['order:read', 'order:write'])]
    public string $sku = '';

    #[Assert\Positive(groups: ['order:create'])]
    #[Groups(['order:read', 'order:write'])]
    public int $quantity = 1;
}

// Processor замінює контролер: HTTP-шар лише передає команду в домен
final readonly class PlaceOrderProcessor implements ProcessorInterface
{
    public function __construct(private PlaceOrder $placeOrder) {}

    /** @param OrderResource $data */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderResource
    {
        $order = $this->placeOrder->handle(new PlaceOrderCommand($data->sku, $data->quantity));
        $data->id = $order->id()->toString();   // повернутий ресурс серіалізується автоматично

        return $data;
    }
}
Що API Platform дає не «CRUD швидше», а генерований контракт: OpenAPI, JSON-LD/Hydra, JSON:API, GraphQL, фільтри, пагінацію, ETag і валідацію з одних метаданих.
Що в 3.x/4.x шар даних це ProviderInterface::provide() і ProcessorInterface::process(), які замінили DataProviderInterface та DataPersisterInterface з 2.x, — контролер більше не точка розширення.
Що #[ApiResource] на Doctrine-сутності перетворює схему БД на публічний контракт, і межа між моделлю персистентності та представленням зникає; на serious-проєкті ресурс роблять окремим класом-DTO.
Що операції описуються поокремо (Get, GetCollection, Post, Patch, Delete) і кожна має власні security, validationContext і групи серіалізації — це і є заміна набору контролерів.
Що межі відомі: складні дії-команди, версіонування API і нестандартні відповіді доводиться робити руками, і в цих випадках звичайний контролер чесніший.
Ставити #[ApiResource] прямо на сутність і потім не мати змоги перейменувати поле в БД без міграції клієнтів.
Писати бізнес-логіку в кастомному контролері операції: у 3.x/4.x правильна точка розширення — processor, а контролер обходить серіалізацію, валідацію й security-експресії.
Плутати security і securityPostDenormalize: перевірку, що залежить від надісланих даних чи від об'єкта після денормалізації, треба робити другим, а не першим.
Обмежувати вибірку в кастомному провайдері замість Doctrine-розширення (QueryCollectionExtensionInterface), через що multi-tenancy не діє на всі операції одразу.
Вважати, що фільтри вмикаються самі: без #[ApiFilter] або #[QueryParameter] параметр у query string просто ігнорується, а не фільтрує.
Не дивитися на кількість запитів: eager-loading-розширення робить join-и автоматично, але має ліміт maxJoins (за замовчуванням 30) і легко ламається на глибоких групах серіалізації.
ПОРАДА

Формулюйте вибір через контракт, а не через швидкість: «API Platform беремо, коли клієнтам потрібен стабільний машиночитний контракт і купа read-моделей з фільтрами; ресурси при цьому — DTO, а сутності лишаються всередині». Одна фраза про DTO-ресурси одразу відрізняє того, хто це підтримував у проді.

Сторінка питання →
ARC
Архітектура·Senior ·DDD ·bounded context ·компроміси

DDD має сенс там, де складність саме в домені: багато правил, які часто змінюються, і потрібна спільна мова з бізнесом; для CRUD-адмінки чи інтеграційного сервісу агрегати й репозиторії додають шарів без вигоди.

Як зрозуміти, що домен достатньо складний для DDD?
Що таке bounded context на прикладі магазину?
Чому папки Domain, Application, Infrastructure ще не DDD?

Domain-Driven Design виправданий тоді, коли складність продукту зосереджена в бізнес-правилах. Ознаки: правила мають винятки й змінюються разом із бізнесом, експерти з різних відділів вкладають різне значення в одні слова, помилка в логіці коштує грошей, продукт житиме роками. У такому середовищі модель, яка говорить мовою бізнесу, окупає себе: зміну вимоги можна знайти в коді по назві, а інваріанти живуть в одному місці.

Найкорисніша частина DDD стратегічна. Bounded context — це межа, всередині якої слово має одне значення. Product у каталозі, на складі й у замовленні означає різні речі, і спроба зробити одну модель на всіх дає гігантський клас, який ніхто не розуміє. Три невеликі моделі з явними інтеграціями через події простіші, навіть якщо тактичних патернів у них мінімум.

Шкодить DDD там, де складності немає. CRUD-адмінка, звіти, інтеграційний сервіс, який перекладає один JSON в інший, не потребують агрегатів і репозиторіїв: пʼять класів на одну форму лише сповільнюють команду. Так само шкодить підміна суті формою: папки Domain, Application, Infrastructure без моделі та мови це не DDD, а бюрократія.

Зріла відповідь містить обидва приклади: контекст, де модель окупилась, і контекст, де від DDD свідомо відмовились на користь транзакційного скрипта й Eloquent. DDD можна застосовувати до одного складного контексту всередині звичайного застосунку, і саме так він найчастіше працює.

// Складний контекст: правила живуть у моделі, мова та сама, що в бізнесу
final class Subscription
{
    private function __construct(
        public private(set) SubscriptionId $id,
        private Plan $plan,
        private Period $period,
        private SubscriptionStatus $status,
    ) {}

    public function renew(Clock $clock): void
    {
        if ($this->status === SubscriptionStatus::Cancelled) {
            throw new CannotRenewCancelledSubscription($this->id);
        }
        if ($this->period->endsAfter($clock->now()->modify('+14 days'))) {
            throw new RenewalTooEarly($this->id, $this->period);   // правило з договору, не з таблиці
        }
        $this->period = $this->period->extendBy($this->plan->billingCycle());
        $this->record(new SubscriptionRenewed($this->id, $this->period));
    }
}

// Простий контекст поруч: CRUD без агрегатів, і це свідомий вибір
final class TagController
{
    public function store(Request $request): RedirectResponse
    {
        Tag::create($request->validate(['name' => ['required', 'max:50']]));
        return back();
    }
}

// Межа контекстів: той самий «продукт», різні моделі, інтеграція через подію
// Catalog\Product   — опис, фото, SEO
// Warehouse\StockItem — sku, залишок, комірка
// Orders\OrderLine   — назва й ціна, зафіксовані на момент покупки
Що цінність DDD у моделі й ubiquitous language, а не в структурі каталогів або в наборі патернів Entity, Repository, Value Object.
Критерії доречності: правила, які змінюються з бізнесом, кілька команд або підсистем із різними значеннями одних слів, довгий горизонт життя продукту.
Що стратегічна частина, тобто bounded contexts і context map, дає найбільше навіть без тактичних патернів, і що межі контекстів визначаються мовою, а не таблицями.
Приклад, де DDD не застосували свідомо: адмінка, звіти, інтеграція, і чому анемічна модель з транзакційним скриптом там була правильним вибором.
Розуміння ціни: більше коду, повільніший старт, потреба в дисципліні команди, і що DDD можна застосовувати до одного контексту, а не до всього застосунку.
Ототожнювати DDD зі структурою папок або з гексагональною архітектурою.
Застосовувати агрегати й репозиторії до CRUD і отримувати пʼять класів на одну форму.
Робити один величезний домен без bounded contexts, де Product означає одне для складу й інше для каталогу.
Проєктувати агрегати від таблиць бази, а не від інваріантів, і отримувати гігантський Order з усіма звʼязками.
Вважати, що DDD вимагає CQRS, event sourcing і мікросервіси.
ПОРАДА

Найсильніша відповідь — приклад, де ви свідомо не застосували DDD і чому це було правильно. Другий за силою: приклад слова, яке в двох контекстах означало різне.

Сторінка питання →
WP
WordPress·Senior ·headless ·REST API ·WPGraphQL

Headless виправданий, коли контент споживає більше ніж один клієнт або команда вже живе в JS-екосистемі; платити за це доводиться прев'ю, редиректами, формами, CSS блоків, меню, SEO-мета й власною інвалідацією кешу — усім, що раніше безкоштовно робила тема.

Замовник хоче Next.js поверх WordPress — які аргументи ви наведете за і проти?
Чому після переїзду на headless редактори скаржаться, що прев'ю не працює?
Ми зробили headless, а сторінки виглядають зламано — чому CSS блоків не застосувався?
Хто інвалідує кеш фронтенду, коли редактор змінює пост?

Headless WordPress означає, що тема більше не рендерить сторінки: WP лишається редакторським інтерфейсом і сховищем, а HTML будує окремий застосунок, який тягне дані через REST (ядро, з WP 4.7) або WPGraphQL. Ключове рішення тут не технічне, а продуктове — скільки різних клієнтів споживає той самий контент. Якщо клієнт один і це сайт, ви обмінюєте готовий рендер на власний і майже нічого не отримуєте. Якщо клієнтів кілька (веб, мобільний застосунок, екрани в залі, партнерська стрічка), або продукт — інтерактивний застосунок, де WP лише одне з джерел даних, або команда фронтенду вже живе в React і має дизайн-систему, — розділення окупається.

Аргумент «headless заради швидкості» майже завжди хибний. Повільність типового WP створюють запити в WP_Query, autoload-опції та десяток плагінів на кожен хіт, і все це нікуди не зникає — воно просто переїжджає з рендера сторінки в рендер JSON. Різниця в тому, що перед темою повносторінковий кеш ставиться однією настройкою, а перед API його треба проєктувати: REST-відповіді залежать від автентифікації, per_page обмежений сотнею, а без _fields кожен пост тягне за собою рендерений контент, мета й _links. Перш ніж пропонувати headless, чесніше показати профіль запитів і спробувати кеш на рівні edge.

Приховані витрати — це рівно те, що безкоштовно робила тема. Прев'ю чернеток вимагає автентифікованих запитів (Application Passwords з WP 5.6 або JWT) і роботи з wp/v2/posts/{id}/autosaves, бо незбережені зміни лежать саме там. CSS блоків генерується на сервері функціями wp_get_global_stylesheet() і wp_enqueue_block_style() (обидві з WP 5.9), а wp_head() на фронтенді ніхто не викличе. Меню через wp/v2/menus анонімно не читаються: ендпоінти вимагають edit_theme_options. Редиректи плагінів застосовуються на template_redirect, якого немає. Форми, шорткоди, srcset, related posts, канонічні URL і _wp_old_slug_redirect — той самий список. Кожен пункт треба або відтворити, або свідомо викинути.

Окремо живе інвалідація. У класичному WP кеш чистило саме ядро й плагін кешування; у headless це ваш код: transition_post_status або save_post → вебхук на фронтенд, який робить revalidatePath/revalidateTag (Next.js App Router, 13.4+). Пастка не в самому вебхуку, а в повноті: зміна поста впливає на його сторінку, на архіви, на головну, на сусідні пости в «схожих», іноді на меню. Тому інвалідують не URL, а теги, і запит роблять неблокуючим чи через wp_cron, інакше кожне збереження в редакторі затримується на час відповіді фронтенду.

Є ще операційна ціна: два деплої, два середовища, два набори логів і два домени. Бекенд лишається повноцінним сайтом, який віддає ті самі тексти за власними URL, тож його закривають від індексації (X-Robots-Tag: noindex, basic auth, обмеження за IP) — інакше отримаєте дублі контенту. Для SEO треба одне джерело правди: або фронтенд рахує мета-теги сам, або бере yoast_head_json з Yoast (з 14.x) і не сперечається з ним. І окремо — команда: підтримувати headless без постійного фронтенд-розробника не вийде, а більшість «просто контентних» сайтів такого розробника не мають. Проміжні варіанти теж існують: блочна тема з theme.json для контентної частини плюс окремий SPA-розділ для інтерактивного, — і для багатьох проєктів це чесніша відповідь, ніж повний headless.

// 1. Інвалідація фронтенду: тему знято, тож кеш чистить хук, а не WP
add_action('transition_post_status', function (string $new, string $old, WP_Post $post): void {
    if ($new !== 'publish' && $old !== 'publish') {
        return; // чернетка → чернетка фронтенду не стосується
    }
    if (wp_is_post_revision($post->ID) || $post->post_type === 'nav_menu_item') {
        return;
    }
    // HTTP-запит просто в хуку сповільнить збереження в редакторі — відкладаємо
    wp_schedule_single_event(time(), 'shop_revalidate', [get_permalink($post), $post->post_type]);
}, 10, 3);

add_action('shop_revalidate', function (string $url, string $type): void {
    wp_remote_post(SHOP_FRONT_URL.'/api/revalidate', [
        'timeout' => 5,
        'blocking' => false,
        'headers' => ['X-Revalidate-Secret' => SHOP_REVALIDATE_SECRET],
        // Окрім самої сторінки — архіви й головна, інакше список лишиться старим
        'body' => wp_json_encode([
            'path' => wp_parse_url($url, PHP_URL_PATH),
            'tags' => ['home', "archive:{$type}"],
        ]),
    ]);
}, 10, 2);

// 2. CSS блоків генерує сервер: без wp_head() фронтенд його не отримає
add_action('rest_api_init', function (): void {
    register_rest_route('shop/v1', '/block-styles', [
        'methods' => 'GET',
        'permission_callback' => '__return_true', // публічні стилі, не дані
        'callback' => static fn (): array => [
            // з WP 5.9: змінні й пресети з theme.json + стилі блоків
            'css' => wp_get_global_stylesheet(['variables', 'presets', 'styles']),
        ],
    ]);
});
Що headless — це рішення про кількість споживачів контенту, а не про швидкість: звичайний сайт майже завжди швидше вивести повносторінковим кешем перед PHP, ніж переписати фронтенд.
Що разом з темою зникає весь її функціонал: прев'ю чернеток, шорткоди, форми, related posts, srcset, меню, канонічні URL і `wp_head()` — кожен пункт треба відтворити руками.
Що прев'ю потребує автентифікації (Application Passwords з WP 5.6 або JWT) і читання `wp/v2/posts/{id}/autosaves`, бо незбережені зміни редактора живуть саме в автозбереженні, а не в пості.
Що CSS блоків генерує сервер: `wp_get_global_stylesheet()` і `wp_enqueue_block_style()` (обидва з WP 5.9) на фронтенді ніхто не викличе, тому стилі треба віддавати окремим ендпоінтом або збирати на боці JS.
Що інвалідацію кешу тепер пише розробник: хук `transition_post_status` → вебхук на фронтенд (`revalidatePath`/`revalidateTag` у Next.js App Router 13.4+), і що забути про батьківські сторінки, архіви та меню — типово.
Що бекенд лишається публічним сайтом і його треба закрити від індексації, інакше отримаєте дублі контенту на двох доменах.
Аргументувати headless продуктивністю, не спробувавши повносторінковий кеш і CDN: більшість «повільних» WP гальмують не через рендер теми.
Планувати бюджет на API-шар і забути про прев'ю та ролі редакторів — саме на цьому проєкти зупиняються після здачі.
Вважати, що `wp/v2/menus` віддасть меню анонімно: ендпоінти меню (з WP 5.9) вимагають капабіліті `edit_theme_options`, публічного читання там немає.
Тягнути `wp/v2/posts` без `_fields` і дивуватись мегабайтним відповідям, а потім упиратись у ліміт `per_page=100`.
Забувати, що `content.rendered` уже містить розгорнуті шорткоди й oEmbed, а `content.raw` — ні, і рендерити на фронтенді сирий HTML із `[gallery]` всередині.
Лишати два джерела правди для SEO: фронтенд генерує свої title/canonical, а Yoast віддає `yoast_head_json` — і вони розходяться.
ПОРАДА

Сформулюйте критерій одним реченням: headless окупається, коли споживачів контенту більше одного (сайт + мобільний застосунок + екрани в залі) або коли продукт — це застосунок, а WP у ньому лише CMS. Для контентного сайту з блогом чесніше сказати «ні» і показати, скільки коштує відтворити прев'ю, форми й меню.

Сторінка питання →
SQL
SQL·Senior ·партиціонування ·PostgreSQL ·MySQL

Партиціонування потрібне тоді, коли дані природно ріжуться по ключу (найчастіше по часу) і старі шматки треба видаляти чи архівувати цілком: DROP/DETACH PARTITION — це операція над метаданими замість мільйонів DELETE. Прискорення читання — побічний ефект, і лише коли ключ партиціонування є в WHERE.

Таблиця подій виросла до 400 млн рядків, DELETE старих даних не встигає за ніч — що робите?
Чи прискорить партиціонування наші SELECT-и?
Як перевести робочу таблицю на партиції без вікна простою?
Скільки партицій — це забагато?

Партиціонування розбиває одну логічну таблицю на набір фізичних таблиць за значенням ключа: у PostgreSQL це декларативні PARTITION BY RANGE | LIST | HASH (з версії 10), у MySQL — RANGE, RANGE COLUMNS, LIST, HASH, KEY на нативній реалізації InnoDB (з 8.0 інші рушії партиціонування не підтримують). Планувальник під час планування й виконання відкидає партиції, які не можуть містити потрібних рядків — це partition pruning, і саме тут головне непорозуміння: прунінг вимагає, щоб ключ партиціонування був у WHERE чи в умові з’єднання. Запит WHERE user_id = 42 на таблиці, партиціонованій по created_at, читає всі партиції і працює повільніше, ніж на непартиційованій таблиці з нормальним індексом.

Тому справжній мотив партиціонування — не читання, а життєвий цикл даних і обслуговування. Видалення місяця подій через ALTER TABLE ... DETACH PARTITION у PostgreSQL або ALTER TABLE ... DROP PARTITION у MySQL — це операція над метаданими й табличним простором, вона не породжує мільйони версій рядків, не роздуває WAL/undo, не створює лагу реплік і не залишає по собі bloat, який доводиться прибирати pg_repack. Побічно менші стають і VACUUM, ANALYZE, перебудова індексів: вони йдуть по одній партиції, а не по терабайтній таблиці. Плюс холодні партиції можна тримати на дешевшому табличному просторі або взагалі винести з бази.

Плата за це — обмеження й обовʼязкова автоматизація. У PostgreSQL ключ партиціонування має входити в первинний ключ і в кожен UNIQUE, глобальних унікальних індексів немає; зовнішні ключі на партиційовану таблицю зʼявилися лише в PG 12. У MySQL ключ теж мусить входити в усі унікальні ключі, а зовнішніх ключів на партиційованих InnoDB-таблицях немає взагалі. Партиції на майбутнє треба створювати наперед — планувальником, командою Artisan або pg_partman; інакше вставка або впаде, або осяде в DEFAULT-партиції, після чого підключення нової партиції з тим самим діапазоном вимагатиме сканування дефолтної під блокуванням.

Операційна частина архівації виглядає так. Відчепити партицію потрібно неблокуючим способом: DETACH PARTITION ... CONCURRENTLY (PG 14+, поза транзакційним блоком; при перерві залишає стан detach pending, який довершує FINALIZE). Далі відчеплена таблиця — звичайна таблиця: COPY у Parquet/CSV на обʼєктне сховище, перевірка контрольних сум і DROP TABLE. У MySQL аналог — EXCHANGE PARTITION WITH TABLE, щоб вийняти дані в окрему таблицю без копіювання. Обидві операції все одно чекають на метадані-блокування за довгими читаннями, тож нічний скрипт запускають з lock_wait_timeout (MySQL) або lock_timeout (PostgreSQL) і ретраями, а не «наосліп».

Межа застосовності проста: якщо немає ключа, по якому дані і фільтруються, і застарівають, партиціонування додасть складності без вигоди — краще індекси, keyset-пагінація і чистка пачками через pt-archiver. Партиціонування також не є шардингом: воно живе в межах одного сервера і не рятує від нестачі IOPS чи обʼєму, а лише робить обслуговування великої таблиці передбачуваним.

-- PostgreSQL 14+: розбиття по діапазону часу
CREATE TABLE events (
    id          bigserial,
    occurred_at timestamptz NOT NULL,
    payload     jsonb NOT NULL,
    PRIMARY KEY (id, occurred_at)   -- ключ партиціонування має бути в PK
) PARTITION BY RANGE (occurred_at);

CREATE TABLE events_2026_09 PARTITION OF events
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- Прунінг спрацює лише за наявності occurred_at у WHERE
EXPLAIN SELECT count(*) FROM events WHERE occurred_at >= '2026-09-01';

-- Архівація місяця: відчіпляємо без довгого блокування (не в транзакції)
ALTER TABLE events DETACH PARTITION events_2026_03 CONCURRENTLY;
-- відчеплена таблиця живе окремо: вивантажити в холодне сховище і прибрати
DROP TABLE events_2026_03;          -- метадані, а не мільйони DELETE

-- Підключення історичної таблиці без повного сканування під блокуванням
ALTER TABLE events_2026_02 ADD CONSTRAINT events_2026_02_rng
    CHECK (occurred_at >= '2026-02-01'
       AND occurred_at <  '2026-03-01') NOT VALID;
ALTER TABLE events_2026_02 VALIDATE CONSTRAINT events_2026_02_rng;
ALTER TABLE events ATTACH PARTITION events_2026_02
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- MySQL 8.4: та сама ідея, ключ мусить входити в кожен унікальний ключ
-- ALTER TABLE events DROP PARTITION p2026_03;
-- SET SESSION lock_wait_timeout = 5;  -- щоб не висіти за довгим читанням
Що головний мотив — дешеве видалення й обслуговування (DROP PARTITION замість DELETE, VACUUM та REINDEX по одній партиції, бекап лише активної), а не магічне прискорення запитів.
Що прунінг партицій працює лише коли ключ партиціонування є в WHERE або JOIN; без нього план читає всі партиції, і стає гірше, ніж було.
Обмеження на ключі: у PostgreSQL ключ партиціонування має входити в PRIMARY KEY і будь-який UNIQUE; у MySQL — у кожен унікальний ключ, а зовнішні ключі на партиційованих InnoDB-таблицях не підтримуються.
Операційний план архівації: у PostgreSQL 14+ `DETACH PARTITION ... CONCURRENTLY`, вивантаження відчепленої таблиці й `DROP TABLE`; у MySQL — `ALTER TABLE ... DROP PARTITION` або `EXCHANGE PARTITION`.
Що партиції треба створювати наперед (планувальник, pg_partman), інакше вставка в неіснуючий діапазон падає або втрапляє в DEFAULT-партицію, з якої її потім важко дістати.
Якщо партиціонування не варіант — видалення пачками по keyset-діапазону з паузами, з оглядом на реплікаційний лаг, а не один гігантський DELETE.
Партиціонувати «щоб SELECT був швидший», коли проблему вирішує звичайний індекс: партиціонування не замінює індекс і не зменшує кількість читаних рядків, якщо ключ не у WHERE.
Партиціонувати по `created_at`, а фільтрувати в запитах по `user_id`: прунінгу немає, планувальник обходить сотні партицій, латентність зростає.
Робити партицію на день на п’ять років уперед: тисячі партицій роздувають планування, `pg_class` і памʼять під час prepared statements.
Чистити історію одним `DELETE FROM events WHERE created_at < now() - interval '1 year'`: довга транзакція, розпухання undo/WAL, лаг реплік, а в PostgreSQL місце все одно не повертається без VACUUM FULL чи pg_repack.
Викликати `ATTACH PARTITION` на великій таблиці без заздалегідь провалідованого CHECK — база сканує її цілком під блокуванням.
Забути, що `DROP PARTITION` у MySQL і `DETACH` у PostgreSQL чекають на завершення довгих читань: без `lock_wait_timeout`/`statement_timeout` нічний скрипт стає інцидентом.
ПОРАДА

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

Сторінка питання →
PHP
Core PHP·Senior ·WeakMap ·WeakReference ·памʼять

WeakReference (PHP 7.4+) і WeakMap (PHP 8.0+) тримають обʼєкт, не збільшуючи його refcount: як тільки обʼєкт знищується, посилання стає null, а запис у WeakMap зникає сам — це штатний спосіб робити привʼязані до обʼєктів кеші й реєстри в довгоживучих процесах.

Ви кешуєте розраховані права в масиві за spl_object_id — що з цим не так у воркері Octane?
Чим WeakMap відрізняється від SplObjectStorage?
Як привʼязати дані до обʼєкта, не подовживши йому життя?
Чому запис у WeakMap не зник, хоча я зробив unset() ключа?

Основа памʼяті в PHP — підрахунок посилань: кожен обʼєкт має refcount, і щойно він падає до нуля, обʼєкт знищується негайно, з викликом __destruct(). Будь-яка звичайна змінна, властивість чи елемент масиву, що вказує на обʼєкт, цей лічильник збільшує — тобто утримує обʼєкт живим. Слабке посилання — це виняток із правила: WeakReference::create($obj) (PHP 7.4+) запамʼятовує обʼєкт, не чіпаючи refcount. Поки на обʼєкт є хоч одне сильне посилання, $weak->get() повертає його; коли останнє зникає, обʼєкт знищується за звичайним сценарієм, а get() починає повертати null. До 7.4 це вміла лише PECL-розширення weakref, тож на старих проєктах ви цього API не побачите.

WeakMap (PHP 8.0+) — надбудова над тією ж ідеєю: мапа, де ключ — обʼєкт (тільки обʼєкт, скаляр дасть TypeError), значення — будь-що, і слабким є саме ключ. Клас реалізує ArrayAccess, Countable та IteratorAggregate, тому працює як звичайний масив: $map[$obj] = $data, isset($map[$obj]), count($map), foreach ($map as $obj => $data). Головна властивість — запис видаляється автоматично разом зі знищенням ключа, без жодного коду очищення з вашого боку. Це те, чого не дають альтернативи: SplObjectStorage теж мапить обʼєкти на дані, але тримає ключі сильно, а масив, індексований spl_object_id(), обʼєкт не тримає, зате накопичує мертві записи й ризикує колізією — ідентифікатор знищеного обʼєкта PHP може видати новому, і кеш віддасть чужі дані.

Практичний сенс зʼявляється там, де процес живе довго: Octane, RoadRunner, Swoole, queue:work у режимі демона, довгі CLI-команди, ReactPHP/Amp. У класичному FPM-запиті все одно все звільняється наприкінці, тому витік у пер-обʼєктному кеші там непомітний; у демоні той самий статичний масив росте від першого запиту до перезапуску воркера. Канонічний сценарій для WeakMap — метадані, привʼязані до обʼєкта і не потрібні довше за нього: обчислені права користувача, memoized-результат дорогого методу для конкретної сутності, стан гідратації або ознака «цей обʼєкт уже провалідовано» у межах одного job. У прикладі PermissionResolver кеш живе рівно стільки, скільки живе $user: count() сам падає до нуля, коли воркер відпускає сутність.

Слабкість односпрямована, і на цьому найчастіше спотикаються. WeakMap тримає значення сильно, тому будь-який шлях від значення назад до ключа перетворює слабкий запис на вічний: $map[$order] = ['owner' => $order] з останнього блоку коду означає ланцюжок WeakMap → масив → $order, і unset($order) уже нічого не змінює. Лікується це зберіганням даних без зворотного посилання або загорнутим WeakReference усередині значення. Друга межа — цикли: якщо обʼєкт-ключ входить у циклічний граф (батько тримає дитину, дитина тримає батька), refcount ніколи не досягне нуля сам, і запис зникне лише після проходу збирача циклів, тож у тестах перед перевіркою count() варто викликати gc_collect_cycles(). Третя — обидва класи не серіалізуються: serialize() на них кине виняток, тому в сесію, кеш чи payload черги вони не потрапляють.

Нарешті, варто чесно назвати, чим WeakMap не є. Це не кеш із витісненням: у нього немає ані TTL, ані ліміту розміру, ані LRU — єдиний критерій видалення це смерть ключа. Якщо обʼєкт живе весь процес (синглтон із контейнера, case enum, зареєстрований слухач), WeakMap не дасть нічого, крім зайвої непрямості, і звичайна властивість буде чеснішою. І це не заміна аналізу витоків: якщо памʼять воркера росте, спершу треба знайти, хто тримає посилання (php-memprof, підрахунок живих обʼєктів у контрольних точках, memory_get_usage(true) і gc_status() у метриках), і лише потім вирішувати, чи справді проблемне місце — це реєстр, якому слабких ключів вистачить.

final class PermissionResolver
{
    /** @var WeakMap<User, list<string>> ключ тримається слабко */
    private WeakMap $cache;

    public function __construct(private readonly PermissionStorage $storage)
    {
        $this->cache = new WeakMap;          // PHP 8.0+
    }

    /** @return list<string> */
    public function for(User $user): array
    {
        // ??= працює через ArrayAccess: offsetExists → offsetGet → offsetSet
        return $this->cache[$user] ??= $this->storage->load($user->id);
    }

    public function cachedCount(): int
    {
        return count($this->cache);          // WeakMap реалізує Countable
    }
}

$resolver = new PermissionResolver($storage);
$user = new User(42);
$resolver->for($user);
echo $resolver->cachedCount();               // 1

// WeakReference (PHP 7.4+) не інкрементує refcount; new заборонено
$weak = WeakReference::create($user);
unset($user);                                // зникло останнє сильне посилання

var_dump($weak->get());                      // NULL — обʼєкт уже знищено
echo $resolver->cachedCount();               // 0 — запис пішов разом із ключем

// Пастка: значення тримає ключ, слабкість працює лише в один бік
$map = new WeakMap;
$order = new Order;
$map[$order] = ['owner' => $order];          // WeakMap → значення → ключ
unset($order);
echo count($map);                            // 1 — памʼять не звільниться
Що слабке посилання не інкрементує refcount: обʼєкт звільняється в момент, коли зникає останнє *сильне* посилання, а слабке після цього просто перестає працювати.
Що WeakMap — це мапа «обʼєкт → будь-яке значення» зі слабким ключем: запис видаляється автоматично разом із ключем, тому `count($map)` зменшується сам, без вашого коду очищення.
Що SplObjectStorage і звичайний масив, індексований `spl_object_id()`, цю задачу не розвʼязують: перший тримає обʼєкти сильно, другий накопичує мертві записи, а ідентифікатори ще й перевикористовуються після знищення обʼєкта.
Що проблема стає видимою саме в довгих процесах — Octane, RoadRunner, Swoole, `queue:work` у режимі демона, ReactPHP/Amp, — бо між запитами процес не помирає і статичні реєстри ростуть вічно.
Що значення у WeakMap тримається сильно: якщо воно (прямо чи через ланцюжок) посилається на ключ, запис не звільниться ніколи — слабкість односпрямована.
Що WeakMap не скасовує збирач циклів: якщо ключ у циклічному графі, запис зникне лише після проходу gc_collect_cycles, а не миттєво.
Казати «WeakMap — це кеш, який сам чиститься за LRU/TTL»: він нічого не витісняє за розміром чи часом, єдиний критерій — смерть ключа.
Класти у WeakMap рядок або int як ключ: `$map['user-42'] = ...` дає TypeError, ключем може бути тільки обʼєкт.
Робити `new WeakReference($obj)` — пряма інстанціація заборонена, є лише `WeakReference::create($obj)`.
Зберігати у значенні WeakMap сам обʼєкт-ключ (`$map[$o] = ['owner' => $o]`) і дивуватись, що памʼять не звільняється: значення тримає ключ живим.
Використовувати WeakMap для обʼєктів, які й так живуть весь процес — синглтонів із контейнера, case-ів enum, зареєстрованих слухачів: слабкість там не дає нічого, крім накладних витрат.
Вважати `unset($obj)` гарантією звільнення: якщо на обʼєкт лишилось інше сильне посилання (у логері, у події, у замиканні зі звʼязаним `$this`), `WeakReference::get()` і далі поверне обʼєкт.
ПОРАДА

Формулюйте через refcount: «сильне посилання каже обʼєкту жити, слабке — лише питає, чи він ще живий». Далі одне речення практики: у довгому воркері метадані, привʼязані до обʼєкта, тримають у WeakMap, бо масив за `spl_object_id` там перетворюється на витік із перевикористаними ключами.

Сторінка питання →
ARC
Архітектура·Senior ·модульний моноліт ·мікросервіси ·межі модулів

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

У нас моноліт на 300 тисяч рядків і деплой раз на тиждень. Це вже привід переходити на мікросервіси?
Як ви зрозумієте, що конкретний модуль пора виносити в окремий сервіс?
Ми розпиляли моноліт на вісім сервісів, і тепер релізимо їх усі разом однією командою. Що ми зробили не так?
Чим межа модуля в моноліті відрізняється від межі мікросервісу, якщо в обох випадках це просто інтерфейс?

Різниця між модулем і сервісом лежить не в коді, а в межі процесу. Усередині моноліта звертання одного модуля до іншого — це виклик методу: він або відбувся, або кинув виняток, він синхронний, він швидкий і, найважливіше, він може бути в тій самій транзакції, що й локальний запис. Щойно та сама межа стає мережевою, зникають усі три властивості одразу. Зʼявляється третій стан «невідомо» (запит пішов, відповідь не повернулася, але операція могла виконатися), зʼявляється затримка, яка складається вздовж ланцюжка, і зникає спільний COMMIT. Тому питання «моноліт чи мікросервіси» — це не питання про чистоту архітектури: чисті межі, порти, окремі схеми даних і заборона крос-модульних залежностей цілком досяжні в одному деплої. Це питання про те, чи готові ви заплатити розподіленою узгодженістю за незалежність деплою й масштабування.

Звідси випливає й порядок дій. За замовчуванням береться модульний моноліт: один деплой, кілька модулів із вузьким публічним контрактом, у кожного свої таблиці, звертання одне до одного тільки через порт. Ключове тут — межі мають бути машинно перевіреними, інакше вони не витримають і кварталу. qossmic/deptrac описує шари й дозволені залежності у deptrac.yaml і завалює CI на порушенні; PHPArkitect і arch-тести Pest роблять те саме в термінах правил на неймспейси. Другий рівень захисту — дані: у Doctrine це окремі entity manager-и на модуль, у Laravel — окремі connection-и або принаймні тверда заборона на звʼязки Eloquent між модулями. Без цього перший же звіт із JOIN на чужі таблиці зшиває модулі назад, і «межа» лишається тільки в теці.

Сигнал до виділення сервісу завжди про різницю, а не про розмір. Різний профіль ресурсів: модуль рендерингу PDF або обробки відео тримає CPU й памʼять, і через нього доводиться масштабувати весь застосунок. Різний цикл релізу: команда готова релізити щодня, але стоїть у спільній черзі, і це вимірюється в днях затримки, а не у відчуттях. Інший рантайм: довгоживучий процес, WebSocket, інша версія PHP чи взагалі інша мова. Ізоляція ризику й даних: платіжна частина з окремим доступом і аудитом. Різна вимога до доступності: нестабільна зовнішня інтеграція не має класти основний трафік. «Кодова база велика» і «деплой довгий» у цей список не входять — обидва лікуються модулями, тестами й пайплайном, значно дешевше за мережу між класами.

Ціна розділення концентрується в одному місці: транзакція, що охоплювала два модулі, більше не існує. Двофазний коміт у типовому PHP-стеку не розглядають — практичної підтримки ні в PDO, ні в чергах немає. Замість нього — outbox: у тій самій транзакції, що й локальний запис, зберігається повідомлення, а окремий процес доставляє його далі. У Symfony Messenger цю роль грає доктрин-транспорт, який пише в messenger_messages тим самим зʼєднанням, а messenger:consume забирає; у Laravel — драйвер database разом із after_commit у config/queue.php або ->afterCommit() на джобі, щоб подія не поїхала раніше за COMMIT. Далі приймальна сторона має бути ідемпотентною за ключем повідомлення, а сценарій — мати компенсацію на випадок, коли другий крок не вдався. Якщо на етапі проєктування виявляється, що операція мусить бути атомарною, це найкраща з можливих новин: межу сервісу тут проводити не можна, і ви дізналися це до, а не після винесення.

Найдорожча помилка в цій темі — розподілений моноліт: сервіси є, а незалежності немає. Ознаки перевіряються за півгодини: чи можна зарелізити один сервіс без інших; чи ділять вони базу або міграції; чи ходять один в одного синхронним ланцюжком (три сервіси по 99.9% дають уже близько 99.7% на ланцюжку, і це помітно); чи піднімається локально один сервіс без усього стека. Якщо відповіді погані, правильний рух — не додавати сервісів, а зливати назад ті, що ділять дані й цикл релізу. Коли ж рішення ділити ухвалено свідомо, робиться це поступово, у стилі strangler fig: спершу єдиний порт на всі звертання, потім переведення того, що можна, на асинхронний обмін і відділення даних, потім версійований контракт із таймаутами, ретраями й ідемпотентними ключами, і лише в кінці — перенесення реалізації в окремий деплой, спершу на частині трафіку. Кожен крок має цінність сам по собі, і на будь-якому з них можна зупинитися, якщо виявиться, що причини для окремого процесу насправді не було.

// Публічний контракт модуля Billing: єдина точка входу для інших модулів.
// Решта класів Billing для них закрита — це перевіряє deptrac у CI.
interface Invoices
{
    public function issue(IssueInvoice $command): InvoiceId;
}

// Сьогодні: виклик у тому самому процесі й у тій самій транзакції.
final readonly class LocalInvoices implements Invoices
{
    public function __construct(private IssueInvoiceHandler $handler) {}

    public function issue(IssueInvoice $command): InvoiceId
    {
        return ($this->handler)($command);
    }
}

// Завтра, якщо Billing поїде окремим деплоєм, змінюється лише ця реалізація
// і біндинг у контейнері. Код модуля Hiring не чіпаємо взагалі.
final readonly class HttpInvoices implements Invoices
{
    public function __construct(private PendingRequest $http) {}

    public function issue(IssueInvoice $command): InvoiceId
    {
        // Мережа приносить те, чого не було в методі: таймаут, ретрай, ідемпотентність
        $response = $this->http
            ->timeout(3)                 // краще швидка помилка, ніж зависла черга воркерів
            ->retry(2, 200)              // ретрай безпечний лише завдяки ключу нижче
            ->withHeader('Idempotency-Key', $command->invoiceId) // ULID генерує викликач
            ->post('/invoices', ['order_id' => $command->orderId]);

        return InvoiceId::fromString($response->throw()->json('id'));
    }
}

// Чого цей код не ховає: транзакції, що охоплювала обидва модулі, більше немає.
// Якщо виклик мусив бути атомарним із локальним записом — потрібен outbox
// і компенсація, і саме тут вирішується, чи проходить межа сервісу в цьому місці.
Що мікросервіси — це рішення про межу процесу й деплою, а не про якість коду: чисті межі досяжні в моноліті, і саме там їх треба спершу зробити.
Що причини для виділення сервісу називаються конкретно й перевіряються цифрами: різний профіль масштабування, різний цикл релізу під окрему команду, інший рантайм чи вимоги ізоляції — а не «моноліт великий».
Що ціна розділення — це втрата транзакції між модулями: там, де був один COMMIT, зʼявляються saga, outbox, ідемпотентні хендлери й компенсації.
Що межі всередині моноліта мають бути машинно перевірені (deptrac, PHPArkitect, arch-тести Pest), інакше «модулі» — це просто теки, і за рік вони зростуться.
Що кандидат розуміє антипатерн розподіленого моноліта: сервіси, які релізяться разом і ходять один в одного синхронно ланцюжком, дають ціну мікросервісів без їхньої користі.
Що виносити треба через контракт і strangler-підхід: спершу єдина точка входу в модуль і асинхронний обмін, потім винесення реалізації, а не «перепишемо на сервіси за квартал».
Називати причиною розміру кодової бази або довгий деплой: і те, і те лікується модулями, тестами й пайплайном, а не мережею між класами.
Ділити за технічними шарами («сервіс API», «сервіс бази», «сервіс нотифікацій») замість бізнес-можливостей: тоді будь-яка фіча зачіпає всі сервіси одразу.
Вважати, що спільна база між сервісами — прийнятний компроміс: щойно два деплої пишуть в одну таблицю, це один сервіс із двома процесами і подвійним ризиком міграцій.
Обіцяти «просто загорнемо в HTTP» і не закласти таймаут, ретрай та ідемпотентність: перший же лаг сусіда кладе весь ланцюжок запитів.
Розраховувати на розподілену транзакцію (2PC) через PDO чи чергу: у типовому PHP-стеку її немає, і бізнес-логіку доводиться переписувати на компенсації.
Мірятися кількістю сервісів і забувати про операційну ціну: окремий CI, окремий моніторинг, трасування, версіювання контрактів, локальний запуск усього стека розробником.
ПОРАДА

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

Сторінка питання →
LR
Laravel·Senior ·Octane ·Swoole ·стан між запитами

Octane тримає застосунок у памʼяті між запитами через Swoole, FrankenPHP або RoadRunner: бутстрап відбувається один раз, але стан більше не скидається сам.

Чому під Octane один користувач бачить дані іншого?
Чим scoped відрізняється від singleton під Octane?
Що треба перевірити в застосунку перед переходом на Octane?

Класичний PHP-FPM створює застосунок з нуля на кожен запит: автозавантаження, провайдери, конфігурація, роутинг. Octane завантажує все один раз у воркер на Swoole, FrankenPHP чи RoadRunner і далі обробляє запити тим самим процесом. Звідси виграш: бутстрап Laravel зникає з кожного запиту. Сам PHP-код не стає швидшим.

Ціна — стан, який більше не помирає разом із запитом. Три джерела проблем: singleton у контейнері, статичні властивості класів і глобальні змінні чи memoization у хелперах. Синглтон, який отримав Request у конструкторі, на другому запиті тримає Request першого користувача. Статичний кеш, який раніше жив 50 мс, тепер живе добу й росте.

Octane сам скидає те, про що знає: auth, session, database connections, cache resolvers, translator. Про ваші класи він не знає. Тому перед міграцією потрібен аудит привʼязок: усе, що тримає стан запиту, переводиться на scoped, статичні кеші отримують очищення на RequestTerminated, а Request передається в методи, а не в конструктори довгоживучих сервісів.

Перед переходом варто виміряти, де йде час. Якщо запит триває 500 мс через SQL і зовнішні API, економія 30 мс бутстрапу непомітна, а ризики витоків реальні. Octane виправданий на легких ендпоінтах з великим RPS, де бутстрап є значною частиною часу відповіді.

// НЕБЕЗПЕЧНО під Octane: Request живе в синглтоні між запитами
final class CurrentTenant
{
    private ?Tenant $tenant = null;

    public function __construct(private Request $request) {}

    public function get(): Tenant
    {
        return $this->tenant ??= Tenant::whereHost($this->request->getHost())->firstOrFail();
    }
}
$this->app->singleton(CurrentTenant::class); // другий користувач побачить tenant першого

// БЕЗПЕЧНО: scoped скидається після кожного запиту
$this->app->scoped(CurrentTenant::class);

// Або взагалі не тримати Request у стані: брати його в момент виклику
final class CurrentTenant
{
    public function get(Request $request): Tenant { /* ... */ }
}

// Прихований витік: статичний кеш росте з кожним запитом
final class Pricing
{
    private static array $cache = [];   // ніколи не очищається під Octane
}

// Скидання власного стану між запитами
Octane::tick('flush-pricing', fn () => Pricing::flush())->seconds(60);
Event::listen(RequestTerminated::class, fn () => Pricing::flush());
Що виграш іде саме з відсутності бутстрапу фреймворку на кожен запит, а не з магічного прискорення PHP-коду.
Що singleton тепер живе між запитами, тому синглтон, який зберігає Request, користувача чи будь-які дані запиту, протікає між користувачами.
Що є три джерела витоків стану: singleton у контейнері, статичні властивості класів і глобальні змінні або memoization у хелперах.
Що Octane сам скидає частину стану (auth, session, database, cache resolvers), але не знає про ваші класи.
Практику: замінити singleton на scoped там, де є стан запиту, перевіряти memory через --max-requests, тримати ліміт памʼяті, і памʼятати, що інʼєкція Request у конструктор синглтона це антипатерн.
Казати, що Octane компілює PHP чи робить код швидшим: він лише прибирає повторний бутстрап.
Інжектити Request або Auth у конструктор singleton-сервісу: під FPM це працювало, під Octane дає чужі дані.
Не помічати статичні кеші в пакетах: static::$resolved, memoized конфіги, реєстри, які ростуть з кожним запитом.
Переходити на Octane без бенчмарка власного застосунку: якщо час іде на SQL і зовнішні API, бутстрап це 5 % і виграшу майже немає.
Забувати про конкурентність Swoole: блокуючий sleep або довгий запит тримає воркер, а коррутини змінюють семантику деяких розширень.
ПОРАДА

Обовʼязково згадайте, що синглтони, які приймають Request, треба переписати на scoped, інакше дані одного користувача побачить інший. І назвіть FrankenPHP як актуальний варіант поруч зі Swoole та RoadRunner.

Сторінка питання →
PHP
Core PHP·Senior ·PHP 8.5 ·pipe operator ·clone with

PHP 8.5 (реліз 20 листопада 2025) додав оператор конвеєра `|>`, синтаксис `clone $obj with {...}`, атрибут `#[\NoDiscard]` з приведенням `(void)`, вбудоване розширення URI з класами `Uri\Rfc3986\Uri` та `Uri\WhatWg\Url`, а також дрібніші зручності — `array_first()`/`array_last()`, атрибути на константах, замикання в константних виразах і бектрейси для фатальних помилок.

Ви вже дивилися на 8.5? Що з нового реально взяли б у продакшн, а що ні?
Що робить оператор `|>` і чим він кращий за вкладені виклики функцій?
Навіщо `clone with`, якщо вже є `__clone()` і звичайні `with*()`-методи?
Чому нам більше не радять `parse_url()` і що прийшло на заміну?

PHP 8.5 вийшов 20 листопада 2025 року, і за структурою це типовий мінорний реліз: три зміни, які видно прямо в синтаксисі (|>, clone with, #[\NoDiscard]), одне велике доповнення стандартної бібліотеки (розширення URI) і десяток дрібниць — array_first()/array_last(), атрибути на константах, замикання в константних виразах, final для просунутих у конструкторі властивостей, get_error_handler()/get_exception_handler(), бектрейси для фатальних помилок, персистентні share-хендли cURL. Сильна відповідь на це питання — не перелік, а сортування: що змінює те, як ви пишете код щодня, що закриває конкретний клас багів, а що є нішевим і чекатиме свого випадку.

Оператор конвеєра |> розвертає ланцюжок викликів зліва направо: замість htmlspecialchars(trim($raw)) пишеться $raw |> trim(...) |> htmlspecialchars(...). Механіка проста і має рівно одну пастку: права частина — це вираз, значення якого має бути callable, і викликається він з одним аргументом. Тому там стоїть синтаксис callable першого класу trim(...) (PHP 8.1), замикання або змінна з callable, але не trim() — такий запис викличе функцію без аргументів. Функції з параметром за посиланням у конвеєр не передаються взагалі, а для функцій із кількома обовʼязковими аргументами доводиться загортати виклик у fn, бо часткового застосування (str_replace('a', 'b', ?)) у PHP немає — відповідний RFC відхилили. Пріоритет у |> нижчий за арифметику й конкатенацію, тож 'x' . $y |> f(...) спершу склеїть рядки, а вже потім передасть їх далі; у сумнівних місцях (особливо поруч із ?? і тернарником) простіше поставити дужки. І окремо: жодного «пропускати null», як у ?->, тут немає — null спокійно піде в наступну ланку і впаде вже на її типі.

clone with закриває найнудніший бойлерплейт незмінних обʼєктів. До 8.5 wither писався як new static($this->a, $this->b, $new, $this->d) — і кожна нова властивість вимагала правки всіх таких методів. Тепер return clone $this with {url: $url}; копіює обʼєкт і присвоює лише перелічені властивості. Важливі деталі: конструктор не викликається, тому валідація з нього не спрацює; копіювання лишається поверхневим, тож вкладені мутабельні обʼєкти й далі спільні і глибоку копію робить __clone(), який викликається вже після присвоєння нових значень; readonly-властивість змінити так можна, але лише з коду, який має право її записувати, тобто зсередини класу-власника — ззовні буде Error: Cannot modify readonly property. Це саме той випадок, коли фічу варто брати одразу: вона не змінює семантику, лише прибирає рядки, які й так були помилконебезпечними.

Атрибут #[\NoDiscard] позначає функцію чи метод, чий результат не можна мовчки викидати; разом із ним у мову додали приведення (void), яким ігнорування оформлюють свідомо. Ефект — E_WARNING, а не виняток, тож це підказка, а не гарантія, і користь від неї є там, де попередження реально видно в логах і CI. Найкращі кандидати — withers незмінних обʼєктів і чисті перетворення, у яких виклик без присвоєння не робить нічого. Розширення URI — інша за вагою зміна: воно приносить Uri\Rfc3986\Uri (сувора граматика RFC 3986, resolve(), equals(), toString()/toRawString()) і Uri\WhatWg\Url (алгоритм браузерів, нормалізація хоста й IDN через getAsciiHost()/getUnicodeHost()). Обидва незмінні, з with*()-методами й без __toString(), а невдалий розбір — це Uri\InvalidUriException / Uri\WhatWg\InvalidUrlException у конструкторі або null зі статичного parse(). Практичний сенс — вибір стандарту стає явним: parse_url() не відповідає жодному з них, лише розрізає рядок і мовчки ковтає значну частину сміття, а розбіжність між вашою перевіркою і тим, куди насправді піде браузер, — класичне джерело SSRF-обходів.

Межі варто назвати чесно. Новий синтаксис — це підвищення мінімальної версії: для застосунку, який ви деплоїте самі, це питання CI й версії на серверах, а для бібліотеки з зовнішніми користувачами |> або clone with у коді означає відсікти всіх, хто ще на 8.3–8.4. Перед першим рядком нового синтаксису переконайтесь, що всі інструменти, які парсять код — статичний аналізатор, Pint/PHP-CS-Fixer, генератори документації, — уже вміють 8.5, бо інакше вони не розберуть файл цілком. І тримайте пропорцію: clone with і розширення URI дають користь одразу і майже без ризику, #[\NoDiscard] — точково на кількох ключових API, а |> варто вводити лише там, де ланцюжок справді складається з унарних функцій; конвеєр із трьох fn поспіль читається гірше, ніж три звичайні змінні з іменами.

use Uri\InvalidUriException;
use Uri\Rfc3986\Uri;

final class Link
{
    public function __construct(
        public readonly string $url,
        public readonly string $host,
    ) {}

    /** clone with: решту властивостей не перелічуємо, readonly пишемо зі scope класу */
    public function withUrl(string $url): static
    {
        return clone $this with {url: $url};
    }
}

// Виклик, чий результат відкинули, дасть E_WARNING саме з цим поясненням
#[\NoDiscard('поверніть нормалізований рядок: сам виклик нічого не змінює')]
function normalize(string $raw): string
{
    // праворуч від |> має стояти ВИРАЗ-callable: trim(...) або замикання
    return $raw
        |> trim(...)
        |> fn (string $u): string => rtrim($u, '/');
}

$raw = '  https://Example.COM/docs/?utm_source=x#top  ';

try {
    // сувора граматика RFC 3986; «як у браузері» — Uri\WhatWg\Url
    $uri = new Uri(normalize($raw));
} catch (InvalidUriException $e) {
    throw new InvalidArgumentException('Некоректне посилання', previous: $e);
}

// null прибирає компонент; __toString() у класу немає — лише явний toString()
$canonical = $uri->withQuery(null)->withFragment(null)->toString();
$link = (new Link($uri->toString(), $uri->getHost() ?? ''))->withUrl($canonical);

normalize($raw);        // Warning: return value should either be used...
(void) normalize($raw); // свідоме ігнорування — попередження зникає
Що праворуч від `|>` стоїть вираз, який має дати callable, тому пишуть `trim(...)`, а не `trim()`; часткового застосування (partial application) у PHP немає, і для функцій із кількома аргументами доводиться писати `fn`.
Що `clone with` — це не глибоке копіювання, а звичайний `clone` із присвоєнням перелічених властивостей, і саме він прибирає бойлерплейт `with*()`-методів у readonly-DTO.
Що `#[\NoDiscard]` дає `E_WARNING`, а не помилку, і що свідоме ігнорування результату пишеться приведенням `(void)`.
Що розширення URI дає два різні набори правил — сувору RFC 3986 і браузерну WHATWG — і що `parse_url()` не відповідає жодному стандарту, тому для перевірки користувацького вводу він поганий вибір.
Що senior-відповідь містить план апгрейду: мінімальна версія в `composer.json`, матриця CI, статичний аналіз і форматер, які вміють парсити новий синтаксис, і вікно підтримки версії.
Що кандидат не вигадує фіч: наприклад, у PHP немає ані оператора `|>` з автоматичним пропуском `null`, ані вбудованого часткового застосування.
Писати `$x |> strtoupper()` замість `$x |> strtoupper(...)`: перший варіант викличе `strtoupper()` без аргументів і спробує використати результат як callable.
Вважати, що конвеєр «зупиняється на null», як `?->`: жодного короткого замикання немає — `null` просто піде в наступну ланку і там впаде на типі.
Пробувати `$array |> sort(...)`: функції з параметром за посиланням у конвеєр не передаються.
Розраховувати, що `clone $order with {status: ...}` спрацює ззовні класу для `readonly`-властивості — доступ на запис перевіряється так само, як при звичайному присвоєнні, і буде `Cannot modify readonly property`.
Думати, що `#[\NoDiscard]` кидає виняток або що ним уже позначені всі «небезпечні» функції стандартної бібліотеки — це opt-in-атрибут насамперед для вашого коду.
Інтерполювати обʼєкт URI в рядок: у нових класів немає `__toString()`, є явні `toString()`/`toRawString()` (і `toAsciiString()`/`toUnicodeString()` у WHATWG-версії).
Називати «новинками 8.5» речі з попередніх релізів — property hooks і асиметричну видимість (8.4), readonly-класи (8.2), `never`/enum (8.1).
ПОРАДА

Побудуйте відповідь як рішення, а не як список: «синтаксично найпомітніші три — `|>`, `clone with`, `#[\NoDiscard]`; найкорисніше для продакшну — розширення URI, бо воно прибирає самописну валідацію посилань поверх `parse_url()`; `clone with` беру одразу у withers, `|>` — точково, бо без часткового застосування він швидко перетворюється на ланцюжок `fn`».

Сторінка питання →
ARC
Архітектура·Senior ·outbox ·dual write ·повідомлення

Outbox pattern прибирає проблему подвійного запису: подію зберігають рядком у таблиці `outbox` тією ж транзакцією, що й зміну стану, а окремий процес-релей уже після коміту читає ці рядки й публікує в брокер. База і подія стають атомарними, ціною чого є доставка at-least-once — споживач зобовʼязаний бути ідемпотентним.

Ми пишемо в базу і одразу публікуємо подію в Kafka. Транзакція відкотилась, а подія вже пішла — як це прибрати?
Чому не можна просто дочекатись коміту й після нього викликати publish()?
Що станеться, якщо воркер упаде між COMMIT і відправленням у брокер?
Ви поклали подію в таблицю outbox — скільки разів її отримає споживач?

Проблема називається dual write: у транзакції ви змінюєте базу, а поряд треба сказати про це решті світу — покласти повідомлення в Kafka, RabbitMQ, SQS чи Redis. Це два різні сховища без спільної транзакції, і жоден порядок дій не рятує. Опублікуєте всередині транзакції — ROLLBACK відкотить рядок, а споживачі вже отримали подію про те, чого не сталося: гроші «списані», лист надісланий, склад зарезервував товар. Опублікуєте після COMMIT — між комітом і publish() лишається проміжок у кілька мілісекунд, і падіння процесу, OOM-kill контейнера чи розрив зʼєднання з брокером саме там означають, що стан змінився, а події не буде ніколи, і ніхто про це не дізнається. Двофазний коміт закрив би питання теоретично, але Kafka, SQS і Redis його не пропонують, а MySQL XA історично болісний у відновленні підвішених гілок — на практиці цей шлях не беруть.

Вихід у тому, щоб зробити атомарним не «база + брокер», а «база + база»: подію записують рядком у таблицю outbox тим самим зʼєднанням і тією самою транзакцією, що й зміну стану. Тепер відкат забирає обидва записи разом, а успішний COMMIT означає, що подія існує як факт, зафіксований на диску. Окремий процес-релей після коміту читає невідправлені рядки, публікує їх у брокер і позначає sent_at. Ключова деталь вибірки — SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL 9.5+, MySQL 8.0+): він дозволяє кільком релеям розбирати різні пачки й не дає двом процесам взяти ту саму сотню рядків. Чого робити не варто — тримати позначку id > :last зовні: значення послідовності видаються до коміту, тому рядок з меншим id може стати видимим пізніше за рядок з більшим, і подія тихо випаде з обробки.

Головне, чого outbox не дає — exactly-once. Релей може опублікувати повідомлення і впасти до UPDATE ... SET sent_at, після рестарту він опублікує його вдруге; сам брокер теж працює в режимі at-least-once. Тому дедуплікація не опція, а друга половина патерна: кожна подія несе message_id, згенерований ще в транзакції запису, а споживач в одній транзакції вставляє цей id у таблицю оброблених повідомлень з унікальним індексом і виконує корисну дію. Порушення унікальності = «вже робили», повідомлення підтверджують і йдуть далі. Це та сама ідея ключа ідемпотентності, що й у платежах, тільки застосована до споживача черги. Порядок теж не глобальний: у межах агрегату його дає ключ партиціювання (aggregate_id як ключ повідомлення в Kafka) або номер версії всередині події, який споживач порівнює з уже застосованим.

У payload кладуть уже серіалізований стан на момент транзакції, а не лише ідентифікатор. Різниця принципова: релей, який за id піде читати поточний рядок, віддасть стан на момент читання — після ще двох змін, а подія має описувати факт, що стався. Тонка подія з самим id теж має право на життя (персональні дані, великі документи), але тоді треба свідомо визнати, що споживач працює зі свіжішим станом, і закласти версіонування. Поряд з payload у рядку тримають тип події, ключ агрегату, occurred_at і версію схеми — цього достатньо, щоб через рік додати новий формат, не ламаючи старих споживачів.

У PHP-стеку є приємна деталь: те, що часто описують як «треба зробити outbox», у багатьох проєктах уже стоїть. Черга Laravel на драйвері database пише завдання в таблицю jobs, а Doctrine-транспорт Symfony Messenger (doctrine://default) — у messenger_messages. Якщо це те саме зʼєднання і диспетч відбувається всередині вашої транзакції, ви вже отримали транзакційний outbox без єдиного власного класу, а воркер грає роль релея. Плутати з цим dispatch()->afterCommit() (чи after_commit => true) і DispatchAfterCurrentBusMiddleware не можна: вони лише відкладають відправлення за коміт, лікуючи «job не бачить щойно створеної моделі», і вікно втрати між COMMIT і publish у них залишається.

Ціна патерна — затримка й експлуатація. Подія доходить не миттєво, а за час циклу релея, і це треба закласти в UX там, де користувач очікує реакції одразу. Таблиця росте: рядки або видаляють одразу після публікації, або позначають і чистять пакетно за розкладом, і в PostgreSQL частковий індекс WHERE sent_at IS NULL тут майже обовʼязковий, бо інакше індекс тягне за собою весь архів, а постійні UPDATE+DELETE роздувають таблицю. Обовʼязковий і моніторинг: алерт на вік найстарішого невідправленого рядка й на розмір черги — застряглий релей інакше виявляють через скаргу «клієнту не прийшов лист», коли минуло вже пів дня. Коли polling впирається в межу або систем-джерел стає багато, наступний крок — CDC: Debezium читає WAL/binlog і публікує рядки outbox без запитів до бази, ціною Kafka Connect, слотів реплікації та ще одного сервісу, за яким треба стежити.

// 1. Запис стану і події — одна транзакція, одне зʼєднання. Брокера тут немає.
DB::transaction(function () use ($order): void {
    $order->markPaid();
    $order->save();

    DB::table('outbox')->insert([
        'message_id'   => (string) Str::ulid(), // ключ дедуплікації для споживача
        'aggregate_id' => $order->id,           // ключ партиції: порядок у межах замовлення
        'type'         => 'order.paid',
        'payload'      => json_encode([        // стан НА момент транзакції, не id
            'order_id' => $order->id,
            'amount'   => $order->amount_cents,
            'currency' => $order->currency,
        ]),
        'occurred_at'  => now(),
        'sent_at'      => null,
    ]);
}); // ROLLBACK відкотить і зміну, і подію — фантомних подій не буває

// 2. Релей: окремий процес, крутиться в циклі. Публікує вже після коміту.
final readonly class OutboxRelay
{
    public function __construct(private Publisher $broker) {}

    public function drainBatch(int $limit = 100): int
    {
        return DB::transaction(function () use ($limit): int {
            // SKIP LOCKED: кілька релеїв беруть різні пачки (PostgreSQL 9.5+, MySQL 8.0+)
            $rows = DB::table('outbox')
                ->whereNull('sent_at')
                ->orderBy('id')
                ->limit($limit)
                ->lockForUpdate()->skipLocked()
                ->get();

            foreach ($rows as $row) {
                // Падіння тут -> рядок лишиться невідправленим -> повтор.
                // Падіння після publish, але до update -> дубль. Звідси at-least-once.
                $this->broker->publish($row->type, $row->aggregate_id, $row->payload, $row->message_id);
                DB::table('outbox')->where('id', $row->id)->update(['sent_at' => now()]);
            }

            return $rows->count();
        });
    }
}
Чітке формулювання проблеми: база й брокер — два різні сховища без спільної транзакції, тому будь-який порядок «запис + publish» ламається на падінні між ними.
Що outbox не робить доставку exactly-once: він гарантує «подія не загубиться і не зʼявиться без коміту», але дублі неминучі, бо релей може впасти між publish і позначкою sent.
Що релей має брати рядки через `SELECT ... FOR UPDATE SKIP LOCKED` (MySQL 8.0+, PostgreSQL 9.5+), а не через високу водяну позначку по `id` — інакше на PostgreSQL він пропускатиме рядки з меншим id, закомічені пізніше.
Що в outbox кладуть уже серіалізований payload на момент транзакції, а не лише id сутності: релей, який читає поточний стан, віддасть стан пізнішого часу і зруйнує сенс події.
Що на боці споживача пару виду outbox/inbox замикає таблиця оброблених message_id, а не «сподіваємось, брокер не задублює».
Що черга на базі (`messenger_messages` у Symfony Messenger, `jobs` у Laravel) на тому ж зʼєднанні й у тій же транзакції — це вже outbox, і окрему таблицю вигадувати не треба.
Плутати outbox з `dispatch()->afterCommit()` у Laravel чи `DispatchAfterCurrentBusMiddleware` у Symfony: вони лише зсувають відправлення за коміт, але між COMMIT і `publish()` процес усе одно може впасти, і подія зникне назавжди.
Публікувати в брокер усередині транзакції: якщо далі буде ROLLBACK, споживачі вже отримали подію про те, чого не сталося, і компенсувати це нічим.
Обіцяти exactly-once і не робити дедуплікацію на споживачі: дублі — нормальний режим роботи outbox, а не аварія.
Тягнути XA / двофазний коміт: Kafka, Redis, SQS його не підтримують у потрібному вигляді, а MySQL XA історично має проблеми з відновленням підвішених гілок; ціна не окупається.
Забирати рядки простим `SELECT ... LIMIT 100` без блокування і запускати два релеї: обидва прочитають ту саму сотню і опублікують усе двічі без потреби.
Не чистити таблицю: outbox росте вічно, а в PostgreSQL постійні UPDATE+DELETE ще й дають роздування таблиці й тиск на autovacuum.
ПОРАДА

Скажіть одним реченням, звідки проблема: «база й брокер — два сховища без спільної транзакції, тож атомарним може бути лише запис у ту саму базу». І одразу назвіть ціну: at-least-once, тому дедуплікація на споживачі — частина патерна, а не окрема опція.

Сторінка питання →
OPS
DevOps·Senior ·масштабування ·stateless ·сесії

Треба винести назовні весь стан, який зараз лежить на локальному диску чи в памʼяті процесу: сесії — у Redis/БД, завантажені файли — в обʼєктне сховище, кеш і локи — у спільний Redis, черги й планувальник — в окремі процеси з дедуплікацією; липкі сесії це не рішення, а спосіб не помітити проблему.

Додали другий вузол за балансувальником — і користувачів почало кожен другий запит викидати з логіну. Чому?
Ми ввімкнули липкі сесії на балансувальнику, і все запрацювало. Що з цим рішенням не так?
Куди дівати файли, які завантажують користувачі, коли серверів більше ніж один?
Що саме у вашому застосунку заважає прямо зараз запустити три копії контейнера замість однієї?

Сам PHP-процес між запитами стану не тримає — після кожного запиту він усе забуває, і в цьому сенсі мова масштабується краще за більшість інших. Проблема в тому, що застосунок роками писався в припущенні «диск і памʼять у нас одні на всіх», і стан осідає в кількох конкретних місцях: у каталозі сесій, у завантажених файлах, у кеші й локах, у чергах і cron, у логах. Питання, яке треба поставити до кожного з них, одне: що станеться, якщо наступний запит того самого користувача прийде на інший вузол, а цей вузол просто зникне. Все, що на це питання відповідає «зламається», і є список робіт.

Найпершим стріляє сесія. Дефолтний session.save_handler=files кладе серіалізований масив у session.save_path, тобто в локальну ФС конкретного вузла; балансувальник розкидає запити, і користувач через раз бачить форму логіну. Спільним сховищем робить або сам PHP (session.save_handler=redis з session.save_path="tcp://host:6379?database=2" від phpredis, або memcached), або фреймворк: у Laravel SESSION_DRIVER=redis|database (з 11-ї версії дефолт скелета — database), у Symfony — framework.session.handler_id з RedisSessionHandler. Тут є дві деталі, на яких валяться. Перша: нативний files тримає flock на файлі до кінця запиту, тому паралельні AJAX-запити одного користувача виконуються послідовно; у phpredis це поведінка вимикається за замовчуванням і вмикається redis.session.locking_enabled=1, а власна сесія Laravel не блокує нічого взагалі — там останній запис перемагає. Друга: сховище спільне, а ключі мають бути однакові, бо cookie з ідентифікатором сесії зашифрована APP_KEY (у Symfony APP_SECRET підписує signed URI та remember-me); згенерований під час деплою ключ дає вузол, який не може прочитати чужу cookie.

Далі файли й кеш. Завантаження користувачів переїжджають у обʼєктне сховище — S3 чи сумісне (MinIO, DigitalOcean Spaces) через диск s3 і league/flysystem-aws-s3-v3; storage:link і public/storage при цьому втрачають сенс, приватні файли віддаються через Storage::temporaryUrl(). З кешем тонше: opcache і зібрані артефакти (config:cache, route:cache, view:cache) — це похідні від коду, вони мають бути на кожному вузлі й будуються під час збірки образу, це нормально. А от кеш даних локальним бути не може: APCu живе в межах процесу FPM, файловий — у межах вузла, і скидання ключа на вузлі A залишає вузол B зі старими даними. Разом із кешем переїжджає все, що на ньому побудоване: Cache::lock(), RateLimiter, мітка queue:restart, локи withoutOverlapping() і onOneServer(). Найнеприємніше, що вони не падають — вони просто перестають бути глобальними гарантіями й стають локальними.

Черги й планувальник із «ще одного процесу на вебсервері» стають окремою одиницею розгортання. Воркер не потребує nginx і масштабується власним темпом, зате потребує тієї самої черги (redis, SQS, database) і коректного завершення: на SIGTERM queue:work дороблює поточну задачу й виходить, тому terminationGracePeriodSeconds має бути більшим за найдовший job, інакше задача обірветься посередині. schedule:run навпаки має спрацьовувати рівно один раз: або окремий CronJob в оркестраторі, або ->onOneServer() на кожній задачі з обовʼязково спільним лок-стором. У контейнері до цього додається загальне правило: писати можна лише в змонтовані томи й у зовнішні сервіси, бо шар запису викидається разом із контейнером — тому логи йдуть у stderr (LOG_CHANNEL=stderr) і збираються збирачем, а не ротуються всередині. І окремо: за балансувальником треба оголосити довірені проксі (trustProxies у bootstrap/app.php, framework.trusted_proxies у Symfony), інакше X-Forwarded-For і X-Forwarded-Proto ігноруються, у логах усі запити приходять з однієї адреси, а згенеровані посилання стають http://.

Липкі сесії (ip_hash у nginx, cookie ... insert у HAProxy, стікі target group в ALB) варто вміти назвати запахом і пояснити чому. Вони не усувають локальний стан, а лише роблять його непомітним: навантаження розподіляється нерівномірно (один NAT — один вузол), автоскейлінг перестає допомагати, бо нові вузли не забирають наявні сесії, виведення вузла на деплой означає розлогінених користувачів, а падіння — втрачені кошики. Плюс тестування деградує: помилка «стан не спільний» проявиться не одразу, а через півроку під час першого ж інциденту. Легітимні винятки є — WebSocket і SSE справді привʼязані до процесу, і липкість тут не милиця, а вимога; так само вона нормальна як тимчасовий місток на час міграції сесій.

Межа підходу в тому, що абсолютно stateless застосунків не буває — стан не зникає, а переїжджає, і те, що переїхало, стає новою точкою відмови. Redis із сесіями, кешем і чергами покладе весь кластер швидше, ніж це зробив би один вебвузол, тож за ним іде реплікація чи Sentinel, і окреме питання — чи можна пережити його недоступність (сесії — ні, кеш — так, якщо код готовий до промаху). Другий компроміс — латентність: те, що було читанням з локального диска чи з APCu за мікросекунди, стає мережевим викликом, тому гарячі незмінні дані іноді свідомо лишають у локальному APCu з коротким TTL, приймаючи розузгодженість у кілька секунд. Третій — вартість переходу: якщо застосунок писався десять років з session_start() і move_uploaded_file() у public/, чесна відповідь на співбесіді включає етапність — спершу сесії й ключі, потім файли, потім кеш і локи, і лише в кінці вимкнення липкості як фінальна перевірка, що стану на вузлах більше немає.

# ── /etc/php/8.4/fpm/conf.d/90-session.ini ────────────────────────────
# Було (типовий одиничний сервер): сесія — файл у локальній ФС вузла.
#   session.save_handler = files
#   session.save_path    = "/var/lib/php/sessions"
# Стало: спільне сховище через розширення redis (phpredis 6.x).
session.save_handler = redis
session.save_path    = "tcp://redis.internal:6379?database=2"
redis.session.locking_enabled = 1   # відтворює блокування, яке давав files
redis.session.lock_expire     = 30  # щоб зависла вимога не тримала сесію вічно
session.gc_maxlifetime        = 7200 # для redis це TTL ключа, GC не потрібен

# ── .env (Laravel 12) — увесь локальний стан переїжджає у сервіси ─────
SESSION_DRIVER=redis        # було file → storage/framework/sessions
CACHE_STORE=redis           # було file; APCu теж живе в межах процесу
QUEUE_CONNECTION=redis      # було sync або database «бо один сервер»
FILESYSTEM_DISK=s3          # було local → storage/app/public
LOG_CHANNEL=stderr          # логи в stdout/stderr, не у файл контейнера
APP_KEY=base64:...          # ОДИН на всі вузли, інакше cookie не розшифрувати

# ── Перевірка, що стан справді спільний, а не «здається спільним» ────
# на вузлі A:
php artisan tinker --execute 'Cache::put("probe", gethostname(), 60);'
# на вузлі B — має вивести імʼя вузла A, а не null:
php artisan tinker --execute 'echo Cache::get("probe") ?? "null";'

# ── Планувальник: cron на всіх вузлах, дедуплікація в коді ───────────
# * * * * * php /app/artisan schedule:run >> /dev/null 2>&1
# Schedule::command('reports:send')->daily()->onOneServer();
#   ↑ потрібен redis/memcached/database/dynamodb: файлові локи локальні,
#     винятку не буде, а звіт піде стільки разів, скільки вузлів.

# ── Запах, а не рішення: липкі сесії на балансувальнику ──────────────
# nginx:   upstream app { ip_hash; server web1; server web2; }
# haproxy: cookie SRVID insert indirect nocache
# Якщо після їх вимкнення застосунок ламається — стан досі локальний.
Перелік місць, де ховається стан: `session.save_path`, завантажені файли, кеш і локи, черги й cron, логи — і що кожне з них має окреме рішення.
Що сесія за замовчуванням це `session.save_handler=files` у локальній ФС вузла, а спільною вона стає через `session.save_handler=redis` з `session.save_path="tcp://..."` або через драйвер застосунку (`SESSION_DRIVER=redis|database`).
Що спільним має бути не лише сховище, а й ключі: один `APP_KEY` (Laravel) чи `APP_SECRET` (Symfony) на всі вузли, інакше вузол B не розшифрує cookie, видану вузлом A.
Розуміння, що APCu і файловий кеш живуть у межах процесу чи вузла, тому `Cache::lock()`, rate limiter, `queue:restart` і `->onOneServer()` без спільного стора мовчки перестають гарантувати те, що обіцяють.
Що липкі сесії — тимчасовий милиць: вони ламають рівномірність навантаження, автоскейлінг і виведення вузла з ротації, а падіння вузла все одно втрачає сесії.
Що планувальник і воркери — це окремі процеси, а не щось, що запускається на кожному вебвузлі «за компанію».
Змонтувати NFS у `session.save_path` і вважати задачу закритою: додається мережева затримка на кожен запит і блокування файлу сесії через `flock` по мережі.
Вирішити проблему липкими сесіями (`ip_hash` у nginx, `cookie SRVID insert` у HAProxy) і залишити стан локальним — після перезапуску вузла всі його користувачі розлогінені.
Генерувати `php artisan key:generate` під час деплою або в `Dockerfile`: у кожного вузла (чи в кожній збірці) свій `APP_KEY`, і сесії з cookie перестають розшифровуватись.
Лишити `FILESYSTEM_DISK=local` і `storage:link`: аватарка, завантажена на вузол A, дає 404 при відкритті з вузла B.
Запускати `schedule:run` з cron на кожному вузлі без `->onOneServer()` — щоденний звіт розсилається стільки разів, скільки вузлів.
Забути про довірені проксі: без `trustProxies` (Laravel) чи `framework.trusted_proxies` (Symfony) `$request->ip()` повертає адресу балансувальника, а `url()` генерує `http://` за HTTPS-балансувальником.
ПОРАДА

Скажіть так: «Горизонтальне масштабування — це не про сервери, а про пошук стану. Я проходжу по списку: сесія, файли, кеш і локи, черги, cron, логи — і для кожного питаю, що станеться, якщо наступний запит прийде на інший вузол, а цей вузол зараз зникне». Далі окремо додайте, що липкі сесії — це індикатор незакритого пункту зі списку, а не рішення.

Сторінка питання →
OPS
DevOps·Senior ·міграції ·zero-downtime ·деплой

Схема змінюється у фази expand і contract: спершу зміни, сумісні зі старим кодом, потім деплой коду, який працює з обома варіантами, і лише після повного викочування прибирання старого.

Як перейменувати колонку без простою?
Чому не можна додавати NOT NULL без default на великій таблиці?
Що таке expand and contract?

Під час zero-downtime деплою стара й нова версії коду певний час працюють одночасно з однією базою. Тому кожна міграція має бути сумісна з попереднім кодом, а кожна версія коду з попередньою і наступною схемою. Цей принцип називають expand and contract: спершу розширення схеми, сумісне з усім, потім переключення коду, і лише після повного викочування прибирання старого.

Безпечні операції: додати nullable-колонку, нову таблицю, індекс через CONCURRENTLY. Небезпечні: видалення, перейменування, зміна типу, NOT NULL на існуючій колонці. Небезпечні операції розкладаються на кілька релізів. Перейменування колонки займає чотири: додати нову, писати в обидві й читати стару, читати нову й писати в обидві, писати лише в нову й видалити стару. Між другим і третім кроком іде backfill батчами по первинному ключу, щоб не тримати довге блокування й не роздувати WAL.

Окрема категорія — DDL, який блокує таблицю. У PostgreSQL додавання NOT NULL сканує таблицю під блокуванням; обхід через CHECK ... NOT VALID, VALIDATE CONSTRAINT без блокування запису і потім SET NOT NULL. Індекс без CONCURRENTLY зупиняє запис на час побудови. Будь-який DDL запускається з lock_timeout, щоб не чекати блокування вічно й не ставити в чергу всі запити за собою.

Міграції виконуються один раз, окремим кроком до перемикання трафіку, а не в entrypoint кожної репліки, і мають план відкату. Успіх міграції є умовою розкатки нового образу.

-- Задача: перейменувати users.name у users.full_name без простою

-- Реліз 1 (expand): нова nullable-колонка, миттєво
ALTER TABLE users ADD COLUMN full_name text;

-- Реліз 2: код пише в обидві колонки, читає стару. Backfill батчами у фоні
UPDATE users SET full_name = name
WHERE id BETWEEN 1 AND 10000 AND full_name IS NULL;
-- ... наступні діапазони з паузою

-- Реліз 3: код читає full_name. NOT NULL без довгого блокування (PostgreSQL)
ALTER TABLE users ADD CONSTRAINT users_full_name_nn CHECK (full_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_full_name_nn;   -- сканує без блокування запису
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;      -- PG 12+ використає CHECK, без скану
ALTER TABLE users DROP CONSTRAINT users_full_name_nn;

-- Індекс без блокування запису
CREATE INDEX CONCURRENTLY users_full_name_idx ON users (full_name);

-- Реліз 4 (contract): код більше не торкається name
ALTER TABLE users DROP COLUMN name;

-- Запобіжник для будь-якого DDL: не чекати блокування вічно
SET lock_timeout = '3s';
SET statement_timeout = '30s';
Назву патерну expand and contract і розуміння, що під час деплою одночасно працюють стара й нова версія коду з однією базою.
Що кожна міграція має бути сумісна з попередньою версією коду: додавання nullable-колонки або нової таблиці безпечне, видалення й перейменування ні.
Що перейменування робиться в кілька релізів: нова колонка, подвійний запис, backfill батчами, перемикання читання, видалення старої.
Що деякі DDL блокують таблицю або переписують її: ALTER з NOT NULL без default, зміна типу, індекс без CONCURRENTLY у PostgreSQL, і як це обійти.
Що міграції запускаються один раз до перемикання трафіку, окремим кроком із lock_timeout, а не всередині кожного контейнера на старті.
Видаляти або перейменовувати колонку в тому ж релізі, що й код: під час перекриття версій одна з них падає.
Додавати NOT NULL колонку без default на таблиці з мільйонами рядків і ловити повне переписування або блокування.
Створювати індекс без CONCURRENTLY у PostgreSQL і блокувати запис на хвилини.
Робити backfill одним UPDATE на всю таблицю замість батчів і тримати блокування й WAL.
Запускати migrate у entrypoint кожного контейнера й отримувати гонку між репліками.
ПОРАДА

Ключове слово — expand and contract. Назвіть його, опишіть обидві фази і наведіть приклад з перейменуванням колонки на чотири релізи.

Сторінка питання →
SQL
SQL·Senior ·deadlock ·блокування ·InnoDB

Deadlock — це цикл очікувань блокувань: база сама його виявляє й відкочує одну транзакцію (MySQL 1213/40001, PostgreSQL 40P01), тому застосунок зобовʼязаний повторити її, а зменшують частоту єдиним порядком блокування, короткими транзакціями та індексами під UPDATE.

Раз на добу в логах «Deadlock found when trying to get lock; try restarting transaction» — що робити?
Чим deadlock відрізняється від lock wait timeout?
Два запити оновлюють різні рядки — звідки взагалі взявся deadlock?
Чи можна позбутися deadlock-ів повністю, а не ретраїти їх?

Deadlock виникає, коли транзакція A тримає блокування, потрібне B, а B тримає блокування, потрібне A: у графі очікувань зʼявляється цикл, який не розірветься сам ніколи. Тому бази не чекають, а виявляють його. InnoDB перевіряє граф щоразу, коли транзакція стає в чергу за блокуванням (innodb_deadlock_detect увімкнено за замовчуванням), і відкочує жертву — транзакцію, яка змінила найменше рядків, тобто найдешевшу для скасування. PostgreSQL робить це ліниво: сесія, яка прочекала довше за deadlock_timeout (1 секунда), запускає перевірку, і якщо цикл знайдено, саме вона отримує помилку 40P01 deadlock detected із DETAIL про те, який процес на що чекав.

Це принципово інша подія, ніж lock wait timeout, і плутанина тут коштує найдорожче. Lock wait timeout exceeded (MySQL 1205) означає лінійне очікування без циклу, яке впʼялося в innodb_lock_wait_timeout — 50 секунд за замовчуванням; при дефолтному innodb_rollback_on_timeout=OFF відкочується лише останній оператор, і транзакція лишається відкритою з половиною змін, тож застосунок мусить явно зробити ROLLBACK. Deadlock натомість відкочує транзакцію-жертву повністю: повторювати треба весь блок від BEGIN, а не оператор, що впав. Звідси вимога до архітектури — блок транзакції має бути ідемпотентним, а листи, платіжні виклики та HTTP до зовнішніх систем живуть за межами COMMIT.

Друга поширена хиба — вважати, що без явного FOR UPDATE deadlock неможливий. Блокування бере кожен UPDATE, DELETE та INSERT, і причини найчастіше буденні: два сценарії оновлюють ті самі рядки в різному порядку; пакетний upsert з несортованим набором ключів; INSERT, який на конфлікті унікального індексу спершу бере shared-лок на існуючому записі; вставка в дочірню таблицю, що тримає лок на батьківському рядку за зовнішнім ключем. Окремо варто памʼятати, що блокування беруться по індексних записах: UPDATE ... WHERE status = 'pending' без індексу на status заблокує все, що просканував, а в MySQL на REPEATABLE READ ще й проміжки між записами через gap- і next-key-локи.

Діагностика зводиться до трьох кроків. У MySQL: SHOW ENGINE INNODB STATUS показує секцію LATEST DETECTED DEADLOCK з обома операторами, іменами індексів і режимами блокувань — але лише останній випадок, тому на проді вмикають innodb_print_all_deadlocks, і всі події падають в error log; поточні очікування видно в performance_schema.data_lock_waits і data_locks. У PostgreSQL текст помилки вже містить обидва процеси, а живу картину дають pg_blocking_pids() разом із pg_stat_activity, плюс log_lock_waits для очікувань довших за deadlock_timeout. Мета читання — знайти два місця в коді, які беруть ті самі обʼєкти в різному порядку.

Профілактика працює в такому порядку. Єдиний порядок блокування (найпростіше — завжди за зростанням первинного ключа: ORDER BY id ... FOR UPDATE, сортований і дедуплікований масив у пакетних операціях). Коротші транзакції: усе, що можна порахувати до BEGIN, рахується до нього, мережеві виклики виносяться назовні. Індекси під умови UPDATE, щоб площа блокування збігалася з набором рядків, які справді змінюються. Там, де вистачає одного оператора, блокування не потрібне взагалі — UPDATE ... WHERE balance >= :amount із перевіркою affected rows атомарний за визначенням, а для черг є FOR UPDATE SKIP LOCKED. І межа чесності: повністю прибрати deadlock-и в конкурентному застосунку неможливо, тому ретрай із невеликим backoff лишається обовʼязковим — правильна ціль не «нуль deadlock-ів», а рідкісні й непомітні для користувача.

-- Класичний цикл: сесії беруть ті самі рядки у зворотному порядку
-- Сесія A                                   | Сесія B
BEGIN;                                    -- | BEGIN;
UPDATE accounts SET balance = balance - 10
  WHERE id = 1;   -- X-лок на рядку 1     -- | UPDATE ... WHERE id = 2;  -- X-лок на 2
UPDATE accounts SET balance = balance + 10
  WHERE id = 2;   -- чекає на B           -- | UPDATE ... WHERE id = 1;  -- чекає на A
-- MySQL: ERROR 1213 (40001) Deadlock found; вся транзакція-жертва відкочена
-- PostgreSQL: ERROR 40P01 deadlock detected (детектор спрацював за deadlock_timeout)

-- Виправлення: єдиний порядок блокування в усіх сценаріях
BEGIN;
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;  -- завжди за зростанням
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;

-- Діагностика MySQL 8.0
SHOW ENGINE INNODB STATUS;                    -- секція LATEST DETECTED DEADLOCK, лише останній
SET GLOBAL innodb_print_all_deadlocks = ON;   -- усі випадки — в error log
SELECT * FROM performance_schema.data_lock_waits;  -- хто кого чекає прямо зараз

-- Діагностика PostgreSQL
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type, left(query, 60)
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- ALTER SYSTEM SET log_lock_waits = on;  -- очікування довші за deadlock_timeout (1 с) — у лог

-- Часто блокувань узагалі не треба: один атомарний оператор замість read-modify-write
UPDATE accounts SET balance = balance - 10 WHERE id = 1 AND balance >= 10;
Що deadlock — це виявлений цикл у графі очікувань, а не таймаут: InnoDB перевіряє граф одразу і відкочує транзакцію, яка змінила менше рядків, PostgreSQL запускає детектор після deadlock_timeout (1 с) і вбиває ту сесію, чиє очікування спричинило перевірку.
Різницю з lock wait timeout: у MySQL deadlock (1213) відкочує всю транзакцію-жертву, а «Lock wait timeout exceeded» (1205) при дефолтному innodb_rollback_on_timeout=OFF відкочує лише оператор, лишаючи транзакцію відкритою з половиною змін.
Що deadlock можливий без жодного FOR UPDATE: звичайні UPDATE, INSERT з унікальним ключем, ON DUPLICATE KEY UPDATE, вставка в дочірню таблицю з FK і навіть один пакетний UPDATE конфліктують блокуваннями.
Конкретні інструменти діагностики: SHOW ENGINE INNODB STATUS і innodb_print_all_deadlocks, performance_schema.data_lock_waits; у PostgreSQL pg_blocking_pids(), pg_locks, log_lock_waits і DETAIL у тексті помилки.
Що блокування беруться по індексних записах: UPDATE без придатного індексу блокує все, що просканував, тому індекс — це не лише швидкість, а й менша площа конфлікту.
Плутати deadlock із lock wait timeout і «лікувати» його підняттям innodb_lock_wait_timeout: цикл не розсмокчеться за жодний час очікування.
Вважати deadlock багом бази або наслідком неправильного рівня ізоляції й шукати рішення в SERIALIZABLE, який навпаки додає конфліктів.
Ретраїти транзакцію, всередині якої є Mail::send або запит до платіжного шлюзу: після повтору клієнт отримає два листи і два списання.
Ловити 40001 і повторювати лише останній оператор: транзакція-жертва вже відкочена цілком, повторювати треба її всю з самого BEGIN.
Робити пакетний UPDATE або upsert з масиву id у довільному порядку: два воркери з перетинними наборами блокують рядки в різній послідовності й регулярно зустрічаються в циклі.
Дивитись лише SHOW ENGINE INNODB STATUS і робити висновок про частоту: там зберігається тільки останній deadlock, без innodb_print_all_deadlocks історії немає.
ПОРАДА

Скажіть, що deadlock — штатна подія розподіленого доступу, а не аварія: правильна архітектура це «повторюваний блок транзакції + єдиний порядок блокування». І одразу назвіть різницю 1213 і 1205 — вона показує, що ви бачили ці помилки в проді, а не читали про них.

Сторінка питання →
LR
Laravel·Senior ·профілювання ·Telescope ·Debugbar

Спершу розкласти час: браузерний waterfall відділяє TTFB від фронтенду, а далі Debugbar/Clockwork/Telescope локально й Blackfire або Pulse у проді показують, куди йдуть мілісекунди всередині PHP. Оптимізують за спаданням внеску: кількість запитів (N+1) → важкі запити з `EXPLAIN` → синхронні зовнішні виклики в чергу → гігієна деплою (`opcache`, `php artisan optimize`, `composer -o`), і лише потім кеш і Octane.

Сторінка відкривається 4 секунди. З чого почнете і чим будете міряти?
Профайлер показує, що 70% часу — в `PDO::execute`. Що це означає і які наступні кроки?
Debugbar каже 1.8 с, а користувач скаржиться на 8 с. Хто з них бреше?
Ми зробили `php artisan optimize` і винесли розсилку в чергу — сторінка все одно повільна. Чому такий порядок дій був неправильний?

Перш ніж називати інструменти, треба відповісти на питання «повільно де». Час до появи сторінки складається щонайменше з пʼяти шматків: мережа й TLS, час до першого байта (тобто робота PHP), завантаження статики, виконання JavaScript у браузері й запити, які цей JavaScript робить далі. Перший крок — вкладка Network у DevTools: якщо TTFB документа 180 мс, а сторінка «думає» 6 секунд, оптимізовувати Laravel безглуздо, і чесна відповідь на співбесіді починається саме з цього розмежування. Якщо ж повільний TTFB, час усередині PHP розкладається на завантаження фреймворку (автозавантаження, реєстрація провайдерів, читання конфігу), запити до БД, синхронні виклики зовнішніх сервісів, рендер шаблонів і серіалізацію. Дешевий спосіб зробити цю декомпозицію видимою навіть без профайлера — заголовок Server-Timing із кількома мітками або власне логування, як у прикладі коду: пара чисел total_ms, db_ms, queries майже завжди одразу каже, куди дивитись.

Інструменти відрізняються не «зручністю», а тим, що вони бачать і скільки коштують. Laravel Debugbar (barryvdh/laravel-debugbar) — це панель, що вбудовується в HTML-відповідь: він показує список запитів із часом і дублікатами, таймлайн, вʼюхи, події й памʼять. Це dev-інструмент: для JSON-API він не працює звичним чином, у проді його не вмикають, а його власні накладні витрати помітні, тож абсолютні числа з нього — орієнтир, а не вимір. Clockwork (itsgoingd/clockwork) збирає ті самі дані, але віддає їх не в HTML, а окремим ендпоїнтом і заголовками, тому працює для API, artisan-команд і job у чергах — там, де Debugbar сліпий. Telescope — це вже не панель, а журнал: watchers пишуть запити, HTTP-запити, кеш, пошту, job і винятки в таблиці telescope_entries, і саме тому він однаково корисний для розслідування «що сталося о 14:07» і небезпечний як постійно ввімкнена штука на продакшені. Laravel Pulse — протилежний за дизайном інструмент: він семплить і агрегує, показуючи повільні запити, повільні маршрути, повільні job і споживачів ресурсів, тобто відповідає на питання «що болить загалом», а не «що сталося в цьому конкретному запиті».

Коли треба спуститися нижче рівня «який запит повільний» і зрозуміти, який саме PHP-код зʼїдає час, беруть справжній профайлер. Xdebug 3 із xdebug.mode=profile і xdebug.start_with_request=trigger (запуск через XDEBUG_TRIGGER=PROFILE, щоб не профілювати все підряд) пише cachegrind-файли в xdebug.output_dir, які читають у KCachegrind, Webgrind або PhpStorm; звідти видно дерево викликів, кількість викликів і власний час функцій. Ціна — сповільнення в рази й спотворення пропорцій: очікування на БД і мережу лишається таким, як було, а PHP-частина роздувається, тому за профілем Xdebug легко «оптимізувати» те, що в проді не важить нічого. Blackfire і Tideways зроблені саме для того, щоб цього уникнути: вони інструментують вибірково, дають розбивку по wall time, CPU, I/O, памʼяті й кількості SQL-запитів та HTTP-викликів, працюють на продакшені й уміють blackfire run для CLI та assertions у CI («ця сторінка не має робити більше N запитів»). Окремо варто знати про семплінг-профайлери (php-spx, excimer) — вони дешеві настільки, що їх тримають увімкненими постійно. І базова гігієна вимірювання: якщо у вас локально завантажений Xdebug, увімкнений Debugbar і APP_DEBUG=true з логуванням запитів — ваші числа не мають нічого спільного з продом.

Тепер про порядок оптимізацій, бо саме за нього ставлять плюс. Оптимізують за спаданням внеску в загальний час, і на реальних Laravel-проєктах рейтинг майже завжди такий. Перше — кількість запитів до БД: сторінка на 200–2000 запитів — це N+1, і тут виграш вимірюється не відсотками, а порядками (механіка й лікування — в окремій картці про N+1). Друге — окремі важкі запити: коли запитів десяток, але один іде 900 мс, далі говорить EXPLAIN/EXPLAIN ANALYZE, індекси, порядок колонок у складеному індексі, глибокий OFFSET і COUNT(*) заради пагінації. Третє — синхронні зовнішні виклики: відправка листа, звернення до платіжки, генерація PDF чи ресайз усередині HTTP-запиту роблять час відповіді заручником чужого сервера; це виносять у чергу (ShouldQueue), а дрібне — у dispatch(...)->afterResponse(). Четверте — гігієна деплою, яку часто пропускають, хоч вона одноразова: composer install --no-dev --optimize-autoloader (за можливості --classmap-authoritative), php artisan optimize (кеш конфігу, роутів, подій і вʼюх), APP_DEBUG=false, кеш і сесії не на файлах, а в Redis. Останнє звучить дрібно, але файловий драйвер сесій блокує файл на час запиту, і паралельні запити одного користувача шикуються в чергу — типовий «повільний дашборд», де кожен віджет чекає на попередній.

Окремо — OPcache, бо це єдина інфраструктурна річ, яку варто вміти перевіряти руками. Без нього PHP компілює тисячі файлів на кожен запит; з ним — читає готовий байткод із розділюваної памʼяті. Але «увімкнений» не означає «налаштований»: дефолтний opcache.max_accelerated_files=10000 для Laravel із залежностями замалий, і переповнений кеш дає плаваючу деградацію, яку в профайлері видно як розмазаний час компіляції. Дивляться на opcache_get_status(): num_cached_keys проти max_cached_keys, wasted_percentage, hit rate. У проді opcache.validate_timestamps=0 прибирає перевірку часу зміни файлів, але деплой зобовʼязаний перезапускати php-fpm; opcache.jit для типового веб-навантаження дає близько нуля, бо код чекає на БД, а не рахує; preload має практичний сенс разом із FrankenPHP або Octane. І тут же межа: php artisan config:cache після себе вимагає, щоб env() не викликався ніде, крім config/, а route:cache не переживе замикань у роутах — обидві помилки зазвичай виявляються на проді й виглядають як зламана функціональність, а не як проблема продуктивності.

І про компроміси, які варто назвати вголос. Кеш (Cache::remember, кеш фрагментів, HTTP-кеш) — потужний, але це не діагностика: він ховає вартість, додає інвалідацію й перетворює деградацію на рідкісну та непередбачувану (перший запит після скидання, cache stampede на популярному ключі). Octane прибирає bootstrap із кожного запиту, але приносить свій клас багів зі станом між запитами, і братися за нього до того, як прибрано N+1, — це прискорювати те, що займає 5% часу. Черга не робить нічого швидшим — вона переносить роботу й додає вам стан «в обробці», ідемпотентність і моніторинг воркерів. Нарешті, метрика: орієнтуватися треба на p95/p99, а не на середнє, і міряти на реальному обсязі даних, бо сторінка, що літає на 100 рядках, помирає на 100 тисячах через відсутній індекс. Дисципліна проста й саме вона відрізняє senior-відповідь: одна зміна — один повторний вимір, інакше через тиждень ніхто не скаже, що саме допомогло, і чи допомогло взагалі.

final class PerformanceServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Ліниве завантаження звʼязку — головне джерело N+1: локально падаємо одразу,
        // у проді не ламаємо сторінку, а пишемо модель і звʼязок у лог.
        Model::preventLazyLoading(! $this->app->isProduction());
        Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
            Log::channel('perf')->warning('lazy load', [
                'model' => $model::class,
                'relation' => $relation,
            ]);
        });

        $queries = 0;
        $queryMs = 0.0;

        DB::listen(function (QueryExecuted $query) use (&$queries, &$queryMs): void {
            $queries++;
            $queryMs += $query->time; // мілісекунди, вимір самого драйвера

            if ($query->time >= 200) {
                // $query->sql — з плейсхолдерами, значення не потрапляють у лог
                Log::channel('perf')->warning('slow query', ['ms' => $query->time, 'sql' => $query->sql]);
            }
        });

        // terminating() виконується вже після відправлення відповіді — нічого не сповільнює.
        $this->app->terminating(function () use (&$queries, &$queryMs): void {
            // Під Octane константа ставиться раз на воркер: там потрібна власна мітка часу.
            $totalMs = (microtime(true) - LARAVEL_START) * 1000;

            if ($totalMs < 1000) {
                return;
            }

            Log::channel('perf')->warning('slow request', [
                'total_ms' => round($totalMs),
                'db_ms' => round($queryMs),   // db_ms ≈ total_ms -> винна база, а не PHP
                'queries' => $queries,        // 200 запитів по 2 мс -> це N+1, а не «повільна БД»
                'peak_mb' => round(memory_get_peak_usage(true) / 1048576, 1),
            ]);
        });
    }
}
Що починають з вимірювання й декомпозиції часу, а не з гіпотези: DevTools waterfall або `Server-Timing` спершу відповідають на питання «повільно на сервері чи в браузері».
Що кандидат розрізняє інструменти за ціною й місцем: Debugbar — лише dev і лише HTML-відповіді, Clockwork — ще й API/CLI/черги, Telescope — запис у БД з watchers і `telescope:prune`, Xdebug-профайлер — офлайн-аналіз із кратним сповільненням, Blackfire/Tideways/Pulse — продакшен.
Що Xdebug у режимі `profile` спотворює пропорції (роздуває виклики функцій відносно I/O) і що сам факт завантаженого розширення в dev робить локальні числа непорівнянними з продом.
Що оптимізують за внеском у загальний час, а не за красою: 300 запитів по 1 мс і один запит на 900 мс лікуються по-різному, і `EXPLAIN` відрізняє одне від одного.
Що `php artisan optimize`, `composer install --no-dev --optimize-autoloader` й правильно налаштований OPcache — це дешева одноразова гігієна на деплої, а не оптимізація алгоритму, і вона не рятує від N+1.
Що в чергу виносять роботу, результат якої не потрібен у цій відповіді (лист, PDF, вебхук, ресайз), і що черга нічого не пришвидшує — вона переносить час на воркер.
Що дивляться на p95/p99 і на медіану, а не на середнє, і міряють на прогрітому кеші й реальних обсягах даних.
Починати з `Cache::remember()` навколо повільного місця: кеш ховає симптом, а перший запит після інвалідації, деплой і кожен новий фільтр повертають ті самі 4 секунди.
Тримати Xdebug увімкненим локально (`xdebug.mode=debug,develop`) і робити з локальних мілісекунд висновки про продакшен — накладні витрати там кратні.
Вважати профайл із `xdebug.mode=profile` картою реального часу: cachegrind добре показує, що викликається мільйон разів, і погано — скільки насправді чекали на мережу й диск.
Ставити `config:cache` і залишати `env('SOMETHING')` у сервіс-провайдері чи моделі: після кешування конфігу `env()` поза файлами `config/` повертає `null`, і це зазвичай виглядає як «зламався інший модуль», а не як помилка деплою.
Робити `route:cache` при закритих у роутах замиканнях і не розуміти `LogicException: Unable to prepare route ... Uses Closure`.
Оптимізувати PHP, коли TTFB — 200 мс, а 6 секунд дає 4 МБ JS і десяток запитів до сторонніх скриптів у браузері.
Тримати Telescope увімкненим у проді з усіма watchers і без `telescope:prune`: кожен запит починає писати десятки рядків у ту саму базу, яку ви й намагаєтесь розвантажити.
Міряти середній час відповіді: одна сторінка адмінки на 12 секунд у середньому по 200 тис. запитів не видно взагалі.
Змінювати п'ять речей одночасно, а потім не мати змоги сказати, що саме допомогло.
ПОРАДА

Скажіть, що у вас є фіксована послідовність: розкласти час (браузер → TTFB → PHP → БД → зовнішні виклики), знайти найбільший доданок, змінити одну річ, повторно виміряти. І одразу назвіть, що дає найбільше на реальних проєктах: кількість запитів, а не їхня швидкість. Фраза, яку запамʼятовують: «кеш і Octane — це не діагностика, а анестезія; спершу треба знати, що болить».

Сторінка питання →
SF
Symfony·Senior ·Messenger ·retry ·failed transport

Транспорт налаштовується з retry_strategy: кількість спроб, початкова затримка й множник; вичерпані повідомлення йдуть у failed transport, а логічні помилки відкидаються одразу через UnrecoverableMessageHandlingException.

Що відбувається з повідомленням, яке впало в handler?
Як відрізнити тимчасову помилку від логічної в черзі?
Як не втратити повідомлення після вичерпання спроб?

Повтори в Messenger — частина конфігурації транспорту, а не коду обробника. retry_strategy задає кількість спроб, початкову затримку, множник і максимальну затримку, тобто експоненційний backoff. При помилці Messenger додає до повідомлення RedeliveryStamp з номером спроби й публікує його назад у транспорт із затримкою, тому лічильник живе в самому повідомленні й працює однаково з AMQP, Doctrine чи Redis.

Надійність починається з класифікації помилок. Тимчасові — таймаут, обрив зʼєднання, deadlock — повторювати є сенс. Логічні — сутність не існує, дані невалідні, картка відхилена — повторювати шкідливо: пʼять спроб із backoff лише відкладають той самий результат. Для них є UnrecoverableMessageHandlingException, яка одразу відправляє повідомлення у failed transport. Зворотний випадок — RecoverableMessageHandlingException, яка змушує повторити навіть після ліміту.

Failed transport — не смітник, а черга на ручний розбір. Після виправлення причини повідомлення повертають через messenger:failed:retry, безнадійні видаляють. На розмір цієї черги має бути алерт, бо саме він, а не кількість retry, показує реальні втрати.

Оскільки між спробами частина роботи могла виконатись, обробник мусить бути ідемпотентним: перевіряти стан агрегату перед дією і зберігати результат під унікальним ключем. Різні типи повідомлень варто розводити по різних транспортах із власними стратегіями: платежам потрібно більше спроб і довший backoff, ніж сповіщенням.

// config/packages/messenger.yaml
// framework:
//   messenger:
//     failure_transport: failed
//     transports:
//       payments:
//         dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
//         retry_strategy: { max_retries: 5, delay: 2000, multiplier: 3, max_delay: 300000 }
//       failed: 'doctrine://default?queue_name=failed'
//     routing:
//       App\Message\CapturePayment: payments

#[AsMessageHandler]
final class CapturePaymentHandler
{
    public function __invoke(CapturePayment $message): void
    {
        $order = $this->orders->find($message->orderId)
            ?? throw new UnrecoverableMessageHandlingException('Order gone'); // не повторювати

        if ($order->isCaptured()) {
            return; // ідемпотентність: повтор після часткового виконання безпечний
        }

        try {
            $this->gateway->capture($order->paymentId(), $order->total());
        } catch (GatewayTimeout | ConnectionException $e) {
            throw new RecoverableMessageHandlingException('Gateway unavailable', previous: $e); // повторити з backoff
        } catch (CardDeclined $e) {
            $order->markDeclined($e->reason());
            throw new UnrecoverableMessageHandlingException('Declined', previous: $e);
        }

        $order->markCaptured();
    }
}
Що retry живе в конфігурації транспорту, а не в try/catch усередині handler: max_retries, delay, multiplier, max_delay дають експоненційний backoff.
Розділення помилок на два класи: тимчасові (мережа, таймаут, deadlock) повторюємо, логічні (невалідні дані, сутність не існує) відкидаємо одразу через UnrecoverableMessageHandlingException.
Що RecoverableMessageHandlingException навпаки змушує повторити, навіть якщо ліміт вичерпано.
Що failed transport це не смітник, а черга для ручного розбору: messenger:failed:show, retry, remove, і на її розмір має бути алерт.
Що handler мусить бути ідемпотентним, бо між спробами частина роботи могла виконатись, і що для власної логіки можна реалізувати RetryStrategyInterface.
Реалізовувати повтори циклом try/catch у handler: воркер блокується на sleep, і немає ні backoff, ні обмеження спроб, ні failed transport.
Повторювати все підряд: валідаційна помилка після п'яти спроб з backoff лише відкладає неминуче й засмічує логи.
Не налаштувати failed transport: після вичерпання спроб повідомлення просто зникає з ack.
Ставити однакову затримку без множника: під час падіння зовнішнього API всі повідомлення повертаються одночасно і добивають його.
Ігнорувати, що повідомлення після redelivery може отримати інший воркер: локальний стан у handler між спробами не зберігається.
ПОРАДА

Сильна відповідь містить моніторинг: алерт на розмір failed-черги, а не тільки на кількість retry. І приклад, як ви розділили Unrecoverable і Recoverable у реальному обробнику.

Сторінка питання →
PHP
Core PHP·Senior ·OPcache ·JIT ·preload

OPcache тримає в спільній памʼяті вже скомпільовані опкоди, прибираючи парсинг і компіляцію з кожного запиту — це і є основний виграш; preload (PHP 7.4+) додатково тримає звʼязані класи фреймворку, а JIT (PHP 8.0+, вимкнений за замовчуванням через `opcache.jit_buffer_size=0`) компілює гарячі опкоди в машинний код і майже нічого не дає типовому веб-застосунку, бо той упирається в I/O, а не в CPU.

У нас увімкнули JIT на проді, а сторінки не стали швидшими — чому?
Що саме кешує OPcache: результат виконання скрипта чи щось інше?
Ми задеплоїли новий код, а сервер віддає старий — що не так із налаштуваннями?
Що таке preload і чим він відрізняється від звичайного кешу опкодів?

PHP лишається мовою «кожен запит з нуля»: інтерпретатор читає файл, проганяє його через лексер, парсер і компілятор, отримує масив опкодів і лише тоді починає їх виконувати. Для фреймворку це сотні файлів на кожен запит — і вся ця компіляція викидається одразу після відповіді. Zend OPcache (у ядрі з PHP 5.5) прибирає саме цей крок: скомпільовані опкоди складаються у спільну памʼять (shared memory), доступну всім воркерам PHP-FPM того ж майстра, і наступний запит бере готове. Важливо правильно назвати межу: OPcache кешує опкоди, а не результат — код усе одно виконується щоразу, запити до бази робляться щоразу, шаблони рендеряться щоразу. Саме тому це «безкоштовні» десятки відсотків, а не чарівна кнопка.

Другий, менш очевидний виграш — файлові перевірки. За замовчуванням opcache.validate_timestamps=1 і opcache.revalidate_freq=2: раз на дві секунди OPcache робить stat() на кожен включений файл, щоб помітити зміни. На локальній машині це необхідно, у продакшені — чистий податок на syscalls, тим більший, чим повільніша файлова система (класично боляче на NFS і на монтованих томах у Docker). У продакшені ставлять opcache.validate_timestamps=0, і разом із цим приймають зобовʼязання: код у скомпільованому вигляді відтепер змінює тільки перезапуск. Деплой має закінчуватись graceful reload PHP-FPM (SIGUSR2) або підняттям нового контейнера; opcache_reset() теж працює, але скидає кеш різко для всіх воркерів одночасно, даючи сплеск CPU на перекомпіляції. Пропущений reload — це та сама класична історія «задеплоїли, а на сайті старий код»; жодних помилок у логах при цьому не буде.

Preload (opcache.preload, PHP 7.4+) іде на крок далі. Указаний файл виконується один раз під час старту майстер-процесу — до появи будь-якого запиту, від користувача opcache.preload_user, якщо PHP стартує від root. Усе, що цей файл завантажив через require або opcache_compile_file(), лишається в памʼяті звʼязаним: ієрархія класів побудована, інтерфейси й трейти підключені, тож при запиті такі класи доступні без автозавантажувача взагалі. Обмеження варто озвучувати самому, не чекаючи уточнення: класи, чиї батьки або інтерфейси не резолвляться на момент preload, тихо пропускаються (Can't preload unlinked class); preload-скрипт не повинен робити нічого, що залежить від конкретного запиту; на Windows механізм не підтримується; і головне — оновити preload-код можна лише рестартом, opcache_reset() його не чіпає. Через це preload у dev-оточенні — вірний спосіб згаяти день на «мої правки не застосовуються». Реалістична вигода — одиниці відсотків на середньому фреймворку; Symfony генерує preload.php із prod-контейнера з коробки, Laravel — ні.

JIT (PHP 8.0+) — теж частина OPcache, і в цьому вже половина відповіді. Він працює не з вихідним кодом, а з опкодами: гарячі ділянки транслюються в машинний код у буфер opcache.jit_buffer_size. Найчастіша плутанина на співбесіді — opcache.jit=tracing виглядає як «увімкнено», але за замовчуванням opcache.jit_buffer_size=0, і JIT просто вимкнений; вмикає його саме буфер. Чому веб від нього майже нічого не отримує: у типовому запиті більшість часу — це очікування бази й мережі, а гарячі внутрішні операції (функції масивів, рядків, PCRE, серіалізація) і так виконуються в C, куди JIT не дотягується; додайте до цього короткий час життя запиту, за який трасувальний JIT ледве встигає прогрітись. Виграш у рази реальний на іншому профілі навантаження: чисельні розрахунки, попіксельна обробка зображень, парсери, довгі CLI-процеси. У PHP 8.4 бекенд JIT переписали на нову проміжну репрезентацію (IR framework) — код підтримувати стало легше, порядок величини для веба не змінився.

Практичний висновок для продакшену виглядає нудно і саме тому правильно: увімкнений OPcache із memory_consumption під розмір кодової бази, max_accelerated_files із запасом над реальною кількістю .php-файлів (значення округлюється вгору до наступного числа з ряду 3907/7963/16229/32531/65407/130987), validate_timestamps=0 плюс reload на деплої, save_comments=1 — бо докблоки читає і рефлексія, і низка бібліотек. Далі — метрики з opcache_get_status() у моніторингу: hit rate нижче 95%, ненульові oom_restarts або num_cached_keys під стелею означають, що кеш регулярно скидається і застосунок половину часу живе на холодному. Preload — наступний крок, коли базове вже налаштоване й ви готові платити рестартом. JIT — останній і найчастіше зайвий: якщо профіль не показує, що понад половина часу запиту йде в самому PHP-коді, вмикати його — це додати ризик без прискорення. І, як завжди, найбільші виграші лежать поза цим списком — у запитах до бази, кількості HTTP-викликів і кешуванні відповідей.

<?php
// preload.php — виконується один раз при старті php-fpm (opcache.preload=/app/preload.php)
// php.ini прод:
//   opcache.enable=1
//   opcache.memory_consumption=256          ; SHM під опкоди
//   opcache.interned_strings_buffer=16      ; буфер під інтерновані рядки, МБ
//   opcache.max_accelerated_files=32531     ; більше за реальну кількість .php у релізі
//   opcache.validate_timestamps=0           ; без stat() — деплой зобовʼязаний робити reload FPM
//   opcache.save_comments=1                 ; докблоки потрібні рефлексії; не вимикати
//   opcache.preload=/app/preload.php
//   opcache.preload_user=www-data
//   opcache.jit_buffer_size=0               ; JIT вимкнено: веб-запит упирається в I/O, не в CPU

require __DIR__.'/vendor/autoload.php';     // автозавантажувач потрібен, щоб звʼязати батьків

$classmap = require __DIR__.'/vendor/composer/autoload_classmap.php';

foreach ($classmap as $class => $file) {
    // виключаємо те, що не має бути звʼязаним назавжди або тягне побічні ефекти
    if (str_contains($file, '/tests/') || str_contains($file, '/Database/Migrations/')) {
        continue;
    }

    // compile_file кладе опкоди в SHM; клас, чий батько не резолвиться,
    // буде пропущено з попередженням "Can't preload unlinked class"
    @opcache_compile_file($file);
}

// Перевірка на проді: hit rate має бути 99%+, а рестарти — нульові.
// php -r '$s = opcache_get_status(false); var_dump(
//     $s["opcache_statistics"]["opcache_hit_rate"],
//     $s["opcache_statistics"]["oom_restarts"],   // > 0 => замало memory_consumption
//     $s["opcache_statistics"]["num_cached_keys"], // близько до max => підняти max_accelerated_files
//     $s["preload_statistics"]["memory_consumption"] ?? null
// );'
Що PHP компілює файл при кожному запиті, і OPcache прибирає саме крок «читання файлу → лексер → парсер → компілятор», а не виконання опкодів.
Що `opcache.validate_timestamps=0` у продакшені знімає `stat()` з кожного включеного файлу, але тоді деплой зобовʼязаний завершуватись перезавантаженням FPM (або `opcache_reset()`), інакше сервер віддаватиме старий код.
Що `opcache.preload` (PHP 7.4+) виконується один раз при старті master-процесу, зберігає класи вже звʼязаними і робить їх доступними без автозавантажувача, а оновити їх можна лише рестартом — `opcache_reset()` preload не чіпає.
Що JIT — це частина OPcache, вмикається `opcache.jit_buffer_size`, і дає відчутний ефект тільки на CPU-bound коді (математика, обробка зображень, довгі CLI-процеси), а не на типовому запиті з базою й шаблонами.
Що діагностика ведеться числами з `opcache_get_status()`: hit rate, `num_cached_keys` проти `max_cached_keys`, `wasted_memory`, `oom_restarts` — а не відчуттям «наче швидше».
Казати, що OPcache «кешує результат виконання скрипта» або «кешує сторінки» — він кешує опкоди, і код усе одно виконується при кожному запиті.
Вмикати `opcache.jit=tracing` і вважати, що JIT працює: без ненульового `opcache.jit_buffer_size` він лишається вимкненим.
Ставити `opcache.validate_timestamps=0` і продовжувати деплоїти простим `git pull` без перезавантаження FPM — далі йде година пошуку «чому не застосувались зміни».
Вмикати preload на дев-машині й потім не розуміти, чому правки в класі не видно до рестарту php-fpm.
Лишати `opcache.max_accelerated_files` за замовчуванням у великому проєкті: файлів більше за ліміт, частина класів компілюється щоразу заново, а в статусі росте `misses`.
Вимикати `opcache.save_comments`, щоб «зекономити памʼять»: ламаються всі бібліотеки, які читають докблоки (Doctrine annotations, частина рефлексії в PHPUnit).
Обіцяти бізнесу «PHP 8 з JIT — це вдвічі швидше», спираючись на синтетичні бенчмарки Mandelbrot.
ПОРАДА

Одна фраза, яка закриває питання: «Реальні гроші лежать в OPcache — прибрати компіляцію з кожного запиту й прибрати `stat()` через `validate_timestamps=0`; preload — ще кілька відсотків для фреймворку ціною рестарту на деплої; JIT для веба — статистична похибка, він для CPU-bound коду». Далі одразу додайте, чим замінюєте валідацію таймстемпів: graceful reload FPM у кінці деплою.

Сторінка питання →
PHP
Core PHP·Senior ·памʼять ·GC ·воркери

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

Чому воркер черги росте в памʼяті, хоча кожен job невеликий?
Що таке refcount і чому його недостатньо?
Коли в PHP взагалі запускається збирач сміття?

Основний механізм керування памʼяттю в PHP — підрахунок посилань. Кожен zval знає, скільки змінних чи властивостей на нього вказують; щойно лічильник падає до нуля, памʼять звільняється негайно, без участі збирача. Саме тому у звичайному запиті про GC можна не думати.

Refcount не справляється з циклами. Якщо два обʼєкти тримають один одного, після unset зовнішніх змінних у кожного лишається одне посилання від сусіда, і нуль недосяжний. Для таких випадків існує збирач циклів: можливі корені накопичуються в root buffer, і коли він заповнюється, алгоритм обходить граф і звільняє недосяжні підграфи. Запуск не привʼязаний до часу, а з PHP 7.3 поріг адаптивний.

Проблемою GC стає в довгих процесах: воркери черг, Octane, RoadRunner, демони. Процес живе години, тому все, що не звільнилось, накопичується: цикли між обʼєктами, статичні кеші, реєстри слухачів, identity map ORM. Виглядає це як повільний ріст RSS воркера до OOM.

Лікування починається з діагностики, а не з gc_collect_cycles. Метрики memory_get_usage у логах воркера, ліміт памʼяті й кількості завдань для контрольованого перезапуску, WeakMap для кешів, привʼязаних до обʼєктів, і gc_status для розуміння, чи взагалі збирач знаходить сміття. Якщо не знаходить, витік у ваших статичних структурах, а не в циклах.

final class Node
{
    public ?Node $peer = null;
}

$a = new Node;
$b = new Node;
$a->peer = $b;
$b->peer = $a;      // цикл: refcount кожного = 2

unset($a, $b);      // refcount кожного = 1, памʼять не звільнена
gc_collect_cycles(); // лише збирач циклів прибере обидва

// Типова пастка у довгому воркері: статичний кеш росте вічно
final class Registry
{
    private static array $seen = [];
    public static function remember(object $o): void { self::$seen[] = $o; }
}

// Безпечна альтернатива: WeakMap не тримає обʼєкт живим
$cache = new WeakMap;
$cache[$order] = computeTotals($order); // зникне разом з $order

// Контроль у воркері
if (memory_get_usage(true) > 256 * 1024 * 1024) {
    exit(12); // супервізор перезапустить процес
}
Що основний механізм це refcount у zval: обʼєкт звільняється миттєво, щойно лічильник посилань падає до нуля.
Що refcount не бачить циклів: два обʼєкти, які тримають одне одного, ніколи не досягнуть нуля, тому існує окремий збирач циклів на root buffer.
Що збирач запускається не за таймером, а коли root buffer заповнюється (10 000 можливих коренів), і в PHP 7.3+ поріг адаптивний.
Практику для довгих процесів: gc_collect_cycles у воркері, memory_get_usage у метриках, обмеження max-jobs або memory для перезапуску воркера, обережність зі статичними кешами.
Розуміння, що витік у воркері частіше спричинений не GC, а вашим кодом: статичні масиви, реєстри подій, накопичені слухачі, identity map ORM.
Казати, що PHP звільняє памʼять лише наприкінці запиту: більшість обʼєктів звільняється одразу при refcount = 0.
Вважати, що unset($obj) гарантовано звільняє обʼєкт: якщо на нього є інші посилання або цикл, памʼять лишається.
Плутати збирач циклів з opcache або з памʼяттю Zend MM: memory_get_usage показує аллокатор PHP, а не RSS процесу.
Лікувати витік через gc_collect_cycles на кожен job, не знайшовши, хто саме тримає посилання.
ПОРАДА

Сильна відповідь містить практику: gc_collect_cycles у довгих воркерах, memory_get_usage у метриках і перезапуск воркера після N запитів. Ще краще розповісти про реальний витік, який ви знайшли.

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

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

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

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

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

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

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

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

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

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

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

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

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

Сторінка питання →
ARC
Архітектура·Senior ·ідемпотентність ·платежі ·черги

Клієнт передає idempotency key, сервер зберігає його разом із результатом операції під унікальним індексом і на повторний запит повертає збережений результат замість другого платежу.

Клієнт натиснув «Оплатити» двічі: як не списати гроші двічі?
Де зберігати idempotency key і скільки?
Що робити, якщо вебхук від платіжки приходить повторно?

Повтори в платіжній системі неминучі: подвійний клік, таймаут мережі з автоматичним retry, redelivery з черги, повторний вебхук провайдера. Ідемпотентність означає, що будь-який із цих повторів дає той самий результат, що й перший виклик, без другого списання.

Базовий механізм — idempotency key. Клієнт генерує унікальний ключ на бізнес-операцію, а не на HTTP-запит, і передає його заголовком. Сервер атомарно резервує ключ у таблиці з унікальним індексом, виконує операцію й зберігає результат разом із ключем. Повторний запит із тим самим ключем отримує збережену відповідь. Той самий ключ передається платіжному провайдеру, тому навіть падіння між викликом провайдера і збереженням результату не створює другого платежу.

Гонку закриває саме унікальний індекс, а не перевірка в коді: два одночасні запити проходять if (! exists) разом, але лише один пройде INSERT. Стан ключа має проміжне значення processing, щоб повтор під час виконання отримав 409 з Retry-After, а не чужий результат і не другу спробу. Ключ із тим самим значенням, але іншим тілом запиту повертає 422.

Вебхуки провайдера обробляються за тим самим принципом: дедуплікація по event id через унікальний індекс і перевірка стану агрегату перед кожною дією, бо доставка гарантована щонайменше один раз, а не рівно один. Ключі мають час життя, зазвичай від доби до трьох, і чистяться фоновим процесом.

// CREATE TABLE idempotency_keys (key text, client_id bigint, request_hash text,
//   status text, response_code int, response_body jsonb, created_at timestamptz,
//   PRIMARY KEY (client_id, key));

final class ChargeAction
{
    public function __invoke(ChargeRequest $request): JsonResponse
    {
        $key = $request->header('Idempotency-Key') ?? abort(400, 'Idempotency-Key required');
        $hash = hash('sha256', $request->getContent());

        // 1. Атомарне резервування ключа: унікальний індекс закриває гонку
        try {
            IdempotencyKey::create(['key' => $key, 'client_id' => $request->clientId(), 'request_hash' => $hash, 'status' => 'processing']);
        } catch (UniqueConstraintViolationException) {
            $existing = IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)->firstOrFail();

            if ($existing->request_hash !== $hash) {
                abort(422, 'Idempotency-Key reused with a different payload');
            }
            if ($existing->status === 'processing') {
                return response()->json(['status' => 'processing'], 409)->header('Retry-After', '2');
            }

            return response()->json($existing->response_body, $existing->response_code); // збережений результат
        }

        // 2. Сама операція: провайдер отримує той самий ключ, повтор не створить другий платіж
        $charge = $this->gateway->charge($request->amount(), $request->card(), idempotencyKey: $key);

        // 3. Результат зберігається разом з ключем в одній транзакції
        DB::transaction(function () use ($key, $request, $charge) {
            Payment::create(['charge_id' => $charge->id, 'amount' => $request->amount()]);
            IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)
                ->update(['status' => 'succeeded', 'response_code' => 201, 'response_body' => ['charge' => $charge->id]]);
        });

        return response()->json(['charge' => $charge->id], 201);
    }
}
Що ідемпотентність означає однаковий результат при повторі, і що повтори неминучі: подвійний клік, таймаут із retry, redelivery з черги, повторний вебхук.
Що ключ генерує клієнт на одну бізнес-операцію, а не на запит, і сервер зберігає його разом із результатом під унікальним індексом.
Що унікальний індекс у базі є останньою лінією захисту: перевірка існування в коді не закриває гонку двох одночасних запитів.
Що обробка йде через стани: pending, processing, succeeded, failed, і повтор під час processing отримує 409 або чекає, а після завершення отримує збережену відповідь.
Що вебхуки платіжного провайдера дедуплікуються по event id, а стан агрегату перевіряється перед кожною дією, бо доставка щонайменше один раз.
Перевіряти if (! exists) у коді й вважати, що цього досить: два запити проходять перевірку одночасно.
Генерувати ключ на сервері на кожен запит: повторний запит отримує новий ключ і створює другий платіж.
Повертати на повтор новий результат замість збереженого, або повертати 200 без тіла, ламаючи клієнта.
Зберігати ключі вічно без TTL або, навпаки, лише в Redis без гарантій, втрачаючи їх при рестарті.
Не розрізняти повтор того самого запиту й інший запит з тим самим ключем: другий має отримувати 422, а не чужий результат.
ПОРАДА

Згадайте унікальний індекс у БД як останню лінію захисту: перевірка в коді гонку не закриває. І назвіть, як обробляєте повтор під час processing, а не лише після завершення.

Сторінка питання →
LR
Laravel·Senior ·архітектура ·actions ·DTO

Стандартної структури вистачає, доки логіка сценарію вміщається в контролер плюс модель; далі вузьке місце — не назви тек, а те, що бізнес-правило не має одного власника. Дія з одним публічним методом і незмінним DTO на вході дає цього власника без DDD, а тактичні патерни DDD додають лише там, де є інваріанти, які треба захищати, і мова, якою говорить бізнес.

У нас `app/Services` розрісся до шістдесяти класів, `UserService` на 900 рядків — з чого починати рефакторинг?
Коли ви заводите теку `Domain` чи `modules/`, а коли достатньо стандартних `app/Models` і `app/Http`?
Чим Action відрізняється від сервісу і навіщо поруч ще DTO, якщо є Form Request?
Навіщо репозиторій над Eloquent, якщо Eloquent сам по собі Active Record?

Почніть з того, чого стандартна структура насправді не робить. Laravel не нав'язує архітектури: app/ — це просто PSR-4-неймспейс App\, і в Laravel 11 скелет ще й помітно схуднув — зникли app/Http/Middleware/*, app/Console/Kernel.php та app/Exceptions/Handler.php, їхні налаштування переїхали в bootstrap/app.php (withRouting(), withMiddleware(), withExceptions()), а список провайдерів застосунку — у bootstrap/providers.php. Тобто фреймворк уже не диктує навіть тек: php artisan make:controller, make:class, make:enum створюють їх на вимогу, а App\Domain\Billing працюватиме без жодного рядка конфігурації. Через це «великий проєкт розвалився» майже ніколи не означає «не вистачило папок». Означає воно інше: у бізнес-сценарію немає власника. Створення підписки живе трьома рядками в контролері, п'ятьма в Observer, ще трьома в джобі й окремою гілкою в консольній команді, яка робить те саме для імпорту — і коли правило змінюється, треба знайти всі чотири місця.

Стандартної структури — тонкий контролер, Form Request, модель зі скоупами, ресурс на виході — вистачає рівно доти, доки сценарій вміщається в один читабельний метод контролера. Це не «для маленьких проєктів»: у великому застосунку більшість ендпоїнтів так і виглядають, і загортати Model::create($request->validated()) у три класи — чиста втрата. Симптоми, за якими справді пора рухатись далі, конкретні: той самий сценарій викликається більш ніж з одного входу (HTTP, черга, консоль, вебхук); у контролері з'явилася транзакція разом із зовнішнім викликом; ви не можете відповісти на питання «де в коді створюється підписка» одним файлом. Зверніть увагу, що жоден із них не про кількість рядків.

Наступна сходинка — дії. Дія це клас на один сценарій із дієсловом у назві та одним публічним методом: StartSubscription::handle(), CancelOrder::handle(). Laravel сам користується цим патерном — Jetstream кладе app/Actions/Fortify/CreateNewUser.php, і це той самий підхід. Ключова відмінність від звичного app/Services: там клас іменують за сутністю (UserService), тому він приймає все, що стосується користувача, і за рік перетворюється на god-клас без будь-якої межі всередині. Дія такої межі не втрачає — щоб додати логіку, треба або змінити наявний сценарій, або завести новий клас. Дія ж володіє і небезпечними частинами: DB::transaction() живе всередині неї, а не в контролері, бо межа консистентності — властивість сценарію; події диспатчаться після коміту, інакше черга підхопить джобу раніше, ніж транзакція завершиться (для черг це лікується опцією after_commit у config/queue.php або $afterCommit на слухачі, але надійніше просто не кидати подію з Observer моделі).

Вхід дії варто описувати DTO, і це не бюрократія, а різниця в тому, звідки сценарій можна викликати. Form Request — HTTP-об'єкт: він валідує вхід, авторизує, живе в Illuminate\Foundation\Http і потребує реального запиту; передати його в консольну команду чи в ShouldQueue-джобу неможливо. DTO — це final readonly class з іменованими типізованими властивостями (PHP 8.2 дав readonly class, 8.4 — property hooks і асиметричну видимість, якщо потрібні обчислювані поля), який будують з validated(), з CSV-рядка чи з payload вебхука однаково. Дає він три речі: сигнатуру, яку перевіряє PHPStan замість array $data, стабільність при рефакторингу (перейменування поля ламає компіляцію, а не мовчки віддає null) і можливість тестувати сценарій без HTTP взагалі. Якщо ручних DTO багато, spatie/laravel-data замінює їх класами Data з from(), мапінгом імен, виведенням правил валідації з типів і генерацією TypeScript — ціною ще однієї залежності та трохи магії.

Модулі — це вже про залежності між частинами, а не про сценарії. Технічно модуль у Laravel дешевий: другий PSR-4-префікс у composer.json, composer dump-autoload, власний сервіс-провайдер із loadRoutesFrom(), loadMigrationsFrom(), loadViewsFrom() і біндінгами в register(). Дорога частина — дисципліна: без явного правила «Billing спілкується з Catalog лише через опублікований контракт або подію» модулі за півроку зростуться в один. Тому правило має бути виконуваним — pest-plugin-arch (expect('App\Domain')->not->toUse('Illuminate')) для шарів усередині модуля, Deptrac для міжмодульних залежностей, і обидва в CI. Ділити варто за бізнес-спроможностями (Billing, Catalog, Hiring), а не за шарами: modules/Controllers — це стандартна структура з зайвим рівнем вкладеності.

Межа з DDD проходить не там, де з'являються теки Domain/Application/Infrastructure — це шарувата архітектура, і сама по собі вона до DDD відношення не має. Тактичні патерни (агрегати з інваріантами, value objects, репозиторії над власними доменними об'єктами, доменні події) окуповуються за двох умов одночасно: у домені є правила, які треба захищати від некоректних станів, і є мова, якою бізнес справді говорить і яка живе в коді. Тоді ціна — подвійна модель (доменний об'єкт плюс Eloquent для персистентності), ручний мапінг і заборона на Illuminate у домені — купує безпеку змін. Якщо ж правило звучить як «статус із чотирьох значень, при переході в paid шлемо лист», агрегат нічого не захищає, а лише додає класів. Практичний компроміс для великого Laravel-застосунку зазвичай такий: більшість модулів — звичайний Laravel з діями й DTO, Eloquent прямо як модель даних; один-два контексти, де складність реальна, — з повноцінним доменним шаром і арх-тестом, що не пускає туди фреймворк. Про те, як розпізнати такий контекст і що дає стратегічна частина DDD, докладніше в картці [[architecture/when-ddd-helps]].

// DTO: незмінний контракт сценарію. Без Illuminate — його однаково будує
// контролер, консольна команда й споживач черги.
final readonly class NewSubscription
{
    public function __construct(
        public int $customerId,
        public string $planCode,
        public ?string $promoCode = null,
    ) {}
}

// Дія: один сценарій, один публічний метод, залежності через конструктор.
final class StartSubscription
{
    public function __construct(private PaymentGateway $gateway) {}

    public function handle(NewSubscription $input): Subscription
    {
        // Межа транзакції належить дії: не контролеру й не обзерверу моделі.
        $subscription = DB::transaction(function () use ($input): Subscription {
            $customer = Customer::query()
                ->whereKey($input->customerId)
                ->lockForUpdate()   // паралельний запит не створить другу підписку
                ->firstOrFail();

            $charge = $this->gateway->charge($customer, $input->planCode);

            return $customer->subscriptions()->create([
                'plan_code' => $input->planCode,
                'charge_id' => $charge->id,
            ]);
        });

        // Після коміту: слухач не прочитає стан, якого ще немає в базі.
        SubscriptionStarted::dispatch($subscription);

        return $subscription;
    }
}

final class SubscriptionController
{
    // Контейнер сам збудує дію за тайп-хінтом — фасади й new тут не потрібні.
    public function store(StoreSubscriptionRequest $request, StartSubscription $action): JsonResponse
    {
        $data = $request->validated();
        $subscription = $action->handle(new NewSubscription(
            customerId: $request->user()->id,
            planCode: $data['plan'],
            promoCode: $data['promo'] ?? null,
        ));

        return SubscriptionResource::make($subscription)->response()->setStatusCode(201);
    }
}
Що стандартна структура — це не «для маленьких проєктів», а розумний дефолт, і замінюють її за симптомом (сценарій розповзся по контролеру, обзервері, джобі й команді), а не за розміром репозиторію.
Що `app/Services` без правил швидко стає набором god-класів: критерій — один клас на один сценарій із дієсловом у назві (`StartSubscription`), а не один клас на сутність (`UserService`).
Що DTO і Form Request вирішують різні задачі: Form Request валідує HTTP-вхід і живе в `Illuminate`, DTO — типізований контракт сценарію, який однаково приходить з HTTP, консольної команди й черги.
Що дія володіє межею транзакції й побічними ефектами: `DB::transaction()` усередині дії, події — після коміту, а не з обзервера моделі.
Що технічно модуль у Laravel — це PSR-4-неймспейс у `composer.json` плюс власний сервіс-провайдер із `loadRoutesFrom()`/`loadMigrationsFrom()`, і що межі тримаються тестами (`pest-plugin-arch`, Deptrac), а не домовленістю.
Де межа з DDD: агрегати, репозиторії й доменні події виправдані там, де є інваріанти й ubiquitous language; для CRUD це п'ять класів на одну форму.
Починати рефакторинг з тек: створити `Domain/Application/Infrastructure`, перекласти туди ті самі моделі й вважати, що це DDD.
Робити `UserService`, `OrderService`, `ProductService` — один клас на таблицю; через рік це ті самі контролери, лише без HTTP.
Вважати Form Request доменним DTO і тягнути `App\Http\Requests\...` у джобу чи консольну команду — сценарій стає невикликуваним поза HTTP.
Загортати Eloquent у репозиторій, який повертає `Builder`: абстракція протікає повністю, а `with()` і скоупи стають недоступними в місці виклику.
Ставити `DB::transaction()` у контролері й кидати події з `Observer` моделі — слухач стартує до коміту й читає стан, якого ще немає.
Створити теку `modules/` і чекати, що Laravel її підхопить: без запису в `autoload.psr-4` і `composer dump-autoload` жоден клас не завантажиться.
Робити модулі «за шаром» (`modules/Controllers`, `modules/Models`) — це стандартна структура з зайвим рівнем вкладеності.
Заводити інтерфейс до кожного класу «щоб мокати»: одна реалізація й один мок замість тесту на реальному сценарії.
Переносити доменний код у `Domain`, але лишати в ньому `auth()`, `config()` і `now()` — глобальні хелпери прив'язують шар до фреймворка не гірше за `use Illuminate\...`.
ПОРАДА

Сформулюйте критерій, а не структуру: «у кожного бізнес-сценарію має бути рівно одне місце, куди по нього приходять». Далі покажіть сходинки — контролер плюс модель → дія з DTO → модуль зі своїм провайдером і арх-тестом → тактичний DDD у тому одному контексті, де є інваріанти. І назвіть приклад, де ви свідомо зупинилися на першій сходинці.

Сторінка питання →
SQL
SQL·Senior ·транзакції ·ізоляція ·PostgreSQL

MySQL за замовчуванням REPEATABLE READ, PostgreSQL — READ COMMITTED; рівень визначає, які аномалії можливі, але втрачене оновлення закривають не рівнем, а атомарним UPDATE або SELECT FOR UPDATE.

Що таке phantom read і на якому рівні він можливий?
Чому два паралельні запити списали з балансу більше, ніж там було?
Чому SERIALIZABLE вимагає повторювати транзакцію?

Рівень ізоляції визначає, що транзакція бачить із паралельних змін. READ UNCOMMITTED допускає читання незафіксованих даних і в PostgreSQL фактично дорівнює READ COMMITTED. READ COMMITTED бачить лише закомічені дані, але між двома своїми запитами може побачити різні значення. REPEATABLE READ фіксує знімок на початку транзакції; у PostgreSQL це також закриває фантоми, у MySQL InnoDB звичайні читання йдуть зі знімка, а locking reads захищені gap-локами. SERIALIZABLE гарантує результат, еквівалентний послідовному виконанню. За замовчуванням MySQL працює на REPEATABLE READ, PostgreSQL на READ COMMITTED, і для більшості застосунків дефолт лишають.

Практична проблема, яку рівень ізоляції не вирішує, — втрачене оновлення. Два запити читають баланс 100, обидва рахують у PHP і обидва записують результат: одне списання зникає. На READ COMMITTED і REPEATABLE READ це відбувається мовчки. Рішення — не читати й записувати окремо: UPDATE ... SET balance = balance - :amount WHERE balance >= :amount виконує обидві дії атомарно під блокуванням рядка, а нуль affected rows означає недостатньо коштів. Коли між читанням і записом потрібна логіка, застосовують SELECT ... FOR UPDATE у короткій транзакції.

SERIALIZABLE у PostgreSQL реалізований без блокувань: база відстежує залежності й відкидає одну з конфліктних транзакцій із помилкою serialization_failure. Це штатна ситуація, і код має повторити транзакцію цілком. Так само треба обробляти deadlock. У відповіді найкраще працює реальний випадок: подвійне списання, двічі використаний промокод, і те, як саме гонку закрили.

-- Lost update: обидві транзакції читають 100, обидві пишуть 100 - 70 = 30.
-- Рівень ізоляції нижче SERIALIZABLE цього не ловить.
SELECT balance FROM accounts WHERE id = 1;        -- 100 в обох сесіях
UPDATE accounts SET balance = 30 WHERE id = 1;    -- списали 140, баланс 30

-- Рішення 1: атомарний UPDATE з умовою, перевіряємо affected rows
UPDATE accounts
SET balance = balance - 70
WHERE id = 1 AND balance >= 70;                   -- друга сесія отримає 0 rows

-- Рішення 2: блокування рядка на час короткої логіки
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- друга сесія чекає
-- бізнес-перевірки в PHP
UPDATE accounts SET balance = balance - 70 WHERE id = 1;
INSERT INTO ledger (account_id, amount, operation_key) VALUES (1, -70, 'op-9f1c'); -- UNIQUE(operation_key)
COMMIT;

-- Рішення 3: SERIALIZABLE, готові повторити при 40001
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... на serialization_failure застосунок повторює всю транзакцію

-- Черга на базі: воркери беруть різні рядки без очікування
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
Чотири рівні й аномалії, які кожен допускає: dirty read, non-repeatable read, phantom read, і lost update як окрема практична проблема.
Що READ COMMITTED бачить зміни інших транзакцій між своїми запитами, а REPEATABLE READ фіксує знімок на початку і в PostgreSQL також закриває фантоми.
Що патерн прочитати в PHP, порахувати, записати ламається на будь-якому рівні нижче SERIALIZABLE, а рішення це UPDATE з виразом і умовою або SELECT FOR UPDATE.
Що SERIALIZABLE у PostgreSQL це SSI без блокувань, який відкидає транзакцію з помилкою serialization_failure, тому код мусить бути готовий повторити її.
Досвід реальної гонки: подвійне списання, дублікат промокоду, перевищення ліміту, і як саме її закрили.
Вважати, що REPEATABLE READ гарантує відсутність будь-яких аномалій, зокрема lost update.
Вважати, що вищий рівень ізоляції автоматично захищає від подвійного списання: без блокування або атомарного UPDATE дві транзакції все одно прочитають той самий баланс.
Використовувати SELECT FOR UPDATE без транзакції або з довгою логікою всередині, тримаючи блокування на час HTTP-запиту.
Не обробляти deadlock і serialization failure: замість повтору транзакції показувати користувачу 500.
Не знати, що в MySQL REPEATABLE READ фантоми частково закриті gap-локами, а в PostgreSQL знімком, і поведінка при UPDATE конфліктних рядків відрізняється.
ПОРАДА

Наведіть реальний випадок гонки, який ловили в продакшені — це переконує краще за перелік рівнів. І покажіть, що знаєте різницю дефолтів MySQL і PostgreSQL.

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