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'](takePageContextas 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 (PHP's default text/html is suppressed too). There is no null shortcut, so 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.
->withCookie($name, $value, $options)/->withoutCookie($name, $options)— a copy that sets/deletes a cookie (see below)->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 automaticHEADuses).
If the caller passes a Content-Type in $headers, it takes precedence over the factory default.
Cookies#
withCookie() appends with setcookie(), so the cookie that Authenticator::login() queued when regenerating the session id stays.
return Response::redirect('/admin')
->withCookie('staff', '1', ['expires' => new DateTimeImmutable('+1 year')]);The defaults are path /, httponly true, samesite Lax, secure false and expires 0, and $options overrides them (domain too). Names starting with __Secure- / __Host- default secure to true. A name, path or domain a browser would refuse, SameSite=None without secure, or a prefix rule violation throws InvalidArgumentException when the response is built. Do not use withHeader('Set-Cookie', …) for cookies.
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 |
Bodiless 204 / 304 | No Content-Type unless the handler sets one |
| 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 uncaught exception, and a handler returning something other than a Response, becomes a JSON 500 ({"error": "Internal Server Error"} in production, → uncaught exceptions). For work that should answer with its own status, use 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), buttry/catchis fine because it is inside the handler closure.- Passing a non-JSON-encodable value to
Response::json()raises aRuntimeException(= 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 (Rosanna 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).
Revisions (3)
- v1.1.0: usePHP→Rosanna(rosanna-defer)に合わせて更新
- v0.31.0: withCookie()、未捕捉例外の JSON 500 を追記
- Created