Relayer
DocsRoutingapi-routes

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 setting http_response_code() first and having it implicitly converted. Returning anything other than a Response fails loudly and immediately with a RuntimeException (= 500). There is no ApiResponder.

<?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.

FactoryUse
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 automatic HEAD uses).

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)#

CaseBehavior
Undefined OPTIONS204 + Allow (user code does not run)
Undefined HEADRuns the GET handler and returns it with the body dropped
Bodiless 204 / 304No Content-Type unless the handler sets one
Method with no handler405 + Allow (JSON body)
route.php was removed404 {"error":"Not Found"} (JSON preserved)
Auth failureA 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.php only 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.php returns Response::json([...]) with a : Response annotation. The 304 short-circuit via ETag goes through CachePolicy::emit() / isNotModified() / sendNotModified(). Because route.php does not go through the HTML pipeline (Rosanna component rendering), it does not emit a page-like Set-Cookie: PHPSESSID, and the JSON response is itself eligible for CDN caching (for getting the page side into the same state, see CDN Caching).

Last revised — 2026-11-04
Revisions (3)
  • v1.1.0: usePHP→Rosanna(rosanna-defer)に合わせて更新
  • v0.31.0: withCookie()、未捕捉例外の JSON 500 を追記
  • Created