italix/encode

Context-aware output encoding as a type: HTML text, attributes, URLs and JSON, with a template lint that finds unencoded output

Maintainers

Package info

github.com/italix-net/encode

pkg:composer/italix/encode

Transparency log

Statistics

Installs: 4

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

1.1.1 2026-08-29 20:22 UTC

This package is not auto-updated.

Last update: 2026-08-30 22:27:15 UTC


README

PHP Version License

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 &amp; 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.