Routing
Glueful’s router supports explicit route files, route groups, middleware, named routes, and PHP attributes.
Most applications start with routes/api.php in glueful/api-skeleton.
Route Files
Every *.php file in your app's routes/ directory is loaded automatically, in alphabetical order, with $router (a Glueful\Routing\Router) and $context (the ApplicationContext) in scope. Files whose name starts with _ (e.g. _helpers.php) are skipped, so you can keep partials there and require them yourself. There is no Route facade — always register on $router.
App routes load before framework routes, so an app route wins over a generic framework route on the same path.
API Prefix
Your route files are not prefixed automatically — you control prefixes with groups (the skeleton uses a v1 group). Framework and extension API routes (/auth/*, /blobs/*, /me, …) are mounted under the configured API prefix, /api/v1 by default; health and docs routes (/health, /healthz, /ready, the docs UI) stay unprefixed.
API_USE_PREFIX=true # false → no "/api" segment (e.g. api.example.com/v1/...)
API_PREFIX=/api
API_VERSION_IN_PATH=true # false → no "/v1" segment
API_VERSION=1
To put your own routes on the same prefix, use the api_prefix() helper:
$router->group(['prefix' => api_prefix($context)], function (Router $router) {
$router->get('/posts', [PostController::class, 'index']); // /api/v1/posts
});
Basic Routes
use Glueful\Http\Response;
$router->get('/welcome', function () {
return Response::success(['message' => 'Welcome']);
});
$router->get('/users', [UserController::class, 'index']);
$router->post('/users', [UserController::class, 'store']);
HTTP Methods
$router->get('/users', ...);
$router->post('/users', ...);
$router->put('/users/{id}', ...);
$router->patch('/users/{id}', ...);
$router->delete('/users/{id}', ...);
$router->head('/status', ...);
$router->options('/users', ...);
OPTIONS is answered automatically for CORS preflight (a 204 with an Allow
header listing the methods registered for that path), so you only need
$router->options(...) when you want to take over preflight with your own
handler — an explicit OPTIONS route takes precedence over the automatic
response.
Route Parameters
$router->get('/users/{id}', function (int $id) {
return Response::success(['id' => $id]);
});
Parameter Constraints
$router->get('/users/{id}', [UserController::class, 'show'])
->where('id', '\d+');
$router->get('/posts/{year}/{month}', [PostController::class, 'index'])
->where([
'year' => '\d{4}',
'month' => '\d{2}',
]);
Route Groups
Groups are the normal way to organize versioned APIs:
use Glueful\Routing\Router;
$router->group(['prefix' => 'v1'], function (Router $router) {
$router->get('/users', [UserController::class, 'index']);
$router->post('/users', [UserController::class, 'store']);
});
That produces:
GET /v1/usersPOST /v1/users
Group Middleware
$router->group([
'prefix' => 'v1/admin',
'middleware' => ['auth']
], function (Router $router) {
$router->get('/dashboard', [AdminController::class, 'dashboard']);
});
Route Middleware
$router->get('/profile', [ProfileController::class, 'show'])
->middleware('auth');
$router->post('/upload', [UploadController::class, 'store'])
->middleware(['auth', 'rate_limit:10,60']); // 10 requests per 60 seconds
Parameters follow a colon and are comma-separated (auth:optional, rate_limit:10,60). They arrive as strings; the built-in middleware read numeric and boolean values from them ("300", "true"/"false", "1"/"0"), and a value they can't parse falls back to the configured default. For example csrf:3600,false,false sets the token lifetime, double-submit and origin checks, and request_logging:both,true,true sets the log mode, headers and bodies.
Since 1.91: csrf: and request_logging: params take effect. Earlier releases ignored them and ran on defaults, so check routes that pass them when you upgrade: the values you wrote now apply.
Middleware aliases
| Alias | What it does |
|---|---|
auth | Authenticate via JWT bearer or API key. Params: optional (anonymous allowed), admin |
auth_to_request | Copy the authenticated identity into request attributes (auth.user, auth.roles, …) for later middleware |
session_cookie | Accept the HttpOnly browser-session cookie; put it before auth (see Authentication) |
require_scope | Enforce API-key or JWT scopes (auto-attached by #[RequireScope]) |
gate_permissions | Enforce #[RequiresPermission] / #[RequiresRole] (auto-attached) |
admin | Admin-only access control |
csrf | CSRF token check for state-changing requests, configured by config/security.php's csrf block. Params: lifetime,doubleSubmit,validateOrigin |
rate_limit / enhanced_rate_limit | Rate limiting (see below); API_RATE_LIMITING_ENABLED=false turns it off globally |
allow_ip | IP allowlist |
lockdown | Enforce emergency lockdown (system-wide mode, IP blocks) |
security_headers | Security response headers |
conditional_cache | ETag / Last-Modified conditional responses |
field_selection | GraphQL-style ?fields= / expand= projection (auto-attached by #[Fields]); api.field_selection.enabled = false leaves responses unprojected |
filter | Query-string filtering and sorting |
validate | Run #[Validate] rules. Use this alias, not the deprecated Glueful\Routing\Middleware\ValidationMiddleware class |
request_logging | Request/response logging. Params: mode,headers,bodies,level,slowMs |
metrics | API metrics collection |
tracing | Distributed tracing spans; passes requests through unless TRACING_ENABLED=true |
api_version | API version negotiation |
You can also pass a middleware class name instead of an alias.
Rate limits
The string form is rate_limit:attempts,windowSeconds[,by], where by is ip (the default), user or endpoint. For tiers, algorithms or several windows, configure the limit on the route with the ->rateLimit() builder. The router attaches the rate_limit middleware for you:
// ->rateLimit(int $attempts, int $perMinutes = 1, ?string $tier = null,
// string $algorithm = 'sliding', string $by = 'ip')
$router->post('/v1/reports/export', [ReportController::class, 'export'])
->rateLimit(3, 15) // 3 per 15 minutes, per IP
->rateLimit(20, 1440, by: 'user'); // stack calls for multi-window limits
$algorithm is fixed, sliding, or bucket; $by is ip, user, or endpoint. Builder and attribute config takes precedence over string params. If the route already lists rate_limit (or enhanced_rate_limit), it isn't added a second time. Over-limit requests get a 429 with rate-limit headers, and a RateLimitExceededEvent is dispatched.
Since 1.91: ->rateLimit() and #[RateLimit] enforce their limit on their own. Earlier releases stored the limit but didn't apply it unless you also added ->middleware('rate_limit'). That extra middleware is harmless now, but you no longer need it. The third string param (user/endpoint) also applies now; before, every request was keyed by IP. API_RATE_LIMITING_ENABLED=false (api.rate_limiting.enabled) also takes effect now and disables every limit, so check apps that set it. See Rate Limiting.
Named Routes
Routes can be named:
$route = $router->get('/users/{id}', [UserController::class, 'show'])
->where('id', '\d+')
->name('users.show');
To generate a URL, use the router by name or the route object:
$url = $router->url('users.show', ['id' => 123]); // /users/123
$url = $route->generateUrl(['id' => 123], ['tab' => 'posts']); // /users/123?tab=posts
Controller Resolution
When you register [Controller::class, 'method'], the router resolves the controller through the container. There is no autowire fallback for unregistered classes, so every controller must be registered — in the skeleton, add it to app/Providers/AppServiceProvider::services() with 'autowire' => true (see Controllers). An unregistered controller fails with "Service not found" when its route is hit.
Handler parameters are resolved by name/type: route parameters ({id} → int $id), the Symfony Request, container services, and typed request DTOs.
Attribute Routing
Glueful also supports attributes. Controllers in app/Controllers/ are scanned for route attributes automatically at boot (they still need container registration). Attribute routes are not API-prefixed — put the full prefix in #[Controller].
Class-level prefix:
use Glueful\Routing\Attributes\Controller;
use Glueful\Routing\Attributes\Get;
use Glueful\Http\Response;
#[Controller(prefix: '/v1/users')]
class UserController
{
#[Get('/{id}', where: ['id' => '\d+'])]
public function show(int $id): Response
{
return Response::success(['id' => $id]);
}
}
Shorthand method attributes are available for each verb —
#[Get], #[Post], #[Put], #[Patch], #[Delete], #[Options] — under
Glueful\Routing\Attributes.
Generic route attribute (one or more methods):
use Glueful\Routing\Attributes\Route;
class HealthController
{
#[Route('/ping', methods: 'GET', name: 'ping')]
public function index(): Response
{
return Response::success(['ok' => true]);
}
// The methods array accepts GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS.
#[Route('/resource/{id}', methods: ['PUT', 'PATCH'], where: ['id' => '\d+'])]
public function replaceOrUpdate(int $id): Response
{
return Response::success(['id' => $id]);
}
}
Route attributes
These method attributes attach behaviour to an attribute-defined route:
use Glueful\Api\RateLimiting\Attributes\RateLimit;
use Glueful\Routing\Attributes\{Controller, Get, Post, Middleware, Fields, RequireScope, ResponseStatus};
#[Controller(prefix: '/api/v1/posts', middleware: ['auth'])]
class PostController extends BaseController
{
#[Get('/')]
#[Fields(allowed: ['id', 'title', 'author', 'author.name'], strict: true)]
#[RateLimit(attempts: 60, perMinutes: 1)]
#[RateLimit(attempts: 1000, perHours: 1)]
public function index(): Response { /* ... */ }
#[Post('/')]
#[RequireScope('write:posts')]
#[ResponseStatus(201)]
public function store(CreatePostData $input): PostData { /* ... */ }
}
| Attribute | Effect |
|---|---|
#[Middleware('x')] / #[Middleware([...])] | Adds middleware (class or method level). |
#[RateLimit(...)] | Repeatable; attempts, perMinutes/perHours/perDays, tier, algorithm, by. Auto-attaches rate_limit. On the class it applies to every method that has no #[RateLimit] of its own (a method-level set replaces the class-level one). #[RateLimitCost(cost: 10)] weights expensive endpoints. |
#[Fields(...)] | Field-selection whitelist and limits (allowed, strict, maxDepth, maxFields, maxItems, useAdvancedPatterns). Auto-attaches field_selection, which projects the response by ?fields= / ?expand=. In strict mode a field outside the whitelist returns 400. See Field Selection. |
#[RequireScope('scope')] | Requires the scope on the credential (an API key's scopes, or a JWT identity's scopes); auto-attaches require_scope. One attribute = any-of; stacked attributes = all-of. A JWT with no scopes is denied. See Authentication. |
#[RequiresPermission] / #[RequiresRole] | Authorization gates; auto-attach gate_permissions. Stacked #[RequiresRole] means any one of the roles; stacked #[RequiresPermission] means all of them. |
#[ResponseStatus(201)] | Success status for a returned ResponseData DTO (2xx only). |
Documenting Endpoints (OpenAPI)
Glueful builds its OpenAPI 3.1 spec code-first from the live route table and your PHP types. Build the spec and a browsable UI with:
php glueful generate:openapi --ui
Without any annotations, the generator infers a lot from the route table and your types: paths and path parameters, security from auth, the standard error responses, field-selection params from #[Fields], the request body from a typed RequestData parameter, and the success response from a ResponseData return type. For what types can't express, annotate controller methods with #[ApiOperation], #[QueryParam], #[ApiRequestBody] and #[ApiResponse]. Route docblock tags are no longer read. Unless #[ApiOperation(tags: [...])] sets them, operations are tagged by the first path segment after the API prefix (/api/v1/users → "Users"). Since 1.91: before, everything under /api/v1 was tagged "Api", so generated SDKs and UI grouping change on regeneration.
→ See OpenAPI for the attributes, generator options and SDK generation.
Route Commands
php glueful route:debug # list every registered route
php glueful route:debug --method=POST --path=/auth # filter by method / path substring / --name
php glueful route:cache:status
php glueful route:cache:clear
There is no route:cache command: the compiled route cache is written at boot when ROUTE_CACHE=true (the default). Clear it after changing routes if they don't show up, and run php glueful route:cache:clear on every deploy.
Since 1.91: routes loaded from the cache keep their rate limits, #[Fields] config and #[RequireScope] requirements. In earlier releases a cache-hydrated route lost them, so scoped routes served from the cache let requests through. The cache format is now v3. Old cache files are discarded on the next boot, but clear the cache on deploy anyway (see Upgrading).
Closure handlers are not cached
The route cache compiles handlers to a serializable form, and closures cannot be serialized. When the router detects any route that uses a closure handler, it skips writing the cache entirely (and logs which routes are responsible), and on load it discards a cache file that still contains closure placeholders. The routes keep working — they are just resolved live on every request, with no compiled-cache speedup.
This is fine for prototyping, but for production routing performance use a cacheable handler instead of a closure:
// Not cacheable — resolved live on every request:
$router->get('/welcome', fn() => Response::success(['ok' => true]));
// Cacheable — array or string handler:
$router->get('/welcome', [WelcomeController::class, 'index']);
Attribute-defined routes always use [Controller::class, 'method'] handlers, so
they are always cacheable.