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

Laravel для Middle: питання на співбесіду

8 питань рівня Middle з теми Laravel з розгорнутими відповідями, порадами та перевіркою.

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

Сторінка питання →
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 падає з помилкою ще на етапі розробки. Додайте, як логуєте кількість запитів на запит у продакшені.

Сторінка питання →
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 згадуйте як те, що дає ті самі опції на рівні супервізора плюс метрики й теги, а не як заміну розуміння.

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

Сторінка питання →
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 для подій усередині транзакцій.

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

Сторінка питання →
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()` — захист від того, що на протухлий ключ одночасно прийдуть сотні запитів.

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

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