PHP Templates: Escaping Discipline, Layouts, Components, and Partials

Build a template layer where escaping user-controlled output is the default, understand exactly which htmlspecialchars() flags and contexts matter (HTML body vs. attribute vs. URL vs. JavaScript), assemble pages from a shared layout plus reusable partials and components, and know when a raw, unescaped value in a template is correct rather than a bug.

A PHP template's entire job is producing HTML, and that puts it on the front line of cross-site scripting (XSS): every single value interpolated into markup is a place an attacker's payload could ride along if escaping is skipped even once. The layout/partial/component structure covered elsewhere organizes *how* a page is assembled from reusable pieces; this lesson goes one layer deeper into the specific discipline of making escaping the default rather than an afterthought -- which function to call, which context each one is safe in, why raw HTML is sometimes legitimately unescaped, and how to structure a template layer so a forgotten escape is the rare exception instead of a one-in-a-thousand hope.

htmlspecialchars() is not one setting -- know the flags and the context

htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8') is the call to actually reach for by default in PHP 8.1+, not the bare htmlspecialchars($value) many tutorials show. ENT_QUOTES escapes both single and double quotes (the default only escapes double quotes as of older PHP versions' historical default, which is unsafe the moment a value is placed inside a single-quoted HTML attribute like value='<?= $x ?>'). ENT_SUBSTITUTE replaces invalid UTF-8 byte sequences with the Unicode replacement character instead of returning an empty string for the whole call, which matters because a single malformed byte in unrelated input must not silently blank out an entire page. Since PHP 8.1, htmlspecialchars() actually already defaults to ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 when no flags argument is given at all, and the encoding defaults to UTF-8 -- so the safe call in modern PHP can be as short as htmlspecialchars($value), but writing the flags explicitly (or through a shared helper) keeps that guarantee visible and independent of which PHP version happens to be running.

Escaping is context-dependent, and htmlspecialchars() alone only covers one context: HTML body text and HTML attribute values. It does NOT make a value safe to drop into a <script> block (use json_encode() with JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP for a value going into inline JS), into a href/src URL (validate/allow-list the scheme and consider urlencode()/rawurlencode() for a query-string component, since htmlspecialchars() does not stop a javascript: URL), or into CSS. Treating htmlspecialchars() as a universal 'make it safe' incantation for every context is itself a common source of real vulnerabilities.

• HTML body / attribute text: htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') -- or bare htmlspecialchars($v) on PHP 8.1+, which now defaults to the same flags. • Value embedded in a <script> block: json_encode($v, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) -- htmlspecialchars() alone is not sufficient here. • Value embedded in an href/src URL: validate the scheme (reject javascript:, data: where not intended) and encode dynamic query components with urlencode()/rawurlencode(). • ENT_QUOTES matters even for attributes written with double quotes today -- a later edit to single-quoted attributes must not silently reopen an injection point.

Escape by default: a single helper, applied everywhere

The most reliable way to make escaping the default instead of a remembered step is to route every dynamic interpolation through one short, greppable helper -- conventionally named e() or h() -- so that <?= e($comment->body) ?> is the normal, unremarkable thing every template does, and a bare <?= $comment->body ?> stands out immediately in a code review as the exception that needs justifying. A codebase-wide search for <?= followed directly by a raw variable (not wrapped in e(...)) becomes a cheap, mechanical way to audit for missed escapes, precisely because the convention is so uniform.

This is also why a templating engine like Twig (used with Symfony) or Blade (used with Laravel) auto-escapes every {{ variable }} output by default and requires an explicit opt-out ({{ variable|raw }} in Twig, {!! $variable !!} in Blade) to print something unescaped -- they've simply made the 'escape by default, opt out deliberately' discipline a language feature instead of a team convention. Plain PHP templates can approximate the same safety by making the short helper the only thing anyone ever reaches for, and treating a raw echo/<?= of a scalar value as something that should not appear in a code review without a comment explaining why.

When raw, unescaped output is actually correct

Not every value in a template is unescaped user input -- some values are already-safe HTML that must NOT be escaped again, and knowing the difference is exactly as important as knowing when to escape. The clearest case is a fully-assembled HTML string produced by trusted server code (a layout's $content variable captured via output buffering, or the return value of a component's own render() method that already escaped every value it interpolated internally) -- re-running htmlspecialchars() on that string would double-encode entities that are already correct (& would become &amp;), visibly corrupting the page.

The other legitimate raw-output case is HTML deliberately produced from a trusted, sanitized source -- markdown rendered to HTML by a library, or rich-text content that has been passed through an HTML sanitizer (like HTMLPurifier) that strips dangerous tags/attributes while permitting a safe allow-list (<b>, <a href>, etc.). The sanitizer's output is safe to echo raw specifically because sanitization already happened; echoing unsanitized rich text raw, on the assumption that 'it's HTML so it doesn't need escaping', is the mistake -- sanitization and escaping solve different problems (permitting safe markup vs. neutralizing all markup) and only one of them belongs on genuinely user-authored rich content.

• Raw (no htmlspecialchars) is correct for: a layout's $content from ob_get_clean(), a component's own already-escaped render() output, output from an HTML sanitizer applied to rich text. • Raw is a bug for: any value that traces back to user input and has not been through htmlspecialchars() or a real sanitizer first. • 'It's HTML so it must not need escaping' is the exact reasoning that leads to stored XSS -- sanitize untrusted rich text explicitly; don't just skip escaping and hope.

Layouts, components, and partials as an escaping boundary

Structuring a template layer into a shared layout, small components, and reusable partials (the same file-organization pattern used across a PHP application's views) also draws a clear boundary for where escaping responsibility lives: each partial or component is responsible for escaping every raw value it interpolates itself, and hands its caller back either a fully-escaped rendered string or nothing at all -- never a half-escaped fragment where the caller has to guess which parts are already safe. A renderUserCard(User $user): string component that internally calls e($user->name) and e($user->bio) and returns one complete HTML string means every caller can echo that return value directly, with the escaping already handled at the one place that knows the data's shape.

This also makes reusable partials safer to reuse across contexts: a partials/badge.php that expects $label in scope and escapes it internally is safe to include from any page, regardless of where $label originally came from, because the escaping lives with the rendering logic rather than being the including page's responsibility to remember each time.

A shared escaping helper, used consistently across a template layer

e() is deliberately narrow -- string|int|float|null in, string out -- so passing an array or object by mistake is a type error, not silent wrong output. jsValue() is a distinct function, not an overload of e(), precisely because HTML-escaping and JS-string-escaping are different problems that must not be conflated.

<?php
declare(strict_types=1);

/** The one function every template interpolation should go through for HTML body/attribute context. */
function e(string|int|float|null $value): string
{
    return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

/** For a value that will be embedded inside a <script> block, not HTML markup. */
function jsValue(mixed $value): string
{
    return json_encode($value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_THROW_ON_ERROR);
}

// component.php -- returns one fully-escaped string; callers never re-escape it
function renderUserCard(object $user): string
{
    return '<div class="user-card"><h3>' . e($user->name) . '</h3><p>' . e($user->bio) . '</p></div>';
}

Code Examples

$searchTerm comes straight from the query string, so it is escaped at both interpolation points -- once inside a double-quoted HTML attribute (value="...") and once as HTML body text. Because e() applies ENT_QUOTES, an attacker submitting q="><script>alert(1)</script> has both the double quote and angle brackets neutralized into harmless entities in each location.

value="<?= e($searchTerm) ?>" Common mistake: Writing value="<?= $searchTerm ?>" unescaped would let a submitted q value containing a double quote close the attribute early and inject arbitrary markup or a new onmouseover=... attribute right into the tag.

nl2br() is applied AFTER e(), not before -- e() first neutralizes any HTML in the comment body (including a literal '<br>' an attacker typed), and only then does nl2br() convert real newline characters into safe, PHP-generated <br> tags. Calling nl2br() before escaping would let user-typed HTML tags survive alongside the generated ones. Expected result: <article class="comment"><header>Ada -- 2026-03-14</header><p>Great post!<br />Looking forward to more.</p></article> safeUrl() addresses a gap htmlspecialchars() does not cover on its own: escaping the URL's characters does nothing to stop a javascript:alert(1) URL from executing when clicked, because that string contains no HTML-special characters to escape in the first place. Validating the scheme against an allow-list before the value ever reaches the href attribute closes that gap; htmlspecialchars() still runs afterward to handle any HTML-special characters in a legitimate URL.

Common mistake: Escaping profileUrl with e() alone and skipping safeUrl() looks safe (no angle brackets or quotes to worry about) but would still let href="javascript:..." execute on click -- URL context needs scheme validation, not just character escaping.

<?php
declare(strict_types=1);

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

$searchTerm = $_GET['q'] ?? '';
?>
<form method="get" action="/search">
  <input type="text" name="q" value="<?= e($searchTerm) ?>">
  <button type="submit">Search</button>
</form>
<p>Showing results for: <?= e($searchTerm) ?></p>
<?php
declare(strict_types=1);

// partials/comment.php -- expects $comment (object with ->author, ->body, ->postedAt) in scope
?>
<article class="comment">
  <header><?= e($comment->author) ?> -- <?= e($comment->postedAt->format('Y-m-d')) ?></header>
  <p><?= nl2br(e($comment->body)) ?></p>
</article>

<?php
// page.php
$comments = fetchComments($postId);
foreach ($comments as $comment) {
    include __DIR__ . '/partials/comment.php';
}
<?php
declare(strict_types=1);

final class UserCard
{
    public function __construct(
        private readonly string $name,
        private readonly string $bio,
        private readonly string $profileUrl,
    ) {}

    public function render(): string
    {
        // Every raw value is escaped exactly once, inside the component that owns it.
        return '<div class="user-card">'
            . '<a href="' . e($this->safeUrl($this->profileUrl)) . '">'
            . e($this->name) . '</a>'
            . '<p>' . e($this->bio) . '</p>'
            . '</div>';
    }

    private function safeUrl(string $url): string
    {
        // Allow only http/https -- rejects javascript:, data:, and similar schemes outright.
        $scheme = parse_url($url, PHP_URL_SCHEME);
        return in_array($scheme, ['http', 'https'], true) ? $url : '#';
    }
}

$card = new UserCard(name: $user->name, bio: $user->bio, profileUrl: $user->websiteUrl);
echo $card->render(); // caller never re-escapes -- render() already returned a complete, safe string

Where escaping responsibility lives

Escaping happens exactly once, at the point each raw value is interpolated -- never again on an already-assembled HTML string.

Untrusted input (user)
        |
        v
  [ every interpolation point: e($value) ] <-- escaping happens HERE, always
        |
        v
  Partial / component renders a COMPLETE, already-escaped string
        |
        v
  Layout echoes that string RAW (no re-escaping -- it's already safe)
        |
        v
  Browser renders exactly the intended markup, nothing injected

Important Rules

Route every dynamic HTML-body/attribute interpolation through one shared helper (e()/htmlspecialchars() with ENT_QUOTES | ENT_SUBSTITUTE) so escaping is the visible default, not a remembered exception.

Never use htmlspecialchars() alone to make a value safe for a <script> block or a URL -- use json_encode() with the JSON_HEX_* flags for JS, and validate the URL scheme against an allow-list for hrefs/srcs.

Never re-escape an already-assembled HTML string (a layout's $content, a component's render() output) -- that double-encodes entities and corrupts the page.

Sanitize (with a real HTML sanitizer) any genuinely user-authored rich text before echoing it raw -- 'it's HTML so it doesn't need escaping' is the reasoning behind most stored-XSS bugs.

Give each partial/component sole responsibility for escaping the raw values it interpolates, and have it return one complete, safe string its caller never needs to re-escape.

Where this shows up

Every major PHP templating engine formalizes exactly this discipline: Twig auto-escapes every {{ }} output and requires |raw as an explicit, reviewable opt-out; Laravel's Blade does the same with {{ }} vs. {!! !!}. A team building templates in plain PHP without one of these engines is reproducing that same guarantee by hand through a shared e() helper -- and the moment that discipline lapses (a raw echo slipping into a template during a rushed feature), the result is a real, exploitable stored or reflected XSS vulnerability, which is why 'escape by default, raw-output only with a stated reason' is treated as close to a hard rule in production PHP codebases rather than a style preference.

<a href="<?= $user->profileUrl ?>"><?= htmlspecialchars($user->name) ?></a>

<a href="<?= htmlspecialchars($safeProfileUrl) /* already scheme-validated */ ?>"><?= htmlspecialchars($user->name) ?></a>

The name is correctly escaped, but the URL is echoed completely raw -- a profileUrl value of "javascript:alert(document.cookie)" contains no angle brackets or quotes for htmlspecialchars() to catch, so it would render as a clickable link that executes arbitrary JavaScript. HTML-escaping and URL-scheme validation are two separate checks; a URL needs both.

[multiple-choice] Why is htmlspecialchars($value) alone NOT sufficient to make a value safe to embed inside a <script> block? A. htmlspecialchars() cannot process strings longer than 255 characters B. htmlspecialchars() escapes HTML-special characters like < and ", but does not perform JavaScript-string escaping, so a value can still break out of or manipulate the surrounding JS syntax even with no HTML-special characters in it C. <script> blocks are never parsed as HTML in modern browsers, so escaping is irrelevant there D. htmlspecialchars() always throws an exception when called inside a <script> context

B. htmlspecialchars() escapes HTML-special characters like < and ", but does not perform JavaScript-string escaping, so a value can still break out of or manipulate the surrounding JS syntax even with no HTML-special characters in it — HTML-escaping and JavaScript-string-escaping solve different problems for different contexts. A value safely escaped for HTML body/attribute text can still contain characters (quotes, backslashes) that break out of a JS string literal or alter script logic; json_encode() with the JSON_HEX_* flags is the correct tool for embedding a value inside a <script> block.

[multiple-choice] A layout template echoes $content, which was captured from a page template via ob_start()/ob_get_clean() after every individual value inside it was already escaped with e(). What should the layout do with $content? A. Echo it raw, without calling e() or htmlspecialchars() again -- re-escaping it would double-encode entities that were already correctly escaped at each interpolation point B. Call e($content) again to be extra safe, since more escaping can never hurt C. Reject $content entirely because output buffering is never safe to use with escaped content D. Convert $content to an array before echoing it

A. Echo it raw, without calling e() or htmlspecialchars() again -- re-escaping it would double-encode entities that were already correctly escaped at each interpolation point — $content is already a fully-assembled, correctly-escaped HTML string by the time it reaches the layout -- re-running htmlspecialchars()/e() on the whole string would re-encode entities that are already correct (turning & into &amp;), visibly corrupting the rendered page rather than adding safety.

[multiple-choice] A component escapes a user-supplied URL with htmlspecialchars() before putting it in an href attribute, but does not check the URL's scheme. What risk remains? A. None -- htmlspecialchars() fully neutralizes any dangerous URL B. A javascript: URL can still execute on click, because it contains no HTML-special characters for htmlspecialchars() to escape in the first place -- scheme validation is a separate check from character escaping C. The href attribute will fail to render at all D. htmlspecialchars() will throw a fatal error on any URL containing a colon

B. A javascript: URL can still execute on click, because it contains no HTML-special characters for htmlspecialchars() to escape in the first place -- scheme validation is a separate check from character escaping — htmlspecialchars() only neutralizes HTML-special characters (<, >, ", ', &); a string like javascript:alert(1) contains none of those and passes through unchanged, so it still executes when the link is clicked. Preventing this requires validating the URL's scheme against an allow-list (http/https) before ever placing it in an href, independent of HTML escaping.

Write a function renderNotice(string $type, string $message, string $linkUrl, string $linkText): string that returns one complete HTML string: a <div class="notice notice-{type}"> containing the escaped message, followed by an <a> tag using $linkUrl as href and $linkText as its text. The href must only be emitted if $linkUrl's scheme is http or https (render the anchor without an href, or omit it entirely, otherwise). Both $message and $linkText must be HTML-escaped.

function renderNotice(string $type, string $message, string $linkUrl, string $linkText): string { $scheme = parse_url($linkUrl, PHP_URL_SCHEME); $hrefAttr = in_array($scheme, ['http', 'https'], true) ? ' href="' . e($linkUrl) . '"' : ''; return '<div class="notice notice-' . e($type) . '">' . e($message) . ' <a' . $hrefAttr . '>' . e($linkText) . '</a>' . '</div>'; }

Reuse the e() helper for $type, $message, and $linkText. Use parse_url($linkUrl, PHP_URL_SCHEME) and an in_array() allow-list check before deciding whether to include the href attribute at all.