Template Rendering
Templates turn prepared application data into HTML. Add product views to the routed catalog without putting repository calls, authorization decisions, redirects, or validation rules inside PHP templates.
src/View/SafeHtml.php
src/View/TemplateRenderer.php
templates/layout.php
templates/products/index.php
Allow-List Template Names
A request must never become an include path. Give the renderer an application-owned map from logical names to files:
<?php
declare(strict_types=1);
final readonly class SafeHtml
{
public function __construct(public string $value) {}
}
final class TemplateRenderer
{
public function __construct(
private string $baseDirectory,
private array $templates,
) {}
public function render(string $name, array $view = []): string
{
$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.');
}
$startingLevel = ob_get_level();
ob_start();
try {
(static function (string $path, array $view): void {
require $path;
})($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;
}
}
}
Configure it once in the application bootstrap:
<?php
declare(strict_types=1);
// no-execute: requires the TemplateRenderer class and project directory.
$renderer = new TemplateRenderer(
dirname(__DIR__) . '/templates',
[
'layout' => 'layout.php',
'products/index' => 'products/index.php',
],
);
The template receives one local $view array. Avoiding extract() prevents context keys from colliding with renderer variables and makes every dependency visible at the use site. The static closure also prevents templates from gaining $this access to the renderer.
Escape For The Output Context
Use an HTML helper for text and quoted attribute values:
<?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);
}
ENT_QUOTES covers both quote types in a quoted HTML attribute, and ENT_SUBSTITUTE prevents invalid UTF-8 from collapsing the whole output. HTML escaping is not URL validation, JavaScript encoding, CSS encoding, or JSON encoding. Build route segments with a route-aware URL generator or rawurlencode() before applying e() to the final URL attribute. Do not interpolate untrusted values into inline JavaScript or CSS.
Render A Complete Product List
The controller prepares a narrow context. templates/products/index.php verifies the expected shape and performs presentation only:
<?php
// no-execute: template requires renderer-owned view data.
/** @var array{products: list<array{id: int|string, name: string, price_cents: int|string, status: string}>} $view */
$products = $view['products'] ?? null;
if (!is_array($products)) {
throw new LogicException('Product list view data is missing.');
}
?>
<section aria-labelledby="products-heading">
<h1 id="products-heading">Products</h1>
<?php if ($products === []): ?>
<p>No products found.</p>
<?php else: ?>
<ul>
<?php foreach ($products as $product): ?>
<?php
$id = filter_var($product['id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($id === false) {
throw new LogicException('Product ID is invalid.');
}
$name = (string) ($product['name'] ?? '');
$status = (string) ($product['status'] ?? '');
$priceCents = filter_var($product['price_cents'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0],
]);
if ($priceCents === false) {
throw new LogicException('Product price is invalid.');
}
?>
<li data-status="<?= e($status) ?>">
<a href="/products/<?= rawurlencode((string) $id) ?>"><?= e($name) ?></a>
<span>$<?= e(formatCents($priceCents)) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
A missing or malformed controller value becomes a controlled application failure rather than a notice followed by misleading HTML. The template does not query PDO, inspect $_GET, or decide whether the current user is allowed to see a product.
Make Trusted Markup Explicit
Render the page fragment first, then pass it to the layout as SafeHtml:
<?php
declare(strict_types=1);
// no-execute: requires the configured renderer and repository.
$productsHtml = $renderer->render('products/index', [
'products' => $repository->page(50, 0),
]);
$page = $renderer->render('layout', [
'title' => 'Products',
'content' => new SafeHtml($productsHtml),
]);
$response = new Response($page, 200, ['Content-Type' => 'text/html; charset=utf-8']);
The layout rejects an ordinary string in the trusted-markup slot:
<?php
// no-execute: template requires renderer-owned view data.
/** @var array{title: string, content: SafeHtml} $view */
$title = $view['title'] ?? null;
$content = $view['content'] ?? null;
if (!is_string($title) || !$content instanceof SafeHtml) {
throw new LogicException('Layout view data is invalid.');
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title) ?></title>
</head>
<body>
<main><?= $content->value ?></main>
</body>
</html>
SafeHtml does not make arbitrary content safe. It marks a review boundary: only HTML already produced by a trusted template should be wrapped. User text remains an ordinary string and must be escaped at its final HTML context.
Verify Rendering And Cleanup
Render empty and populated lists, a name containing <script>, quotes in data-status, invalid UTF-8, malformed row data, an unknown template name, a missing configured file, and a template that throws after printing. After every failure, assert that ob_get_level() equals its value before rendering. Confirm the controller returns the rendered page through Response rather than echoing around the router.
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.