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

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

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

Тема
Рівень
4 питання
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 у тому одному контексті, де є інваріанти. І назвіть приклад, де ви свідомо зупинилися на першій сходинці.

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