Practical Capstone Projects

Template Rendering

Map Names To Known Files

Request data never becomes an include path. Configure logical names once:

PHP example
// 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:

PHP example
// 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:

PHP example
// 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:

PHP example
// 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 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);
}

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:

PHP example
// 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:

PHP example
// 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 example
<?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:

PHP example
// 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 $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.