The rules

Each of these replaces a habit that fails silently with one that fails loudly. They are the reason the code looks the way it does.

Nothing builds HTML from a string

A view returns a Node. Only Element and Doctype ever write a <, and escaping and the URL-scheme check both live in render() — so they hold however an element was built, and a javascript: link is refused where it would be written rather than rendered.

Hand-authored HTML is parsed, never trusted

It enters the tree only through Element::containingHtml(), which parses it against the app's vocabulary and refuses any tag or attribute the app did not declare. Nothing a request can influence is ever parsed — and this page is not parsed at all: it is a tree its view builds, in each of its languages.

Visible text is translatable, and a view never names a language

A catalog is an enum, each case carries its words in every language the app offers, and the tree resolves each one against the nearest lang when it renders. A sentence with a link or a piece of code in it is a Sentence, whose parts each language places where its word order wants them. A translatable with no lang above it throws rather than guessing.

Names and values are typed

A header is a HeaderName case and a HeaderValue. An attribute is an AttributeName case. An address is a Path case, and a link is ->to(…), never a concatenation. A value with a grammar is a class, and a fixed vocabulary is an enum — so a misspelled name is an error where it is written, not a default somewhere else.

A group is a collection

A group crossing a public boundary is an immutable, lazy Collection or SearchableCollection: with() copies, and a chain of steps fuses into one pass at the first call that needs a result.

Five habits are refused

The first three may be excused, by an attribute that carries its reason: #[BareArray], #[BareString], #[BareCall].

A result that matters cannot be dropped

Builders and queries carry #[\NoDiscard] with a message, so a copy-returning call whose copy is thrown away raises a warning — and the test suites fail on warnings.

Nothing ends a request but the app, and every decision returns

A response's answer() is an Answer, and App::handle() answers a whole request without sending it. Only App::run() sends. A gate's refusal is a value it returns, marked #[\NoDiscard] so a dropped one fails the suite, and the caller returns it in turn. Nothing calls exit. So every answer, a 401, a 303 or a 405 included, is a status, headers and a body a test can read in-process, through Phpanta\Test\TestRequest.

The full argument for each is in docs/guidelines.md and CLAUDE.md.