italix / encode
Context-aware output encoding as a type: HTML text, attributes, URLs and JSON, with a template lint that finds unencoded output
Requires
- php: >=7.4
Requires (Dev)
- italix/testing: ^2.0
This package is not auto-updated.
Last update: 2026-08-30 22:27:15 UTC
README
Context-aware output encoding as a type, plus a lint that fails the build on template output nobody encoded.
use Italix\Encode\Html as H; <h1><?= H::e($title) ?></h1> <div <?= H::attribs(['data-geo' => $geo, 'disabled' => $locked]) ?>> <a href="<?= H::url("/{$lang}/admin/index.html") ?>"> <span><?= H::raw($icon_svg) ?></span> <script>const labels = <?= H::j($labels) ?>;</script>
The problem is not that escaping is hard
htmlspecialchars() is one call. The problem is that escaping is invisible when absent: a
template that forgot it renders correctly for every value anyone tries by hand, and wrongly for the
one value an attacker sends. Nothing in the page, in the tests, or in a code review distinguishes the
two.
So the encoding is given a type. Html is a string that is already encoded, and markup that did
not come from an encoder can only enter through raw():
grep -rn 'H::raw' src/Views src/Themes
That is the complete list of places where trusted markup is emitted — a review of a few lines instead
of a review of every echo in the codebase.
Encoders compose by nesting, one layer of the document at a time
JSON inside an HTML attribute is two layers, and attribs() applies them in that order. A value
reaching a JavaScript string inside an onclick is three, and no single call can express that —
nesting can.
| call | context | note |
|---|---|---|
H::e($v) |
HTML text | passes an Html through unchanged, so double encoding is not a thing you can do by accident |
H::raw($h) |
trusted markup | the only door in; does not look at what it is given |
H::j($v) |
a JavaScript literal | throws on an Html — a JS literal is not an HTML context, and quietly accepting one there is how & ends up in a string the browser shows a user |
H::attribs([…]) |
a whole attribute list | false/null drop the attribute, true renders it bare |
H::url($u, $q) |
an href/src |
builds the query string too |
Usage is by class alias, deliberately
There are no global helper functions. A function declared in a template is a global function, and PHP fatals on the second include of the same partial.
This is an encoder, not a sanitizer
The two are different jobs. Encoding is deterministic, lossless, and depends on the output context;
sanitizing is lossy, depends on a security policy, and works by whitelist. raw() does not inspect
its argument. Untrusted rich content — uploaded SVG, rendered Markdown, pasted HTML — must pass a
sanitizer before it reaches raw().
The lint
An encoder that must be invoked is not a default. bin/encode-lint turns "we always encode" from a
habit into something CI can refuse to merge:
$ encode-lint src/Views src/Themes
src/Views/orders/index.php:41 $row['customer_name']
src/Themes/Default/layouts/admin.php:123 $css_class
encode-lint: 92 unencoded outputs in 21 files.
It exits 1 when it finds any, so it stands as a build step. It reads PHP's own token stream
rather than matching source text, because a regex cannot tell <?= $x ?> from the same characters
inside a string literal or a comment. For every echo it splits the expression into the terms that
actually reach the page — through concatenation, comma lists, ??, and the branches of a ternary,
but not a ternary's condition, which is tested and discarded — and requires each one to be a
literal, a numeric cast, an encoder call, or a match against the project's allow list.
The alias is resolved from the file's own use statements, so a file that writes H::e(...)
without importing Italix\Encode\Html is reported. The lint accepts the encoder, not the letter H.
$lint = new Lint( ['/^\$view->get\(/', '/^\$form_html->/'], // allow list ['php'], // extensions [\Italix\I18n\T::class => ['e', 'choice_e']] // other things that encode ); foreach ($lint->check_paths(['src/Views', 'src/Themes']) as $finding) { printf("%s:%d: %s\n", $finding['file'], $finding['line'], $finding['expression']); }
The third argument matters more than it looks. Anything else in your stack that returns already-
encoded output — a translator's e(), a Markdown renderer — has to be declared here, or the lint
reports every use of it. A lint with thirty false positives is worse than no lint, because it
looks like coverage and gets ignored like noise.
What it cannot see
It reads source text. A value assembled at runtime and emitted through an allow-listed expression passes unexamined, and a template outside the paths it is given is not checked at all. This is a static guarantee standing in for one the template engine does not provide — good enough to keep a codebase honest, not the same thing as escaping in the compiler.
Requirements
php >= 7.4. Nothing else — no extensions, no dependencies. That is deliberate: this package sits
underneath others (italix/i18n requires it), so anything it dragged in would arrive in projects
that never asked for it.
Installing it puts encode-lint in vendor/bin whether or not your project has templates.