coroq / html
HTML escaping for PHP templates
Requires
- php: ^8.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^9.5
README
HTML escaping for PHP templates.
Install
composer require coroq/html
Quickstart
use function Coroq\Html\h; <p><?= h('cookies & cream') ?></p> <!-- Output: <p>cookies & cream</p> -->
Use h() when echoing - it's safe everywhere.
How it works
h() uses deferred escaping - it doesn't escape immediately. Instead, it wraps the value in an Html object.
Html objects are like DOM elements - they have a tag, attributes, and children. Children can be strings or other Html objects:
use Coroq\Html\Html; $label = (new Html())->tag('strong')->append('Home'); $link = (new Html()) ->tag('a') ->attr('href', '/home') ->addClass('nav-link') ->append($label); // Html as child echo $link; // Output: <a href="/home" class="nav-link"><strong>Home</strong></a>
h() wraps the value in an Html object without a tag, just adding the value as a child:
$wrapped = h('text'); // Returns Html object with no tag, 'text' as child echo $wrapped; // Output: text (escaped at render time)
So you can call h() without worrying about double-escaping:
echo h('&'); // wraps '&' → & echo h(h('&')); // already an Html → returned unchanged → & $link = (new Html())->tag('a')->attr('href', '/x'); echo h($link); // already an Html → returned unchanged → <a href="/x"></a>
At render time, string children are escaped, Html children render themselves.
Where h() goes
<p><?= h($name) ?></p> // yes - escape as you echo <a href="<?= h($url) ?>"> // yes - h() escapes quotes too <?= h(el('a', ['href' => $url])) ?> // yes - attributes are escaped for you <?= h(el('a', ['href' => h($url)])) ?> // no - throws; pass the plain value
Building elements
el($tag, $attrs, $children) creates any element. Attribute values that are null or false are left out:
use function Coroq\Html\{h, el}; echo h(el('article', ['id' => 'post-1'], [ el('h2', [], $title), el('p', ['class' => 'lead'], $summary), ]));
There are no per-tag helpers. A mistyped tag shows up in the output, so a shorter name buys little; if a project writes the same element often, a one-line function of its own reads better than anything this library could guess:
$card = fn($body) => el('div', ['class' => 'card'], $body);
An element can also be built up step by step, which is how you adjust one that was handed to you:
echo h(el('div')->addClass('card')->append($content));
Conditional markup
use function Coroq\Html\{h, el}; <!-- Link if URL exists, otherwise plain text --> <span><?= h($user->url ? el('a', ['href' => $user->url], $user->name) : $user->name) ?></span> <!-- Highlight errors --> <td><?= h($hasError ? el('span', ['class' => 'error'], $value) : $value) ?></td>
A null or false value removes an attribute, so a condition can go straight into the value:
<?= h(el('input', ['type' => 'text', 'name' => 'detail', 'disabled' => !$editable])) ?> <!-- $editable = false: <input type="text" name="detail" disabled> --> <!-- $editable = true: <input type="text" name="detail"> -->
Repeating markup
echo h(el('ul', [], array_map(fn($item) => el('li', [], $item), $items)));
append() adds each item of an iterable as a child, so repeating markup is plain array_map. The list must be flat - a nested one throws, since nesting an array has no meaning in the output.
Reusing markup
An Html object is mutable, so a variable holding one is not safe to reuse - every use would share and modify the same element:
$cell = el('td', ['class' => 'num']); // don't $cell = fn($v) => el('td', ['class' => 'num'], $v); // do
To reuse a piece of markup, write a function that returns a fresh element.
Trusted HTML
use function Coroq\Html\{h, noEscape}; echo h(noEscape('<strong>bold</strong>'));
Never use noEscape() with user input.
HTML templates
Html objects build markup through method calls. When markup is easier to write as actual HTML, use a template: HTML with embedded PHP — classic PHP style — that renders as an HtmlInterface object.
Extend HtmlTemplate:
use Coroq\Html\HtmlTemplate; use function Coroq\Html\h; final class ProductCard extends HtmlTemplate { public function __construct( private Product $product, private int $priceWithTax, ) { } protected function render(): void { ?> <div class="card"> <h5><?= h($this->product->name) ?></h5> <p>JPY <?= h(number_format($this->priceWithTax)) ?></p> </div> <?php } }
The constructor takes the data the template uses; render() echoes the HTML, escaping with h() as usual.
Echo it, or embed it anywhere trusted HTML goes:
echo new ProductCard($product, 5500);
<td><?= h(new ProductCard($product, 5500)) ?></td>
echo h(el('div', ['class' => 'cart'], new ProductCard($product, 5500)));
A template's output is not escaped — like noEscape(), but structural: the template escapes its own data inside render().
Custom HTML values
HtmlInterface marks a value as already-safe HTML — anything implementing it is rendered as-is. Html, NoEscape and HtmlTemplate all do.
Extend HtmlTemplate for hand-written markup. Implement HtmlInterface directly to build the string yourself:
use Coroq\Html\HtmlInterface; use function Coroq\Html\h; final class Badge implements HtmlInterface { public function __construct(private string $label, private string $color) {} public function __toString(): string { return '<span class="badge badge-' . h($this->color) . '">' . h($this->label) . '</span>'; } }
Escape interpolated values with h(). Concatenation calls __toString(), so no cast is needed.
Requirements
- PHP 8.0 or higher
- No dependencies
Scope
Does: HTML escaping, element generation, class-based HTML templates Doesn't: Template engines, HTML parsing, DOM manipulation
Reference
Core functions
h($content)- escape content, safe to call multiple timesnoEscape($html)- mark HTML as safe (dangerous with user input)
HtmlTemplate
- extend it, take the template's data as constructor parameters, and echo the HTML in
render(): void - echo the object or cast it to string to render
Elements
el($tag, $attrs, $children)- any element; void tags such asbrandimgget no closing tag
Fluent methods
->tag($name)- set the tag name->append($content)- add content; each item of an iterable becomes a child, andnulladds nothing->prepend($content)- the same, at the beginning->children($iterable)- replace all children->attr($name, $value)- set attribute (anullorfalsevalue removes it)->attrs($array)- set several attributes at once->addClass($classes)- add CSS classes; an empty ornullname adds nothing->id($id)- set id;nullremoves->data($name, $value)- set adata-attribute->style($name, $value),->styles($array)- add CSS properties->title($value),->alt($value),->role($value)- set those attributes;nullremoves->autocomplete($value),->placeholder($value)- the same->ariaLabel($value),->ariaHidden($bool)-aria-labelandaria-hidden; otheraria-*go throughattr()->close($mode)- forceHtml::CLOSE,Html::NO_CLOSEorHtml::SELF_CLOSE->wrap($wrapper)- put this element inside another and return the wrapper
An Html is write-only. Nothing comes back out of it except the rendered,
escaped HTML, so a value you read from one can never be mistaken for a value the
library has already made safe. Do any inspecting or rewriting of your content
before you put it in.