Repository navigation
Problem Domain
Note
This page is the why behind the code. Every section names a concrete failure mode observed in production PHP shops, then maps it to the v4 mechanism that closes it. If you only read one wiki page, read this one.
PHP serves a huge share of the web, but the templating landscape is split:
| Camp | Tools | Reality |
|---|---|---|
| Framework land | Twig, Blade, Latte | Excellent β if you're inside Symfony/Laravel/Composer-land |
| CMS land | WordPress theme API | Works, but theme "templates" are raw PHP with escaping left to the author |
| Legacy land |
html4.pl-style processors, hand-rolled str_replace, Apache SSI files |
Enormous installed base, zero maintenance, zero tests, $GLOBALS everywhere |
HTML.PHP lives deliberately in the third camp's rescue zone: projects that cannot adopt a framework, hosting that has no Composer, documents that already speak Apache SSI, and codebases where a single fat file is a deployment feature, not a smell. Version 4 rebuilds that world on 2026-grade engineering instead of 2004-grade habits.
mindmap
root((HTML.PHP v4))
Legacy rescue
html4.pl trees
GLOBALS config
No tests
Hosting reality
No Composer
Single file
PHP-only tooling
Security debt
Unescaped output
SSI source disclosure
exec in templates
Ops gaps
No linting
Silent failures
No i18n tooling
Modern runtimes
Long-running workers
Multi-tenancy
Static state = leaks
Symptom. The original library (and a generation of scripts descended from
html4.pl) resolved %{NAME} against $GLOBALS, configured itself through
$GLOBALS['HTML_*'], stored block state in $GLOBALS['html'], and let
LoadTemplate()/Show_HTML() mutate process-wide arrays.
Why it fails.
- Untestable: two tests in one process contaminate each other.
- Unreasonnable: any code anywhere can change what a template renders.
- Unportable: long-running workers (Swoole, RoadRunner, FrankenPHP β the default way to run PHP in 2026) keep stale globals across requests.
- Multi-tenant-hostile: two sites in one process share one config.
v4 answer. A single immutable Config value
object (unknown keys throw), a lazy Container, and a
Scope stack that is the only variable source. #set
writes die with the render. The core contains no static mutable state at
all β a token-level test scans every file for eval/$GLOBALS/superglobals
on each CI run, so the property can never silently regress.
Symptom. Shared hosting, managed WordPress without SSH, agency deploys
over FTP/SFTP. A vendor/ tree of 300 files is operationally worse than one
file, and composer update on a live box is a ritual nobody wants to perform.
v4 answer. Both worlds, from one source tree:
-
PSR-4
src/for Composer projects (packagespaulmann/html-php,paulmann/html-php85); -
single-file bundles (
php83/html.php,php85/html.php, ~490 KB) built bytools/build.php, with a CI drift gate (build.php --check) so a bundle can never lag its source.
Zero runtime dependencies is a hard constraint, not a marketing line:
PSR-shaped interfaces are shipped in-library, and adapters (Psr3Adapter,
Psr16Adapter) plug real PSR implementations in when the host has them.
Symptom. v3 defaulted HTML_ESCAPE_VARS = false. Every %{user_input}
was an open door; every audit started with "find all the places that forgot
|escape".
v4 answer. Auto-escaping is ON by default. Opt-out is explicit,
per-variable (|raw) and can be removed entirely (syntax.allow_raw_filter: false) for user-editable templates. Context escapers exist for attributes,
URLs, inline JS/JSON and CSS. The escaping decision moved from every
template author, every time to the security policy, once.
Important
Migrating a v3 site? Render key templates with auto_escape both ways and
diff β see Migration Β§5.
Symptom. Thousands of .shtml trees rely on Apache mod_include. The
moment the site moves to nginx + PHP-FPM, LiteSpeed, a container, or a static
export, every <!--#include virtual--> silently becomes a comment. WordPress
content with SSI snippets is inert. And PHP-side SSI clones tend to be
half-dialects with their own syntax.
v4 answer. A real mod_include dialect: #config #set #include (file/virtual, stub=, wait=) #echo (encoding=none/entity/url/base64) #if/#elif/#else/#endif with the full expression grammar (regex, file tests,
logic), #exec (policy-gated), #fsize #flastmod #printenv #block/#endblock,
plus a DirectiveRegistry for custom directives. It runs over the rendered
document (Apache semantics), works on arbitrary strings
($engine->processSsi($postContent) β the WordPress the_content use case),
and reads its environment from an injected RequestContext instead of
$_SERVER, so CLI rendering, queue jobs and tests see deterministic values.
Symptom. gettext requires ext-gettext + .mo compilation + msgfmt
on the deploy path; plural rules are copy-pasted regex folklore; nobody knows
which strings are untranslated until a German customer screenshots an
English checkout; JSON "translation services" and PO catalogues never meet.
v4 answer.
- Catalogue readers for PO, MO (binary, both endians), JSON and plain PHP arrays β no extension required; multiple paths merge per locale.
- A safe
Plural-Formsevaluator (nevereval) plus built-in rules for 60+ languages β including the Frenchn > 1rule that v3 simply lacked. - Full tooling loop:
bin/htmlphp i18n:extractβ POT with file:line references β translate βi18n:auditβ CI exit code 1 when incomplete. The same commands ship aswp htmlphp i18n β¦and an admin-screen tab. - Template-native translation:
@{key},@Trans/@Pluralblocks with positional args,|translatefilter, per-render locale override.
Symptom. Statics + globals are fine in classic PHP-FPM (process dies after the request). Under FrankenPHP/Swoole workers, or in multi-tenant SaaS, they leak across requests: stale locale, stale catalogues, other tenants' filters.
v4 answer. Engines are instances; every memoized structure (parse trees,
locator results, catalogues, compiled expressions) hangs off the engine and
has an explicit flush (clearCache(), Translator::reset(),
Expression::reset()). Time is an injected ClockInterface β tests freeze it,
workers never see stale DATE_LOCAL. Two engines with different configs
coexist in one process; the test suite asserts their independence.
Symptom. WP sites accumulate: template fragments nobody dares touch, translation catalogues 40 % stale, object caches nobody can selectively invalidate, "why is this page slow" with zero data, plugin PHP pasted into theme files.
v4 answer. A maintenance-first WordPress layer (all optional β the core
never mentions WordPress): object-cache + transient backend with an
enumerable key registry; rotating logs under uploads/; health checks
(PHP support window, debug display, disk, cron, pending updates, engine
diagnostics backlog); the linter over theme template roots; i18n audit;
RenderStats in the admin bar; wp htmlphp CLI; an admin screen rendered
by the engine itself (themes can override each tab with a template).
Symptom. An unclosed <!--@Loop--> ships Friday, explodes Monday at
3 a.m. under a specific data shape. Template syntax has no compiler, no CI,
no editor support β so errors surface as wrong pages, not failed builds.
v4 answer. A real parser (tag stream β node tree) shared by the
renderer and a standalone linter: 25 issue codes across structure,
expressions, filters, includes and SSI, with file/line, severities,
--ignore= baselining, JSON output and CI exit codes
(bin/htmlphp lint templates/). Structural errors are exceptions at render
time too β silent skip was the v3 behaviour that let rot accumulate.
Symptom. 35 string-keyed $GLOBALS settings; a typo
(HTML_SSI_EXC) silently means "default"; bounds live in comments.
v4 answer. Nine typed readonly config groups + six enums; every key has a
declared default, type and bound; unknown keys throw ConfigException at
construction; config:dump prints the effective config as JSON. A typo is a
deploy-time error, not a production mystery.
Symptom. A missing variable renders "", a denied include renders
nothing, a broken #if renders the else-branch β and the only trace is a
shrug.
v4 answer. Layered, explicit signals: capped structured
Diagnostics (stable codes, where
frames) β optional PSR-3-shaped logger β tagged events
(htmlphp.error, htmlphp.rendered with RenderStats, WP-mirrored hooks) β
strict mode that converts lenient paths into exceptions. You choose how loud
failure is; it is never silent by architecture.
| # | Problem | v4 mechanism | Where |
|---|---|---|---|
| 1 |
$GLOBALS-driven engine |
immutable Config, DI Container, Scope stack |
Architecture |
| 2 | no-Composer hosting | single-file bundles + PSR-4, drift-gated | Getting Started |
| 3 | XSS by default | auto-escape ON, raw policy, context escapers |
Security |
| 4 | SSI fragmentation | full mod_include dialect + registry + RequestContext
|
SSI Guide |
| 5 | i18n fragmentation | PO/MO/JSON/PHP + safe plurals + extract/audit | I18n Guide |
| 6 | hidden state in workers | instance engines, injected clock, explicit flushes | Architecture |
| 7 | WP maintenance gap | bridge, toolkit Γ12, admin screen, WP-CLI | WordPress |
| 8 | runtime-only template bugs | parser + linter + CI exit codes | CLI |
| 9 | config sprawl | typed groups, bounds, unknown-key rejection | Configuration |
| 10 | silent failure | diagnostics β logger β events β strict mode | Troubleshooting |
- Not an MVC view layer. No routers, controllers, form builders.
- Not Twig. No macros/embeds/operators-everywhere; the surface stays small enough to learn in an afternoon β that's the product.
-
Not a component framework. No design system, no asset pipeline
(the WP
Assetshelper is cache-busting, nothing more). -
Not an excuse for
eval. The expression languages are interpreters over whitelisted grammars; a "just add PHP tags" request will be declined. - Not backward compatible with 3.x/2.x. Deliberately: see the Migration guide.
- Templates are data. Nothing in a template can execute code the host didn't sanction.
- Explicit beats ambient. Config, request, time and scope are injected; the only singleton in the universe is the WordPress bridge (and it says so).
- Fail loud, degrade soft. Structure errors throw; data errors diagnose; policies decide the volume.
- One source of truth. src/ β bundles β docs β wiki, all generated or drift-checked; behaviour differences between editions are forbidden.
- Boring operations. Atomic writes, hashed cache keys, deny-lists, budgets, linters β the unglamorous machinery that makes 3 a.m. quiet.
β‘οΈ Next: Getting Started Β· Curious about trade-offs? Architecture β Design decisions Β· Where this is all going: ROADMAP.
HTML.PHP 5.0.0 Β· MIT Β© Mikhail Deynekin Β· Repository Β· Releases Β· Report a security issue
Wiki pages mirror docs/ β found a mistake? Edit the page or open an issue.
π Home Problem Domain Getting Started
Template Syntax Expressions & Math SSI Guide I18n Guide WordPress Integration CLI
Configuration API Cheatsheet Diagnostics & Troubleshooting
Architecture Security Model Testing & CI
πΊοΈ ROADMAP Migration 4.x β 5.0 Migration 3.x β 4.0 Contributing Repository β