Error Rendering¶
How NeNe shapes error responses across the REST and HTML paths. Pair with docs/development/error-codes.md (the envelope catalog) — this document explains what the user sees on each side.
Audience: anyone writing a controller, contributor wiring a new error class, or a reviewer auditing a security-sensitive page. Trial source: FT7 (docs/field-trials/2026-05-field-trial-7.md).
REST vs HTML response shape, at a glance¶
| Situation | REST caller (Accept: application/json) |
HTML caller (browser, Accept: text/html) |
|---|---|---|
| Route matches no controller / no action | 404 + ApiFailureEnvelope (errorCode: NOT-FOUND) — application/json |
404 + static 404.html — text/html |
Unhandled \Throwable in a *Rest handler |
500 + ApiFailureEnvelope (errorCode: INTERNAL-ERROR) |
(would be the same path; see "Pre-dispatch errors" below) |
Unhandled \Throwable in a *Action handler |
(HTML caller path) | 500 + static 500.html |
Nene\Xion\DomainException thrown from any handler |
catalog HTTP status + ApiFailureEnvelope carrying the errorCode |
catalog HTTP status + domain-error.html template with {{errorCode}} / {{errorMessage}} placeholders substituted |
Unauthenticated request to a protected *Rest action |
401 + SESSION-CLOSED envelope |
(not directly applicable — see HTML below) |
Unauthenticated request to a protected *Action action |
(REST path) | 302 Location: redirect to unauthorizedRedirect() (default: LOGOUT_URI) |
CSRF rejection on a state-changing *Rest request |
403 + CSRF-TOKEN-INVALID envelope (automatic in ControllerBase::run()) |
(REST path) |
| CSRF rejection on an HTML POST form | (REST path) | 403 + static csrf.html — provided the controller called requireCsrfFromPost() (must-call helper added by #316) |
Wrong HTTP method on an action (only the wrong-verb *Rest exists) |
405 + METHOD-NOT-ALLOWED envelope + Allow: header |
(REST path) |
| Wrong HTTP method on an HTML action | (REST path) | NeNe has no HTML-rendered 405. Build it inside *Action() by branching on $this->method and rendering your own template; the framework does not auto-render. |
The full envelope catalog lives in docs/development/error-codes.md and the runtime source in config/error_codes.php.
404 — route resolution failures¶
Dispatcher::notFoundResponse() (class/xion/Dispatcher.php) handles both no-controller and no-action cases. It branches on the request's Accept header (see Dispatcher::wantsJson()):
- A JSON-preferring request (
application/jsonorapplication/*withqoutrankingtext/html) gets theNOT-FOUNDJSON envelope. - Everything else (no
Accept,text/html, generic wildcard) gets the static404.htmlpage at the project root.
404.html is not a Smarty template — replace the file to rebrand. The dispatcher itself does not distinguish between "controller class doesn't exist" and "controller exists but no method matches"; both surface as a single 404 shape on each side. If you need finer granularity inside a controller, throw notFound() (which reuses the same 404.html plus 404 status).
500 — unhandled \Throwable¶
The top-level catch in htdocs/index.php branches on RouteContext::isRest():
- REST:
ApiFailureEnvelope(errorCode: INTERNAL-ERROR, HTTP 500). - HTML: static
500.htmlat the project root.
The thrown exception is always logged through Xion\Log::getInstance('error') regardless of the branch — the response body never contains the message or trace. This is intentional. If you need to surface the raw exception to a developer locally, read the log; do not re-introduce an APP_DEBUG branch on the body without an explicit ADR.
Pre-dispatch errors¶
If an error fires before ControllerBase::run() calls RouteContext::set(...) (autoload failure, initializer crash, etc.), RouteContext::getInstance() is still at its default (mode = 'Action'). The catch will render the HTML 500 page even for a REST URL. Pure-dispatch errors that pre-empt run() (404 / 405 / ROUTE-CONFLICT) go through their own dispatcher emitters and are unaffected.
Domain failures inside a controller¶
Nene\Xion\DomainException is the canonical way for a controller to surface a business failure with a catalog code (TODO-NOT-FOUND, TODO-TITLE-REQUIRED, ...). Throw it from anywhere inside the action and the top-level catch in htdocs/index.php handles the rendering:
- REST: JSON envelope with the carried
errorCode, status from the catalog. - HTML:
domain-error.htmltemplate with{{errorCode}}and{{errorMessage}}placeholders, status from the catalog. The substitution usesstrtr()afterhtmlspecialchars()escaping; the file is a plain HTML document with no Smarty bootstrap.
Adding a new domain code: add the entry to config/error_codes.php, document it in docs/development/error-codes.md, and throw it like any existing code. No template change is needed unless you want a different visual for one particular code.
Auth: redirect vs envelope¶
ControllerBase::sessionCheck() branches on RouteContext::isRest():
- REST (
*Restaction):HttpTerminationwith theSESSION-CLOSEDenvelope, 401. - HTML (
*Action): redirect (302 +Location:) to$this->unauthorizedRedirect(). The default returns the framework-wideLOGOUT_URIconstant; overrideunauthorizedRedirect()in a controller to send a specific protected section to a dedicated login page (ADR-0004 documents the hook).
Because the dispatcher prefers *Rest over *Action for the same verb (see Action method precedence), defining both shapes for one URL silently turns the HTML redirect into a JSON 401. Don't.
CSRF protection¶
Two layers exist:
- REST:
ControllerBase::run()callsCsrfProtectionPolicyautomatically on logged-in state-changing requests (POST/PUT/PATCH/DELETE). A missing or wrongX-CSRF-Tokenheader emits the 403CSRF-TOKEN-INVALIDenvelope. No controller code is needed. - HTML forms: opt-in per controller. Embed the token in the form via
$this->csrfToken()and<input type="hidden" name="csrf_token" value="{$t_csrf_token}">. In the POST handler, call$this->requireCsrfFromPost()(added by #316) before any write. On failure it terminates with the 403csrf.htmlpage (HTML) or the same 403 envelope (REST), so a missingifcannot silently accept the request.
The lower-level verifyCsrfFromPost(): bool is still available for handlers that need to recover from a CSRF failure (re-render the form with field-level errors instead of a flat 403 page). Prefer requireCsrfFromPost() for everything else.
See the tutorial at docs/tutorials/building-a-service.md § "Protect an Authenticated Form" for the full skeleton.
Method-mismatch (405)¶
Dispatcher::resolveActionRoute() returns status 405 when the action exists in REST mode for other verbs but not the requested one. The dispatcher emits the METHOD-NOT-ALLOWED envelope with the Allow: header listing supported verbs. This path is JSON-only — the framework does not currently render a 405 HTML page. If you have an HTML form that must reject a wrong verb, branch on $this->method inside actionAction() and render the response yourself.
Authoring an HTML controller error response¶
When the response is purely controller-driven (validation, business rules), the conventional shapes inside an HTML *Action() are:
| What happened | Idiom |
|---|---|
| Path doesn't exist for an HTML view | $this->notFound(); — reuses 404.html, sets 404 |
| Auth failure (handled by the framework) | none — sessionCheck() runs in run() and redirects automatically |
| CSRF failure on a state-changing POST | $this->requireCsrfFromPost(); — terminates with 403 csrf.html on failure |
| Business-rule failure with an envelope code | throw new \Nene\Xion\DomainException('CODE'); — rendered via domain-error.html |
| Wrong HTTP method on a side-effect URL | guard with if ($this->method !== 'POST') { $this->location('...'); return; } |
| Validation failure that should re-render the form | render with $this->VIEW->setTemplate('xxx/yyy.tpl'); do not throw — re-display the form with field-level errors |
Decoration ordering reminder¶
Several error paths exit before ControllerBase::run() returns. Cross-cutting response decoration (security headers, request IDs) belongs in HttpEmitter (or a wrapper around it) rather than at the tail of run(). See the "Response decoration and the error-path early-exit trap" section in docs/development/error-codes.md for the full list.
Related¶
docs/development/error-codes.md— envelope catalog + decoration trap.docs/development/coding-standards.md— Action method precedence, URL convention.docs/review/html-controller.md— HTML controller review checklist.docs/review/rest-controller.md— REST controller review checklist.docs/tutorials/building-a-service.md— Protect an Authenticated Form (CSRF), HTML/REST symmetry.docs/field-trials/2026-05-field-trial-7.md— the trial that surfaced every behavior documented above.- ADR 0003 — generic
ApiFailureEnvelopeshape rationale. - ADR 0004 —
unauthorizedRedirect()hook for HTML auth.