Lesson 40 • Advanced
Template Engines 🎨
By the end of this lesson you'll keep logic out of your HTML, escape output so it's XSS-safe, and use Twig's inheritance, includes, and filters to build clean, DRY views you'd ship to production.
What You'll Learn in This Lesson
- Explain why mixing PHP logic and HTML becomes unmaintainable
- Write a plain-PHP template that escapes output with htmlspecialchars
- Capture a template's HTML as a string with output buffering
- Read and write core Twig syntax: {{ }}, {% %}, filters, and {# #}
- Prevent XSS with auto-escaping instead of manual escaping
- Build DRY layouts with extends, block, and include partials
echo, foreach, and $variables feel new, start with Introduction to PHP first.php file.php locally. The Twig snippets need the twig/twig package installed with PHP + Composer; the Output panel shows what each would render.1️⃣ The Problem: Logic Tangled With HTML
A template is the file that produces the HTML a visitor sees — the presentation layer. The trouble starts when you stuff business logic (deciding what to show, calculating values, talking to a database) into that same file. The result below works, but read it: HTML strings, an if decision, a loop, and string concatenation are all knotted together. Change the markup and you risk breaking the logic; change the logic and you risk breaking the markup.
<?php
// THE PROBLEM: business logic and HTML mashed into one file.
// It works, but it is hard to read, hard to test, and easy to break.
$user = ['name' => 'Ada', 'isAdmin' => true];
$items = ['Keyboard', 'Mouse', 'Monitor'];
// HTML, PHP logic, and string concatenation are all jumbled together:
echo "<h1>Welcome, " . $user['name'] . "</h1>";
if ($user['isAdmin']) {
echo "<a href='/admin'>Admin panel</a>"; // logic decision sits inside the view
}
echo "<ul>";
foreach ($items as $item) {
echo "<li>" . $item . "</li>"; // loop + markup + concatenation, all mixed
}
echo "</ul>";
?><h1>Welcome, Ada</h1><a href='/admin'>Admin panel</a><ul><li>Keyboard</li><li>Mouse</li><li>Monitor</li></ul>The fix is separation of concerns: a controller prepares the data, and a template only displays it. That one rule is what every template engine — from plain PHP to Twig — exists to enforce.
2️⃣ Plain-PHP Templates (and Why You Must Escape)
You don't need a library to write a clean template. PHP's alternative syntax — if (...): ... endif; and foreach (...): ... endforeach; — plus the short echo tag <?= ... ?> lets a template read almost like HTML. The non-negotiable rule: escape every value you print. htmlspecialchars() turns dangerous characters like < into the harmless entity <, so a visitor can't smuggle a <script> into your page. Skipping this is the classic XSS (cross-site scripting) hole.
<?php
// THE FIX (step 1): a plain-PHP TEMPLATE — presentation only, no business logic.
// Two rules make this safe and readable:
// 1. ALWAYS escape output with htmlspecialchars() so user data can't inject HTML.
// 2. Use the short "alternative syntax" (if: ... endif; / foreach: ... endforeach;)
// so the template reads like HTML, not like code.
// A small helper keeps escaping short — call it 'e' (for "escape"):
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
// The data the template needs is prepared elsewhere (a "controller") and passed in:
$user = ['name' => 'Ada <dev>', 'isAdmin' => true];
$items = ['Keyboard', 'Mouse', 'Monitor'];
?>
<h1>Welcome, <?= e($user['name']) ?></h1>
<?php if ($user['isAdmin']): ?>
<a href="/admin">Admin panel</a>
<?php endif; ?>
<ul>
<?php foreach ($items as $item): ?>
<li><?= e($item) ?></li>
<?php endforeach; ?>
</ul><h1>Welcome, Ada <dev></h1>
<a href="/admin">Admin panel</a>
<ul>
<li>Keyboard</li>
<li>Mouse</li>
<li>Monitor</li>
</ul>Notice Ada <dev> in the output: the <dev> was escaped, so it shows as text instead of becoming a (broken) HTML tag. That's the whole game — display user data, never execute it.
3️⃣ Capturing Output With a render() Function
So far a template prints straight to the browser. Real apps want the finished HTML as a string first — to wrap it in a layout, cache it, or send it in an email. That's what output buffering does: ob_start() begins capturing everything you echo, and ob_get_clean() stops and hands it back as a string. A six-line render() function built on this is the essence of every template engine.
<?php
// A tiny render() function: run a template file and CAPTURE its output as a string,
// instead of sending it straight to the browser. This is the heart of every
// template engine — "give me the finished HTML so I can do something with it".
function render(string $file, array $data = []): string {
extract($data); // turn $data['name'] into a $name variable for the template
ob_start(); // start capturing everything echoed from here on
include $file; // run the template; its echoes go into the buffer
return ob_get_clean(); // stop capturing and RETURN the captured HTML as a string
}
// --- imagine card.php contained: <p>Hello, <?= htmlspecialchars($name) ?>!</p> ---
// For this self-contained demo we inline the same idea with a heredoc string:
$name = 'Ada';
$html = (function () use ($name) {
ob_start();
echo "<p>Hello, " . htmlspecialchars($name) . "!</p>";
return ob_get_clean(); // $html now holds the string, NOT printed yet
})();
echo "Captured " . strlen($html) . " characters.\n"; // we can measure it
echo $html . "\n"; // ...then print it when readyCaptured 19 characters.
<p>Hello, Ada!</p>4️⃣ Twig: Syntax, Filters & Auto-Escaping
Twig is the most popular PHP template engine (it ships with Symfony). It gives templates a tidy syntax of their own and — crucially — auto-escapes every output, so you get XSS protection for free. Three pieces of syntax cover most of it: {{ value }} prints a value (escaped), {% ... %} runs logic like if and for, and {# ... #} is a comment. Filters transform a value with a pipe — {{ name|upper }} — and chain left to right.
{# product.html.twig — a Twig template. {# ... #} is a comment, never rendered. #}
{# {{ ... }} prints a value. Twig AUTO-ESCAPES it — no htmlspecialchars needed. #}
<h1>{{ product.name }}</h1>
{# Filters transform a value with a pipe | (chain them left to right). #}
<p>Price: {{ product.price|number_format(2) }}</p>
<p>SKU: {{ product.sku|upper }}</p>
{# {% ... %} is logic: if / for / set. Logic decisions live here, not in HTML. #}
{% if product.inStock %}
<span class="ok">In stock</span>
{% else %}
<span class="out">Sold out</span>
{% endif %}
<ul>
{% for tag in product.tags %}
<li>{{ tag|title }}</li>
{% endfor %}
</ul>
{# User input is escaped automatically — this is XSS-safe by default: #}
<p>Review: {{ review }}</p><h1>Wireless Keyboard</h1>
<p>Price: 89.90</p>
<p>SKU: KB-001</p>
<span class="ok">In stock</span>
<ul>
<li>Wireless</li>
<li>Bluetooth</li>
</ul>
<p>Review: <script>alert(1)</script></p>.twig). The Output is what Twig renders given product, review = "<script>alert(1)</script>". Install twig/twig to run it.Look at the last line of output: the malicious <script> in review came out as escaped text, not a live tag — and you wrote no htmlspecialchars() at all. That automatic safety is the single biggest reason to use Twig over hand-rolled PHP. To print trusted HTML on purpose you'd opt out with the |raw filter — sparingly.
5️⃣ Template Inheritance & Partials
Repeating the header and footer in every page is the fastest way to an inconsistent site. Template inheritance fixes it: a base layout defines named {% block %} regions, and each page {% extends %} that base, overriding only the blocks it needs. For smaller reusable chunks — a nav bar, a product card — you {% include %} a partial. Together they keep your views DRY (Don't Repeat Yourself): one layout, many pages.
{# base.html.twig — ONE layout shared by every page. #}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}MyShop{% endblock %}</title>
</head>
<body>
{% include 'partials/nav.twig' %} {# pull in a reusable partial #}
<main>{% block content %}{% endblock %}</main>
{% include 'partials/footer.twig' %}
</body>
</html>
{# products/show.html.twig — a CHILD page. It fills in only what changes. #}
{% extends 'base.html.twig' %} {# inherit the whole layout above #}
{% block title %}{{ product.name }} — MyShop{% endblock %}
{% block content %}
<h1>{{ product.name }}</h1>
<p>{{ product.price|number_format(2) }}</p>
{% endblock %}
{# partials/nav.twig — a small reusable snippet included by the layout. #}
<nav><a href="/">Home</a> · <a href="/cart">Cart</a></nav>.twig). show.html.twig inherits the whole shell from base.html.twig and fills in just the title and content blocks; the nav partial is shared. Render them with the PHP below.Here's the PHP that loads Twig and renders that template — note 'autoescape' => 'html' (XSS protection) and the cache directory, which compiles templates to plain PHP so rendering is fast in production.
<?php
// Rendering Twig from PHP: composer require twig/twig
require 'vendor/autoload.php';
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
$loader = new FilesystemLoader(__DIR__ . '/templates'); // where .twig files live
$twig = new Environment($loader, [
'cache' => __DIR__ . '/var/cache', // compile templates to PHP (fast!)
'autoescape' => 'html', // XSS protection ON by default
]);
// Pass data in; get finished, escaped HTML back:
echo $twig->render('products/show.html.twig', [
'product' => ['name' => 'Wireless Keyboard', 'price' => 89.99],
]);composer require twig/twig and .twig files on disk, so it can't run on a paste-only sandbox. Install it locally to render real templates.{{ $name }} prints (and auto-escapes), @if / @foreach are logic, @extends / @section handle inheritance, @include pulls in partials, and {!! $html !!} prints raw HTML on purpose. Learn the concepts here and you already know Blade.6️⃣ Your Turn: Escape It Yourself
Time to practise. The plain-PHP template below prints data straight from a form, so it must escape. Fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — make this plain-PHP template SAFE and complete.
// The visitor's name and bio come from a form, so they could contain HTML.
// Fill in each blank marked ___ , then run it.
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
$name = 'Sam <b>';
$bio = '<script>alert(1)</script>';
?>
<!-- 1) Print the name, ESCAPED, inside the <h1> -->
<h1>Hello, <?= ___ ?></h1> <!-- 👉 use e($name) so the <b> can't break the page -->
<!-- 2) Print the bio, ESCAPED, inside the <p> -->
<p><?= ___ ?></p> <!-- 👉 use e($bio) so the <script> is shown, not run -->
<?php
// ✅ Expected output:
// <h1>Hello, Sam <b></h1>
// <p><script>alert(1)</script></p>
?><h1>Hello, Sam <b></h1>
<p><script>alert(1)</script></p>___ with e($name) and e($bio). The <b> and <script> should appear as escaped text, not run.Now a Twig one. Complete the template with the right filter and loop keyword, then study how Twig renders it.
{# 🎯 YOUR TURN — finish this Twig template. Fill in each ___ , then study the output. #}
{# 1) Print the heading text, uppercased, with the |upper filter #}
<h1>{{ heading|___ }}</h1> {# 👉 the filter that makes text UPPERCASE #}
{# 2) Loop over every product name #}
<ul>
{% ___ name in names %} {# 👉 the Twig keyword that starts a loop (pairs with endfor) #}
<li>{{ name }}</li>
{% endfor %}
</ul>
{# Given: heading = "today's deals", names = ["Keyboard", "Mouse"] #}
{# ✅ Expected output:
<h1>TODAY'S DEALS</h1>
<ul>
<li>Keyboard</li>
<li>Mouse</li>
</ul>
#}<h1>TODAY'S DEALS</h1>
<ul>
<li>Keyboard</li>
<li>Mouse</li>
</ul>___ with upper and the second with for. Then the heading uppercases and each name lists out.Common Errors (and the fix)
- A user's
<script>actually runs (XSS!) — you printed a value without escaping it. In plain PHP wrap every output inhtmlspecialchars(...); in Twig let auto-escaping do it and never reach for|rawon user input. Unescaped output is the #1 template security bug. - Business logic creeping into the template — database queries or total calculations inside the view make it untestable and fragile. Do the work in a controller and pass the finished data in; the template should only loop, branch, and format.
- Production feels slow / high CPU on every request — you didn't enable Twig's
cache. Set'cache' => __DIR__ . '/var/cache'so templates compile to PHP once instead of being re-parsed on every page load. - "Unexpected token" / template won't parse in Twig — you mixed the tags up.
{{ }}is only for printing a value; control flow likeifandformust use{% %}, and every{% if %}/{% for %}needs its matching{% endif %}/{% endfor %}. - "Notice: Undefined variable" in a plain-PHP template — the template expected data the controller never passed. Decide the variables a template needs up front and always hand them in (Twig fails loudly on this; plain PHP only warns).
Pro Tips
- 💡 Templates are dumb on purpose. If you feel the urge to write real logic in a view, that's a signal to move it back into PHP and pass the result in.
- 💡 Trust auto-escaping; audit every
|raw. Each|raw(or Blade's{!! !!}) is a deliberate hole — only ever for HTML you generated yourself. - 💡 extends for the shell, include for the chunks. One base layout per site; reusable partials (nav, card, footer) included wherever needed.
📋 Quick Reference — Templating
| Syntax | Where | What It Does |
|---|---|---|
| htmlspecialchars($v) | Plain PHP | Escape output (manual XSS protection) |
| ob_start / ob_get_clean | Plain PHP | Capture echoed HTML as a string |
| {{ value }} | Twig | Print a value (auto-escaped) |
| {% if %} {% for %} | Twig | Logic (needs endif / endfor) |
| {{ v|filter }} | Twig | Transform a value (upper, number_format) |
| {# comment #} | Twig | Comment (never rendered) |
| {% extends %} {% block %} | Twig | Inherit a layout, override regions |
| {% include %} | Twig | Insert a reusable partial |
Frequently Asked Questions
Q: Why use a template engine like Twig instead of plain PHP in HTML?
Plain PHP works, but mixing logic and markup gets unreadable fast and it is easy to forget to escape output, which opens you up to XSS attacks. A template engine gives you a short, HTML-friendly syntax, escapes every value by default, and enforces a clean split: controllers prepare data, templates only display it. Twig also compiles templates to cached PHP, so the readability costs you almost nothing at runtime.
Q: What is auto-escaping, and why does it matter?
Auto-escaping means the engine automatically converts dangerous characters (like < and >) into harmless HTML entities (< and >) every time it prints a value. That turns a malicious <script>alert(1)</script> from a user into visible text instead of running code — which is exactly how you prevent cross-site scripting (XSS). In plain PHP you must remember htmlspecialchars() on every single output; Twig does it for you on every {{ }} unless you explicitly opt out with the raw filter.
Q: What is the difference between extends and include?
extends is inheritance: a child template builds on a parent layout and overrides its named blocks, so every page shares one skeleton (header, footer, structure). include is composition: it drops another template's content into the current spot, which is perfect for reusable partials like a nav bar or a product card. Rule of thumb — extends for the page's overall shell, include for repeated chunks inside it.
Q: Should I ever put logic in a template?
Keep heavy logic out. Querying a database, calculating an order total, or deciding which records to show belongs in a controller or service, not the view. Templates should only contain presentation logic: a simple if to show or hide an element, a for loop over data you were already handed, and filters to format values. If a template starts doing real work, move that work back into PHP and pass the finished result in.
Q: What is Blade, and how does it compare to Twig?
Blade is Laravel's built-in template engine. It uses @ directives and {{ }} echoes (for example @if, @foreach, @extends, @section) and, like Twig, auto-escapes {{ }} output to protect against XSS — you use {!! !!} to print raw HTML deliberately. The concepts are identical to Twig (inheritance, sections/blocks, includes/partials, escaping); only the syntax differs. If you work in Laravel you'll use Blade; in Symfony or framework-free projects, Twig is the common choice.
Mini-Challenge: A Product Card
No code is filled in this time — just a brief and an outline. Write the Twig template yourself, render it with the twig/twig library (or translate it to a plain-PHP template and run it on onecompiler.com/php), then check it against the expected output. This is the write-run-check loop you'll use on every real view.
{# 🎯 MINI-CHALLENGE: a product card template (Twig). #}
{# No code is filled in — work from the steps below, then study your output. #}
{#
You're given: product = { name: "USB Cable", price: 12.5, onSale: true }
tags = ["cheap", "popular"]
1. Print the product name inside an <h2> (auto-escaped — that's free in Twig).
2. Print the price with the number_format(2) filter, prefixed with $.
3. If product.onSale is true, print <span class="sale">On sale!</span>
using a {% if %} ... {% endif %} block.
4. Loop over tags with {% for tag in tags %} and print each in an <li>.
✅ Expected output:
<h2>USB Cable</h2>
<p>$12.50</p>
<span class="sale">On sale!</span>
<ul>
<li>cheap</li>
<li>popular</li>
</ul>
#}
{# your template here #}<h2>, format the price with number_format(2), show the sale badge with {% if %}, and list the tags with {% for %}. Match the expected output exactly.🎉 Lesson Complete!
- ✅ Templates handle presentation; controllers handle logic — keep them apart (separation of concerns)
- ✅ In plain PHP, escape every output with
htmlspecialchars()to stop XSS - ✅
ob_start()+ob_get_clean()capture a template's HTML as a string - ✅ Twig:
{{ }}prints (auto-escaped),{% %}is logic,{# #}comments,|filtertransforms - ✅ Auto-escaping gives XSS safety for free;
|rawopts out (use rarely) - ✅
extends+blockshare one layout;includereuses partials — that's DRY - ✅ Blade (Laravel) mirrors all of this with
@directives - ✅ Next lesson: API Monitoring — track usage, log requests, and build audit trails
Sign up for free to track which lessons you've completed and get learning reminders.