API Routes#
Placing a route.php in a directory makes that path a JSON/HTTP endpoint instead of a page. The file returns only a map of handlers keyed by HTTP method, and each handler is autowired exactly the same way as a functional page factory. It does not go through the layout or HTML pipeline (the equivalent of Next.js Route Handlers).
A handler must return a
Response. There is no path for returning raw data or for settinghttp_response_code()first and having it implicitly converted. Returning anything other than aResponsefails loudly and immediately with aRuntimeException(= 500). There is noApiResponder.
<?php
// src/Pages/api/users/route.php
declare(strict_types=1);
use App\Service\UserRepository;
use Polidog\Relayer\Http\Request;
use Polidog\Relayer\Http\Response;
return [
'GET' => fn (UserRepository $users): Response => Response::json(['users' => $users->all()]),
'POST' => function (Request $req, UserRepository $users): Response {
$users->create($req->allPost());
return Response::json(['ok' => true], 201);
},
];Conventions#
- Keys are HTTP methods (case-insensitive). Values are autowired
closures. It is the same resolver as pages, so PageContext, Request, Identity, and container services are injected by type.
- A handler's return value is always a
Response. Status and
headers are always explicit (there is no path that guesses them).
- The value of a dynamic segment
[param]is obtained inside the
handler from $ctx->params['id'] (take PageContext as an argument).
- A directory holds either a page or a
route.php, never both
(the scanner errors if both are present).
- This file is declaration-free (it is re-evaluated on every
request, so just return the map).
Building a Response#
The constructor is closed. You create instances with named factories.
| Factory | Use |
|---|---|
Response::json($data, $status = 200, $headers = []) | JSON-encodes (immediate encoding, slashes/Unicode unescaped) + Content-Type: application/json; charset=utf-8. RuntimeException if unencodable |
Response::text($body, $status = 200, $headers = []) | Plain text + text/plain; charset=utf-8 |
Response::noContent($status = 204) | No body, no Content-Type. The "null means 204" magic is gone (be explicit) |
Response::redirect($location, $status = 302) | Bodyless Location redirect |
Response::make($body = null, $status = 200, $headers = []) | Escape hatch for a raw body (CSV, an empty 201, etc.; no implicit Content-Type) |
Instance methods derive a copy.
->withHeader($name, $value)— a copy with one header set/overwritten
(case-insensitive).
->withoutBody()— a copy that keeps status/headers but drops the
body (the same one the automatic HEAD uses).
If the caller passes a Content-Type in $headers, it takes precedence over the factory default.
What is handled automatically (no hand-writing)#
| Case | Behavior |
|---|---|
Undefined OPTIONS | 204 + Allow (user code does not run) |
Undefined HEAD | Runs the GET handler and returns it with the body dropped |
| Method with no handler | 405 + Allow (JSON body) |
route.php was removed | 404 {"error":"Not Found"} (JSON preserved) |
| Auth failure | A non-null Identity argument / $ctx->requireAuth() throws → automatic JSON 401/403 (not the page's HTML login 302) |
$ctx->redirect('/x') | A Location response if the handler itself calls it (an intentional handler action, not the auth gate) |
$ctx->abort($s) / notFound() | {"error":"<reason>"} + JSON with that status (not an HTML error page) |
If you explicitly define OPTIONS / HEAD, those take precedence. Do not judge authentication yourself; leave it to $ctx->requireAuth() or an Identity-typed argument (see Authentication).
Error handling#
The framework catches and converts to JSON only three: AuthorizationException, RedirectException, and HttpException ($ctx->abort() / $ctx->notFound()). abort()/notFound() can be used inside route.php handlers too, producing {"error":"<reason>"} + JSON with that status instead of an HTML error page (the same API/HTML boundary as the JSON 401/403 on auth failure). Any other exception is not turned into JSON and becomes a bare 500 (an empty body in production with display_errors off), breaking the JSON contract. Wrap risky work in try/catch inside the handler closure and convert it to a Response.
return [
'POST' => function (Request $req, Service $svc): Response {
try {
return Response::json(['ok' => true, 'result' => $svc->run($req->allPost())]);
} catch (\DomainException $e) {
return Response::json(['error' => $e->getMessage()], 422);
} catch (\Throwable $e) {
\error_log((string) $e); // log on the server side
return Response::json(['error' => 'internal error'], 500); // do not expose details
}
},
];route.phponly returns a map (no class/function declarations),
but try/catch is fine because it is inside the handler closure.
- Passing a non-JSON-encodable value to
Response::json()raises a
RuntimeException (= 500) at the point where the handler assembled the response. No partial body is emitted.
Real example#
This site's /api/search is src/Pages/api/search/route.php; it injects Request and DocStore and returns full-text search results as JSON. Since a directory holds either a page or a route.php, it is in a separate directory from the HTML search page (/search).
This documentation site's own
src/Pages/api/search/route.phpreturnsResponse::json([...])with a: Responseannotation. The 304 short-circuit via ETag goes throughCachePolicy::emit()/isNotModified()/sendNotModified(). Becauseroute.phpdoes not go through the HTML pipeline (use-php component rendering), it does not emit a page-likeSet-Cookie: PHPSESSID, and the JSON response is itself eligible for CDN caching (for getting the page side into the same state, see CDN Caching).
Change history (1)
- Created