Templates, Layouts and Escaping in PHP

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

PHP 8.4

PHP is already a template language. The problem was never the language — it is fetching data and printing HTML in the same block of code. This chapter extracts a view layer with as little machinery as possible.

Goals

  • Views only print. They never open a database connection.
  • Every dynamic value is escaped.
  • Layouts and partials are reusable.
  • The structure stays readable without pulling in a template engine.

Directory layout

app/
  Controllers/
    PostController.php
  Repositories/
    PostRepository.php
  Support/
    render.php
    escape.php
  Views/
    layout.php
    posts/
      index.php
      show.php
    partials/
      nav.php
public/
  index.php

The escape helper

<?php
// app/Support/escape.php
declare(strict_types=1);

function e(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

Three details matter: ENT_QUOTES escapes both quote styles, ENT_SUBSTITUTE replaces invalid UTF-8 with a replacement character instead of silently returning an empty string, and the encoding is explicit. Every variable in a template goes through e() — including the database column you “know” is safe.

The render function

<?php
// app/Support/render.php
declare(strict_types=1);

function render(string $view, array $data = [], ?string $layout = 'layout'): string
{
    $viewFile = __DIR__ . '/../Views/' . $view . '.php';
    if (!is_file($viewFile)) {
        throw new RuntimeException("View not found: {$view}");
    }

    extract($data, EXTR_SKIP);     // array keys become local variables
    ob_start();
    require $viewFile;
    $content = ob_get_clean();

    if ($layout === null) {
        return $content;
    }

    $layoutFile = __DIR__ . '/../Views/' . $layout . '.php';
    ob_start();
    require $layoutFile;           // the layout prints $content
    return (string) ob_get_clean();
}

Used from an entry point:

// public/index.php
require __DIR__ . '/../vendor/autoload.php';

$posts = (new PostRepository($pdo))->published();

echo render('posts/index', [
    'title' => 'Posts',
    'posts' => $posts,
]);

extract() can overwrite $viewFile or $layoutFile, which is why EXTR_SKIP is mandatory here. For larger projects the safer convention is to pass the array as-is and read $data['posts'] inside the template — no name collisions, and static analysis can follow it.

The layout

<?php
// app/Views/layout.php
/** @var string $content */
/** @var string $title */
?>
<!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>
    <?php require __DIR__ . '/partials/nav.php'; ?>
    <main class="container">
      <?= $content ?>
    </main>
  </body>
</html>

<?= $content ?> is deliberately not escaped: the layout receives HTML that has already been rendered, and escaping would turn the tags into text. Mark that variable as the single trusted value in the project and never let user input reach it.

A view

<?php
// app/Views/posts/index.php
/** @var list<array{id:int,title:string,status:string}> $posts */
?>
<h1><?= e($title) ?></h1>

<?php if ($posts === []): ?>
  <p>No posts yet.</p>
<?php else: ?>
  <ul class="post-list">
    <?php foreach ($posts as $post): ?>
      <li>
        <a href="/posts/<?= e((string) $post['id']) ?>"><?= e($post['title']) ?></a>
        <span class="badge"><?= e($post['status']) ?></span>
      </li>
    <?php endforeach; ?>
  </ul>
<?php endif; ?>

The alternative syntax (if: / endif;, foreach: / endforeach;) reads much better when HTML is interleaved. <?= is shorthand for <?php echo; avoid the bare <? short tag, which depends on short_open_tag.

Partials

<?php
// app/Views/partials/nav.php
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$items = ['/' => 'Home', '/posts' => 'Posts', '/about' => 'About'];
?>
<nav class="nav">
  <?php foreach ($items as $href => $label): ?>
    <a href="<?= e($href) ?>" <?= $current === $href ? 'aria-current="page"' : '' ?>>
      <?= e($label) ?>
    </a>
  <?php endforeach; ?>
</nav>

A partial depends only on the variables it is given (plus request globals), and never queries the database.

The controller prepares the data

final class PostController
{
    public function __construct(private PostRepository $posts) {}

    public function index(): string
    {
        return render('posts/index', [
            'title' => 'Posts',
            'posts' => $this->posts->published(),
        ]);
    }

    public function show(int $id): string
    {
        $post = $this->posts->find($id);
        if ($post === null) {
            http_response_code(404);
            return render('errors/404', ['title' => 'Not found']);
        }

        return render('posts/show', [
            'title' => $post->title,
            'post'  => $post,
        ]);
    }
}

Templates receive finished data: pagination info, preformatted dates, the result of permission checks. Beyond a count() or a date() format, business logic belongs in the controller.

Several regions on one page

// collect extra assets, then hand everything to the layout
$scripts = ['/assets/post.js'];
$content = render('posts/show', ['post' => $post], null);

require __DIR__ . '/../Views/layout.php'; // uses $content and $scripts

Cleaner than echoing <script> tags from inside a view: dependencies are declared in one place and the layout decides the output order.

Common mistakes

1. Escaping only the fields that “look dangerous”

Titles, usernames and URL parameters all come from users. Escape all of them; the only exception is HTML that has already been rendered.

2. Putting a user-supplied URL straight into href

// a javascript: URL executes script when clicked
<a href="<?= e($url) ?>">

Escaping is not enough — validate the scheme:

if (!preg_match('#^https?://#i', $url)) {
    $url = '#';
}

3. require '../../config.php' inside a template

Relative paths depend on the working directory and break the moment the entry point changes. Use __DIR__, or let render() resolve paths.

4. Variable collisions after extract()

A template’s $posts gets overwritten and the error message makes no sense. Prefix the data keys, or skip extract() entirely.

5. Forgetting Content-Type

header('Content-Type: text/html; charset=utf-8');

Without it the browser may guess latin-1 and render UTF-8 text as mojibake.

6. Leaving output buffers open

If an exception fires after ob_start(), the error page is preceded by half-rendered HTML. Wrap the render in try/finally with ob_end_clean() in the finally.

7. Encoding business rules in the template

“Published and not deleted and owned by the current user” written three times will disagree with itself. Compute a canEdit boolean in the controller and pass it in.

Summary

  • render() owns views, layouts and output buffering; templates only display.
  • e() is the default action in a template; the only unescaped value is the layout’s $content.
  • Alternative syntax keeps PHP templates close to plain HTML.
  • Controllers prepare data, permissions and status codes; views never query.
  • Any HTML response needs charset=utf-8 in the headers.

Next: making failures visible in development and invisible to users in production.