Skip to content

Problem Domain

Mikhail Deynekin edited this page Oct 11, 2026 · 1 revision

Problem Domain β€” why HTML.PHP exists

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.

The setting (2026)

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
Loading

Problem 1 β€” The html4.pl inheritance: templates driven by globals

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.

Problem 2 β€” The "no Composer" hosting reality

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 (packages paulmann/html-php, paulmann/html-php85);
  • single-file bundles (php83/html.php, php85/html.php, ~490 KB) built by tools/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.

Problem 3 β€” XSS by default

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.

Problem 4 β€” SSI fragmentation

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.

Problem 5 β€” i18n fragmentation

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-Forms evaluator (never eval) plus built-in rules for 60+ languages β€” including the French n > 1 rule 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 as wp htmlphp i18n … and an admin-screen tab.
  • Template-native translation: @{key}, @Trans/@Plural blocks with positional args, |translate filter, per-render locale override.

Problem 6 β€” Hidden state vs. modern runtimes

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.

Problem 7 β€” The WordPress maintenance gap

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

Problem 8 β€” Templates that only fail at runtime

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.

Problem 9 β€” Configuration sprawl

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.

Problem 10 β€” Silent failure / no observability

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 β†’ mechanism map

# 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

Non-goals (what HTML.PHP is not)

  • 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 Assets helper 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.

Design principles distilled

  1. Templates are data. Nothing in a template can execute code the host didn't sanction.
  2. Explicit beats ambient. Config, request, time and scope are injected; the only singleton in the universe is the WordPress bridge (and it says so).
  3. Fail loud, degrade soft. Structure errors throw; data errors diagnose; policies decide the volume.
  4. One source of truth. src/ β†’ bundles β†’ docs β†’ wiki, all generated or drift-checked; behaviour differences between editions are forbidden.
  5. 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.

Clone this wiki locally