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