Repository navigation
Architecture
How HTML.PHP is put together, why it's shaped this way, and what keeps it honest.
Note
Canonical spec: docs/ARCHITECTURE.md.
flowchart TB
subgraph L4["Layer 4 Β· Facades"]
Engine
WP["WordPress\\* (optional)"]
end
subgraph L3["Layer 3 Β· Domain"]
TPL["Template\\ RendererΒ·TreeRendererΒ·TagParserΒ·SubstitutionΒ·FiltersΒ·ExpressionΒ·LocatorΒ·Linter"]
SSI["Ssi\\ ProcessorΒ·ScannerΒ·ContextΒ·ExpressionΒ·ExecΒ·Registry"]
I18N["I18n\\ TranslatorΒ·CatalogΒ·PluralFormsΒ·LocaleΒ·FormatsΒ·ExtractorΒ·Auditor"]
end
subgraph L2["Layer 2 Β· Infra"]
IO["Io\\ FilesΒ·AtomicWriterΒ·PathGuard"]
HTML["Html\\ EscaperΒ·Cleaner"]
CACHE["Cache\\ FileΒ·ApcuΒ·ArrayΒ·NullΒ·Psr16"]
LOG["Log\\ FileΒ·ErrorLogΒ·SyslogΒ·ArrayΒ·NullΒ·Psr3"]
EVT["Event\\ Dispatcher"]
end
subgraph L1["Layer 1 Β· Contracts"]
CFG["Config tree + enums Β· Container Β· Scope Β· RequestContext Β· Diagnostics Β· interfaces Β· exceptions"]
end
subgraph L0["Layer 0 Β· Support"]
SUP["Str Β· Pcre Β· Clock"]
end
L4 --> L3 --> L2 --> L1 --> L0
Rule: dependencies point down only; layers 0β3 never read superglobals or
statics. Host state enters once, at layer 4, through constructors. A
token-level test (13_security.test.php) scans every source file for
eval/$GLOBALS/$_* on each CI run β the rule is enforced, not
aspirational.
sequenceDiagram
participant H as Host
participant E as Engine
participant R as Renderer
participant P as TagParser
participant TR as TreeRenderer
participant S as Ssi\Processor
participant C as CacheInterface
H->>E: render(name, vars, options)
E->>R: renderFile(located path)
R->>C: get(key = version+locale+policies+vars hash)
alt cache hit
C-->>R: cached output
else miss
R->>P: parse(source) β memoized per sha1
P-->>R: Node tree
opt @Extends present
R->>TR: collect child sections β render parent chain (β€ 8)
end
R->>TR: walk tree (loopsΒ·ifsΒ·partialsΒ·setΒ·trans)
TR-->>R: assembled document
R->>S: SSI pass (DocumentInfo anchored)
S-->>R: directives resolved
opt SSI reintroduced template syntax
R->>TR: one guarded re-render
end
R->>C: set(key, output, ttl)
end
R-->>E: output + RenderStats ('htmlphp.rendered')
E-->>H: string
Why a node tree instead of v3's regex recursion?
v3 matched balanced blocks with nesting-aware regex recursion β clever, but
unbalanced input degraded to "diagnose and skip", the linter had to
re-implement scanning, and every render re-derived structure. v4 parses once
into Node trees: unbalanced tags are impossible to represent (they throw
at parse), the linter reuses the exact same parser, and repeated renders reuse
the memoized tree. Cost: one more class. Worth it.
Why does substitution stay regex-based?
%{β¦}/@{β¦} replacement is the hot path; the v3 patterns are
ReDoS-hardened (bounded quantifiers, Pcre wrappers converting backtrack
limits into exceptions) and battle-tested by hundreds of assertions. Parsing
variables into the AST would duplicate that logic for no measurable win β
the parse memo already covers structure.
Why SSI runs last, once?
Apache semantics: the server sees the final document. Running SSI after the
template language means directives emitted by loops/partials work, budgets
apply to the assembled output, and behaviour matches what .shtml authors
expect. v3's passes loop (SSI β blocks β SSI β blocks) is replaced by one
guarded re-substitution pass that only fires when SSI output actually
reintroduced template syntax β deterministic instead of iterative.
Why two expression evaluators?
SSI #if must keep mod_include string semantics ('2' > '10' is true β
byte order; everything is a string). Template @If must feel like PHP
(2 > 10 is false; arrays, booleans, nulls are first-class). One evaluator
satisfying both would break one of the two contracts, so there are two small,
purpose-built interpreters β both eval-free, both length/depth-capped.
The template evaluator, in turn, has exactly one implementation with
several entry points: @If, @Set, loop ranges, @Trans count and (since
v5) inline %{= β¦} substitutions all call Template\Expression::evaluate().
Inline math is therefore not a second language and not a second trust
boundary β see Expressions & Math β Security model.
Accessor primitives live in Scope (step() for author-written dotted
paths, index() for one dynamic key), so the "no objects via dynamic key"
rule exists in exactly one place.
Why no compile-to-PHP cache?
An executable cache directory is an attack surface on exactly the shared hosting this library targets, and the output cache + parse memo already cover the common wins. A closure-emitting backend is on the ROADMAP as opt-in, benchmark-gated, and hard-documented "never for untrusted templates" β only if the serialized-AST tier proves insufficient.
Per-engine instance state only:
| Structure | Flush |
|---|---|
| Parse memo (LRU 512 per engine) | Engine::clearCache() |
| Locator memo | Engine::clearCache() |
| Expression AST memo (static, immutable trees) | Template\Expression::reset() |
| Translator catalogues | Translator::reset() |
| Diagnostics (capped 100) | Diagnostics::clear() |
Safe under FrankenPHP/Swoole workers and multi-tenant processes: two engines
share nothing mutable (test-enforced), time is injected (ClockInterface),
request data is injected (RequestContext).
flowchart LR
SRC["src/ (PHP 8.3 floor)\n119 files, PSR-4"] --> B[tools/build.php]
OV["src-8.5/ overlays\nVersion Β· Diagnostics Β· RenderStats Β· Renderer"] --> B
B --> P83["php83/html.php\n(namespace-blocked bundle + version guard)"]
B --> P85["php85/html.php\n(overlays swapped in)"]
P83 --> M{{"test matrix:\n8.3Β·8.4Β·8.5 Γ srcΒ·php83Β·php85\nΓ intl on/off = 14 CI jobs"}}
P85 --> M
SRC --> M
B -. --check .-> D[CI drift gate]
The 8.5 overlays use pipe operator |> (render pipeline), array_find(),
asymmetric visibility, a property hook (RenderStats::$summary) and
#[\NoDiscard] β at genuine seams, whole-file, matrix-tested. The 8.5 bundle
intentionally fails to parse below 8.5; the 8.3 bundle carries a runtime
version guard.
- Dependency-free runner (
tests/TestRunner.php): groups, filters, per-test$GLOBALS/superglobal snapshot-restore, assertion origins. - 15 case files Β· 386 tests / 1286 assertions Β· deterministic UTC + frozen clocks + in-test-built binary MO fixtures + 60-function WP stub set.
- The suite is also the architecture guard: no-eval/no-globals token scan, strict-types check, engine-independence assertions, security containment cases. β Testing & CI
Filters Β· SSI directives Β· cache/log/translator/clock implementations Β·
escaper subclass Β· event tags (β WP hooks) Β· container overrides Β·
config with*/merge. One sentence rule: if you need to fork the library,
we missed an extension point β file an issue.
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 β