Building an API
FloCMS includes a JSON API runtime, hostkurd/flocms-api, already wired up in new projects. Routes are explicit and versioned, every response is JSON with security headers, and the pieces most APIs need are built in: authentication, validation, resources, pagination, CORS, rate limiting, OpenAPI documents and a test client.
It runs on ordinary shared hosting: PHP 8.1 or newer with PDO, mbstring and fileinfo. APCu is used for rate limits when it's available.
How it's wired
public/.htaccesssends every/api/v1/...request topublic/api.php.public/api.phploads the same bootstrap and.envas your pages, sets up the middleware, and loads your routes.- Your routes live in
api/routes.php, and your controllers and resources inapi/(namespaceApp\Api\).
Try it on a new site:
curl http://localhost:8000/api/v1/health
# {"success":true,"data":{"status":"ok","db":"not_configured"}}
The API path has no session and no CSRF check by default. Private routes are protected with the auth middleware. See authentication.
Defining routes
api/routes.php returns a function that receives the router:
<?php
use App\Api\Controllers\ContactController;
use App\Api\Controllers\UsersController;
use FloCMS\Api\Http\HealthController;
use FloCMS\Api\Router;
return static function (Router $router): void {
$router->version('v1', static function (Router $router): void {
$router->get('/health', new HealthController())->name('health');
$router->post('/contact', [ContactController::class, 'store'])
->name('contact.store')
->middleware('throttle:forms', 'idempotent');
$router->group('/users', static function (Router $router): void {
$router->get('', [UsersController::class, 'index'])->name('index');
$router->get('/{id:\d+}', [UsersController::class, 'show'])->name('show');
}, ['auth:users.manage'], namePrefix: 'users.');
});
};
These are the routes of a new project. GET /api/v1/users/5 runs UsersController::show() with $id = 5.
Methods
$router->get('/posts', $handler);
$router->post('/posts', $handler);
$router->put('/posts/{id}', $handler);
$router->patch('/posts/{id}', $handler);
$router->delete('/posts/{id}', $handler);
$router->map(['GET', 'POST'], '/search', $handler);
head() and options() exist too, but you rarely need them:
- a
HEADrequest runs theGETroute without a body - an
OPTIONSrequest answers204with anAllowheader - a known path with the wrong method answers
405withAllow
Parameters
A parameter in braces matches one path segment. Add a regular expression after a colon to constrain it:
$router->get('/posts/{slug}', ...); // any single segment
$router->get('/authors/{id:\d+}', ...); // digits only
$router->get('/{country:[a-z]{2}}/cities', ...); // two letters
Values reach the handler URL-decoded. A constraint can't match across segments: {path:.+/.+} is rejected.
Groups and versions
group() shares a path prefix, middleware and a name prefix with the routes inside it. Groups can be nested:
$router->group('/admin', function (Router $router): void {
$router->get('/stats', [StatsController::class, 'index'])->name('stats'); // admin.stats
}, ['auth:settings.view'], namePrefix: 'admin.');
version('v1', ...) is a group with the prefix /v1 and the name prefix v1..
Matching order
Routes match in the order you register them, and the first match wins. Register specific routes before general ones:
$router->get('/posts/featured', ...); // before {slug}
$router->get('/posts/{id:\d+}', ...); // before {slug}
$router->get('/posts/{slug}', ...);
With APP_DEBUG=true, the router is strict: a route that can never match, such as /posts/{id} after /posts/{slug}, or a duplicate route name, throws an error. In production the same problems are logged to storage/logs/api.log.
Route names
Name routes to build their URLs:
$router->url('v1.users.show', ['id' => 5], ['include' => 'roles']);
// "/api/v1/users/5?include=roles"
Versioning and deprecation
Keep old clients working by adding a new version next to the old one, and mark the old one as deprecated:
use FloCMS\Api\Middleware\Deprecated;
$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.
Important
The skeleton's public/.htaccess only sends /api/v1 to api.php. Before you add v2, change that rule, for example to RewriteRule ^api/v[0-9]+(/.*)?$ api.php [L,QSA].
What every request gets
The middleware in public/api.php runs for every API request, in this order:
- Security headers:
nosniff,X-Frame-Options,Referrer-Policy,Permissions-PolicyandCache-Control: no-store - JSON errors: every error, a 404 too, becomes a JSON response
- CORS for the origins in
API_CORS_ORIGINS. See CORS and rate limiting. - Maintenance mode: 503 while
php flo downis active, except for/api/v1/healthand allowed IPs - Locale from
?lang=orAccept-Language - Rate limiting:
API_RATE_LIMITrequests per minute per client - Body limits: bodies over 2 MB are rejected, and malformed JSON is a 400
Then the route's own middleware runs. See middleware.
Next steps
For services and modules, see the container and module API route setup.
- Controllers and resources: handlers, responses and errors
- Authentication: tokens, API keys and the admin session
- Validation: checking request data
- Pagination: pages, sorting and filters
- Testing: calling your API from PHPUnit
Commands
php flo api:routes # every API route with its name and middleware
php flo list api # all API commands
php flo route:list shows the API routes next to your page routes.
Note
The old /api/<controller>/<action> route, with methods prefixed api_, has no authentication and skips CSRF checks. It is off unless LEGACY_API=true is set in .env. Move such endpoints to api/routes.php.
Using the API without the skeleton
In a Composer project that requires hostkurd/flocms-api, create an entry point and route requests to it. This example serves one endpoint without FloCMS application bootstrap or a database:
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use FloCMS\Api\Kernel;
use FloCMS\Api\Middleware\BodySizeMiddleware;
use FloCMS\Api\Middleware\ExceptionMiddleware;
use FloCMS\Api\Middleware\JsonBodyMiddleware;
use FloCMS\Api\Middleware\SecurityHeadersMiddleware;
use FloCMS\Api\Router;
use FloCMS\Core\Http\Request;
use FloCMS\Core\Support\Container;
$container = new Container();
$router = new Router();
$router->get('/api/v1/health', static fn (): array => ['status' => 'ok'])->name('health');
$kernel = new Kernel(router: $router, container: $container);
$kernel->middleware([
new SecurityHeadersMiddleware(),
new ExceptionMiddleware(),
new BodySizeMiddleware(),
new JsonBodyMiddleware(),
]);
$kernel->handle(Request::fromGlobals())->send();
The router matches the complete request path; it does not automatically add /api or /v1. Register authentication, CORS, throttling and application services as your endpoints require. The skeleton's aliases and middleware are not supplied by loading the package alone.
middleware([...]) replaces the global list; pipe($middleware) appends one entry. A kernel starts with exception middleware and also has an exception-rendering fallback, but configure the full pipeline explicitly to get the behavior your application needs.
$kernel->includeRequestIdInBody() adds request_id to successful envelopes created through the response factory. The X-Request-ID header is always set by the kernel; plain custom Response bodies are not rewritten.
For files that return route registration callables or definition lists, use RouteLoader($router, $container)->load($path). Module route files use loadModules().