Production-grade single-file PHP template engine with Apache SSI compatibility, legacy
html4.plparity, a hardened security model, and a zero-dependency test suite of 276 tests / 748 assertions. Drop-in replacement β no Composer, no extensions beyond the PHP standard library.
html.php renders templates that mix plain HTML with SSI directives, %VARIABLE% substitution, numeric formatting, and legacy html4.pl-style blocks. It is deliberately distributed as one file so it can be dropped into shared hosting (/var/www/lib/html.php), copied over FTP, or vendored into any project without a build step. Version 2.0.1 keeps the entire public API of the Perl-era library while replacing the fragile parts with a bounded, escaping-by-default, traversal-safe implementation.
- Escaping is on by default.
<!--#echo var="NAME" -->now HTML-escapes its output unlessHTML_SSI_ECHO_ESCAPEis explicitly disabled. - No more superglobal mutation.
$_GET,$_POST,$_COOKIEand$_REQUESTare never rewritten on include. Sanitised input is available on demand viaHTML_Sanitized_Input(). - Strict variable-name gate. Every
$GLOBALSread/write driven by a template directive passes_htmlValidVariableName()β empty names, null bytes, path-like names and superglobal tricks are rejected. - Safer include containment. Resolved include paths are normalised and compared case-insensitively on Windows/macOS, so a file outside the document root can never be reached even on case-insensitive filesystems.
- Flexible and safe directive parsing. SSI attributes are parsed generically, so attribute order no longer matters, while quoted values containing
>are still handled correctly.
strftimeβdateconversion fixed. Literal characters in an SSI time format are escaped, soUpdated at %H:%Mrenders asUpdated at 14:30instead of leakingdate()tokens.%hnow maps to the abbreviated month (Sep), matchingstrftimesemantics.normalize()keeps its<br>marker. Line breaks survive normalisation, and a forged marker in the input can no longer fabricate a<br>.Read_HTML()respects published blocks. A block loaded from an<!--@block N-->marker is no longer overwritten by the file's residual text.clean_html()drops code elements.<script>and<style>elements (including an unterminated opener) are removed together with their contents; every other removed tag still keeps its inner text.- Quiet file writes.
WriteFile()suppresses the diagnostic of its locked in-place fallback and still reports the failure as aRuntimeException. Show_HTML()accepts block'0'. A numeric block of0is no longer treated as "missing".
- A zero-dependency test runner with 276 tests / 748 assertions across 35 groups, full
$GLOBALSand superglobal isolation,--filterand--verboseflags, ANSI colour when attached to a TTY, and meaningful exit codes.
<?php
require __DIR__ . '/html.php';
// 1. Publish template variables.
$GLOBALS['Title'] = 'Dashboard';
$GLOBALS['User'] = 'mikhail';
$GLOBALS['Amount'] = 1234.5678;
// 2. Render a template string.
$html = HTML('<h1>%{Title}</h1><p>Welcome, %{User}!</p><td>%{Amount.Round:2}</td>');
echo $html;
// <h1>Dashboard</h1><p>Welcome, mikhail!</p><td>1234.57</td>Render a file with SSI includes:
<?php
require __DIR__ . '/html.php';
$GLOBALS['HTML_WWW_PATH'] = __DIR__ . '/www'; // document root for "/ssi/head.htm"
echo HTML(file_get_contents(__DIR__ . '/www/page.htm'));<!-- www/page.htm -->
<!--#config timefmt="%Y-%m-%d" -->
<!--#set var="PageTitle" value="Reports" -->
<!--#include virtual="/ssi/head.htm" -->
<h1><!--#echo var="PageTitle" --></h1>
<p>Generated <!--#echo var="DATE_LOCAL" --></p>Run the test suite:
php tests/run-tests.php # 276 tests, 748 assertions
php tests/run-tests.php --verbose
php tests/run-tests.php --filter=security- π What's New in 2.0.1
- β‘ Quick Start
- β¨ Key Features
- π¦ Requirements & Installation
- ποΈ Project Layout
- π§ Core Concepts
- π API Reference
- π§ SSI Directive Reference
- βοΈ Configuration Reference
- π‘οΈ Security Model
- π Backward Compatibility & Migration
- π§ͺ Testing
- π¬ Examples
- π Troubleshooting
- π€ Contributing
- π License
- π¨βπ» Author & Support
- No Composer, no autoloader, no build step. One
requireand you are done. - No non-standard extensions. Only
pcre,mbstring,ctypeandjson-free core functionality β all part of a default PHP build. - FTP-friendly. Ideal for shared hosting where
/var/www/lib/html.phpis shared by many virtual hosts.
#config,#set,#include virtual,#echoand the legacy#echovariant, with the same evaluation order as Apache (#configβ#setβ includes β#echoβ@var).- Attribute order is irrelevant β
<!--#set var="A" value="B" -->and<!--#set value="B" var="A" -->are equivalent. - Built-in SSI variables:
DATE_LOCAL,DATE_GMT,DOCUMENT_URI,DOCUMENT_NAME,DOCUMENT_ROOT,QUERY_STRING,SERVER_NAME,HTTP_USER_AGENT,LAST_MODIFIED,REMOTE_USER. strftimeformats are translated todate()tokens, including%j(three-digit day of year) and%%,%n,%t.
LoadTemplate()/LoadVar()block loading,Read_HTML(),Show_HTML(),Divide(),lc_Tags(),normalize(),clean(),clean_html(),WriteFile(),TrimSpaces().%{NAME},#{NAME}and%[NAME]substitution,@{KEY}translation lookups, and theRound/Clean Round/Div/Round Divnumeric directives.PARITYalternation and<!--@Split-->support for alternating output.
- Escaping-by-default output for SSI echo, an optional
HTML_SSI_ECHO_ALLOWread allow-list, and reserved-name protection for every superglobal and configuration key. - Bounded recursion: SSI depth, SSI tag count, include byte size, template byte size, block depth and precision are all capped by configuration.
- Include containment with
..rejection (HTML_SSI_ALLOW_PARENTopt-in) and case-insensitive path comparison. - Atomic file writes with mode preservation and verified complete payload.
- 276 tests / 748 assertions covering the public API, SSI semantics, security boundaries, block loading, cleaning helpers, file I/O, numeric helpers and every fixed regression.
- Test isolation is enforced by the runner (snapshot/restore of
$GLOBALS, superglobals and output buffering), so test order can never matter. - Content is byte-compared against
gmdate()for time-sensitive assertions, so the suite cannot flake on a second or midnight tick.
- PHP: 8.3 or newer (the library refuses to load on older versions with a clear
RuntimeException). - Extensions:
pcre,ctype,mbstringβ all enabled by default in standard builds. - Filesystem: any;
WriteFile()is Windows- and POSIX-aware. - Web server: any (Apache, Nginx + PHP-FPM, IIS, CLI). SSI is processed by the library, not by the web server, so no
mod_includeis required. - Composer: not required and not supported by design.
# Shared hosting: one library shared by many sites
scp html.php user@host:/var/www/lib/html.php<?php
require '/var/www/lib/html.php';mkdir -p lib && cp html.php lib/html.php<?php
require __DIR__ . '/lib/html.php';git clone https://github.com/paulmann/HTML.PHP.git html-php
cd html-php
php tests/run-tests.php # verify the library on your PHP build
php examples/01-quick-start.phpphp -l html.php # No syntax errors detected
php tests/run-tests.php # SUMMARY: 276 passed, 0 failedWindows note. The project directory is named
html-php, notHTML.PHP: NTFS is case-insensitive, soC:\path\HTML.PHPisC:\path\html.phpand a folder with that exact name cannot coexist with the library file. Keep the folder name case-distinct from the file name on Windows and macOS.
html-php/
βββ html.php # the entire library β this is the deliverable
βββ README.md # this document
βββ CHANGELOG.md # Keep-a-Changelog history
βββ LICENSE # MIT
βββ .gitignore
βββ examples/
β βββ 01-quick-start.php # render a template string
β βββ 02-ssi-partials.php # #include, #set, #echo, #config
β βββ 03-legacy-blocks.php # LoadTemplate / Read_HTML / Show_HTML
β βββ 04-helpers-tour.php # normalize, clean_html, Divide, sanitizeXSS
βββ tests/
βββ run-tests.php # entry point
βββ bootstrap.php # library bootstrap + fixtures
βββ TestRunner.php # zero-dependency runner and assertions
βββ cases/
βββ 01_foundation.test.php # 20 tests β public API surface
βββ 02_variables.test.php # 31 tests β variable substitution
βββ 03_ssi.test.php # 63 tests β SSI directives and time formats
βββ 04_ssi_security.test.php # 36 tests β security boundaries
βββ 05_blocks.test.php # 37 tests β legacy blocks and Show_HTML
βββ 06_cleaning.test.php # 34 tests β normalize / clean_html / helpers
βββ 07_files.test.php # 15 tests β WriteFile atomicity
βββ 08_numeric.test.php # 25 tests β Divide and rounding
βββ 09_regressions.test.php # 15 tests β one per fixed defect
Variables live in $GLOBALS and are pulled into the markup with one of three braced forms:
$GLOBALS['Title'] = 'Reports';
$GLOBALS['Company'] = 'Deynekin';
echo HTML('<h1>%{Title} β %{Company}</h1>'); // <h1>Reports β Deynekin</h1>
echo HTML('<h1>#{Title}</h1>'); // <h1>Reports</h1>
echo HTML('<h1>%[Title]</h1>'); // <h1>Reports</h1> (normalised on a later pass)@{KEY} performs a translation lookup: it asks the resolver for Translate.KEY, and when that global is absent the key itself is emitted as the fallback text.
$GLOBALS['Translate.Welcome'] = 'Willkommen';
echo HTML('@{Welcome}'); // Willkommen
echo HTML('@{Unknown}'); // UnknownA bare %NAME% is not a substitution form β it is left in the output untouched. Use %{NAME}.
Resolution goes through the resolver supplied to processHtml(). The default resolver, update_template(), returns $GLOBALS[$name] when the global exists (and is not null), the suffix for a Translate.* name, and an empty string otherwise.
Names that are written into $GLOBALS from a template (#set, <!--@var-->, <!--@Block NAME-->) and names read by SSI (#echo, ##echo) must match:
^[A-Za-z_][A-Za-z0-9_.-]{0,127}$
Names failing this pattern are silently ignored β they are never written to $GLOBALS and never echoed. Note that %{NAME} substitution is resolved by the resolver itself and is not subject to that gate, so only reference globals your templates are meant to expose.
<!--#config timefmt="%Y-%m-%d %H:%M" -->
<!--#set var="PageTitle" value="Quarterly Report" -->
<!--#set value="Built by $Company" var="FooterNote" -->
<!--#include virtual="/ssi/header.htm" -->
<h1><!--#echo var="PageTitle" --></h1>
<footer><!--#echo var="FooterNote" --></footer>
<span><!--#echo var="DATE_LOCAL" --></span>Evaluation order inside a single file is fixed and intentional:
| Step | Directive | Effect |
|---|---|---|
| 1 | #config |
Stores the time format used by later #echo of date variables |
| 2 | #set |
Publishes a variable into $GLOBALS and is removed from the output |
| 3 | #include |
Inserts a partial (recursively processed) |
| 4 | #echo / ##echo |
Emits a built-in SSI variable or a template variable |
| 5 | <!--@var="NAME"value--> |
Stores value and emits it in place |
Because #set runs before includes and echoes, variables declared at the top of a page are visible to every partial below it.
<!--@Block Header-->
<header>%Title%</header>
<!--@End Block Header-->
<!--@block 0-->
First alternating variant
<!--@end-->
<!--@block 1-->
Second alternating variant
<!--@end-->$body = LoadTemplate(__DIR__ . '/page.htm'); // publishes $GLOBALS['Header']
$GLOBALS['html'] = [];
Read_HTML(__DIR__ . '/page.htm', 0); // publishes numeric blocks 0 and 1
echo Show_HTML(['html' => '0', 'output' => 'var', 'SSI' => 'off']);Numeric directives are written inside the same braces, as %{KEY.<directive>}:
| Directive | Syntax | Example | Result |
|---|---|---|---|
| Round | %{KEY.Round:N} |
%{Amount.Round:2} |
1234.57 |
| Clean Round | %{KEY.Clean Round:N} |
%{Amount.Clean Round:2} |
1234.57 (no .00 for whole numbers) |
| Div | %{KEY.Div DIVISOR} |
%{Amount.Div 4} |
full-precision quotient, 0 for a zero divisor |
| Round Div | %{KEY.Round Div DIVISOR:N} |
%{Amount.Round Div 4:2} |
308.64 |
| Clean Round Div | %{KEY.Clean Round Div DIVISOR:N} |
%{Amount.Clean Round Div 4:2} |
308.64 |
$GLOBALS['Amount'] = 1234.5678;
echo HTML('%{Amount.Round:2}'); // 1234.57
echo HTML('%{Amount.Round Div 4:2}'); // 308.64
echo HTML('%{Amount.Div 0}'); // 0
echo Divide(10, 4); // 2.5
echo Divide(10, 0); // 0 β a zero divisor never raisesThe Round family rounds to N decimals; Clean Round drops a trailing .00 when the value is an exact integer. Div and its rounding variants take the divisor after a space (no colon), while the precision follows a colon.
Float precision. A bare
Divreturns PHP's raw float representation β%{Amount.Div 4}on1234.5678yields308.64195000000001, not308.64195. UseRound Div/Clean Round Divwhenever the output is shown to a human.
| Function | Signature | Description |
|---|---|---|
HTML |
HTML(string $html): string |
Main entry point. Runs processSSI() then processHtml() and recurses up to HTML_MAX_TEMPLATE_PASSES times. |
processSSI |
processSSI(string $html, ?string $currentFile = null): string |
Processes SSI directives only. $currentFile enables relative includes. |
processHtml |
processHtml(string $html, callable $updateTemplate): string |
Variable substitution with a caller-supplied resolver. |
update_template |
update_template(string $varName): string |
Default resolver used by HTML(). Returns $GLOBALS[$varName] when the global exists and is not null; for a Translate.* name it returns the remainder as literal fallback text; otherwise an empty string. |
| Function | Signature | Description |
|---|---|---|
LoadTemplate |
LoadTemplate(string $file): string |
Loads a template, publishes <!--@Block NAME--> sections into $GLOBALS, returns the remainder. Returns 'Template Not Found' / 'Bad Template File' instead of throwing. |
LoadVar |
LoadVar($handle, string $startPattern): string |
Legacy helper that reads one block from an open stream. |
Read_HTML |
Read_HTML(string $file, int $block = 1): string |
Parses <!--@block N--> numeric blocks into $GLOBALS['html'] and returns the requested block. |
Show_HTML |
Show_HTML(mixed ...$arguments): ?string |
Renders an html4.pl-style block. Accepts one associative array or legacy key/value pairs. |
Show_HTML() options:
| Key | Values | Meaning |
|---|---|---|
html |
block number (0, 1, β¦) |
Which block from $GLOBALS['html'] to render. 0 is valid. |
output |
(default), var*, file, comb |
Default echoes; var* returns the string; file/comb write to handle. |
handle |
stream resource | Destination for output => file/comb. |
SSI |
off |
Skips SSI processing for the block. |
exec_symbol |
string | Overrides the %β¦% substitution symbol. |
PARITY |
truthy | Alternates between the two halves split by <!--@Split-->. |
www_path |
path | Document root used for SSI includes. |
| Function | Signature | Description |
|---|---|---|
normalize |
normalize(string $buffer, mixed $br = null, mixed $cleanEdges = false): string |
Flattens markup to text. <br> is preserved unless $br is truthy; $cleanEdges trims leading/trailing punctuation. |
clean_html |
clean_html(string $html): string |
Keeps only allow-listed tags (attributes stripped, comments removed), drops <script>/<style> elements entirely, normalises entities. |
clean |
clean(string $closing, string $tag): string |
Legacy helper that reads the $GLOBALS['TAGS'] allow-list. |
lc_Tags |
lc_Tags(string $input): string |
Lower-cases tag names. |
TrimSpaces |
TrimSpaces(mixed $string): string |
Removes whitespace and non-word characters, preserving Unicode letters. |
Divide |
Divide(mixed $a, mixed $b): float|int |
Safe division; a zero divisor yields 0. |
| Function | Signature | Description |
|---|---|---|
sanitizeXSS |
sanitizeXSS(array $input): array |
Recursively HTML-escapes an array (ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, double-encode off). |
HTML_Sanitized_Input |
HTML_Sanitized_Input(): array |
Returns ['get' => β¦, 'post' => β¦, 'cookie' => β¦, 'request' => β¦] sanitised, without touching the superglobals. |
WriteFile |
WriteFile(string $file, mixed $data): bool |
Atomic write (temp file + rename, locked in-place fallback). Throws InvalidArgumentException / RuntimeException on invalid paths or incomplete writes. |
| Directive | Syntax | Notes |
|---|---|---|
| Config | <!--#config timefmt="FMT" --> |
strftime-style format stored in HTML_SSI_TIMEFMT. |
| Set | <!--#set var="NAME" value="VALUE" --> |
Any attribute order; $VAR / ${VAR} inside VALUE expands once; \$ escapes a literal dollar. Reserved and built-in names are never writable. |
| Include | <!--#include virtual="/ssi/head.htm" --> |
Absolute paths resolve from the document root, relative paths from the including file, with a document-root fallback. Blocked outside the root unless HTML_SSI_ALLOW_PARENT is enabled. |
| Echo | <!--#echo var="NAME" --> |
Built-in SSI variables win over template variables. Escaped unless HTML_SSI_ECHO_ESCAPE is disabled. |
| Legacy echo | <!--##echo var="NAME" --> |
Reads template variables, honours HTML_SSI_ECHO_ALLOW. |
| Inline var | <!--@var="NAME"VALUE--> |
Stores the trimmed value and emits the raw content in place. |
| Block | <!--@Block NAME--> β¦ <!--@End Block NAME--> |
Named block consumed by LoadTemplate() / LoadVar(). |
| Numeric block | <!--@block N--> β¦ <!--@end--> |
Numeric block consumed by Read_HTML(). |
| Split | <!--@Split--> |
Splits a block into two alternating variants for PARITY mode. |
%Y %y %C %m %d %e %H %k %I %l %M %S %p %P %B %b %h %A %a %Z %z %u %w %G %V %U %W %D %F %T %R %r %c %x %X %s %n %t %% are translated to date() equivalents. Anything else is passed through as a literal, and literal letters in the format string are escaped so they can never be mistaken for date() tokens.
All options are plain globals with ??= defaults β set them before including the library, or simply assign them afterwards.
| Global | Default | Range (clamped) | Purpose |
|---|---|---|---|
HTML_MAX_SSI_DEPTH |
16 |
1β128 | Maximum include recursion depth. |
HTML_MAX_SSI_TAGS |
256 |
1β10000 | Maximum number of SSI tags processed per render. |
HTML_MAX_TEMPLATE_PASSES |
2 |
1β16 | Recursion limit of the HTML() pipeline. |
HTML_MAX_BLOCK_DEPTH |
64 |
1β1024 | Maximum nesting depth of <!--@Block--> loading. |
HTML_MAX_PRECISION |
100 |
0ββ¦ | Upper bound for numeric-directive precision. |
HTML_MAX_TEMPLATE_BYTES |
16 MiB |
up to 512 MiB | Largest template LoadTemplate() / Read_HTML() will read. |
HTML_MAX_INCLUDE_BYTES |
8 MiB |
up to 256 MiB | Largest file #include will accept. |
HTML_SSI_ALLOW_PARENT |
false |
bool | Allow .. in include paths (off by default). |
HTML_SSI_ECHO_ESCAPE |
true |
bool | HTML-escape #echo output. |
HTML_SSI_ECHO_ALLOW |
[] |
list/array | When non-empty, restricts which names #echo may read. |
HTML_SSI_TIMEFMT |
%A, %d-%b-%Y %H:%M:%S %Z |
string | Default SSI date format. |
HTML_AUTO_SANITIZE_INPUT |
false |
bool | Legacy opt-in that rewrites the superglobals on include. |
HTML_ALLOWED_TAGS |
br, p, h1βh4, strong, b, ul, ol, li, hr |
map | Allow-list used by clean_html(). |
HTML_WWW_PATH |
$_SERVER['DOCUMENT_ROOT'] |
path | Document root for absolute SSI includes. |
html.php is a template renderer, not a sandbox β but it assumes templates, variable names and include paths can be attacker-influenced, and refuses to be the weak link.
| Threat | Mitigation |
|---|---|
| Output injection through SSI echo | HTML_SSI_ECHO_ESCAPE is true by default; #echo output is HTML-escaped. |
Overwriting $_SESSION, $_SERVER, config globals |
HTML_RESERVED_GLOBALS is enforced for every directive-driven read and write. |
| Overwriting built-in SSI variables | HTML_SSI_BUILTIN_VARS are never writable. |
Hostile variable names (null bytes, ../, spaces, leading digits) |
_htmlValidVariableName() gate on every $GLOBALS access. |
Reading arbitrary globals through #echo |
Optional HTML_SSI_ECHO_ALLOW allow-list. |
Path traversal through #include |
.. rejected unless HTML_SSI_ALLOW_PARENT; resolved path must stay inside the document root; comparison is case-insensitive on Windows/macOS. |
| Include/recursion denial of service | Depth, tag-count, template-size and include-size caps; cycle detection via an include stack; self-includes terminate. |
| Silent partial writes | WriteFile() uses temp file + rename with a locked fallback and verifies the full payload length. |
| Superglobal corruption | HTML_AUTO_SANITIZE_INPUT is false; sanitised data is returned by value from HTML_Sanitized_Input(). |
Deployment guidance. Keep
HTML_SSI_ALLOW_PARENToff, keepHTML_SSI_ECHO_ESCAPEon, and preferHTML_Sanitized_Input()over rewriting the superglobals. Escape at the output boundary rather than mutating input.
The public API is unchanged from 2.0.0 β same function names, same signatures, same return types. Two defaults changed deliberately and are the only source of visible behaviour differences:
| Behaviour | Before | Now | If you need the old behaviour |
|---|---|---|---|
#echo output |
emitted raw | HTML-escaped | Set $GLOBALS['HTML_SSI_ECHO_ESCAPE'] = false; before the include. |
| Superglobals on include | rewritten by sanitizeXSS() |
untouched | Use HTML_Sanitized_Input(), or set $GLOBALS['HTML_AUTO_SANITIZE_INPUT'] = true; before the include. |
Two further narrow edge cases are worth an audit before upgrading an existing site:
- Variable and block names must now match
^[A-Za-z_][A-Za-z0-9_.-]{0,127}$. Names with spaces, leading digits or non-ASCII letters are ignored instead of published to$GLOBALS. %hin an SSI time format now yields the abbreviated month (Sep) instead of the full month name (September), matchingstrftime.
Everything else β SSI ordering, block syntax, %VARIABLE% substitution, numeric directives, html4.pl helpers β behaves as before.
Enabling declare(strict_types=1) inside the library only affects the library's own internal calls; external legacy callers keep PHP's normal coercive argument handling.
The suite is hand-rolled and dependency-free, so it runs anywhere PHP runs.
php tests/run-tests.php # full run
php tests/run-tests.php --verbose # per-test timings and file:line
php tests/run-tests.php --filter=security # substring match on group/test names
php tests/run-tests.php --filter=include --verbose========================================================================
SUMMARY: 276 passed, 0 failed (748 assertion(s))
Exit codes: 0 all green Β· 1 at least one failure Β· 2 harness or bootstrap error.
| Area | Highlights |
|---|---|
| Foundation | Public API surface, version guard, strict_types, no BOM, function contracts. |
| Variables | %NAME%, braces, indirect forms, closure and string resolvers, translation fallback. |
| SSI | All directives, both attribute orders, quote styles, $VAR expansion, nested includes, evaluation order, every time token. |
| SSI security | Reserved globals, built-in names, invalid names, allow-list, escaping defaults, traversal, cycles, depth/size caps. |
| Blocks | Named and numeric blocks, nesting, block 0, marker-less templates, Show_HTML options, PARITY/@Split. |
| Cleaning | Allow-list, attribute stripping, comment removal, script/style dropping, entity normalisation, non-UTF-8 safety. |
| Files | Atomic writes, byte-exact payloads, failure modes, no leaked temp files, zero un-suppressed diagnostics. |
| Numbers | Divide() semantics, precision clamping, numeric parsing. |
| Regressions | One test per defect fixed in the hardening passes, each documented in-line. |
- The runner snapshots and restores
$GLOBALSplus$_SERVER,$_GET,$_POST,$_COOKIE,$_FILES,$_REQUEST,$_ENV,$_SESSIONaround every test. - Output buffering is reset per test, so a stray
echocan never corrupt the report. - A meta-test boots a separate PHP process to prove the library behaves correctly from a pristine interpreter.
- The suite pins
date_default_timezone_set('UTC')and compares time-sensitive output againstgmdate()sampled before and after the render. - Fixtures live in
tests/tmp/, are recreated per run, and are removed on every exit path.
Add your own case by dropping a tests/cases/NN_name.test.php file that calls group() and test(); the runner discovers *.test.php automatically.
php examples/01-quick-start.php # variables + HTML()
php examples/02-ssi-partials.php # document root, #include, #set, #echo, #config
php examples/03-legacy-blocks.php # LoadTemplate / Read_HTML / Show_HTML
php examples/04-helpers-tour.php # normalize / clean_html / Divide / sanitizeXSSAll four exit 0, print only their own output, write their scratch files to the system temp directory and clean up after themselves.
The library guards on PHP_VERSION_ID. Check the PHP binary your web server actually uses β CLI and FPM often differ:
php -v
php -r 'echo PHP_VERSION_ID, PHP_EOL;'An include is skipped when it is missing, unreadable, larger than HTML_MAX_INCLUDE_BYTES, or outside the document root.
$GLOBALS['HTML_WWW_PATH'] = __DIR__ . '/www'; // set the document root explicitly
var_dump(realpath(__DIR__ . '/www/ssi/head.htm'));Remember that a relative virtual="ssi/head.htm" resolves against the directory of the file that contains the directive, while virtual="/ssi/head.htm" resolves against the document root.
A bare %NAME% is not a substitution form. Use the braced syntax:
$GLOBALS['Title'] = 'Reports';
echo HTML('%Title%'); // %Title% β unchanged
echo HTML('%{Title}'); // ReportsThe supported forms are %{NAME}, #{NAME} and %[NAME]; note that %[NAME] is normalised into %{NAME} on a later pass, so it needs the multi-pass HTML() pipeline rather than a single processHtml() call.
Check the exact shape β the divisor is separated by a space, while the precision follows a colon:
| Wrong | Right |
|---|---|
%{AMT.Div:4} |
%{AMT.Div 4} |
%{AMT.Round 2} |
%{AMT.Round:2} |
Names must match ^[A-Za-z_][A-Za-z0-9_.-]{0,127}$ and must not be a reserved name. Verify in isolation:
php -r 'require "html.php"; var_dump(_htmlValidVariableName("My Name"));' # bool(false)That is escaping-by-default. If the value is trusted markup:
$GLOBALS['HTML_SSI_ECHO_ESCAPE'] = false; // before requiring html.phpOtherwise render the value through HTML()/Show_HTML() where the markup is intentional.
Automatic mutation is off by default. Replace the implicit behaviour explicitly:
$input = HTML_Sanitized_Input();
$page = $input['get']['page'] ?? 1;The target directory must exist and be writable, and an existing target file must be writable too. WriteFile() never creates directories β create them first:
if (!is_dir($dir)) { mkdir($dir, 0775, true); }
WriteFile($dir . '/out.htm', $rendered);%j is a zero-padded day-of-year and is expanded after date() formatting; make sure the format string is passed through #config timefmt rather than embedded in date() directly.
git clone https://github.com/paulmann/HTML.PHP.git html-php
cd html-php
php tests/run-tests.php # green before you start
php examples/02-ssi-partials.php # sanity-check the runtime- Fork the repository and create a feature branch (
git checkout -b feature/ssi-attribute-x). - Keep the single-file contract.
html.phpmust remain a drop-in, dependency-free file. Do not split it into classes/namespaces in this distribution. - Preserve the public API. New behaviour goes into new functions; existing signatures stay frozen.
- Add a test for every fix. Bug fixes belong in
tests/cases/09_regressions.test.phpwith a comment naming the defect. - Document defaults. If you introduce a configuration global, add it to the Configuration Reference table and to
HTML_RESERVED_GLOBALS. - Keep the suite green on a clean interpreter and from a foreign working directory.
- Update
CHANGELOG.mdunder anUnreleasedheading.
- SSI parity: optional
exec/printenv/flastmod/fsizedirectives to complete Apache parity. - Syntax documentation: a machine-readable grammar for the
%{β¦}/#{β¦}/@{β¦}substitution family. - Streaming includes: a callback hook so applications can resolve includes from a database or object store.
- Additional test coverage:
HTML_SSI_ECHO_ALLOWeffects on block publishing, POSIX-specific file-permission behaviour. - Localisation: message catalogues for the library's exception strings.
- Benchmarks: a repeatable micro-benchmark harness for large templates.
- Static analysis: a PHPStan/Psalm level that the single-file layout can satisfy.
This project is licensed under the MIT License β see the LICENSE file for details.
MIT License
Copyright (c) 2026 Mikhail Deynekin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Mikhail Deynekin
- π Website: Deynekin.com
- π§ Email: Mikhail@Deynekin.com
- π GitHub: @paulmann
- π¦ Repository: paulmann/HTML.PHP
- π Documentation β this README covers the API, SSI reference, configuration and security model.
- π Bug reports β include the PHP version, the failing template snippet and the output of
php tests/run-tests.php. - π‘ Feature requests β describe the template construct you need and the expected rendered output.
- π¨ Security issues β report privately by email with a
[SECURITY]prefix; please do not open a public issue.
- Windows-Cleaner-and-Optimizer β Windows cleanup automation with system backup integration.
- Windows-11-25H2-Update-Script β Windows 11 update automation with safety checks.
HTML.PHP 2.0.1 β Apache-SSI-compatible templating in a single hardened PHP file π§©β‘π‘οΈ
π Latest release Β· β Star the repository Β· π Report a bug Β· π‘ Request a feature Β· π Changelog
Important: always run the test suite (php tests/run-tests.php) on your target PHP build before deploying, and review the changed defaults in Backward Compatibility & Migration when upgrading an existing site. Template rendering is not a security sandbox β treat template sources as trusted input, and keep HTML_SSI_ALLOW_PARENT disabled unless you have verified every include path. The author is not responsible for data loss or rendering regressions caused by templates that rely on the pre-2.0.1 defaults.