Practical Capstone Projects

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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 $view array instead of using extract() 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 Response with 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 example
<?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 example
<?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.