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

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

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

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

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