โ† Files

HTML.PHP โ€” Template Processing Library ๐Ÿงฉโšก#

Production-grade PHP 8.3+/8.5 template engine with full Apache SSI
directive support (#if expressions, #exec policies, #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
.


What's new in 5.0.0#

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.

Upgrading#

Two editions#

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).

Installation#

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.5

Option C โ€” WordPress: install the bundled plugin in
wordpress/html-php/ (see
docs/WORDPRESS.md).

Quick start#

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 &lt;World&gt; -->
<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 -->

The one-liner tour#

$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 hits

Architecture at a glance#

Engine  โ”€โ”€ 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).

Documentation#

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

Development#

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 benchmark

CI (.github/workflows/ci.yml) runs the matrix,
a bundle-drift check (build.php --check), PHPStan and PHP-CS-Fixer.

Requirements#

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.

License#

PolyForm Noncommercial 1.0.0 ยฉ Mikhail Deynekin โ€” see LICENSE.