Essentials

Routing

Define routes with closures, controllers, groups, and attributes

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/users
  • POST /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

AliasWhat it does
authAuthenticate via JWT bearer or API key. Params: optional (anonymous allowed), admin
auth_to_requestCopy the authenticated identity into request attributes (auth.user, auth.roles, …) for later middleware
session_cookieAccept the HttpOnly browser-session cookie; put it before auth (see Authentication)
require_scopeEnforce API-key or JWT scopes (auto-attached by #[RequireScope])
gate_permissionsEnforce #[RequiresPermission] / #[RequiresRole] (auto-attached)
adminAdmin-only access control
csrfCSRF token check for state-changing requests, configured by config/security.php's csrf block. Params: lifetime,doubleSubmit,validateOrigin
rate_limit / enhanced_rate_limitRate limiting (see below); API_RATE_LIMITING_ENABLED=false turns it off globally
allow_ipIP allowlist
lockdownEnforce emergency lockdown (system-wide mode, IP blocks)
security_headersSecurity response headers
conditional_cacheETag / Last-Modified conditional responses
field_selectionGraphQL-style ?fields= / expand= projection (auto-attached by #[Fields]); api.field_selection.enabled = false leaves responses unprojected
filterQuery-string filtering and sorting
validateRun #[Validate] rules. Use this alias, not the deprecated Glueful\Routing\Middleware\ValidationMiddleware class
request_loggingRequest/response logging. Params: mode,headers,bodies,level,slowMs
metricsAPI metrics collection
tracingDistributed tracing spans; passes requests through unless TRACING_ENABLED=true
api_versionAPI 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 { /* ... */ }
}
AttributeEffect
#[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.