Pramnos REST API Guide¶
The Pramnos Framework provides comprehensive support for building REST APIs with automatic endpoint generation, request/response handling, and OAuth2 authentication.
REST API Scaffolding¶
Generate API with pramnos init¶
This creates:
- src/Api/Controllers/ - API endpoint controllers
- routes/api.php - API route definitions
- www/api/index.php - API entry point
- Configuration for versioning and CORS
Manual API Setup¶
// app/app.php
\Pramnos\Application\Application::getInstance()
->register('api', function () {
return include __DIR__ . '/api.php';
});
API Controllers¶
Basic Structure¶
<?php
namespace App\Api\Controllers;
use Pramnos\Application\Api as ApiController;
class UsersController extends ApiController
{
public function __construct()
{
parent::__construct();
}
/**
* List all users (GET /api/users)
*/
public function index()
{
$users = \App\Models\User::all();
return $this->json($users, 200);
}
/**
* Get single user (GET /api/users/{id})
*/
public function show($id)
{
$user = \App\Models\User::find($id);
if (!$user) {
return $this->json(['error' => 'Not found'], 404);
}
return $this->json($user, 200);
}
/**
* Create user (POST /api/users)
*/
public function store()
{
$data = $this->request->json();
try {
$user = \App\Models\User::create([
'username' => $data['username'],
'email' => $data['email'],
'password' => hash('sha256', $data['password']),
]);
return $this->json($user, 201);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 400);
}
}
/**
* Update user (PATCH /api/users/{id})
*/
public function update($id)
{
$user = \App\Models\User::find($id);
if (!$user) {
return $this->json(['error' => 'Not found'], 404);
}
$data = $this->request->json();
$user->update($data);
return $this->json($user, 200);
}
/**
* Delete user (DELETE /api/users/{id})
*/
public function destroy($id)
{
$user = \App\Models\User::find($id);
if (!$user) {
return $this->json(['error' => 'Not found'], 404);
}
$user->delete();
return $this->json(null, 204);
}
}
Routing¶
Define API Routes¶
// routes/api.php
$router = \Pramnos\Routing\Router::getInstance();
// API version 1
$router->group(['prefix' => 'api/v1'], function ($router) {
// Users endpoints
$router->get('/users', 'Api/UsersController@index');
$router->get('/users/{id}', 'Api/UsersController@show');
$router->post('/users', 'Api/UsersController@store');
$router->patch('/users/{id}', 'Api/UsersController@update');
$router->delete('/users/{id}', 'Api/UsersController@destroy');
// Posts endpoints
$router->get('/posts', 'Api/PostsController@index');
$router->get('/posts/{id}', 'Api/PostsController@show');
});
Route Groups & Middleware¶
$router->group([
'prefix' => 'api/v1',
'middleware' => ['api', 'auth:api'],
], function ($router) {
// Protected endpoints
$router->post('/profile/update', 'Api/ProfileController@update');
$router->post('/tokens', 'Api/TokenController@create');
});
Request Handling¶
JSON Requests¶
// Get JSON body
$data = $this->request->json();
// Get specific field
$email = $this->request->json('email');
// Get with defaults
$page = $this->request->json('page', 1);
// Raw body
$raw = $this->request->getRawBody();
Validation¶
use Pramnos\Validation\Validator;
public function store()
{
$data = $this->request->json();
$validator = new Validator();
$validator->add('username', 'required|min:3|max:50|unique:users');
$validator->add('email', 'required|email|unique:users');
$validator->add('password', 'required|min:8');
if ($validator->fails()) {
return $this->json($validator->errors(), 422);
}
// Process valid data...
}
Response Handling¶
JSON Responses¶
// Success with data
return $this->json(['user' => $user], 200);
// Created
return $this->json($user, 201);
// No content
return $this->json(null, 204);
// Client error
return $this->json(['error' => 'Invalid input'], 400);
// Not found
return $this->json(['error' => 'Not found'], 404);
// Server error
return $this->json(['error' => 'Internal error'], 500);
Response Objects¶
use Pramnos\Http\Response;
$response = new Response();
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['data' => $user]));
return $response;
getData() and the columns it returns¶
A model's getData() is what most CRUD endpoints serialise. Since 2026-08-16 it
returns every column — including NULL, booleans and decoded JSON.
It did not before. The old implementation kept only values that were is_numeric() or
is_string(), so a column holding NULL was absent from the payload rather than
null, and booleans and JSON columns disappeared with them.
That was not a neutral historical quirk. Overrides in one application do
unguarded — so a record with NULL in that column raised Undefined array key in
production and cast the missing value to 0. The absent key was producing warnings
and wrong numbers, and that measurement is what decided the change rather than
tidiness.
Measured on that application before flipping: 54 models, 42 reaching this through
parent::getData(), 523 keys added across 48 models — 411 NULL, 53 boolean,
55 array.
What to check after upgrading, in order of likelihood:
- code calling
implode(),http_build_query(), or building SQL from the result — a JSON column now arrives as an array where a scalar was assumed; - clients that reject unknown keys;
- anything reading
array_key_exists()as "this record has no value", which now means "this column is null".
To get the old shape back, byte for byte, opt out once in a base model class:
abstract class AppModel extends \Pramnos\Application\Model
{
protected $getDataFullFidelity = false; // the pre-1.2 shape
}
sqlError is no longer in the payload, in either mode. It is a string once a query
has failed, so the old type filter let it through — a failed read put an SQL error
message into whatever was being serialised.
A model that declares no public properties used to return []. Columns assigned to
an undeclared property go through Base::__set into an internal bag, which the filter
dropped whole. They are read now. Where both exist, the declared property wins — the bag
is the fallback, not the source of truth.
When something fails on the server¶
Two things that used to end an API request with a page of HTML no longer do.
A failed list query. Model::_getList() catches a query failure and, with its
$displayerroroutput default of true, called showError() — which exits. The two
lines after it, which record sqlError and return an empty list, are what
ApiListResponse::error() was written against and were unreachable on the one path
that needs them. A request whose Accept names JSON now takes those two lines instead,
so the caller can report the failure in its own envelope:
The page path is unchanged: without a list there is nothing useful to render, so a browser still gets the error page.
A terminal error of any kind. Application::showError() — maintenance mode, an
unsupported PHP version, a database that will not answer — answers JSON to a client
that asked for it, with a real status:
503 while var/MAINTENANCE exists, 500 otherwise. It previously sent an HTML page
with no status code at all, so an API client got 200 OK and failed on parsing
rather than recognising the state. See the
Framework guide for the flag files and Retry-After.
Authentication¶
Token-Based Authentication¶
// routes/api.php
$router->group(['middleware' => ['api', 'auth:api']], function ($router) {
$router->post('/profile', 'Api/ProfileController@show');
$router->post('/profile/update', 'Api/ProfileController@update');
});
POST /account/login — a password for a bearer token¶
The framework's own API login, in Pramnos\Auth\Controllers\ApiAccount. It runs the same
login flow the HTML form does — lockout, credentials, second factor — and swaps the last step
for a token:
POST /account/login {"username": "...", "password": "..."}
→ 200 {"status":"success","access_token":"<jwt>","token_type":"Bearer","user":{…}}
→ 401 {"error":"two_factor_required","methods":[…]} then POST /account/login2fa {"code":"…"}
→ 401 {"error":"invalid_credentials"}
→ 429 {"error":"too_many_attempts","retry_after": <seconds>}
POST /account/logout accessToken: <jwt> (or Authorization: Bearer <jwt>)
Either header is read, and the body may be JSON or a form post.
What POST /account/login2fa answers¶
The second leg carries only the code — the pending login lives server-side — and its four answers are the whole of what a client has to branch on:
POST /account/login2fa {"code": "123456"}
→ 200 {"status":"success","access_token":"<jwt>","token_type":"Bearer","user":{…}}
→ 400 {"error":"missing_code"}
→ 401 {"error":"invalid_code"}
→ 429 {"error":"too_many_attempts","retry_after": <seconds>}
→ 405 {"error":"method_not_allowed"}
Four things to build against:
- A success is byte-for-byte the shape of a login that needed no second factor. Both legs share
tokenResponse(), so a client needs one code path for the outcome, not two. 401 invalid_codeleaves the pending login intact. Try the next code from the authenticator; there is no need to re-send the password. That is the reason this is a second request at all.429is not401. A client that treats them the same will keep retrying, which is what turns a lockout into a loop — honourretry_after.400 missing_codecosts no attempt. The code is checked for emptiness before the flow is asked, so a bug in your form cannot spend your users' attempts and lock them out.
It must be a POST; a code in a query string ends up in access logs, browser history and every
proxy in between, and is worth something for as long as it is valid.
Tokens do not expire by default. auth.token_ttl in app.php is the opt-in, in seconds:
Left unset — or set to 0, or to a negative number — the token never expires, which is the
behaviour every installation had before the setting existed. Giving them an expiry by default
would sign every client out at once on the day the framework was upgraded, so it is a decision
an installation makes.
A TTL is stamped in two places, because two different pieces of code end a token: the JWT's
exp claim, which JWT::decode() refuses once past, and the usertokens.expires column, which
loadByToken() refuses. They are always the same number.
The nbf claim is backdated twelve hours on purpose. A "not before" of exactly now makes the
token invalid on any client whose clock runs a few seconds behind the server's, which is a login
that fails for some users some of the time; exp is what actually bounds the token.
With no expiry, revocation is the only thing that ends an API session — which is what
/account/logout does, and why a client that discards the token without calling it leaves a
credential valid for ever.
OAuth2¶
// Generate token
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=password&client_id=abc&client_secret=xyz&username=user&password=pass
// Use token
Authorization: Bearer token_here
CORS Configuration¶
Database-Driven CORS¶
Configure CORS origins in the database:
// Enable specific origins
INSERT INTO cors_settings (origin, allowed_methods, allowed_headers)
VALUES ('https://example.com', 'GET,POST,PATCH,DELETE', 'Content-Type,Authorization');
// Origins are validated on each request automatically
Middleware¶
// routes/api.php
$router->group(['middleware' => ['cors']], function ($router) {
// CORS headers added automatically
});
Pagination¶
Paginate API Results¶
public function index()
{
$page = $this->request->get('page', 1);
$perPage = $this->request->get('per_page', 20);
$qb = \App\Models\User::queryBuilder();
$total = $qb->count();
$users = $qb->forPage($page, $perPage)->get();
return $this->json([
'data' => $users,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'last_page' => ceil($total / $perPage),
], 200);
}
Sorting, filtering and field selection¶
The list engine assembles its SQL through Application\ApiList\ApiListSqlBuilder, and two of
its three jobs are security rather than formatting:
| What arrives from the caller | What decides whether it reaches the SQL |
|---|---|
?order=name,-created |
the field must be in the model's available-fields whitelist, and match ^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$ |
a structured filter's op |
must be one of = != <> < > <= >= LIKE ILIKE IN "NOT IN" "IS NULL" "IS NOT NULL" |
a filter's value |
goes through prepareInput(); an IN list escapes every member |
?fields=a,b |
the primary key is added if absent, because a row the screen cannot link is not a row |
The three shapes a list parameter arrives in¶
fields and search each come off a URL, so each is accepted three ways and the caller does not
choose which:
| Parameter | Shapes |
|---|---|
fields |
?fields=id,name (comma-separated), ?fields=["id","name"] (URL-encoded JSON), or an array from application code |
search |
?search=yannis (across everything), ?search={"username":"yan"} (URL-encoded JSON, one term per field), or an array |
A JSON search read as a global one would look for the literal {"username":"yan"} in every
column and find nothing — which presents as "the column filters do not work" rather than as a
parsing question.
With no fields at all the source's default set is used, not its whole schema: a source
curates that list to what it is willing to expose, so "no preference" must never mean "everything".
The same set is the fallback when nothing the caller named survives validation.
With a join, a field may be named with or without its table prefix — the schema reports
u.username because that is what the SELECT needs, and requiring the caller to know the alias
would make every joined list harder to call than it is to build. The fields key in the answer is
the caller's list with prefixes stripped: it describes what was asked for, not what the query
selected, so the primary key added below does not appear in it unless the caller named it.
Anything unrecognised is dropped, not rejected. A filter is a request, not an instruction, and answering "no such field" would let a caller enumerate the schema by watching which names produce an error. The same reasoning applies to an order token: an unknown field is skipped, and if that leaves nothing the order falls back to the primary key descending — so the list still works and tells the caller nothing.
When the query fails, the answer is still JSON¶
A list endpoint answers a caller that sent a page number and a search box, so a failure has to come back in the same envelope:
{"error": "Database query failed: …", "data": [], "pagination": null, "fields": [...],
"debug": {"filter": "where …", "order": "order by …", "selectFields": "…"}}
The fragments in debug are the only way to see what was actually run — the caller never sent a
WHERE clause, so without them "it returned nothing" and "it failed" look the same from outside.
That distinction is the reason apiListLastError() exists at all. An unpaginated fetch returns an
empty array both when nothing matched and when the query failed; the error is what tells them
apart, and reporting a failure as an empty list is how a broken filter looks exactly like a table
with nothing in it.
DataTables counts two totals¶
recordsTotal is the count before the search box and recordsFiltered the count after. The
engine has the second one already, and recomputes the first only when a search is active —
with no search the two are identical and the extra query buys nothing, on a screen that is drawn
on every page load. Reporting the filtered count as both would make the table say a search matched
everything there is, and offer one page where there are ninety.
Quoting is per driver, and LIKE becomes ILIKE on PostgreSQL. That last one matters more than
it looks: LIKE on PostgreSQL is case-sensitive, so the same search box would match Yannis and
not yannis on one engine and both on the other — reported as a broken search rather than as a
driver difference.
// A structured filter. Top-level entries are ANDed; an `or` group is parenthesised.
$sql = ApiListSqlBuilder::buildFilterFromConditions(
[
['field' => 'usertype', 'op' => '>=', 'value' => 50],
['or' => [
['field' => 'username', 'op' => 'LIKE', 'value' => '%a%'],
['field' => 'email', 'op' => 'LIKE', 'value' => '%a%'],
]],
],
['usertype', 'username', 'email'] // the whitelist
);
Two edges worth knowing, both asserted:
['field' => 'x', 'op' => '=', 'value' => null]emitsIS NULL, because= NULLmatches nothing and reads as a bug in the caller's data rather than in the query.INwith an empty array is dropped:IN ()is not valid SQL, and inventingIN (NULL)would silently change the meaning to "nothing matches".
Versioning¶
// Multiple API versions
$router->group(['prefix' => 'api/v1'], function ($router) {
// Version 1 endpoints
});
$router->group(['prefix' => 'api/v2'], function ($router) {
// Version 2 endpoints (breaking changes from v1)
});
Reference¶
Related Guides: - Pramnos_Framework_Guide.md — Middleware pipeline, Response Object, ExceptionHandler - Pramnos_Routing_Guide.md — Router::group(), #[RouteGroup] attribute - Pramnos_Authentication_Guide.md — OAuth2 server, JWT, login lockout, 2FA - Pramnos_Security_Guide.md — CSRF, session hardening
API Middleware¶
JsonResponseMiddleware¶
Sets the Content-Type response header before passing to $next. Always a pass-through — never short-circuits.
// Content-Type: application/json; charset=utf-8 (default)
// Content-Type: application/xml; charset=utf-8 (when HTTP_ACCEPT=application/xml)
new \Pramnos\Http\Middleware\JsonResponseMiddleware()
ApiAuthMiddleware¶
Validates the HTTP_APIKEY header via a caller-supplied checker callable, then (optionally) validates a JWT HTTP_ACCESSTOKEN. On success sets $_SESSION['logged'] and $_SESSION['user']. On failure short-circuits and returns a JSON error envelope.
new \Pramnos\Http\Middleware\ApiAuthMiddleware(
apiKeyChecker: fn(string $k) => $app->checkApiKey($k),
authKey: $app->authenticationKey,
appNamespace: $app->applicationInfo['namespace'] ?? null,
)
| Condition | HTTP status | error key |
|---|---|---|
HTTP_APIKEY missing, and no same-origin session (below) |
403 | APIKeyMissing |
| API key invalid | 401 | APIKeyInvalid |
| JWT malformed / unreadable | 403 | InvalidAccessToken |
| JWT valid but user not found | 403 | InvalidAccessToken |
Calling your own API from your own page¶
One caller legitimately has no API key: a page of your own application. A key names the client, and for a same-origin request from your own document the client is you — and a page cannot be given one anyway, because anything the document can read, a reader of the document can read.
So a request with no API key is accepted when both of these hold:
- the session carries an active
web_sessiontoken — which every web login creates; - the request carries
X-CSRF-Tokenmatching the session's CSRF token.
The cookie alone is not enough: the browser attaches it to a cross-site request too. The CSRF token is the half that proves the caller read your page.
Nothing to configure — the pieces are already on a scaffolded page:
- the document prints
<meta name="csrf" content="…">in the<head>, for a signed-in visitor only (an anonymous page gets no tag, because reading the token would start a session on every public URL); assets/js/pf-utils.jsexposeswindow.pfApiHeaders(extra), which adds the header from that tag. Use it for your own calls:
fetch('/api/1.0/admin/users', {
credentials: 'same-origin',
headers: pfApiHeaders({ 'Accept': 'application/json' })
});
Without the header the request is anonymous and answers 403 APIKeyMissing, which is
what Html\SearchBox did before this existed: the box rendered, the endpoint
answered, and typing did nothing.
UnifiedAuthMiddleware (SPA / same-origin auth)¶
Accepts either a Bearer JWT or a session cookie + X-CSRF-Token header. Use this for first-party route groups where you don't require API keys.
$router->group([
'prefix' => '/api/v1',
'middleware' => [
new CorsMiddleware(['https://myapp.com']),
new JsonResponseMiddleware(),
new UnifiedAuthMiddleware(authKey: $app->authenticationKey),
],
], function (Router $r): void {
$r->get('/profile', [ProfileController::class, 'show']);
});
Auth resolution order:
1. Authorization: Bearer <jwt> — validates JWT, loads user from usertokens with explicit scopes
2. Session cookie + X-CSRF-Token header — if session has an active web_session token and CSRF matches
3. No credentials → 401 JSON envelope
Testing an endpoint's status code¶
Dispatch the request through the kernel the entry point uses, then read the status off the kernel:
$api = new \Pramnos\Application\Api();
$api->init();
$api->exec();
$body = (string) $api->render();
$status = $api->lastStatusCode; // int|null — null before the first dispatch
Why not http_response_code(). Under CLI the kernel does not emit the status —
there is nowhere to put it — so a test could not observe it at all. And the status
is half of what an endpoint promises: 400 "you sent no credentials", 401 "they were
wrong" and 405 "wrong verb" are three different instructions to a client, and all
three can carry a body of the same shape. A test asserting only on the body cannot
tell them apart, and an endpoint whose status silently changed kept passing.
lastStatusCode is set for every dispatch, whatever the SAPI, for both response
kinds — a Response object's own status, and the status inside the legacy
array/string envelope.
The one thing it does not cover is a middleware short-circuit (a missing or
invalid API key): those never reach the dispatch, and put their status in the body
instead. Read $decoded['status'] as a fallback, and only when it is numeric —
plenty of endpoints use the word for something else ({"status":"ok"}).
Api::exec() middleware pipeline¶
Api::exec() automatically runs:
Configure CORS via app.php:
'api' => [
'cors_origins' => ['https://spa.example.com'], // config-based
// OR:
'cors_from_db' => true, // read from application_settings table
],
Database-Driven CORS¶
CorsMiddleware::fromApplicationSettings(string $appName): self queries application_settings joined with applications to load the CORS policy from the database. Falls back to ['*'] when:
- DB is unavailable or authserver feature not enabled
- No row found for $appName
- cors_enabled = false