# Error pages

*5 August 2026*

The one screen nobody designs and everybody eventually reads.

Before this, a missing page on Convoro was the words `Page not found.` in Times
New Roman on white. Not broken — a correct 404 with a correct body — and the
exact moment a site stops feeling like a product and starts feeling like
somebody's PHP.

## Three rules

**Say what happened, in the words of somebody it happened to.** Not "404 Not
Found". *There is nothing at this address.*

**Say what they can do next, and make it a link.** A 404 gets a search box,
because somebody who followed a dead link is usually still looking for the
thing it pointed at. A 403 gets a sign-in link when the reader is signed out,
because that is what a 403 usually is. A 419 gets a Go back button, because the
thing they wrote is still in the previous page.

**Never blame them.** "You have entered an invalid URL" is a sentence written by
somebody who has forgotten there is a person on the far end. The link is stale,
the page was removed, the session expired. None of that is theirs.

## One interception point

A controller keeps saying:

```php
return Response::notFound('Topic not found.');
```

and the kernel dresses it on the way out. Two hundred call sites do not need to
know what an error page looks like, and the sentence the controller wrote —
which is more use than any generic wording — becomes the page's explanation.

Where the controller only passed a stock phrase (`Not Found`, `Page not
found.`), the status's own better wording wins instead. That list is short and
explicit rather than clever: guessing at which messages are "real" would
eventually swallow one that mattered.

**Only bare error responses are touched.** A response that already contains
markup was rendered by somebody who meant it; a redirect, a file download and a
JSON payload pass through untouched.

## JSON stays JSON

🚨 The realtime poll, the attachment upload and the whole `/api` surface parse
what comes back. Handing one of them a rendered HTML error page turns a clean
404 into a parse error with nothing in it to say what actually happened — a
worse failure than the one being reported.

So the negotiation happens before anything is rendered: `/api/*`, an `Accept`
of `application/json`, or an `X-Requested-With` without `text/html` gets
`{"error": "…"}` and the same status.

## The 500 carries a reference

A six-character code, written into the log line and printed on the page.

Without one, a report is "it broke" and the log is ten thousand lines with no
way to find the right one. With it, somebody can quote six characters and the
entry is a `grep` away. That is the whole difference between a bug report that
can be acted on and one that cannot, and it costs four bytes of randomness.

The message itself never says more than that something failed. What went wrong
is in the log, where the person who can act on it is, and not on a page served
to whoever asked.

## Two renderers, on purpose

The themed page uses the site's own layout — header, footer, tokens — so an
error still looks like the place it happened in rather than a hole in it.

But a 500 raised while booting cannot depend on the database, the theme and the
template cache all working, because one of those is very likely what just
failed. So there is a second, self-contained page with its palette inlined —
still Convoro's colours, still readable in dark mode, still with a way back —
and the themed renderer simply returns null when it cannot run.

This is why the fallback is not a `<h1>Error</h1>`: the case where the site is
least well is the case where it most needs to look like somebody is home.
