RRelayer
Home/Routing

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

CaseBehavior
Undefined OPTIONS204 + Allow (user code does not run)
Undefined HEADRuns the GET handler and returns it with the body dropped
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 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.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 (use-php 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 updated: 2026-05-19
Change history (1)
  • Created