Роуты, контроллеры и 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. Внедрение зависимостей — прямо в метод:

php
class 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:

php
class 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/4REST-имена: /orders/{order}
Имена роутов «a», «p2»Понятные имена: orders.show

Подводный камень

  1. route:cache несовместим с замыканиями. В проде роуты кешируют для скорости — а замыкания не сериализуются: команда упадёт. Вывод один: в роутах только [Controller::class, 'method'], никаких function (...) {}. Это и есть причина ситуации «на локалке работает, в проде деплой падает».
  2. Порядок middleware определяет доступность зависимостей. auth должен отработать раньше вашего middleware, которое использует $request->user() — иначе пользователь ещё null. Роуты читаются сверху вниз, поэтому общий middleware «до» ставится раньше конкретных.
  3. Binding по ID ≠ binding по slug. Order $order по умолчанию ищет по первичному ключу. Если в URL слаг — надо явно указать: Route::get('/posts/{post:slug}', ...) или getRouteKeyName() в модели. Иначе получите «роут есть, 404 приходит».
  4. 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

Три вывода

  1. Роуты — только карта запросов: URL и контроллер; логика в замыканиях ломает и переиспользование, и route:cache в проде.
  2. Route model binding заменяет ручной findOrFail — и сразу даёт 404; по слагам — только через явное указание {post:slug}.
  3. Middleware — это цепочка до контроллера: прервать или пропустить; порядок регистрации определяет, что уже доступно в $request.

Вопрос-ловушка

Спрашивают так: «Как работает middleware?» Простой ответ «фильтр перед запросом» — слабый. Сильный — показать механизм на коде: middleware умеет пустить запрос дальше (return $next($request)) или остановить (вернуть редирект). Этого достаточно.

Дальше обычно докручивают вопросом: «Ваш middleware логирует пользователя. Куда его поставить — до или после auth?» Если лог нужен для работы с пользователем — после auth: раньше в запросе пользователя ещё нет, он появится только после проверки аутентификации.

И ещё один частый вопрос: «Почему в роутах нельзя оставлять замыкания?» Ответ короткий: в проде роуты кешируют командой route:cache, а замыкания кеш не поддерживает — деплой упадёт с ошибкой.