Lesson 07 / 21
The Template Engine
A template compiler with layouts, components and loops is built; the lesson measures that escaping runs on every output, that compilation is skipped for an unchanged template, and that escaping is not wired on raw output.
Published Updated
In this lesson
- Builds a small compiler that turns a template into PHP code, with layout, component and loop constructs
- Shows with sandbox output that the escaping step runs at every output point
- Measures when the compilation step is skipped in a compiled-file cache
- Reads the lack of escaping in the raw output syntax as an explicitly chosen exception
Contents
When the object container bound two different things to the same name, which one came back depended on registration order. In the view layer a similar question changes direction: when a template produces output, which step that the developer did not write runs every time? The answer is a step that is assumed to be safe, and this lesson measures that assumption.
The measurement axis of this lesson is the same as in the rest of the course: did the expected part run in this request. Every measurement starts with Ledger::newRequest(), parts are recorded with Ledger::onPath(), Ledger::ran() and Ledger::defer(), and the result is read from the classify() function. The decision order is the function itself: first ran, then deferred, then skipped, and if none of these applies, not wired. The developer names the expected part; the function only says which outcome the named part falls into. This lesson’s outcome is ran.
VD1. Templates are .html files under templates/ in the working directory, and their compiled counterparts are written as .php under cache/; a real template engine builds a parser, and here regular expressions are enough. VD2. Escaping is done with htmlspecialchars and only harmless markup is measured; the template data is always an array in the developer’s hands. VD3. File times have one-second resolution; to keep the measurement deterministic, the time is moved forward with touch() and sleep is not used.
The file below builds the Ledger class and the classify() function. It is the same measurement setup as in the previous lessons and is used here as it is.
<?php // ran.php — did an expected part run in this request? declare(strict_types=1); final class Ledger { public static array $path = []; // parts placed on the request's path public static array $ran = []; // parts called within the request public static array $deferred = []; // parts left outside the request public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; } public static function onPath(string $part): void { self::$path[] = $part; } public static function ran(string $part): void { self::$ran[] = $part; } public static function defer(string $part): void { self::$deferred[] = $part; } } function classify(string $part): string { return match (true) { in_array($part, Ledger::$ran, true) => 'ran', in_array($part, Ledger::$deferred, true) => 'deferred', in_array($part, Ledger::$path, true) => 'skipped', default => 'not wired', }; }
Compiling a Template into Code
A template engine turns a text file into PHP code, writes that code once, and runs it on every request. The file below contains a compiler with nine rules, a layout mechanism and a component call. {{ name }} becomes escaped output, the raw output syntax becomes unescaped output, {% each ... %} becomes a loop, {% layout ... %} and {% section ... %} become the layout, and {% component ... %} becomes a component. Escaping is done in the escape() function, and the function records itself when it is called; for every {{ }} the compiler generates code that calls this function, so the step is not left to the template author.
<?php // template.php — a small template compiler and engine declare(strict_types=1); require_once 'ran.php'; function escape(mixed $value): string { Ledger::ran('escaping'); return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } final class TemplateEngine { public array $sections = []; private ?string $layout = null; private array $open = []; public function __construct(private string $dir, private string $cache) { if (!is_dir($cache)) mkdir($cache); } private function compile(string $source): string { $rules = [ '/\{\{!\s*(\w+)\s*\}\}/' => '<?= $$1 ?>', '/\{\{\s*(\w+)\s*\}\}/' => '<?= escape($$1) ?>', '/\{%\s*each (\w+) as (\w+)\s*%\}/' => '<?php foreach ($$1 as $$2): ?>', '/\{%\s*\/each\s*%\}/' => '<?php endforeach; ?>', '/\{%\s*section (\w+)\s*%\}/' => '<?php $engine->openSection(\'$1\'); ?>', '/\{%\s*\/section\s*%\}/' => '<?php $engine->closeSection(); ?>', '/\{%\s*layout (\w+)\s*%\}/' => '<?php $engine->selectLayout(\'$1\'); ?>', '/\{%\s*yield (\w+)\s*%\}/' => '<?= $engine->sections[\'$1\'] ?? \'\' ?>', '/\{%\s*component (\w+)\s*%\}/' => '<?= $engine->component(\'$1\', get_defined_vars()) ?>', ]; return preg_replace(array_keys($rules), array_values($rules), $source); } private function run(string $name, array $data): string { $source = "{$this->dir}/$name.html"; $compiled = "{$this->cache}/$name.php"; Ledger::onPath('compile'); if (!is_file($compiled) || filemtime($compiled) < filemtime($source)) { Ledger::ran('compile'); file_put_contents($compiled, $this->compile(file_get_contents($source))); } $data['engine'] = $this; return self::runFile($compiled, $data); } private static function runFile(string $__file, array $__data): string { extract($__data); ob_start(); require $__file; return ob_get_clean(); } public function show(string $name, array $data = []): string { $page = $this->run($name, $data); if ($this->layout === null) return $page; Ledger::ran('layout'); $layout = $this->layout; $this->layout = null; return $this->run($layout, $data); } public function component(string $name, array $data): string { Ledger::ran('component'); unset($data['__file'], $data['__data']); return $this->run($name, $data); } public function selectLayout(string $name): void { $this->layout = $name; } public function openSection(string $name): void { $this->open[] = $name; ob_start(); } public function closeSection(): void { $this->sections[array_pop($this->open)] = ob_get_clean(); } } function report(array $parts): void { foreach ($parts as $part) printf(" %-10s %s\n", $part, classify($part)); }
Compilation runs when the compiled file does not exist or is older than the source. Ledger::onPath('compile') is written on every call, Ledger::ran('compile') only when the file is written. When a part is placed on the path but never called, classify() classifies it as skipped; this lesson’s opposing measurement rests on that difference.
Three small templates are needed: a main layout, a list page, and one component per row. Each time it runs, the file empties the cache so that the first request always starts from scratch.
<?php // templates.php — writes three templates and empties the compile cache if (!is_dir('templates')) mkdir('templates'); if (!is_dir('cache')) mkdir('cache'); foreach (glob('cache/*') as $old) unlink($old); file_put_contents('templates/main.html', <<<'HTML' <title>{{ title }}</title> <main>{% yield content %}</main> HTML); file_put_contents('templates/list.html', <<<'HTML' {% layout main %} {% section content %} <ul> {% each posts as post %}{% component card %}{% /each %} </ul> {% /section %} HTML); file_put_contents('templates/card.html', "<li>{{ post }}</li>\n");
Seeing Escaping Run
The measurement makes three requests. In the first request the three values in the list are printed with {{ post }}; one of the values contains &, one is <b>bold</b>, and one is a double quote. Expected: the escaping step runs, and the markup stays in the output as text. The second request runs the same template unchanged; compilation should be skipped because the compiled file is not older than the source, but escaping still runs on every request, because it sits inside the compiled code. Before the third request, the time of card.html is moved forward.
<?php // escaping.php — escaping runs on every {{ }} output; compilation is skipped on the second request require_once 'template.php'; require_once 'templates.php'; $engine = new TemplateEngine('templates', 'cache'); $data = ['title' => 'Posts', 'posts' => ['Tom & Jerry', '<b>bold</b>', '"quote"']]; Ledger::newRequest(); echo $engine->show('list', $data), "\n"; report(['escaping', 'layout', 'component', 'compile']); Ledger::newRequest(); $engine->show('list', $data); echo "second request\n"; report(['escaping', 'compile']); touch('templates/card.html', time() + 5); Ledger::newRequest(); $engine->show('list', $data); echo "card.html updated\n"; report(['compile']);
<title>Posts</title> <main><ul> <li>Tom & Jerry</li> <li><b>bold</b></li> <li>"quote"</li> </ul> </main> escaping ran layout ran component ran compile ran second request escaping ran compile skipped card.html updated compile ran
In the first request all four parts are ran. The layout ran because the page template calls the main template after it has run; the component ran because it is called on every turn of the loop; escaping ran at every {{ }} point; and compilation ran because the cache was empty. In the second request the distinction is clear: compilation is skipped, escaping ran. Compilation is a cache decision, escaping is not; even when the decision changes, escaping keeps running because it is inside the compiled code. In the third request only the changed component’s file is recompiled, and the main template stays the same. This measurement measures an outcome, not a count: how many times something ran was a question asked in an earlier course and is not asked here.
The Opposing Measurement: Skipped Compilation
The three requests above already contain the opposing measurement. The compile row of the second request is the case where a step is placed on the path but not called: Ledger::onPath('compile') was written, Ledger::ran('compile') was not. This means skipped. It is not an error; it is the purpose of the cache. The reverse case, where the expected part is never placed on the path at all, is the subject of the next section.
The Bounding Measurement
The claim that escaping runs on every output is bounded when the raw output syntax is used. The measurement below writes two templates: one with {{ summary }}, the other printing the same value with the raw output syntax. The value is the markup <b>bold</b>.
<?php // raw.php — escaping is not wired in the raw output syntax require_once 'template.php'; file_put_contents('templates/raw.html', "<p>{{! summary }}</p>\n"); file_put_contents('templates/escaped.html', "<p>{{ summary }}</p>\n"); $engine = new TemplateEngine('templates', 'cache'); $data = ['summary' => '<b>bold</b>']; foreach (['escaped', 'raw'] as $name) { Ledger::newRequest(); echo $engine->show($name, $data); echo "$name\n"; report(['escaping']); }
<p><b>bold</b></p> escaped escaping ran <p><b>bold</b></p> raw escaping not wired
In raw output, escaping is not wired. The compiler never put the escaping call at that point; onPath() was not written, and neither was ran(). This is why the outcome is not skipped. Escaping runs everywhere except at the raw output point, and the deviation is limited to that point. There is no error in the output and no warning; the page is produced. Raw output exists to print trusted markup, but a person who puts the marker on an unescaped value disables the step with their own hand. For this reason raw output is an explicitly chosen exception, and its syntax is deliberately different: it should be distinguishable even when read out of the corner of the eye.
Summary
- A template engine turns a template into PHP code and writes the compiled file to a cache; layouts, components and loops are part of this translation.
- This lesson’s outcome is ran: because the escaping step is embedded in the code the compiler generates, it ran at every
{{ }}point. - In an unchanged template the compilation step is skipped; escaping ran on every request regardless of compilation.
- In the raw output syntax escaping is not wired: the outcome is not skipped, because the part was never placed on the path.
- Raw output is an explicitly chosen exception; its danger is that it gives no error.
Next Step
A template trusts its data and prints every value given to it. But when a form submitted by a user does not satisfy a rule, does it reach the save step? The next lesson, Forms and Validation, measures validation and examines the save step falling into the skipped outcome.
To keep your progress and take notes, Log in
My notes
Log in to take notes.