Роуты, контроллеры и middleware в Laravel: как запрос находит код
Роуты — это карта приложения: они связывают URL и метод запроса с тем, какой код его обработает. Контроллер — это класс, который принимает запрос, вызывает нужную логику и возвращает ответ, но сам бизнес-логику не пишет. Middleware — это «труба», через которую проходит каждый запрос до контроллера: проверка аутентификации, CSRF, логирование. Понимать связку нужно, потому что через неё работает весь lifecycle запроса: «как запрос попадает в контроллер» — а на практике именно здесь появляется всё лишнее: логика в замыканиях роутов, middleware на каждый чих, десять методов в одном контроллере.
Зачем это нужно
Боль из практики: в проекте новая страница каталога была сделана «быстро» — прямо в роуте, замыканием:
php// ПЛОХО: бизнес-логика прямо в роуте
Route::get('/catalog', function (Request $request) {
$products = DB::table('products')
->when($request->category, fn ($q, $cat) => $q->where('category_id', $cat))
->orderBy('created_at', 'desc')
->paginate(20);
return view('catalog', ['products' => $products]);
});
Выглядело короче, чем контроллер. Через месяц: нужно показать каталог в мобильном приложении через API — и выясняется, что вся выборка заперта в замыкании роута, её нельзя вызвать из другого места и нельзя протестировать отдельно. А заодно обнаружилась вторая мина: в проде роуты кешируются командой php artisan route:cache — и замыкания в кеш не сериализуются, команда падает с ошибкой, если хотя бы один роут объявлен замыканием. Исправление — логика в контроллер:
php// ХОРОШО: роут — только карта, логика — в контроллере
Route::get('/catalog', [CatalogController::class, 'index'])
->name('catalog.index');
Теперь та же выборка переиспользуется контроллером API и тестируется в feature-тестах.
Как это работает
Роуты живут в routes/web.php (веб, с сессиями и CSRF) и routes/api.php (API, с токенами, префикс /api). Регистрация:
php// routes/web.php
use App\Http\Controllers\CatalogController;
use App\Http\Controllers\OrderController;
// класс и метод — рекомендуемый способ (кешируется, поддерживает DI)
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog.index');
Route::post('/orders', [OrderController::class, 'store'])->name('orders.store');
// именованные роуты — вместо хардкода URL в шаблонах и коде
route('catalog.index'); // http://site.test/catalog
Route::get('/orders/{order}', [OrderController::class, 'show'])->name('orders.show');
route('orders.show', ['order' => $id]);
// группы с общими middleware
Route::middleware(['auth'])->prefix('account')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/orders', [OrderController::class, 'myOrders'])->name('orders.my');
});
Route model binding — вместо ручного find:
php// ПЛОХО: вручную
public function show(string $id)
{
$order = Order::findOrFail($id);
return view('orders.show', ['order' => $order]);
}
// ХОРОШО: Laravel сам находит модель, не нашёл — 404
public function show(Order $order)
{
return view('orders.show', ['order' => $order]);
}
Контроллер — класс с методами на каждый сценарий. Создание: php artisan make:controller CatalogController. Внедрение зависимостей — прямо в метод:
phpclass OrderController extends Controller
{
public function __construct(
private OrderService $service, // внедрено из контейнера
) {}
public function show(Order $order) // и модель тоже
{
return view('orders.show', ['order' => $order]);
}
public function store(StoreOrderRequest $request) // Form Request — тема №22
{
$order = $this->service->create($request->validated());
return redirect()->route('orders.show', $order);
}
}
Middleware — слой, через который проходит запрос до контроллера (и ответ после). Каждый слой может пропустить запрос дальше, прервать его или изменить:
php// Цепочка для запроса к /account/dashboard:
// запрос → middleware "web" → middleware "auth" → метод контроллера → ответ
Свой middleware — php artisan make:middleware EnsureUserIsActive:
phpclass EnsureUserIsActive
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->is_active) {
return redirect('/'); // прерываем запрос
}
return $next($request); // пропускаем дальше по цепочке
}
}
Регистрация — в bootstrap/app.php (Laravel 11+) или в Kernel (10 и старше): алиас и, при необходимости, группа. Выше ваших групп есть два «глобальных» набора: группа web (сессии, CSRF-токен, куки) и группа api (rate limiting) — своя логика туда обычно не нужна.
Как это выглядит в Laravel
php artisan route:list— полный список маршрутов с middleware каждого; первый инструмент, когда «роут отдаёт 404, а должен работать».apiResource/resource— семь стандартных маршрутов одним вызовом:index,create,store,show,edit,update,destroy.- Файлы роутов грузит
RouteServiceProvider— там меняются префикс, middleware и лимиты (в Laravel 11 — прямо вbootstrap/app.php). - Порядок middleware важен: сессия должна начаться раньше, чем аутентификация прочитает пользователя из сессии; если ваш middleware в группе
webидёт послеauth,$request->user()в нём ещё пустая.
Плохо / Хорошо
| Плохо | Хорошо |
|---|---|
| Логика в замыкании роута | Контроллер: логика вызываемая и тестируемая |
$request->all() без проверки | Form Request с правилами (тема №22) |
Order::findOrFail($id) в методе | Route model binding: Order $order |
| Свой middleware «на всё» глобально | Middleware точечно: на группу или отдельный роут |
Многословные URL /get-order-by-id/4 | REST-имена: /orders/{order} |
| Имена роутов «a», «p2» | Понятные имена: orders.show |
Подводный камень
route:cacheнесовместим с замыканиями. В проде роуты кешируют для скорости — а замыкания не сериализуются: команда упадёт. Вывод один: в роутах только[Controller::class, 'method'], никакихfunction (...) {}. Это и есть причина ситуации «на локалке работает, в проде деплой падает».- Порядок middleware определяет доступность зависимостей.
authдолжен отработать раньше вашего middleware, которое использует$request->user()— иначе пользователь ещё null. Роуты читаются сверху вниз, поэтому общий middleware «до» ставится раньше конкретных. - Binding по ID ≠ binding по slug.
Order $orderпо умолчанию ищет по первичному ключу. Если в URL слаг — надо явно указать:Route::get('/posts/{post:slug}', ...)илиgetRouteKeyName()в модели. Иначе получите «роут есть, 404 приходит». - CSRF-исключения. Все POST-роуты группы
webзащищены CSRF-токеном; внешние вебхуки платёжных систем на этих роутах будут падать с 419. Их переносят вapi-роуты или исключают черезvalidateCsrfTokens— осознанно, понимая, что там придёт чужой запрос.
Мини-шпаргалка
php// Регистрация: класс+метод, имя, группа
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog.index');
Route::middleware(['auth'])->prefix('account')->group(function () {
Route::resource('orders', OrderController::class)->only(['index', 'show']);
});
// Binding по слагу
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
// Отладка
php artisan route:list --path=orders // все роуты по подстроке
php artisan route:cache && php artisan route:clear
Три вывода
- Роуты — только карта запросов: URL и контроллер; логика в замыканиях ломает и переиспользование, и
route:cacheв проде. - Route model binding заменяет ручной
findOrFail— и сразу даёт 404; по слагам — только через явное указание{post:slug}. - Middleware — это цепочка до контроллера: прервать или пропустить; порядок регистрации определяет, что уже доступно в
$request.
Вопрос-ловушка
Спрашивают так: «Как работает middleware?» Простой ответ «фильтр перед запросом» — слабый. Сильный — показать механизм на коде: middleware умеет пустить запрос дальше (return $next($request)) или остановить (вернуть редирект). Этого достаточно.
Дальше обычно докручивают вопросом: «Ваш middleware логирует пользователя. Куда его поставить — до или после auth?» Если лог нужен для работы с пользователем — после auth: раньше в запросе пользователя ещё нет, он появится только после проверки аутентификации.
И ещё один частый вопрос: «Почему в роутах нельзя оставлять замыкания?» Ответ короткий: в проде роуты кешируют командой route:cache, а замыкания кеш не поддерживает — деплой упадёт с ошибкой.