Production-grade PHP 8.3+/8.5 template engine with full Apache SSI directive support (
#ifexpressions,#execpolicies,#printenv,#block/stub), a gettext-compatible i18n layer (PO/MO/JSON/PHP catalogues, plural rules for 60+ languages, extraction & audit tooling), layout inheritance, scoped partials, a safe expression language β inline in substitutions since v5 (%{= a / b * 100}, nested/dynamic keys,get()indirection) β a render cache, a template linter, profiling stats, an optional WordPress bridge + maintenance toolkit, and a hardened escape-by-default security model with zero$GLOBALS, zero superglobal reads in the core, zero dependencies.
Version 5 is an additive release: every 4.x template renders byte-identically. See docs/MIGRATION-4.x-TO-5.0.md.
| 4.x | 5.0 | |
|---|---|---|
| Math in a substitution tag | compute in @Set first |
%{= done / total * 100 | round:1 } |
| Hash element by a dynamic key | β static dot paths only | %{= prices[currency]}, %{= rows[i].name} |
| Variable inside a variable | β | %{= get(fieldName)}, %{= get('rows[1].name')} |
| Arithmetic filters | div only |
add sub mul div mod pow sqrt pct sum avg clamp |
| Expression functions | 17 | 23 (pow sqrt sum avg clamp get) |
a..b ranges in expressions |
broken (syntax error) | fixed, indexable: %{= (1..3)[1]} |
| Kill switch for inline math | β | syntax.expressions: false (fail-closed) |
Hardening that came with it: bounded AST memo (4 096 FIFO), a 100 000-item
iteration budget on every host-iterable walk, dynamic keys that can never
reach an object property or method, get() paths that resolve against the
scope stack only, and INF/NAN-free arithmetic. Inline expressions reuse
the existing sandboxed evaluator β no eval, whitelisted functions only,
auto-escaped output β so they add no new trust surface. Details:
docs/SECURITY.md Β§4.
- From 4.x β 5.0: additive release, no breaking changes, no deprecations β docs/MIGRATION-4.x-TO-5.0.md.
- From 3.x: 4.0 was a clean-break rewrite β see the full mapping table in docs/MIGRATION-3.x-TO-4.0.md, then upgrade to 5.x. Release history: CHANGELOG.md.
| Edition | File | PHP | Language features |
|---|---|---|---|
| 8.3 (portable) | php83/html.php |
8.3 β 8.5+ | typed class constants, readonly classes, enums |
| 8.5 (modern) | php85/html.php |
8.5+ | everything above plus pipe operator |>, array_find(), property hooks, asymmetric visibility, #[\NoDiscard] |
Both are generated from the same PSR-4 source tree (src/, with
src-8.5/ overlays) by tools/build.php, and
both pass the identical test matrix (386 tests / 1286 assertions per leg,
run with and without ext-intl β the suite is extension-independent).
Option A β single file (no Composer): copy php83/html.php (or
php85/html.php on PHP 8.5) into your project and require it. Done.
Option B β Composer (PSR-4 source):
composer require paulmann/html-php # PHP 8.3/8.4
composer require paulmann/html-php85 # PHP 8.5Option C β WordPress: install the bundled plugin in
wordpress/html-php/ (see
docs/WORDPRESS.md).
require_once __DIR__ . '/php83/html.php'; // or Composer's autoloader
use HtmlPhp\Engine;
use HtmlPhp\Template\RenderOptions;
$engine = Engine::create([
'templates' => ['paths' => [__DIR__ . '/templates']],
'i18n' => ['locale' => 'de', 'paths' => [__DIR__ . '/languages']],
'cache' => ['enabled' => true, 'driver' => 'file', 'directory' => sys_get_temp_dir() . '/htmlphp'],
]);
echo $engine->render('page.html', [
'title' => 'Hello <World>',
'rows' => [['name' => 'Ada'], ['name' => 'Grace']],
]);<!-- templates/page.html -->
<h1>%{title}</h1> <!-- auto-escaped: Hello <World> -->
<ul>
<!--@Loop rows-->
<li>%{_position}. %{name|upper}</li>
<!--@Else-->
<li>@{list.empty}</li> <!-- translated when the list is empty -->
<!--@EndLoop-->
</ul>
<!--#include virtual="/partials/footer.shtml" --> <!-- Apache SSI -->$engine->renderString('%{price|number:2}', ['price' => 1234.5]); // "1,234.50" (locale-aware)
$engine->renderString('%{= done / total * 100 | round:1 }%', $v); // "14.8%" β inline math (v5)
$engine->renderString('%{= prices[currency]}', $v); // dynamic key (v5)
$engine->renderString('%{= get(fieldName)}', $v); // name from data (v5)
$engine->renderString('<!--@If total > 100 -->FREE<!--@Else-->%{ship|number:2}<!--@EndIf-->', $vars);
$engine->renderString('<!--@Set total="price * qty-->%{total|round:2}', $vars);
$engine->render('child.html'); // <!--@Extends "base.html"--> inheritance
$engine->t('{0} files', [42]); // gettext with interpolation
$engine->processSsi($postContent); // SSI over arbitrary strings (WP content!)
$engine->lintDirectory(__DIR__ . '/templates'); // CI-ready template validation
$engine->stats(); // timings, loops, includes, cache hitsEngine ββ facade: render / renderFile / renderString / lint / t / processSsi / stats
β
ββ Config (immutable value object) ββ Container (PSR-11-shaped, lazy)
ββ Template\Renderer ββ pipeline: parse β layouts β tree render β SSI β cache
β ββ TagParser β Node tree ββ TreeRenderer (loops/ifs/sections/partials)
β ββ Substitution (%{} #{} @{} + filters) ββ Expression (safe evaluator)
β ββ Linter (reuses the parser)
ββ Ssi\Processor ββ mod_include: scanner + DirectiveRegistry + policies
ββ I18n\Translator ββ catalogues, plural forms, fallbacks, WP delegation
ββ Contracts\CacheInterface ββ file/apcu/array/null/wp/psr16
ββ Log\LoggerInterface ββ null/array/file/errorlog/syslog/wp/psr3
ββ Event\Dispatcher ββ WP-hook-shaped tags & priorities
ββ Diagnostics ββ capped structured collector (codes stable since 3.x)
ββ WordPress\* ββ Bridge, WpCache, WpLogger, TemplateLoader, Shortcodes,
AdminPage, Cli, Mailer + Maintenance toolkit (optional)
Hard rule enforced by the test suite (token-level scan): the core never
calls eval() and never reads $GLOBALS/superglobals β the only
sanctioned edge points are HtmlPhp\Input (explicit host-input helper) and
the WordPress\* adapters (which exist to talk to WordPress).
| Document | Contents |
|---|---|
| docs/TEMPLATE-SYNTAX.md | substitutions, filters, loops, conditionals, layouts, partials, trans blocks |
| docs/SSI.md | every directive, expression grammar, exec policies, security |
| docs/I18N.md | catalogues, plural rules, extraction, audit, WordPress delegation |
| docs/API.md | full class/method reference |
| docs/CONFIGURATION.md | every config key, bounds and default |
| docs/WORDPRESS.md | bridge, plugin, maintenance toolkit, WP-CLI |
| docs/CLI.md | bin/htmlphp commands |
| docs/SECURITY.md | threat model & hardening guide |
| docs/ARCHITECTURE.md | layering, pipeline, editions, decisions |
| docs/MIGRATION-4.x-TO-5.0.md | 4 β 5 upgrade: new syntax, hardening checklist |
| docs/MIGRATION-3.x-TO-4.0.md | 1:1 mapping for every removed function |
| CHANGELOG.md | release history |
php8.3 tests/run.php # source tree
php8.5 tests/run.php --lib=php85/html.php # 8.5 bundle
bash tools/run-matrix.sh # full matrix (8.3/8.4/8.5 Γ src/bundles)
php tools/build.php # regenerate both bundles
php tools/bench.php # throughput benchmarkCI (.github/workflows/ci.yml) runs the matrix,
a bundle-drift check (build.php --check), PHPStan and PHP-CS-Fixer.
PHP 8.3+ (8.5 edition: PHP 8.5+). No extensions required; mbstring
improves truncation/slugs, intl improves transliteration, apcu enables
the APCu cache driver.
MIT Β© Mikhail Deynekin β see LICENSE.