Middleware
Middleware wraps your handlers. It can inspect or reject a request before the handler runs, and change the response after it. Authentication, rate limits and caching are all middleware.
Route middleware
Add middleware to a route, or to every route of a group:
$router->post('/contact', [ContactController::class, 'store'])
->middleware('throttle:forms', 'idempotent');
$router->group('/users', function (Router $router): void {
// ...
}, ['auth:users.manage']);
Middleware can be written as an alias string, a class name, an object or a closure.
Aliases in a new project
public/api.php defines three aliases:
| Alias | Does |
|---|---|
throttle:public, throttle:forms, throttle:auth |
Named rate limits: API_RATE_LIMIT (60) per minute, 5 per minute, 10 per 15 minutes. See rate limiting. |
auth, auth:users.manage,content.edit |
Requires a personal access token or the admin session, optionally with permissions. See authentication. |
idempotent |
Safe retries with the Idempotency-Key header. See idempotency. |
Add your own in public/api.php:
$kernel->aliases([
// ...the existing aliases
'json' => static fn (): RequireJsonMiddleware => new RequireJsonMiddleware(),
]);
Text after the colon is passed to the alias function, split at commas: auth:users.manage,content.edit calls it with 'users.manage', 'content.edit'.
Global middleware
Global middleware runs for every API request, also for unknown routes, before the route's own middleware. The list in public/api.php is outermost first:
$kernel->middleware([
new SecurityHeadersMiddleware(), // headers on every response, errors included
new ExceptionMiddleware(...), // errors as JSON
new CorsMiddleware(...), // answers preflight requests
new MaintenanceMiddleware(...), // 503 during maintenance
new LocaleMiddleware(...), // ?lang= and Accept-Language
new RateLimitMiddleware(...), // API_RATE_LIMIT per minute per client
new BodySizeMiddleware(), // 413 for bodies over 2 MB
new JsonBodyMiddleware(), // 400 for malformed JSON
]);
Keep this order when you change it: security headers and JSON errors outside, so even errors from later middleware get them; CORS before the rate limit, so preflight requests are never counted.
Writing middleware
php flo make:middleware RequireApiVersion
creates api/Middleware/RequireApiVersion.php:
<?php
declare(strict_types=1);
namespace App\Api\Middleware;
use FloCMS\Api\Contracts\MiddlewareInterface;
use FloCMS\Api\Contracts\RequestHandlerInterface;
use FloCMS\Core\Http\Request;
use FloCMS\Core\Http\Response;
final class RequireApiVersion implements MiddlewareInterface
{
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Before the handler: inspect or reject the request here.
$response = $handler->handle($request);
// After the handler: change the response here.
return $response;
}
}
Use it on a route with ->middleware(new RequireApiVersion()). To stop a request, throw an ApiException, or return a response without calling $handler:
if ($request->header('X-Client-Version') === null) {
throw new \FloCMS\Api\Exceptions\ApiException('X-Client-Version is required.', 400);
}
To pass data to the handler, add a request attribute: $handler->handle($request->withAttribute('client', $client)), and read it with $request->attribute('client').
A closure works too. It receives the request and the next handler, and must return a Response:
$router->get('/report', $handler)->middleware(
static fn (Request $request, RequestHandlerInterface $next): Response => $next->handle($request)
->header('X-Report', 'yes')
);
Built-in middleware
| Middleware | Does |
|---|---|
SecurityHeadersMiddleware |
nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, and Cache-Control: no-store unless a route sets its own |
ExceptionMiddleware($debug) |
Turns exceptions into JSON error responses |
CorsMiddleware($origins) |
CORS headers and preflight answers. See CORS. |
MaintenanceMiddleware |
503 with Retry-After during maintenance mode |
LocaleMiddleware($languages, $default) |
Picks the language from ?lang=, then Accept-Language, then the default; loads the language file and sends Content-Language |
RateLimitMiddleware |
A global rate limit per client |
BodySizeMiddleware($maxBytes) |
413 for bodies over the limit, 2 MB by default |
JsonBodyMiddleware($maxBytes) |
400 for malformed JSON, 413 for large JSON |
RequireJsonMiddleware(allowMultipart: false) |
415 for POST, PUT and PATCH bodies that aren't JSON |
AccessLogMiddleware($loggerOrFile) |
One log line per request: method, path, status, duration, request ID and IP. Never bodies or tokens. |
AuthenticateMiddleware |
Authentication. See authentication. |
IdempotencyMiddleware |
See idempotency. |
CacheControl, ETagMiddleware |
See HTTP caching. |
Deprecated |
See versioning. |
The middleware classes are in the FloCMS\Api\Middleware namespace.
Health check
New projects have a health route for uptime monitors:
curl https://example.com/api/v1/health
| Response | Meaning |
|---|---|
200 {"success":true,"data":{"status":"ok","db":"ok"}} |
The site and its database work |
200 {"success":true,"data":{"status":"ok","db":"not_configured"}} |
The site has no database |
503 {"success":false,"message":"Service unavailable.","errors":{"status":"fail","db":"down"}} |
The database doesn't answer |
The HTTP status stays 200 for success and 503 for an unavailable database. Successful health data is wrapped by ResponseFactory::success(); a database failure uses ResponseFactory::error() and sends Retry-After: 30. The controller returns these fixed health fields, not the database driver's exception details.
The health route keeps working during maintenance mode, so monitors can tell maintenance from an outage.
Maintenance mode
While php flo down is active, the API answers 503 with a JSON message and a Retry-After header. IPs passed to --allow and IPs in API_MAINTENANCE_ALLOWED_IPS keep access. See maintenance mode.
Upload body limits
BodySizeMiddleware applies to multipart and raw upload requests as well as JSON. Its default is 2 MB, while the uploader suggests 5 MB chunks and accepts chunks up to 10 MB by default. Those defaults need coordination when using uploads through this API.
For example, change the existing body middleware entry in public/api.php:
new BodySizeMiddleware(maxBytes: 12 * 1024 * 1024),
new JsonBodyMiddleware(), // retain the independent 2 MB JSON limit
Configure the web server and PHP limits consistently, leaving room for multipart overhead. Alternatively, keep the 2 MB API limit and configure smaller chunks, such as a 1 MB chunk_size and max_chunk_bytes. Raising a route-level limit cannot bypass a rejection by earlier global middleware. See upload limits.