<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · JUNIOR ЧАСТО ПИТАЮТЬ

Як працює роутинг і route model binding у Laravel?

Роутер зіставляє URI з маршрутами в порядку реєстрації, а перетворення `{post}` на об'єкт робить не контролер, а middleware `SubstituteBindings`: воно бере `getRouteKeyName()`, робить `firstOrFail()` і кидає 404 ще до вашого коду. Неявне зв'язування шукає по всій таблиці й ігнорує ієрархію URL, поки не ввімкнути `scopeBindings()`, а `SoftDeletes` мовчки вирізає видалені записи, доки на маршруті не стоїть `withTrashed()`.

Чому `/posts/create` віддає 404, якщо вище в файлі оголошено `Route::get('posts/{post}')`?
У сигнатурі контролера стоїть `Post $post` — хто саме сходив у базу, коли й що станеться, якщо запису немає?
Маршрут `/posts/{post}/comments/{comment}`: я підставив id коментаря з чужого поста — контролер його віддасть?
Модель має `SoftDeletes`, запис у кошику. Чому `/posts/12/restore` повертає 404 ще до першого рядка контролера?
Routing Route Model Binding SubstituteBindings scopeBindings SoftDeletes

Роутер зберігає маршрути у RouteCollection і зіставляє вхідний URI з ними в порядку реєстрації: перевіряється метод, домен, скомпільований регулярний вираз шляху, і перший маршрут, який підійшов, виграє. Звідси класична пастка з posts/create: якщо вище оголошено posts/{post}, слово create чудово підходить під {post}, роутер зупиняється на ньому й далі не дивиться. Лікують це або порядком (літеральні сегменти вище за параметри), або обмеженням патерна: ->whereNumber('post'), ->whereUuid(), ->whereUlid(), ->whereAlpha(), ->whereIn('status', ['draft', 'published']) чи ручний ->where('post', '[0-9]+'). Різниця між обмеженням і перевіркою в контролері принципова: якщо сегмент не пройшов патерн, маршрут вважається незбіглим, роутер іде далі по списку і в базу ніхто не ходить. Route::resource('posts', PostController::class) реєструє сім маршрутів у безпечному порядку сам, тому з ресурсними контролерами цю пастку зустрічають рідше, ніж із ручними.

Тепер найважливіше для співбесіди питання: хто перетворює рядок 12 на об'єкт Post. Не контейнер і не рефлексія контролера. Це middleware Illuminate\Routing\Middleware\SubstituteBindings, яке стоїть у групах web і api (у Laravel 11+ групи налаштовуються в bootstrap/app.php через ->withMiddleware(), у 10 і раніше — в app/Http/Kernel.php). Воно проходить по параметрах маршруту, для кожного дивиться type-hint методу, і для типів-моделей викликає resolveRouteBinding($value), тобто фактично where(getRouteKeyName(), $value)->firstOrFail(). За замовчуванням getRouteKeyName() повертає первинний ключ. ModelNotFoundException перетворюється на NotFoundHttpException у Handler::prepareException(), тож клієнт бачить 404 — і бачить його ще до першого рядка вашого контролера. Практичний висновок: try/catch у контролері марний, а глобальне middleware, зареєстроване до групи, отримає в $request->route('post') ще рядок, а не модель.

Ключ зв'язування змінюють на трьох рівнях, і плутати їх не варто. {post:slug} діє лише на цьому маршруті. getRouteKeyName() у моделі діє глобально й тягне за собою getRouteKey(), тому після зміни всі route('posts.show', $post) почнуть генерувати URL зі slug. Для публічних сторінок зазвичай цього й хочуть, але це зміна всіх посилань одразу, а не одного екрана. Явне зв'язування живе в boot() провайдера: Route::model('post', Post::class) для параметрів без type-hint і Route::bind('post', fn ($value) => Post::published()->where('slug', $value)->firstOrFail()), коли потрібна власна логіка - scope, розшифрування ідентифікатора, пошук по двох колонках. Якщо логіка стосується моделі, а не маршруту, чистіше перевизначити resolveRouteBinding() прямо в моделі. Ще одна дрібниця, про яку забувають: колонка, за якою шукає зв'язування, має унікальний індекс, інакше firstOrFail() тихо віддає перший-ліпший рядок.

Вкладені маршрути за замовчуванням не пов'язані між собою, і це джерело реальних вразливостей. У /posts/{post}/comments/{comment} Laravel незалежно шукає пост по таблиці posts і коментар по таблиці comments; ніхто не перевіряє, що другий належить першому, тож /posts/5/comments/900 покаже коментар із поста 7. Ввімкнути перевірку можна викликом ->scopeBindings() на маршруті або групі: тоді для другого параметра викликається resolveChildRouteBinding(), і запит іде через відношення, ім'я якого вгадується з імені параметра, тобто {comment} перетворюється на $post->comments(). Немає такого методу на моделі - буде BadMethodCallException, а не тихий фолбек на глобальний пошук. Є й неочевидне автоматичне ввімкнення: якщо дочірньому параметру задано власний ключ ({comment:slug}), scoping вмикається сам. Тому дві майже однакові пари маршрутів поводяться по-різному через одну двокрапку. Для ресурсів це записується як ->scoped(['comment' => 'slug']), а вимкнути успадковане від групи scoping можна через ->withoutScopedBindings().

SoftDeletes ламає зв'язування рівно там, де воно потрібне найбільше. Трейт додає глобальний scope з deleted_at is null, і цей scope потрапляє в той самий firstOrFail(). Тому маршрут /posts/{post}/restore для видаленого поста віддає 404 з middleware й до контролера не доходить взагалі: код відновлення виглядає правильним і при цьому недосяжним. Рятує ->withTrashed() на маршруті (з Laravel 9): прапорець перевіряється через $route->allowsTrashedBindings(), і для моделей із трейтом резолв іде через resolveSoftDeletableRouteBinding(). Дві застороги. Прапорець належить маршруту, а не параметру, тож він відкриває видалені записи для всіх неявних зв'язувань цього маршруту, включно з дитиною в scoped-парі; вішати його на цілу групу чи ресурс означає ненароком опублікувати кошик. А якщо ключ зв'язування - slug, видалений запис досі займає своє значення в таблиці, тому withTrashed() цілком може повернути саме його замість нового поста з тим самим slug. Тому в самому контролері перевірка $post->trashed() лишається доречною, а маршрути відновлення краще тримати окремо від маршрутів показу.

// routes/web.php - порядок має значення: перший збіг виграє.
Route::get('posts/create', [PostController::class, 'create']);   // спершу літерал
Route::get('posts/{post}', [PostController::class, 'show']);     // потім параметр

// Обмеження на рівні роутера: нечислове значення -> 404 без походу в базу.
Route::get('orders/{order}', [OrderController::class, 'show'])->whereNumber('order');

// Пошук по slug лише для цього маршруту; route() усе одно підставить id,
// якщо в моделі не змінено getRouteKeyName().
Route::get('blog/{post:slug}', [BlogController::class, 'show']);

Route::middleware('auth')->group(function (): void {
    // scopeBindings: {comment} шукається через $post->comments(), а не по всій таблиці.
    Route::get('posts/{post}/comments/{comment}', [CommentController::class, 'show'])
        ->scopeBindings();

    // Без withTrashed() зв'язування не знайде видалений пост і поверне 404.
    Route::put('posts/{post}/restore', [PostController::class, 'restore'])
        ->withTrashed();

    // Замість 404 - редірект на список.
    Route::get('drafts/{post}', [DraftController::class, 'show'])
        ->missing(fn (): RedirectResponse => redirect()->route('posts.index'));
});

final class PostController
{
    // $post уже об'єкт: його дістав SubstituteBindings до виклику методу.
    public function show(Post $post): View
    {
        return view('posts.show', ['post' => $post]);
    }

    public function restore(Post $post): RedirectResponse
    {
        abort_unless($post->trashed(), 409); // маршрут пускає й невидалені
        $post->restore();

        return redirect()->route('posts.show', $post);
    }
}
Що маршрути перевіряються в порядку реєстрації і перший збіг виграє: `posts/{post}` вище за `posts/create` перехоплює слово `create` як значення параметра.
Що модель у сигнатурі підставляє middleware `SubstituteBindings` з групи `web`/`api`, а не рефлексія контролера — тому в глобальному middleware параметр ще рядок, а в route middleware вже об'єкт.
Що неявне зв'язування — це `firstOrFail()` за `getRouteKeyName()` (за замовчуванням первинний ключ), і `ModelNotFoundException` перетворюється на 404 у `Handler::prepareException()`.
Різницю між `{post:slug}` в маршруті, `getRouteKeyName()` у моделі й `Route::bind()` у провайдері: перше локальне, друге глобальне для моделі, третє дозволяє довільну логіку пошуку.
Що вкладені параметри за замовчуванням не пов'язані: `{comment}` шукається по всій таблиці `comments`, поки не ввімкнено `scopeBindings()` або не задано власний ключ дитини.
Що `SoftDeletes` додає `deleted_at is null` до запиту зв'язування, і маршрут відновлення без `withTrashed()` фізично не може знайти свою модель.
Ставити `Route::get('posts/{post}')` перед `Route::get('posts/create')` і потім шукати баг у контролері, а не в порядку рядків у `routes/web.php`.
Вважати, що `{post}` — це завжди `id`: після зміни `getRouteKeyName()` на `slug` усі згенеровані `route('posts.show', $post)` теж змінюються, бо `route()` бере `getRouteKey()`.
Покладатися на вкладеність URL як на авторизацію: `/posts/5/comments/900` віддасть коментар з поста 7, якщо не ввімкнути scoped bindings.
Робити `try/catch (ModelNotFoundException)` у контролері для неявного зв'язування — виняток кидається раніше, у middleware, і туди він не долетить.
Писати `->where('id', '.*')` або взагалі не обмежувати параметр, а потім дивуватися, що в `Post $post` летить `firstOrFail()` з рядком на 200 символів замість 404 від роутера.
Ліпити `withTrashed()` на весь ресурс, щоб «полагодити» 404 на `restore`, і випадково відкрити публічний показ видалених записів.
Тримати closure-маршрути в проєкті й дивуватися, чому `php artisan route:cache` падає з `LogicException: Unable to prepare route for serialization`.
ПОРАДА

Скажіть уголос, де саме відбувається зв'язування: `SubstituteBindings` у групі middleware, `firstOrFail()` за `getRouteKeyName()`, 404 до входу в контролер. Далі назвіть три перемикачі, якими це керують — `{post:slug}`, `scopeBindings()`, `withTrashed()` — і ви вже відповіли краще за більшість junior-кандидатів, які зупиняються на «Laravel сам знаходить модель по id».

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Контейнер не знає, який саме запис вам потрібен: значення параметра в об'єкт перетворює middleware `SubstituteBindings`, і в контролер приходить уже готова модель. Вкладеність URL сама по собі нічого не гарантує - без `scopeBindings()` (або без явного ключа на дочірньому параметрі) `{comment}` шукається по всій таблиці, і чужий коментар відкриється. `whereNumber()` — це обмеження патерна маршруту: нечисловий сегмент означає, що маршрут просто не збігся, і роутер іде далі по списку. Правильний варіант описує реальний ланцюжок: middleware → `getRouteKeyName()` → `firstOrFail()`, плюс глобальний scope `SoftDeletes` поверх нього.