Skip to content

SSI Guide

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

SSI Guide β€” Apache mod_include under PHP

HTML.PHP speaks the Server Side Includes dialect your .shtml trees already use β€” so migrations from Apache mod_include to nginx/PHP-FPM, containers, or static-plus-PHP hosting keep working without touching the documents.

Note

Canonical spec: docs/SSI.md. This page adds the operational perspective: policies, recipes, parity notes.

Running SSI

Inside renders β€” automatic. The SSI pass runs after the template language, over the assembled document (ssi.enabled, on by default).

Standalone β€” any string (legacy pages, WordPress post content, option values):

use HtmlPhp\Ssi\DocumentInfo;
use HtmlPhp\RequestContext;

$html = $engine->processSsi($content, new DocumentInfo(
    file: '/var/www/page.shtml',       // anchors file= includes + LAST_MODIFIED
    root: '/var/www',                  // containment root for virtual=
    request: RequestContext::fromServer($_SERVER),   // built ONCE at the edge
    vars: ['role' => 'admin'],         // render scope visible as $role in #if/#echo
));

Important

The engine never reads $_SERVER. Hosts construct RequestContext once per request; CLI/jobs use RequestContext::cli($cwd) or a frozen clock for deterministic DATE_LOCAL in tests and static exports.

Directive reference

Directive Attributes Notes
#config timefmt sizefmt errmsg strftime tokens (converted internally β€” no deprecated strftime()); state scoped to the render
#set var value $VAR/${VAR} expansion; reserved names refused; never touches $GLOBALS
#include file virtual stub wait file= = relative to current document; virtual= = from doc root; stub falls back to a #block; wait accepted (sequential engine)
#echo var encoding built-ins β†’ #set vars β†’ scope vars; encoding=none|entity|url|base64; escaped by default
#if #elif #else #endif expr full grammar below; inactive branches drop includes & sets
#exec cmd cgi deny by default β€” see policies
#fsize #flastmod file/virtual (optional) default: current document; honour #config sizefmt/timefmt
#printenv β€” dumps visible vars NAME=value (escaped, allow-listed); disable via ssi.printenv_enabled
#block #endblock name fallback content for #include stub=; never renders in place
custom via registry DirectiveRegistry::register(DirectiveInterface) β€” checked before built-ins

Unknown directives stay in the output verbatim (they're ordinary comments to us) and record ssi-unknown-directive. The v2-era <!--##name--> and <!--@var=…--> forms were removed in v4.

Built-in variables

DATE_LOCAL Β· DATE_GMT Β· LAST_MODIFIED Β· DOCUMENT_URI Β· DOCUMENT_NAME Β· DOCUMENT_ROOT Β· QUERY_STRING Β· SERVER_NAME Β· SERVER_SOFTWARE Β· SERVER_PROTOCOL Β· REQUEST_METHOD Β· REQUEST_URI Β· SCRIPT_NAME Β· CONTENT_TYPE Β· REMOTE_ADDR Β· HTTP_USER_AGENT β€” plus any HTTP_* header from the request context.

Extend/override via the htmlphp.ssi.builtins filter. ssi.echo_allow restricts which names may be echoed at all.

#if expression grammar

Everything is a string (mod_include semantics):

<!--#if expr="$user = 'admin'" -->                     string equality (= or ==)
<!--#if expr="$count > 10" -->                         numeric-aware ordering
<!--#if expr="$uri = /^\/admin\//" -->                 regex match (also with !=)
<!--#if expr="-f /private/notes.txt" -->               file tests: -f -d -e -s -x -r -w -F
<!--#if expr="!(-A /x)" -->                            -A/-U parse but are ALWAYS false
<!--#if expr="($a && $b) || ! $c" -->                  logic + grouping
<!--#if expr="$flag" -->                               bare truthiness ('' and '0' are false)

Warning

-A/-U are Apache subrequest tests. A template engine that performs network I/O is an SSRF vector, so v4 refuses them (false + diagnostic). Use -f on a local file, or a custom directive.

Syntax/length errors never throw: ssi-expr-error diagnostic + false. Cap: limits.max_expression_length (4 096).

#exec policies

ssi.exec_mode Behaviour
deny (default) refuse + ssi-exec-denied diagnostic (+ errmsg if set)
allow-list cmd= binaries must realpath/basename-match ssi.exec_cmd_allow; cgi= must be doc-root-contained with an extension from ssi.exec_cgi_extensions
delegate your callable decides: fn(string $kind, array $info): bool|string β€” true run, false deny, string = literal output

Execution hardening: no shell (proc_open argv + quote-aware tokenizer), minimal CGI env, hard timeout (limits.exec_timeout ≀ 60 s), output cap (limits.max_exec_output_bytes), control-byte stripping. cgi runs php -f spawned, or in-process with ssi.exec_cgi_mode: 'include' (throws β†’ diagnostics, never fatals).

Tip

Need a dynamic fragment? A custom directive is the professional move β€” same power, no shell, testable:

$engine->ssi()->directives()->register(new class implements HtmlPhp\Ssi\DirectiveInterface {
    public function name(): string { return 'cartcount'; }
    public function render(array $attributes, HtmlPhp\Ssi\SsiContext $context): string {
        return (string) Cart::count();   // your code, your tests
    }
});

<!--#cartcount --> now works in any document.

Recipes

Theme-overridable includes β€” remap resolved paths (how the WP bridge does child-theme overrides):

$engine->events()->on('htmlphp.ssi.include_path', function (string $path, string $label): string {
    $theme = get_stylesheet_directory() . '/ssi' . basename($path);
    return is_file($theme) ? $theme : $path;
});

Graceful degradation with stubs β€”

<!--#block name="news-fallback"--><p>News temporarily unavailable.</p><!--#endblock-->
<!--#include virtual="/fragments/news.html" stub="news-fallback" -->

Base64 data handoff to JS β€”

<script>const cfg = JSON.parse(atob('<!--#echo var="config_json" encoding="base64" -->'));</script>

Feature flags from the edge β€”

<!--#if expr="$HTTP_X_BETA = '1'" --> …beta banner… <!--#endif-->

Parity & portability notes

Behaviour Apache HTML.PHP v4
Evaluation point server, per served document engine, after template language, per rendered document
#set scope request render (never globals)
include recursion limits none practical max_ssi_depth 16 Β· max_ssi_tags 256 Β· cycle stack Β· byte caps
-A/-U subrequests supported refused (SSRF-safe), always false
XBitHack, .var content negotiation supported out of scope
#include wait parallel accepted, no-op (sequential)
Legacy ##name/@var never existed removed in v4

➑️ Related: Security β†’ Β§3 exec Β· Configuration β†’ ssi group Β· Troubleshooting β†’ SSI codes

Clone this wiki locally