hostkurd / flocms-api
Optional modern API runtime for Flo Framework: explicit routing, middleware, JSON errors, CORS, authentication contracts, and rate limiting.
Requires
- php: ^8.1
- hostkurd/flocms-cli: ^2.0
- hostkurd/flocms-core: ^2.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- hostkurd/flocms-uploader: 1.2.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- ext-apcu: Faster rate limiting with ApcuRateLimiter.
- hostkurd/flocms-uploader: Uploads through the API (Uploads\HandlesUploads), 1.2+ for chunked uploads.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 16:56:51 UTC
README
hostkurd/flocms-api is the optional API runtime for Flo Framework (FloCMS).
It provides explicit, method-aware routing, a middleware pipeline and JSON
responses, plus the pieces most APIs need: authentication, validation,
resources, pagination, HTTP caching, CORS, rate limiting, OpenAPI docs and a
test client.
It runs on ordinary shared hosting: PHP 8.1+, PDO, mbstring and fileinfo. No daemons, no Node, no extra extensions (APCu is used when available).
- Installation
- Setup
- Routing
- Handlers and controllers
- Middleware
- Errors
- Authentication
- Validation
- Resources
- Pagination
- Sorting, filtering and fields
- HTTP caching
- CORS
- Rate limiting
- Other middleware
- Uploads
- Versioning and deprecation
- Health checks and maintenance mode
- Idempotency
- OpenAPI documentation
- Command line
- Testing your API
Installation
The FloCMS skeleton (1.6+) ships with the API wired up in public/api.php.
For another project:
composer require hostkurd/flocms-api:1.2.0
1.2 requires flocms-cli 2.0, which
runs the php flo api:* commands.
The database features (API keys, tokens, database idempotency store) need their tables. They are a migration, so they are created with the application's own migrations:
php flo migrate # every pending migration, the API's included php flo api:install-schema # or only the API's
You can also import vendor/hostkurd/flocms-api/database/schema/mysql.sql in
phpMyAdmin.
Setup
use FloCMS\Api\Kernel; use FloCMS\Api\Router; use FloCMS\Api\Middleware\{CorsMiddleware, ExceptionMiddleware, JsonBodyMiddleware, SecurityHeadersMiddleware}; use FloCMS\Core\Http\Request; $router = new Router(); $router->version('v1', function (Router $router): void { $router->get('/health', fn () => ['status' => 'ok'])->name('health'); }); $kernel = new Kernel(router: $router, debug: false); $kernel->middleware([ new SecurityHeadersMiddleware(), new ExceptionMiddleware(debug: false), new CorsMiddleware(['https://www.example.com']), new JsonBodyMiddleware(), ]); $kernel->handle(Request::fromGlobals())->send();
Route files return a closure. RouteLoader loads them, and module route files
declared in module.php ('routes' => ['api' => 'routes/api.php']):
// api/routes.php use FloCMS\Api\Router; use App\Api\NewsController; return static function (Router $router): void { $router->get('/v1/news', [NewsController::class, 'index'])->name('news.index')->module('news'); };
(new RouteLoader($router, $container))->load(ROOT . '/api/routes.php'); (new RouteLoader($router, $container))->loadModules($registry, $manager);
Routes owned by a disabled module answer 404 without building the controller.
Routing
$router->get('/v1/properties', $handler); $router->post('/v1/properties', $handler); $router->put(...); $router->patch(...); $router->delete(...); $router->head(...); $router->options(...); $router->map(['GET', 'POST'], '/v1/search', $handler);
Parameters match one path segment. Add a regular expression after a colon to constrain them:
$router->get('/v1/properties/{slug}', ...); // any single segment $router->get('/v1/agents/{id:\d+}', ...); // digits only $router->get('/v1/{country:[a-z]{2}}/cities', ...);
Constraints cannot span segments ({path:.+/.+} is rejected). Values reach
handlers URL-decoded.
Groups share a prefix, middleware, module and name prefix, and nest:
$router->group('/v1', function (Router $router): void { $router->get('/properties', ...)->name('properties.index'); // v1.properties.index }, middleware: [$auth], namePrefix: 'v1.');
Matching order. Routes match in registration order, and the first match wins. Register specific routes before general ones:
$router->get('/v1/p/featured', ...); // before {slug} $router->get('/v1/p/{id:\d+}', ...); // before {slug} $router->get('/v1/p/{slug}', ...);
The router reports routes that can never match (/p/{id} after /p/{slug})
and duplicate route names: as PSR-3 warnings (new Router($logger), also
listed by $router->warnings()), or as exceptions with
new Router(strict: true) / $router->strict(). Strict mode is recommended
in development; it will be the default in 2.0.
Automatic responses: a wrong method gives 405 with Allow; HEAD runs
the GET route without a body; OPTIONS answers 204 with Allow. Explicit
head()/options() routes take precedence.
URLs from route names:
$router->url('v1.properties.show', ['slug' => 'sea-villa'], ['page' => 2]); // "/api/v1/properties/sea-villa?page=2"
Handlers and controllers
A handler is a closure, [Controller::class, 'method'], 'Controller@method',
an invokable class name or object. Arguments are resolved by name and type:
- route parameters by name, cast to
int,floatorboolwhen typed (a value that does not fit is a 422); Request(or a parameter named$request);- other classes from the container;
- defaults and nullable parameters.
Return a Response, an array or scalar (wrapped in
{"success": true, "data": ...}), a resource, or null (204).
use FloCMS\Api\Controller; use FloCMS\Api\Validation\ValidatesRequests; use FloCMS\Core\Http\Request; use FloCMS\Core\Http\Response; final class PropertiesController extends Controller { use ValidatesRequests; public function __construct(private readonly PropertiesModel $properties) { } public function show(Request $request, string $slug): Response { $row = $this->properties->findBySlug($slug) ?? $this->fail('Property not found.', 404); return PropertyResource::make($row)->response($request); } }
Controller helpers: success(), created(), noContent(), fail(),
invalid(), routeId().
Middleware
Middleware implements MiddlewareInterface::process($request, $handler), or
is a callable fn (Request $request, $next): Response. Class names are built
from the container. Kernel middleware runs for every request (also for 404s);
route middleware runs after a route matched.
Order. Lists are outermost first. The recommended global order:
$kernel->middleware([ new AccessLogMiddleware(ROOT . '/storage/logs/api.log'), // optional: sees every final status new SecurityHeadersMiddleware(), // nosniff etc. on every response, errors included new ExceptionMiddleware($debug), // errors as JSON new CorsMiddleware($origins), // answers preflights; CORS headers on errors too new MaintenanceMiddleware(), // optional new LocaleMiddleware($languages, $default), new RateLimitMiddleware(...), // OPTIONS is never counted new BodySizeMiddleware(), new JsonBodyMiddleware(), ]);
Per route: authentication, throttle:<name>, validation of content types,
caching, idempotency, deprecation.
Aliases let route middleware be written as strings:
$kernel->aliases(['throttle' => $limiters->aliasFactory()]); $router->post('/v1/inquiries', ...)->middleware('throttle:forms');
Errors
Every error uses one envelope:
{"success": false, "message": "Validation failed.", "errors": {"email": ["email is required."]}, "request_id": "8f3c..."}
- Throw
ApiException($message, $status, $errors, $headers)for any status, orValidationException($errors)for 422. - Core
HttpExceptionand coreValidationExceptionrender with their status (403, 404, 422...). - Anything else is a logged 500. With
debug: truethe message, class, file and line are shown; in production only "Internal Server Error". Error details of 5xxApiExceptions are hidden in production too. - Every response carries
X-Request-ID(a valid incoming one is reused). For the ID in success bodies as well:$kernel->includeRequestIdInBody(). - Invalid UTF-8 in response data is replaced with U+FFFD and logged, instead
of failing (only for responses built by
ResponseFactory, which includes everything handlers return as arrays). - The Kernel renders errors correctly even without
ExceptionMiddleware.
Which ValidationException? Throw FloCMS\Api\Exceptions\ValidationException
in API code. FloCMS\Core\ValidationException (thrown by core
Validator::validate()) renders the same way; convert it explicitly with
ValidationException::fromCore($e) if needed.
Authentication
All authenticators implement AuthenticatorInterface and are used through
AuthenticateMiddleware, which puts an Identity in the identity request
attribute:
$auth = new AuthenticateMiddleware( $authenticator, roles: [], // any of these roles scopes: ['properties.write'], // all of these scopes permissions: ['content.edit'], // all of these permissions ); $router->post('/v1/properties', ...)->middleware($auth);
No credentials: 401. Not allowed: 403.
| Authenticator | Credential | Identity |
|---|---|---|
ApiKeyAuthenticator($keys) |
X-API-Key: flk_<id>_<secret> |
the key's user, or api-key:<id> for service keys |
BearerTokenAuthenticator($tokens) |
Authorization: Bearer flt_<id>_<secret> (or X-FloCMS-Authorization) |
the token's user |
SessionAuthenticator() |
the FloCMS admin session cookie + X-CSRF-TOKEN |
the logged-in admin |
JwtAuthenticator($secret) |
Authorization: Bearer <HS256 JWT> |
sub |
FirstMatchAuthenticator(...$list) |
tries each in turn |
API keys and tokens are random, shown once, and stored as SHA-256 hashes
(tables api_keys and api_tokens). Verification is constant-time, and
expiry and revocation are checked on every request.
use FloCMS\Api\Auth\{ApiKeyRepository, TokenRepository}; use FloCMS\Api\Database\Connection; $db = new Connection(); // the FloCMS database (App::db()) $issued = (new TokenRepository($db))->issueFor($userId, 'Mobile app', 30 * 86400, ['inquiries.write']); return ['token' => $issued->plaintext]; // only time it is visible (new TokenRepository($db))->revokeAllForUser($userId); // e.g. after a password change
From the command line: php flo api:key:create "Partner feed" --scopes=properties.read.
Users and permissions. Keys and tokens tied to a user are checked with the
same loader core Auth uses (Config::set('auth.user_loader', ...)): deleted
or suspended users lose access immediately, and the user's role drives
$identity->can('users.manage'), which reads the core permissions map with
the same wildcard rules as Auth::can().
Admin session. SessionAuthenticator lets admin pages call /api/v1
with fetch(): it opens the session read-only and only when the cookie is
present, reloads the user through Auth::refresh(), requires admin access,
and checks X-CSRF-TOKEN (sent by csrf.js) on POST/PUT/PATCH/DELETE (419
on mismatch).
JWT. HS256 only; tokens naming any other algorithm are rejected. Use a
secret of at least 32 random bytes from .env. Jwt::encode($claims, $secret)
issues tokens; exp, nbf, iat, and optionally iss/aud are checked.
Validation
use FloCMS\Api\Validation\ValidatesRequests; $data = $this->validate($request, [ 'name' => 'required|string|max:100', 'email' => 'required|email', 'phone' => 'nullable|string|max:30', 'type' => 'required|in:sale,rent', 'price' => 'sometimes|numeric|between:0,10000000', 'agent' => 'sometimes|integer|exists:users,id', 'email2' => 'required|email|unique:subscribers,email', 'code' => ['required', 'regex:/^[A-Z]{2}\d+$/'], // array form when a pattern contains | 'from' => 'nullable|date', 'tags' => 'sometimes|array|max:5', 'agree' => 'required|bool', ]);
The result holds only validated fields; integer, numeric and bool
values come back typed. Failures are a 422 with per-field messages.
$this->validateQuery($request, $rules) validates only the query string, so
?min_price=abc is a 422 instead of silently becoming 0.
Rules: required, string, integer, numeric, min, max, in (the
core rules), email, url (http/https), bool, array, date[:format],
between:min,max, regex:pattern, nullable, sometimes,
exists:table,column, unique:table,column[,ignoreId[,idColumn]].
Outside controllers: (new Validator())->validate($data, $rules). Custom
rules: $validator->extend('even', fn ($value) => $value % 2 ? 'Must be even.' : null).
Resources
Resources decide what a record looks like in the API, so database columns never leak:
use FloCMS\Api\Resources\JsonResource; final class PropertyResource extends JsonResource { protected array $includes = ['images', 'agent']; // allowed in ?include= public function toArray(Request $request): array { return [ 'id' => (int) $this->id, 'title' => $this->title, 'price' => (float) $this->price, 'url' => '/properties/' . $this->slug, 'created_at' => $this->date($this->created_at), // ISO 8601 'notes' => $this->when($request->attribute('identity') !== null, fn () => $this->notes), 'images' => $this->whenIncluded($request, 'images', fn () => ImageResource::collection($this->images)), ]; } } return PropertyResource::make($row); return PropertyResource::collection($rows);
?include= values that are not allowed give a 422.
Pagination
use FloCMS\Api\Pagination\PageRequest; use FloCMS\Api\ResponseFactory; $page = PageRequest::fromRequest($request, defaultPerPage: 20, maxPerPage: 100); $result = $db->table('properties')->where('status', '=', 'sale')->paginate($page->page, $page->perPage); return ResponseFactory::paginated($result, $request, PropertyResource::class);
{"success": true, "data": [...],
"meta": {"page": 2, "per_page": 20, "total": 95, "last_page": 5},
"links": {"first": "/api/v1/properties?page=1", "prev": "...", "next": "...", "last": "..."}}
per_page is clamped to the maximum; invalid values are a 422. Links keep the
query string and are relative to the host. For large or fast-changing lists,
CursorPaginator::paginate($query, $request, 'id', 'desc') with
ResponseFactory::cursorPaginated() pages by an opaque cursor instead of an
offset.
Sorting, filtering and fields
use FloCMS\Api\Query\QueryOptions; $options = QueryOptions::fromRequest( $request, sortable: ['price', 'created_at'], filterable: ['status' => ['eq', 'in'], 'price' => ['gte', 'lte'], 'city'], fields: ['id', 'title', 'price'], defaultSort: '-created_at', ); $result = $options->applyTo($db->table('properties'))->paginate($page->page, $page->perPage);
GET /v1/properties?sort=-price&filter[status][in]=sale,rent&filter[price][gte]=100000&fields=id,title
Only whitelisted names reach the query (map public names to columns with
['price' => 'p.price']); everything else is a 422, and values are always
bound parameters.
HTTP caching
SecurityHeadersMiddleware sends Cache-Control: no-store unless a route
sets its own policy:
use FloCMS\Api\Middleware\{CacheControl, ETagMiddleware}; $router->get('/v1/pages', ...)->middleware(CacheControl::public(300), new ETagMiddleware());
CacheControl::public($maxAge, $sharedMaxAge), private(), noCache(),
noStore() apply to successful GET/HEAD responses; authenticated requests
are never cached publicly. ETagMiddleware adds an ETag and answers 304 Not Modified to If-None-Match, or to If-Modified-Since when the handler set
ResponseFactory::withLastModified($response, $row->updated_at).
CORS
new CorsMiddleware( ['https://www.example.com', 'https://*.example.com'], // or ['*'] without credentials allowCredentials: true, );
- Preflights are answered by the middleware itself (204), before rate limits and authentication. The requested method and headers must be allowed; only allowed headers are echoed.
- Error responses keep their CORS headers, so browsers can read 422/429 messages.
https://*.example.commatches exactly one subdomain label, same scheme and port (notexample.com, nota.b.example.com).X-Request-ID,X-RateLimit-*andRetry-Afterare exposed by default.
Rate limiting
use FloCMS\Api\RateLimit\{FileRateLimiter, ApcuRateLimiter, RateLimiterRegistry}; $store = ApcuRateLimiter::isAvailable() ? new ApcuRateLimiter() : new FileRateLimiter(ROOT . '/storage/cache/api-rate-limit'); $limiters = new RateLimiterRegistry($store, $clientIp, [ 'public' => [60, 60], // 60 per minute 'forms' => [5, 60], 'auth' => [10, 900], // 10 per 15 minutes ]); $kernel->aliases(['throttle' => $limiters->aliasFactory()]); $router->post('/v1/inquiries', ...)->middleware('throttle:forms');
Named limits key requests by identity when authenticated (list the throttle
after AuthenticateMiddleware) and by client IP otherwise. Over the limit:
429 with Retry-After and X-RateLimit-* headers. A global
RateLimitMiddleware works as before. FileRateLimiter removes stale state
on about 1 in 100 hits, and php flo api:gc does it on demand.
ClientIpResolver(['10.0.0.0/8']) only trusts X-Forwarded-For from the
listed proxies (IPs or CIDR ranges).
Other middleware
BodySizeMiddleware($maxBytes): 413 for any body over the limit (checksContent-Lengthfirst).RequireJsonMiddleware(allowMultipart: false): 415 for non-JSON POST/PUT/PATCH bodies.JsonBodyMiddleware($maxBytes): 400 for malformed JSON, 413 for large JSON.LocaleMiddleware($languages, $default): picks the language from?lang=, thenAccept-Language, then the default; sets thelocaleattribute, loads the language file, sendsContent-Language.AccessLogMiddleware($loggerOrFile): method, path, status, duration, request ID and IP per request; never bodies or tokens.SecurityHeadersMiddleware:nosniff,X-Frame-Options,Referrer-Policy,Permissions-Policy, defaultno-store.
Uploads
With hostkurd/flocms-uploader installed, Uploads\HandlesUploads handles
multipart uploads and the uploader's chunked, resumable protocol on API
routes:
final class MediaController extends Controller { use HandlesUploads; public function store(Request $request): Response { return $this->storeUpload($request, Uploader::image()->to('properties'), 'image'); // 201 } public function start(Request $request): Response // POST {"filename", "total_bytes"} { return $this->startChunkedUpload($request, $this->chunks(), $this->owner($request)); } public function append(Request $request, string $id): Response // PATCH, raw chunk, Upload-Offset header { return $this->appendChunk($request, $this->chunks(), $id, $this->owner($request)); } // chunkedUploadStatus(), completeChunkedUpload(), abortChunkedUpload() }
Uploader validation errors are 422s, a chunk at the wrong offset is a 409
with the expected Upload-Offset, and server paths are never returned.
Versioning and deprecation
$router->version('v1', $v1Routes, [new Deprecated(sunset: '2027-06-30', successor: 'https://example.com/api/v2')]); $router->version('v2', $v2Routes);
Deprecated adds Deprecation, Sunset and Link headers to every response
of those routes.
Health checks and maintenance mode
$router->get('/v1/health', new HealthController())->name('health'); // 200 {"status":"ok","db":"ok"} or 503 with "db":"down" new MaintenanceMiddleware(allowedIps: ['203.0.113.7'], clientIp: $clientIp) // 503 + Retry-After while the FloCMS offline_mode setting is on
Idempotency
$router->post('/v1/inquiries', ...)->middleware( new IdempotencyMiddleware(new FileIdempotencyStore(ROOT . '/storage/cache/api-idempotency')) );
A client that sends Idempotency-Key: <uuid> can retry safely: a repeated
request gets the stored response (Idempotent-Replayed: true) instead of
creating a duplicate. Reusing a key with a different body is a 422; a repeat
while the first is still running is a 409. Failed requests are not stored.
DatabaseIdempotencyStore keeps records in the api_idempotency table
instead of files.
OpenAPI documentation
if ($debug) { $router->get('/v1/openapi.json', new OpenApiController(new OpenApiGenerator($router, 'My API', '1.0.0'))); }
The document lists every route with its parameters. Attributes on handlers add detail:
#[Tag('Properties')] final class PropertiesController extends Controller { #[Summary('Show a property')] #[ResponseSchema(200, ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']]])] #[ResponseSchema(404, description: 'Property not found')] public function show(string $slug): Response { ... } #[RequestBody(['type' => 'object', 'required' => ['email'], 'properties' => ['email' => ['type' => 'string']]])] public function inquire(Request $request): Response { ... } }
php flo api:docs --out=public/openapi.json writes the file.
Command line
The API commands are flocms-cli
commands. flocms-api registers them in its composer.json
("extra": {"flocms": {"commands": [...]}}), so php flo finds them on any
FloCMS site, whatever the age of its root flo file. List them with
php flo list api. The database settings come from .env, or from
FLO_API_DSN (plus FLO_API_DB_USER / FLO_API_DB_PASS) when set.
| Command | |
|---|---|
api:install-schema |
run the API's migrations (create the API tables) |
api:key:create <name> [--scopes=a,b] [--user=ID] [--expires=YYYY-MM-DD] |
create an API key (shown once) |
api:key:list, api:key:revoke <id> |
|
api:token:create <user-id> <name> [--scopes=] [--expires=] |
issue a personal access token |
api:token:list [--user=ID], api:token:revoke <id> | --user=ID |
|
api:routes |
list routes, names and middleware |
api:docs [--out=file] |
generate the OpenAPI document |
api:gc [--database] |
delete stale rate-limit and idempotency state (cron-safe) |
api:list |
the API commands |
Some flocms-cli commands include the API automatically:
php flo route:listshows the routes ofapi/routes.phpnext to the controller routes.php flo migratecreates the API tables.php flo schedule:runrunsapi:gcdaily.
vendor/bin/flo-api <command> still runs the API commands on their own.
FloCMS\Api\Console\Application remains as a deprecated wrapper with the
1.1 behavior.
Testing your API
use FloCMS\Api\Testing\{TestClient, StaticAuthenticator}; $client = new TestClient($kernel); $client->get('/api/v1/properties', ['filter' => ['status' => 'sale']]) ->assertOk() ->assertJsonPath('meta.total', 3) ->assertJsonPath('data.0.title', 'Sea villa'); $client->withToken($token) ->postJson('/api/v1/inquiries', ['name' => 'Sara']) ->assertValidationError('email');
TestResponse assertions: assertStatus, assertOk, assertCreated,
assertNoContent, assertUnauthorized, assertForbidden, assertNotFound,
assertUnprocessable, assertHeader, assertHeaderMissing,
assertJsonPath, assertJsonMissingPath, assertJsonCount,
assertValidationError. StaticAuthenticator replaces real credentials in
tests.
Run this package's own tests with composer test. The database tests use
SQLite, and MySQL/MariaDB too when FLO_TEST_MYSQL_HOST (plus _PORT, _USER,
_PASS, _NAME) is set.
Upgrading
See UPGRADE.md and CHANGELOG.md.