Skip to content

Architecture

Mikhail Deynekin edited this page Oct 11, 2026 · 3 revisions

Architecture

How HTML.PHP is put together, why it's shaped this way, and what keeps it honest.

Note

Canonical spec: docs/ARCHITECTURE.md.

Layers

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
Loading

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.

Render pipeline

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
Loading

Design decisions (and the alternatives rejected)

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.

State & concurrency

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

Editions & build

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]
Loading

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.

Testing architecture

  • 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

Extension points

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.

Clone this wiki locally