HardТеория7 min

Подвохи: Routing и Middleware

Каверзные вопросы по route model binding, middleware priority, rate limiting, route groups, fallback routes

Routing и Middleware -- основа HTTP-обработки в Laravel. Вопросы на экзамене часто проверяют нюансы, которые разработчики упускают в повседневной работе.

Route Model Binding

Implicit Binding

<?php

use App\Models\User;

// TRAP 1: Parameter name MUST match variable name
Route::get('/users/{user}', function (User $user) {
    return $user;
});
// {user} matches $user -> works!

// WRONG: parameter name doesn't match
Route::get('/users/{id}', function (User $user) {
    return $user;
});
// {id} does NOT match $user -> $user will NOT be auto-resolved!
// Instead, $user will be injected from container (empty model)

Экзаменационный вопрос: "Будет ли работать route model binding, если параметр маршрута {id}, а переменная User $user?" Ответ: Нет. Имя параметра маршрута ДОЛЖНО совпадать с именем переменной (без $). {user} -> $user.

Custom Key для binding

<?php

// Default: binds by primary key (id)
Route::get('/users/{user}', fn (User $user) => $user);
// /users/1 -> User::find(1)

// Custom key in route:
Route::get('/users/{user:slug}', fn (User $user) => $user);
// /users/john-doe -> User::where('slug', 'john-doe')->firstOrFail()

// Custom key in model (global):
class User extends Model
{
    public function getRouteKeyName(): string
    {
        return 'slug'; // Always bind by slug
    }
}
// Now {user} always resolves by slug

TRAP 2: Если определён getRouteKeyName() в модели, он применяется ВЕЗДЕ. Чтобы в отдельных маршрутах использовать id, нужно явно указать {user:id}.

Scoped Binding (вложенные ресурсы)

<?php

// TRAP 3: Scoped binding with nested resources
Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
    return $post;
});

// When you specify key (:slug), Laravel automatically scopes:
// Post::where('slug', $slug)->where('user_id', $user->id)->firstOrFail()

// Without custom key, scoping is NOT automatic:
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
    return $post;
});
// Post::find($postId) -> NO scoping to user!

// To force scoping WITHOUT custom key:
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
    return $post;
})->scopeBindings();

// Or globally in route group:
Route::scopeBindings()->group(function () {
    Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
        return $post;
    });
});

Экзаменационный вопрос: "Как работает scoped binding в nested resources?" Ответ: Scoped binding автоматически фильтрует дочернюю модель по foreign key родительской ТОЛЬКО когда указан custom key (:slug). Без custom key нужен ->scopeBindings() или Route::scopeBindings()->group().

Binding и Soft Deletes

<?php

// TRAP 4: Soft deleted models are NOT found by default

Route::get('/posts/{post}', function (Post $post) {
    return $post;
});
// /posts/1 -> 404 if post is soft deleted!

// To include soft deleted:
Route::get('/posts/{post}', function (Post $post) {
    return $post;
})->withTrashed();

// Or in model:
class Post extends Model
{
    public function resolveRouteBinding($value, $field = null): ?Model
    {
        return $this->where($field ?? $this->getRouteKeyName(), $value)
            ->withTrashed()
            ->first();
    }
}

Explicit Binding

<?php

// In RouteServiceProvider or AppServiceProvider boot():
use Illuminate\Support\Facades\Route;

public function boot(): void
{
    Route::model('user', User::class);

    // Custom resolution logic:
    Route::bind('user', function (string $value) {
        return User::where('slug', $value)
            ->orWhere('id', $value)
            ->firstOrFail();
    });
}

Enum Binding

<?php

// TRAP 5: Enums in routes (Laravel 11+)
enum Category: string
{
    case Fruit = 'fruit';
    case Vegetable = 'vegetable';
}

Route::get('/foods/{category}', function (Category $category) {
    return $category->value;
});
// /foods/fruit -> works
// /foods/meat -> 404 (not a valid enum value)

Middleware

Middleware Priority

<?php

// TRAP 6: Middleware execution order

// Global middleware runs in ORDER they are registered
// Route middleware runs in ORDER they are applied

// In bootstrap/app.php (Laravel 11+):
->withMiddleware(function (Middleware $middleware) {
    $middleware->priority([
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
        \Illuminate\Routing\Middleware\ThrottleRequests::class,
    ]);
})

// Priority matters! Session MUST start before Auth checks
// because Auth reads session for user data

Middleware Parameters

<?php

// TRAP 7: Passing parameters to middleware

Route::get('/admin', function () {
    // ...
})->middleware('role:admin');

// Multiple parameters:
Route::get('/admin', function () {
    // ...
})->middleware('role:admin,editor');

// In middleware class:
class RoleMiddleware
{
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        if (!in_array($request->user()->role, $roles)) {
            abort(403);
        }

        return $next($request);
    }
}

// Registration in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role' => RoleMiddleware::class,
    ]);
})

Terminable Middleware

<?php

// TRAP 8: terminate() runs AFTER response is sent to client

class LogRequestMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        // Before request (going IN)
        $request->start_time = microtime(true);

        $response = $next($request);

        // After request (going OUT, but response not sent yet)
        return $response;
    }

    // Called AFTER response is sent to browser
    public function terminate(Request $request, Response $response): void
    {
        $duration = microtime(true) - $request->start_time;
        logger()->info("Request took {$duration}s");
    }
}

Экзаменационный вопрос: "Когда вызывается метод terminate() middleware?" Ответ: ПОСЛЕ того, как ответ отправлен клиенту. Это не задерживает ответ. Полезно для логирования, очистки, аналитики.

Middleware Groups

<?php

// TRAP 9: Middleware groups vs individual middleware

// bootstrap/app.php (Laravel 11+):
->withMiddleware(function (Middleware $middleware) {
    // Append to existing 'web' group
    $middleware->web(append: [
        \App\Http\Middleware\LocaleMiddleware::class,
    ]);

    // Prepend to existing 'api' group
    $middleware->api(prepend: [
        \App\Http\Middleware\ApiVersionMiddleware::class,
    ]);

    // Remove from group
    $middleware->web(remove: [
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
    ]);
})

Excluding Middleware

<?php

// TRAP 10: withoutMiddleware on specific routes

Route::middleware('auth')->group(function () {
    Route::get('/dashboard', DashboardController::class);

    // This route does NOT require auth
    Route::get('/public-stats', StatsController::class)
        ->withoutMiddleware('auth');
});

// Works with multiple middleware:
Route::get('/webhook', WebhookController::class)
    ->withoutMiddleware(['auth', 'csrf']);

Rate Limiting

Определение rate limiter

<?php

// In AppServiceProvider boot() or bootstrap/app.php:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

// Multiple limits:
RateLimiter::for('uploads', function (Request $request) {
    return [
        Limit::perMinute(10)->by($request->user()->id),  // 10 per minute per user
        Limit::perDay(100)->by($request->user()->id),     // 100 per day per user
    ];
});

// No limit for specific users:
RateLimiter::for('api', function (Request $request) {
    return $request->user()?->isPremium()
        ? Limit::none()
        : Limit::perMinute(60)->by($request->ip());
});

// Custom response on limit:
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)
        ->by($request->ip())
        ->response(function (Request $request, array $headers) {
            return response('Rate limit exceeded', 429, $headers);
        });
});

Применение rate limiting

<?php

// Apply to route
Route::middleware('throttle:api')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});

// TRAP 11: throttle:X,Y syntax
Route::middleware('throttle:60,1')->get('/test', fn () => 'ok');
// 60 requests per 1 minute

// Using named limiter:
Route::middleware('throttle:uploads')->post('/upload', UploadController::class);

Route Groups

Group attributes

<?php

// TRAP 12: Group attributes are MERGED, not replaced

Route::prefix('admin')
    ->middleware(['auth', 'admin'])
    ->name('admin.')
    ->group(function () {
        // This route:
        Route::get('/users', UserController::class)->name('users');
        // Full URL: /admin/users
        // Full name: admin.users
        // Middleware: auth, admin
    });

// Nested groups:
Route::prefix('api/v1')
    ->middleware('api')
    ->group(function () {
        Route::prefix('admin')
            ->middleware('admin')
            ->group(function () {
                Route::get('/users', fn () => 'users');
                // URL: /api/v1/admin/users
                // Middleware: api + admin (merged!)
            });
    });

Controller group

<?php

// TRAP 13: Controller group (Laravel 11+)
Route::controller(OrderController::class)->group(function () {
    Route::get('/orders', 'index');
    Route::post('/orders', 'store');
    Route::get('/orders/{order}', 'show');
});

// DON'T specify class in the group:
// Route::get('/orders', [OrderController::class, 'index']); // REDUNDANT

Fallback Routes

<?php

// TRAP 14: Fallback route catches ALL unmatched routes

Route::fallback(function () {
    return response()->json(['error' => 'Not Found'], 404);
});

// TRAP: Fallback must be the LAST route defined
// If defined before other routes, those routes still work
// But any non-matching URL hits fallback instead of 404 exception

// Fallback in API:
Route::prefix('api')->group(function () {
    Route::get('/users', UserController::class);

    // API-specific fallback
    Route::fallback(function () {
        return response()->json(['error' => 'API endpoint not found'], 404);
    });
});

Redirect Routes

<?php

// TRAP 15: redirect() route uses 302 by default

Route::redirect('/old', '/new');       // 302 (temporary)
Route::redirect('/old', '/new', 301);  // 301 (permanent)
Route::permanentRedirect('/old', '/new'); // 301

// View route:
Route::view('/about', 'about', ['version' => '1.0']);
// No controller needed!

Route Caching

<?php

// TRAP 16: Route caching and closures

// Closure routes CANNOT be cached!
Route::get('/test', function () {
    return 'test';
});

// Running `php artisan route:cache` will FAIL with closure routes
// Always use controller classes for production

// Cache routes:
// php artisan route:cache

// Clear cache:
// php artisan route:clear

Экзаменационный вопрос: "Что произойдёт при выполнении php artisan route:cache если есть маршруты с closure?" Ответ: Команда выбросит исключение. Closure-based маршруты нельзя кешировать. Используйте контроллеры.

Current Route

<?php

// TRAP 17: Getting current route info

$route = Route::current();        // Route object
$name = Route::currentRouteName(); // 'users.show'
$action = Route::currentRouteAction(); // 'App\Http\Controllers\UserController@show'

// In request:
$request->route();         // Route object
$request->route('user');   // Route parameter value
$request->routeIs('users.*'); // Check if name matches pattern

// In Blade:
@if(request()->routeIs('admin.*'))
    // Admin sidebar
@endif

Subdomain Routing

<?php

// TRAP 18: Subdomain routing captures subdomain as parameter

Route::domain('{account}.app.test')->group(function () {
    Route::get('/users/{id}', function (string $account, string $id) {
        // $account = subdomain value
        // Parameters are passed in ORDER: subdomain first, then route params
    });
});

// TRAP: Subdomain parameter comes BEFORE route parameters in callback!

Проверь себя

Как работает scoped binding в `/users/{user}/posts/{post:slug}`?

Когда вызывается метод terminate() в middleware?

Как передать несколько параметров в middleware?

Что произойдёт при кешировании маршрутов, если есть closure-based routes?

Будет ли работать route model binding если маршрут `/users/{id}` а переменная `User $user`?