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

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

Питання для Junior, Middle та Senior Laravel-розробників: service container, Eloquent і N+1, черги, події, кеш, тестування та архітектура великих застосунків.

Тема
Рівень
16 питань
LR
Laravel·Junior ·service container ·DI ·провайдери

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

final class OrderList extends Component
{
    use WithPagination;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    public function __construct(private Request $request) {}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        $queries = 0;
        $queryMs = 0.0;

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

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

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

            if ($totalMs < 1000) {
                return;
            }

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

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

Сторінка питання →
LR
Laravel·Senior ·архітектура ·actions ·DTO

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

У нас `app/Services` розрісся до шістдесяти класів, `UserService` на 900 рядків — з чого починати рефакторинг?
Коли ви заводите теку `Domain` чи `modules/`, а коли достатньо стандартних `app/Models` і `app/Http`?
Чим Action відрізняється від сервісу і навіщо поруч ще DTO, якщо є Form Request?
Навіщо репозиторій над Eloquent, якщо Eloquent сам по собі Active Record?

Почніть з того, чого стандартна структура насправді не робить. Laravel не нав'язує архітектури: app/ — це просто PSR-4-неймспейс App\, і в Laravel 11 скелет ще й помітно схуднув — зникли app/Http/Middleware/*, app/Console/Kernel.php та app/Exceptions/Handler.php, їхні налаштування переїхали в bootstrap/app.php (withRouting(), withMiddleware(), withExceptions()), а список провайдерів застосунку — у bootstrap/providers.php. Тобто фреймворк уже не диктує навіть тек: php artisan make:controller, make:class, make:enum створюють їх на вимогу, а App\Domain\Billing працюватиме без жодного рядка конфігурації. Через це «великий проєкт розвалився» майже ніколи не означає «не вистачило папок». Означає воно інше: у бізнес-сценарію немає власника. Створення підписки живе трьома рядками в контролері, п'ятьма в Observer, ще трьома в джобі й окремою гілкою в консольній команді, яка робить те саме для імпорту — і коли правило змінюється, треба знайти всі чотири місця.

Стандартної структури — тонкий контролер, Form Request, модель зі скоупами, ресурс на виході — вистачає рівно доти, доки сценарій вміщається в один читабельний метод контролера. Це не «для маленьких проєктів»: у великому застосунку більшість ендпоїнтів так і виглядають, і загортати Model::create($request->validated()) у три класи — чиста втрата. Симптоми, за якими справді пора рухатись далі, конкретні: той самий сценарій викликається більш ніж з одного входу (HTTP, черга, консоль, вебхук); у контролері з'явилася транзакція разом із зовнішнім викликом; ви не можете відповісти на питання «де в коді створюється підписка» одним файлом. Зверніть увагу, що жоден із них не про кількість рядків.

Наступна сходинка — дії. Дія це клас на один сценарій із дієсловом у назві та одним публічним методом: StartSubscription::handle(), CancelOrder::handle(). Laravel сам користується цим патерном — Jetstream кладе app/Actions/Fortify/CreateNewUser.php, і це той самий підхід. Ключова відмінність від звичного app/Services: там клас іменують за сутністю (UserService), тому він приймає все, що стосується користувача, і за рік перетворюється на god-клас без будь-якої межі всередині. Дія такої межі не втрачає — щоб додати логіку, треба або змінити наявний сценарій, або завести новий клас. Дія ж володіє і небезпечними частинами: DB::transaction() живе всередині неї, а не в контролері, бо межа консистентності — властивість сценарію; події диспатчаться після коміту, інакше черга підхопить джобу раніше, ніж транзакція завершиться (для черг це лікується опцією after_commit у config/queue.php або $afterCommit на слухачі, але надійніше просто не кидати подію з Observer моделі).

Вхід дії варто описувати DTO, і це не бюрократія, а різниця в тому, звідки сценарій можна викликати. Form Request — HTTP-об'єкт: він валідує вхід, авторизує, живе в Illuminate\Foundation\Http і потребує реального запиту; передати його в консольну команду чи в ShouldQueue-джобу неможливо. DTO — це final readonly class з іменованими типізованими властивостями (PHP 8.2 дав readonly class, 8.4 — property hooks і асиметричну видимість, якщо потрібні обчислювані поля), який будують з validated(), з CSV-рядка чи з payload вебхука однаково. Дає він три речі: сигнатуру, яку перевіряє PHPStan замість array $data, стабільність при рефакторингу (перейменування поля ламає компіляцію, а не мовчки віддає null) і можливість тестувати сценарій без HTTP взагалі. Якщо ручних DTO багато, spatie/laravel-data замінює їх класами Data з from(), мапінгом імен, виведенням правил валідації з типів і генерацією TypeScript — ціною ще однієї залежності та трохи магії.

Модулі — це вже про залежності між частинами, а не про сценарії. Технічно модуль у Laravel дешевий: другий PSR-4-префікс у composer.json, composer dump-autoload, власний сервіс-провайдер із loadRoutesFrom(), loadMigrationsFrom(), loadViewsFrom() і біндінгами в register(). Дорога частина — дисципліна: без явного правила «Billing спілкується з Catalog лише через опублікований контракт або подію» модулі за півроку зростуться в один. Тому правило має бути виконуваним — pest-plugin-arch (expect('App\Domain')->not->toUse('Illuminate')) для шарів усередині модуля, Deptrac для міжмодульних залежностей, і обидва в CI. Ділити варто за бізнес-спроможностями (Billing, Catalog, Hiring), а не за шарами: modules/Controllers — це стандартна структура з зайвим рівнем вкладеності.

Межа з DDD проходить не там, де з'являються теки Domain/Application/Infrastructure — це шарувата архітектура, і сама по собі вона до DDD відношення не має. Тактичні патерни (агрегати з інваріантами, value objects, репозиторії над власними доменними об'єктами, доменні події) окуповуються за двох умов одночасно: у домені є правила, які треба захищати від некоректних станів, і є мова, якою бізнес справді говорить і яка живе в коді. Тоді ціна — подвійна модель (доменний об'єкт плюс Eloquent для персистентності), ручний мапінг і заборона на Illuminate у домені — купує безпеку змін. Якщо ж правило звучить як «статус із чотирьох значень, при переході в paid шлемо лист», агрегат нічого не захищає, а лише додає класів. Практичний компроміс для великого Laravel-застосунку зазвичай такий: більшість модулів — звичайний Laravel з діями й DTO, Eloquent прямо як модель даних; один-два контексти, де складність реальна, — з повноцінним доменним шаром і арх-тестом, що не пускає туди фреймворк. Про те, як розпізнати такий контекст і що дає стратегічна частина DDD, докладніше в картці [[architecture/when-ddd-helps]].

// DTO: незмінний контракт сценарію. Без Illuminate — його однаково будує
// контролер, консольна команда й споживач черги.
final readonly class NewSubscription
{
    public function __construct(
        public int $customerId,
        public string $planCode,
        public ?string $promoCode = null,
    ) {}
}

// Дія: один сценарій, один публічний метод, залежності через конструктор.
final class StartSubscription
{
    public function __construct(private PaymentGateway $gateway) {}

    public function handle(NewSubscription $input): Subscription
    {
        // Межа транзакції належить дії: не контролеру й не обзерверу моделі.
        $subscription = DB::transaction(function () use ($input): Subscription {
            $customer = Customer::query()
                ->whereKey($input->customerId)
                ->lockForUpdate()   // паралельний запит не створить другу підписку
                ->firstOrFail();

            $charge = $this->gateway->charge($customer, $input->planCode);

            return $customer->subscriptions()->create([
                'plan_code' => $input->planCode,
                'charge_id' => $charge->id,
            ]);
        });

        // Після коміту: слухач не прочитає стан, якого ще немає в базі.
        SubscriptionStarted::dispatch($subscription);

        return $subscription;
    }
}

final class SubscriptionController
{
    // Контейнер сам збудує дію за тайп-хінтом — фасади й new тут не потрібні.
    public function store(StoreSubscriptionRequest $request, StartSubscription $action): JsonResponse
    {
        $data = $request->validated();
        $subscription = $action->handle(new NewSubscription(
            customerId: $request->user()->id,
            planCode: $data['plan'],
            promoCode: $data['promo'] ?? null,
        ));

        return SubscriptionResource::make($subscription)->response()->setStatusCode(201);
    }
}
Що стандартна структура — це не «для маленьких проєктів», а розумний дефолт, і замінюють її за симптомом (сценарій розповзся по контролеру, обзервері, джобі й команді), а не за розміром репозиторію.
Що `app/Services` без правил швидко стає набором god-класів: критерій — один клас на один сценарій із дієсловом у назві (`StartSubscription`), а не один клас на сутність (`UserService`).
Що DTO і Form Request вирішують різні задачі: Form Request валідує HTTP-вхід і живе в `Illuminate`, DTO — типізований контракт сценарію, який однаково приходить з HTTP, консольної команди й черги.
Що дія володіє межею транзакції й побічними ефектами: `DB::transaction()` усередині дії, події — після коміту, а не з обзервера моделі.
Що технічно модуль у Laravel — це PSR-4-неймспейс у `composer.json` плюс власний сервіс-провайдер із `loadRoutesFrom()`/`loadMigrationsFrom()`, і що межі тримаються тестами (`pest-plugin-arch`, Deptrac), а не домовленістю.
Де межа з DDD: агрегати, репозиторії й доменні події виправдані там, де є інваріанти й ubiquitous language; для CRUD це п'ять класів на одну форму.
Починати рефакторинг з тек: створити `Domain/Application/Infrastructure`, перекласти туди ті самі моделі й вважати, що це DDD.
Робити `UserService`, `OrderService`, `ProductService` — один клас на таблицю; через рік це ті самі контролери, лише без HTTP.
Вважати Form Request доменним DTO і тягнути `App\Http\Requests\...` у джобу чи консольну команду — сценарій стає невикликуваним поза HTTP.
Загортати Eloquent у репозиторій, який повертає `Builder`: абстракція протікає повністю, а `with()` і скоупи стають недоступними в місці виклику.
Ставити `DB::transaction()` у контролері й кидати події з `Observer` моделі — слухач стартує до коміту й читає стан, якого ще немає.
Створити теку `modules/` і чекати, що Laravel її підхопить: без запису в `autoload.psr-4` і `composer dump-autoload` жоден клас не завантажиться.
Робити модулі «за шаром» (`modules/Controllers`, `modules/Models`) — це стандартна структура з зайвим рівнем вкладеності.
Заводити інтерфейс до кожного класу «щоб мокати»: одна реалізація й один мок замість тесту на реальному сценарії.
Переносити доменний код у `Domain`, але лишати в ньому `auth()`, `config()` і `now()` — глобальні хелпери прив'язують шар до фреймворка не гірше за `use Illuminate\...`.
ПОРАДА

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

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти
ПІДТЕМИ
Service container Eloquent і N+1 Черги і джоби Події та слухачі Кеш Тестування
НА ЧОМУ ВАЛЯТЬСЯ

На Laravel-позиціях майже завжди питають service container і N+1 — це два маркери, за якими відрізняють Middle від Junior. Senior-рівень додає черги, ідемпотентність і межі домену.