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

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

Рівень, де вже питають про N+1, транзакції, черги, тестування й уміння читати чужий код. Найбільша частина ринку.

Тема
Рівень
38 питань
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·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 на дві колекції одночасно множить рядки, тому другу колекцію краще догрузити другим запитом.

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