Template Rendering
Map Names To Known Files
Request data never becomes an include path. Configure logical names once:
// no-execute: requires the TemplateRenderer class and project directory.
$renderer = new TemplateRenderer(
dirname(__DIR__) . '/templates',
[
'layout' => 'layout.php',
'products/index' => 'products/index.php',
],
);
Inside render(), reject names missing from that application-owned map:
// Partial: first step of TemplateRenderer::render().
$relativePath = $this->templates[$name] ?? null;
if (!is_string($relativePath)) {
throw new InvalidArgumentException('Unknown template.');
}
$path = $this->baseDirectory . '/' . $relativePath;
if (!is_file($path)) {
throw new RuntimeException('Configured template is missing.');
}
A route may choose the logical name in code. A query parameter must not choose it directly.
Pass One Visible Context
Run the template inside a static closure with one $view array:
// Partial: require inside TemplateRenderer::render().
(static function (string $path, array $view): void {
require $path;
})($path, $view);
Avoiding extract() prevents context keys from colliding with renderer variables. The static closure also prevents templates from accessing the renderer through $this.
Restore Buffers After Failure
A renderer must not leak partial HTML when a template throws:
// Partial: output-buffer lifecycle in render().
$startingLevel = ob_get_level();
ob_start();
try {
$this->requireTemplate($path, $view);
if (ob_get_level() !== $startingLevel + 1) {
throw new LogicException('Template changed the output-buffer level.');
}
$output = ob_get_clean();
if (!is_string($output)) {
throw new RuntimeException('Template output could not be captured.');
}
return $output;
} catch (Throwable $exception) {
while (ob_get_level() > $startingLevel) {
ob_end_clean();
}
throw $exception;
}
requireTemplate() is the small static-closure operation shown above. Test a template that prints then throws and another that opens an extra buffer; both must restore the original level.
Escape At The Final Context
For HTML text and quoted attributes, use:
<?php
declare(strict_types=1);
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function formatCents(int $cents): string
{
if ($cents < 0) {
throw new InvalidArgumentException('Price cannot be negative.');
}
return intdiv($cents, 100) . '.'
. str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT);
}
HTML escaping is not URL validation or JavaScript/CSS encoding. Encode a route segment with rawurlencode() first, then escape the completed URL attribute. Avoid interpolating untrusted data into inline script or style.
Validate View Data Before Markup
A template should fail clearly when its controller contract is broken:
// no-execute: product template requires renderer-owned view data.
$products = $view['products'] ?? null;
if (!is_array($products)) {
throw new LogicException('Product list view data is missing.');
}
if ($products === []) {
echo '<p>No products found.</p>';
return;
}
For each row, validate the ID and cents as non-negative integers before using them. Then render one focused item:
// no-execute: partial product row requires validated template variables.
<li data-status="<?= e($status) ?>">
<a href="/products/<?= rawurlencode((string) $id) ?>">
<?= e($name) ?>
</a>
<span>$<?= e(formatCents($priceCents)) ?></span>
</li>
The template does not query PDO, inspect $_GET, or decide authorization.
Distinguish Text From Rendered Markup
A layout must escape its title but intentionally insert a fragment already produced by the renderer:
<?php
declare(strict_types=1);
final readonly class SafeHtml
{
public function __construct(public string $value) {}
}
The controller renders the fragment, wraps only that renderer-produced value, and renders the layout:
// no-execute: requires the configured renderer and repository.
$fragment = $renderer->render('products/index', [
'products' => $repository->page(50, 0),
]);
$page = $renderer->render('layout', [
'title' => 'Products',
'content' => new SafeHtml($fragment),
]);
Inside the layout, require title to be a string and content to be SafeHtml; print e($title) and $content->value. SafeHtml is a review marker, not a sanitizer. Never wrap user text merely to bypass escaping.
Verify The Page
Test empty and populated lists, markup-looking names, quotes in attributes, invalid UTF-8, malformed rows, unknown names, missing files, thrown templates, unbalanced buffers, and an ordinary string passed as layout content. The controller returns the page in a Response with text/html; charset=utf-8.
Practice
Practice: Render A Safe Product Page
Implement the allow-listed renderer, product-list template, layout, and controller composition from the lesson.
Requirements
- Map logical template names to application-owned relative paths.
- Reject unknown names and missing configured files.
- Pass one explicit
$viewarray instead of usingextract()or globals. - Capture output and restore the original buffer level after every exception.
- Detect templates that leave unbalanced output buffers.
- Escape dynamic HTML text and quoted attributes with UTF-8 substitution.
- Encode path segments separately from HTML attribute escaping.
- Keep PDO calls, request access, validation decisions, and authorization outside templates.
- Validate the expected row shape before rendering it.
- Represent renderer-produced markup explicitly instead of treating arbitrary strings as trusted HTML.
- Return the finished page in a
Responsewith an HTML content type.
Verify empty and populated pages, malicious text, quotes, invalid UTF-8, malformed rows, unknown and missing templates, thrown templates, unbalanced buffers, and an ordinary string passed to the layout content slot.
Show solution
<?php
declare(strict_types=1);
// no-execute: requires the configured renderer, repository, and Response class.
$rows = $repository->page(50, 0);
$fragment = $renderer->render('products/index', ['products' => $rows]);
$html = $renderer->render('layout', [
'title' => 'Products',
'content' => new SafeHtml($fragment),
]);
return new Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
The product template uses the explicit context and escapes each final HTML context:
<?php
// no-execute: template requires renderer-owned product view data.
foreach ($view['products'] as $product): ?>
<?php
$id = filter_var($product['id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
$priceCents = filter_var($product['price_cents'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0],
]);
if ($id === false || $priceCents === false) {
throw new LogicException('Product view data is invalid.');
}
?>
<article data-status="<?= e((string) ($product['status'] ?? '')) ?>">
<h2><a href="/products/<?= rawurlencode((string) $id) ?>"><?= e((string) ($product['name'] ?? '')) ?></a></h2>
<p>$<?= e(formatCents($priceCents)) ?></p>
</article>
<?php endforeach; ?>
For the failure test, create a configured template that prints text and then throws. Catch the exception outside render() and assert the buffer level is unchanged. Create a second template that calls ob_start() without closing it; the renderer must reject it and clean every buffer it opened above the starting level.