A Product Page Template: HTML by Default, PHP in the Gaps
The defining move of PHP is that a file can be mostly HTML with executable islands. The server executes the islands and streams the rest verbatim, so a designer can read the file as markup and a developer can see exactly which three lines are dynamic. Here is a complete product template receiving $product and $related:
<?php // templates/product.php ?>
<!DOCTYPE html>
<html lang="en">
<head><title><?= htmlspecialchars($product['name']) ?></title></head>
<body>
<h1><?= htmlspecialchars($product['name']) ?></h1>
<p class="price">$<?= number_format($product['price'], 2) ?></p>
<?php if ($product['stock'] > 0): ?>
<button data-id="<?= (int) $product['id'] ?>">Add to cart</button>
<?php else: ?>
<p class="sold-out">Out of stock — try again later</p>
<?php endif; ?>
<?php if ($related !== []): ?>
<h2>You may also like</h2>
<ul>
<?php foreach ($related as $item): ?>
<li><a href="/product/<?= (int) $item['id'] ?>">
<?= htmlspecialchars($item['name']) ?>
</a></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</body>
</html>
Notice two structural choices.
Control structures use the alternative syntax in templates. if (...): ... endif; and foreach (...): ... endforeach; are not a different language feature — they compile to the same opcodes as braces — but they exist precisely because a template interleaves PHP with indented HTML. A } floating between </li> and </ul> is unreadable; endforeach; marks the block where the markup's indentation ends. The parser has no trouble with a statement that spans an HTML island, because it parses the complete construct before executing anything:
<?php foreach ($rows as $row): ?>
<tr><td><?= htmlspecialchars($row['name']) ?></td></tr>
<?php endforeach; ?>
Templates compose with require. A layout is not a framework invention — splitting a page into partials/header.php, partials/footer.php and requiring them works fine:
<?php require __DIR__ . '/partials/header.php'; ?>
<main>…</main>
<?php require __DIR__ . '/partials/footer.php'; ?>
Because require executes the included file in the current scope, $title assigned before the header include is visible inside it. That shared scope is convenient for partials and dangerous for everything else, which the next topics address. The four include functions differ only in what happens when the file is missing: require and require_once raise a fatal error and stop the page, include and include_once raise a warning and continue — so anything the page cannot render without (a layout, a config) is required, and the _once variants guard against double-rendering when a template is pulled in from two places.
A slightly richer layout pattern captures sections with output buffering and lets the page fill them in any order:
<?php // layouts/base.php ?>
<header><h1><?= htmlspecialchars($pageTitle) ?></h1></header>
<main><?= $content ?></main>
<aside><?= $sidebar ?? '' ?></aside>
<?php // page code builds each section, then renders the layout ?>
<?php ob_start(); ?>
<p>Hero paragraph</p>
<?php $content = ob_get_clean(); ?>
<?php require __DIR__ . '/layouts/base.php'; ?>
This is exactly the machinery frameworks hide from you — a "layout engine" is buffering plus require, nothing more. Once you recognize it, Twig's {% extends %} and Blade's @section stop being magic and become conventions around these two constructs.
One consequence of interleaving: every byte outside the islands — including indentation and the newline after ?> — is output. In a template that is exactly what you want (it is the markup), which is why the "omit the closing tag" rule from Your First PHP Script applies to code files but not to template files. Templates close their tags; libraries do not.
Keeping Templates Dumb: Where the Logic Goes Instead
A template answers one question: how does this data look? The moment it starts answering what does this data mean, the codebase starts rotting. Compare two ways to render an order summary.
<!-- In the template: queries, totals, and permission checks leaking in -->
<?php
$items = $pdo->query("SELECT * FROM order_items WHERE order_id = {$_GET['id']}")->fetchAll();
$total = 0;
foreach ($items as $i) { $total += $i['price'] * $i['qty']; }
if ($_SESSION['user']['role'] === 'admin' || $_SESSION['user']['id'] === $items[0]['owner']): ?>
<p>Total: $<?= number_format($total, 2) ?></p>
<?php endif; ?>
The controller version moves every decision out and leaves the template formatting only:
// controller
$items = $orderService->itemsFor((int) $_GET['id']);
$total = $orderService->total($items);
$user = $auth->user();
render('order_summary', [
'items' => $items,
'total' => $total,
'showTotal' => $user->isAdmin() || $user->owns($order),
]);
<!-- templates/order_summary.php -->
<?php if ($showTotal): ?>
<p>Total: $<?= number_format($total, 2) ?></p>
<?php endif; ?>
The separation buys three things that interviewers recognize immediately.
Testability. total() is a unit test with fixture arrays. The template version's total cannot be tested without a database and a session, so nobody tests it, so it breaks silently.
Handoff. A markup-dominant file can be edited by someone who does not write PHP — adjust classes, swap a <div> for a <section>, restyle the price block — with zero risk of touching business rules, because there are none in there.
One place per decision. A permission check that lives in a template is a permission check that will eventually be omitted from one template. Authorization belongs in the controller or a middleware; the template merely renders the flag it is handed.
How does data reach the template without globals? The pattern every framework uses is extraction into a function scope — Laravel's view() ultimately compiles a template into a plain PHP closure where the data array is extracted as local variables, which is why $total is simply there. A five-line homegrown version:
function render(string $template, array $data = []): void {
extract($data, EXTR_SKIP); // never overwrite local variables
require __DIR__ . "/templates/$template.php";
}
Partials share that scope on purpose (a $title set by the page is read by partials/header.php), and section-capture for layouts is just buffering:
ob_start(); require 'partials/sidebar.php'; $sidebar = ob_get_clean();
The testing payoff is concrete. A template reduced to formatting can be exercised directly — render it with a fixture array and assert on substrings:
$html = renderToString('order_summary', ['total' => 12.75, 'showTotal' => true]);
assert(str_contains($html, 'Total: $12.75'));
$html = renderToString('order_summary', ['total' => 12.75, 'showTotal' => false]);
assert(!str_contains($html, 'Total:'));
No database, no session, no HTTP kernel: the test runs in milliseconds and fails precisely when the markup breaks. The same test against a view that queries and computes needs infrastructure scaffolding that most teams never build, which is why logic-laden views ship untested.
This whole chapter is the argument for template engines as well: Twig and Blade are this discipline made syntactic — autoescaping by default, no arbitrary PHP allowed in the template, explicit {!! !!} for raw output. Native templates run faster and carry zero dependencies, but every escaping call and every boundary decision is yours. Either way the line is the same: the template formats data it is given; it never fetches, computes, or authorizes.
Quiz
1. A shared host runs short_open_tag=Off. Which code still works in a template?
2. A product name containing `Tom & Jerry` displays as `Tom &amp; Jerry`. What happened?
3. Why is `foreach ($rows as $r): ?> <tr>...<?php endforeach; ?>` legal even though HTML sits inside the loop body?
4. A template contains `$items = $pdo->query(...)` and a role check before rendering. What is the primary cost?
Flashcards
Question
What does <?= $name compile to, and since when is it always available?
Click to reveal answer
Answer
It compiles to a plain `echo $name`. Since PHP 5.4 it is available unconditionally — it works even with short_open_tag=Off, unlike <? which is emitted as literal text when the setting is off.
Question
What is the alternative syntax in PHP templates and why does it exist?
Click to reveal answer
Answer
if (...): ... endif; / foreach (...): ... endforeach; — the same opcodes as the brace form, written so block ends align with HTML indentation instead of leaving stray } between tags. A construct may span HTML islands because PHP parses the entire file before executing.
Question
State the escaping rule for PHP templates and the failure mode it prevents.
Click to reveal answer
Answer
Escape exactly once, at output, with the context's encoder: htmlspecialchars for element content and attributes, urlencode for URL components. Escaping on input too causes double-encoding (& renders as &amp;); escaping nowhere causes XSS.
Question
What belongs in a template and what must never be in one?
Click to reveal answer
Answer
Formatting and presentation decisions: iteration over given data, number/date rendering, conditionals over flags the controller passed. Never queries, totals, or permission checks — those live in the controller/service so they can be unit-tested once instead of copied into every view.
Question
How do frameworks make a PHP template receive its variables?
Click to reveal answer
Answer
By extracting the data array into the template's scope: the template compiles to a plain PHP closure/file where $total etc. are local variables (Laravel's view() does this; a homegrown render() uses extract($data, EXTR_SKIP) + require). Layouts and partials reuse that shared scope, with ob_start() used to capture sections.
Revision Notes
Key Takeaways
- 1. A PHP template is HTML by default with executable islands; partials compose via require and share the template's scope
- 2. <?= is unconditional since PHP 5.4 and compiles to echo; <? depends on short_open_tag and is best avoided entirely
- 3. Alternative syntax (if/endif, foreach/endforeach) exists for readable blocks among markup; the parser handles constructs spanning HTML because it parses before executing
- 4. Escape once at output with the right encoder per context — htmlspecialchars for markup, urlencode for URLs — and never on input
- 5. Templates format data they are given; fetching, computing, and authorizing belong in the controller so they stay testable in one place
Interview Tips
- • "Where does output escaping belong?" — at the view boundary, once per output, with context-specific encoders; mention that frameworks like Twig/Blade automate it and native PHP leaves it to you
- • "What is <?= and is it safe on shared hosting?" — yes: unconditional since 5.4, compiles to echo; contrast with <? which depends on short_open_tag and silently becomes literal text
- • "How do MVC frameworks pass data into a PHP view?" — the data array is extracted into the template's scope (compiled closure or extract() + require), which is why view variables need no declaration
- • "A designer needs to restyle a page without touching business logic — how do you structure it?" — markup-dominant template with logic-free islands, partials via require, controller passes computed values and boolean flags such as $showTotal
Cheat Sheet
Embedding PHP Inside HTML
The model: a .php file is HTML by default; <?php ... ?> islands execute on the server and everything else streams through verbatim.
Tag forms:
| Form | Meaning | Availability |
|---|---|---|
<?php ... ?> |
normal block | always |
<?= $expr ?> |
echo $expr |
unconditional since 5.4 — safe everywhere |
<? ... ?> |
short tag | only with short_open_tag=On; avoid (breaks on shared hosts, conflicts with <?xml) |
<% %> |
ASP tag | removed in PHP 7.0 |
Template control structures (alternative syntax reads better among markup):
<?php if ($stock > 0): ?> … <?php else: ?> … <?php endif; ?>
<?php foreach ($rows as $row): ?> … <?php endforeach; ?>
<?php require __DIR__ . '/partials/header.php'; ?>
Same opcodes as the brace form; a construct may span HTML islands because PHP parses the whole file first.
Escaping — once, at output, per context:
<?= htmlspecialchars($v) ?> // element content + attributes (8.1+ default ENT_QUOTES)
<?= urlencode($q) ?> // URL component
<?= $sanitizedHtml ?> // rich text, only after an allowlist sanitizer
Store raw text; never escape on input (double-encoding: & → &amp;).
Separation rule: the template formats data it is given — it never queries, computes totals, or checks permissions. Pass flags ($showTotal), not conditions. Frameworks enforce this (Blade {{ }} autoescapes, {!! !!} is deliberate raw); native PHP puts every boundary decision in your hands.