Routing Recipes
This page covers the advanced routing surface: resolution precedence, cache behavior, performance internals, and the real route patterns the framework ships with.
Route Model Binding
Route model binding is not provided by the router. Resolve models explicitly in handlers or via services/repositories:
use App\Models\User;
use Glueful\Http\Response;
$router->get('/users/{slug}', function (string $slug) use ($router) {
$user = User::query($router->getContext())
->where('slug', $slug)
->firstOrFail();
return Response::success(['user' => $user->toArray()]);
});
CORS
CORS is configured through config/cors.php and its CORS_* env vars (origins, origin patterns, headers, exposed headers, max age, credentials). The router answers OPTIONS preflight automatically for any path with registered routes, returning 204 with an Allow header and the route's methods; an explicitly registered OPTIONS route takes precedence. Regular CORS headers are added to every response, errors included, after dispatch; preflight and actual responses share one origin rule, so origins allowed by CORS_ALLOWED_ORIGIN_PATTERNS work on both. There's no cors middleware alias. See CORS & CSRF.
Route Caching & Closures
Routes are automatically cached in production. In development the route cache uses a short TTL and auto-invalidates when route files change (including framework routes), so no CLI is needed while developing. In production, clear it on deploy with php glueful route:cache:clear.
Closure handlers are not cached. Closures can't be serialized, so when the router sees any closure handler it skips writing the route cache (logging which routes are responsible) and resolves those routes live on every request. For cacheable, production-grade routing, use
[Controller::class, 'method'](or string) handlers rather than closures. Attribute routes are always controller handlers, so they always cache.
To force-clear the cache, run php glueful route:cache:clear, delete storage/cache/routes_dev.php (routes_prod.php in production), or call:
app($context, Glueful\Routing\RouteCache::class)->clear();
The compiled cache stores each route's handler, middleware, path and constraints plus its rate limits and cost, #[Fields] / setFieldsConfig() config, version config and #[RequireScope] groups, so a cache-hydrated route behaves exactly like a freshly registered one (including the auto-attached rate_limit, field_selection and require_scope middleware).
Not Found / Method Not Allowed
The router standardizes JSON error responses for 404 Not Found and 405 Method Not Allowed (405 responses include an Allow header). No explicit fallback route is needed.
Performance & Precedence
How the router resolves a request
The router uses several optimization techniques:
- Static route hash table — O(1) lookup for routes without parameters
- Route bucketing — dynamic routes grouped by first path segment
- Compiled patterns — regex patterns pre-compiled and cached
- Reflection caching — method/parameter reflection cached
Route precedence
The router resolves a request in three tiers:
- Static routes win first. An exact path like
/users/popularalways beats a dynamic/users/{id}, no matter which was registered first — static routes are matched from a hash table before any pattern matching. - Literal first segment beats a parameter first segment.
/users/{id}is preferred over/{resource}/{id}for a request to/users/5, independent of registration order. - Within the same first-segment group there is no specificity ranking — overlapping dynamic patterns are tried in registration order, and the first that matches wins.
Because of tier 3, when two dynamic patterns can match the same path, register the more specific one first:
// Correct: specific before generic
$router->get('/posts/{id}/edit', [PostController::class, 'edit']);
$router->get('/posts/{id}/{action}', [PostController::class, 'action']);
// Wrong: the generic pattern shadows /posts/{id}/edit, which is never reached
$router->get('/posts/{id}/{action}', [PostController::class, 'action']);
$router->get('/posts/{id}/edit', [PostController::class, 'edit']); // unreachable for /posts/5/edit
Parameters match exactly one path segment (default constraint [^/]+), so routes with different segment counts never collide.
Optimization tips
// 1. Static routes always beat dynamic ones (automatic; no ordering needed)
$router->get('/users/popular', $handler); // exact match, checked first
$router->get('/users/{id}', $handler); // pattern match, checked second
// 2. Use specific constraints to fail fast
$router->get('/users/{id}', $handler)->where('id', '\d+');
// 3. Group related routes under a shared prefix
$router->group(['prefix' => '/api/v1'], function ($router) { /* ... */ });
Benchmarks
- Static routes: O(1) hash table lookup (~0.001ms)
- Dynamic routes: optimized regex matching (~0.01ms)
- Route compilation: one-time cost, cached indefinitely
- Middleware pipeline: lazy resolution, minimal overhead
Inspecting routes programmatically
$routes = $router->getAllRoutes(); // method, path, handler, middleware, name
Framework Endpoints Reference
Glueful ships several built-in endpoint groups that demonstrate real-world routing patterns, useful as templates for your own routes. routes/auth.php, routes/resource.php, routes/blobs.php, routes/extensions.php and routes/webhooks.php are mounted under the API prefix (e.g. /api/v1); routes/health.php and routes/docs.php are not.
Health monitoring (routes/health.php)
$router->group(['prefix' => '/health'], function (Router $router) {
// Main health check: high limit for monitoring tools
$router->get('/', [HealthController::class, 'index'])
->middleware('rate_limit:60,60'); // 60/min
// Component checks with lower limits
$router->get('/database', [HealthController::class, 'database'])
->middleware('rate_limit:30,60');
$router->get('/cache', [HealthController::class, 'cache'])
->middleware('rate_limit:30,60');
// Detailed diagnostics require auth
$router->get('/detailed', [HealthController::class, 'detailed'])
->middleware(['auth', 'rate_limit:10,60']);
// Kubernetes-style probes
$router->get('/live', [HealthController::class, 'liveness'])->middleware('rate_limit:60,60');
$router->get('/ready', [HealthController::class, 'readiness'])->middleware('rate_limit:30,60');
$router->get('/startup', [HealthController::class, 'startup'])->middleware('rate_limit:60,60');
});
$router->get('/healthz', [HealthController::class, 'liveness'])
->middleware('rate_limit:60,60');
$router->get('/ready', [HealthController::class, 'readiness'])
->middleware(['rate_limit:30,60', 'allow_ip']);
The group also has /health/middleware, /health/response-api (both behind auth) and /health/queue.
Authentication (routes/auth.php)
$router->group(['prefix' => '/auth'], function (Router $router) {
// Login with strict rate limiting
$router->post('/login', [AuthController::class, 'login'])
->middleware('rate_limit:5,60'); // 5 attempts/min
$router->post('/validate-token', [AuthController::class, 'validateToken'])
->middleware(['auth']);
$router->post('/refresh-token', [AuthController::class, 'refreshToken']);
$router->post('/logout', [AuthController::class, 'logout'])
->middleware(['auth']);
// Cookie-transport session routes, registered only when auth.session_cookie.enabled
if ((bool) config($router->getContext(), 'auth.session_cookie.enabled', false)) {
$router->group(['prefix' => '/session'], function (Router $router) {
$router->post('/refresh', [SessionController::class, 'refresh'])
->middleware('rate_limit:30,60');
$router->post('/logout', [SessionController::class, 'logout']);
});
}
$router->post('/refresh-permissions', [AuthController::class, 'refreshPermissions'])
->middleware(['auth']);
});
$router->get('/csrf-token', [AuthController::class, 'csrfToken']);
With SESSION_COOKIE_ENABLED=true, the same /auth/login route signs browsers in with cookies when the body includes "auth_transport": "cookie"; no separate login route is needed.
Account routes (verify-email, verify-otp, forgot/reset-password) are not in core; they ship with the glueful/users extension.
Webhook management (routes/webhooks.php)
$router->group(['prefix' => '/webhooks'], function (Router $router) {
$router->get('/subscriptions', [WebhookController::class, 'listSubscriptions'])
->middleware(['auth', 'rate_limit:60,60']);
$router->post('/subscriptions', [WebhookController::class, 'createSubscription'])
->middleware(['auth', 'rate_limit:20,60']);
// ... get / patch / delete /subscriptions/{id}, plus rotate-secret, test and stats
$router->get('/deliveries', [WebhookController::class, 'listDeliveries'])
->middleware(['auth', 'rate_limit:60,60']);
$router->post('/deliveries/{id}/retry', [WebhookController::class, 'retryDelivery'])
->middleware(['auth', 'rate_limit:20,60']);
});
Every route needs auth; the controller then requires system.config.view for reads and system.config.edit for changes (including secret rotation and test sends). Since 1.91 these routes ship in core at /api/v1/webhooks/...: remove any app route that mounted WebhookController on the same paths. See Webhooks.
Generic resource routes (routes/resource.php)
$router->group(['prefix' => '/data'], function (Router $router) {
// List, with field selection
$router->get('/{table}', [ResourceController::class, 'index'])
->setFieldsConfig(['strict' => false, 'maxDepth' => 6, 'maxFields' => 200, 'maxItems' => 1000])
->middleware(['auth', 'field_selection', 'rate_limit:100,60']);
// Single record by UUID: higher read limit
$router->get('/{table}/{uuid}', [ResourceController::class, 'show'])
->setFieldsConfig(['strict' => false, 'maxDepth' => 6, 'maxFields' => 200, 'maxItems' => 1000])
->middleware(['auth', 'field_selection', 'rate_limit:200,60']);
// Create / update: moderate limits
$router->post('/{table}', [ResourceController::class, 'store'])
->middleware(['auth', 'rate_limit:50,60']);
$router->put('/{table}/{uuid}', [ResourceController::class, 'update'])
->middleware(['auth', 'rate_limit:30,60']);
// Delete: lowest limit for destructive ops
$router->delete('/{table}/{uuid}', [ResourceController::class, 'destroy'])
->middleware(['auth', 'rate_limit:20,60']);
});
// Bulk routes, only when resource.security.bulk_operations is true
if (config($context, 'resource.security.bulk_operations', false)) {
$router->group(['prefix' => '/data'], function (Router $router) {
$router->delete('/{table}/bulk', [ResourceController::class, 'destroyBulk'])
->middleware(['auth', 'rate_limit:5,60']);
$router->put('/{table}/bulk', [ResourceController::class, 'updateBulk'])
->middleware(['auth', 'rate_limit:10,60']);
});
}
Key patterns
- Rate limiting by sensitivity: login very restrictive (5/min), health generous (60/min), reads high (100–200/min), writes moderate (30–50/min), deletes and bulk operations lowest (5–20/min).
- Security: every resource and webhook-management operation requires
auth; diagnostic health endpoints requireauth; destructive and bulk operations get the lowest limits; bulk routes are opt-in via config. - Conditional registration: optional surfaces (
/auth/session/*, bulk routes) are registered only when enabled, so disabled features have no endpoints at all. - URL structure: grouped by feature (
/auth,/health,/data,/webhooks), with generic resources under/data/{table}so they don't collide with your own routes.
Related
- Essentials → Routing — the fundamentals
- Advanced → Middleware — middleware pipeline + alias catalog
- Features → Field Selection —
?fields=/?expand=projection