Writing a Template System in PHP
Real applications usually use Twig, Blade, or the template layer supplied by their framework. Twig and Blade auto-escape ordinary output by default, which removes a repetitive failure point from everyday template work. Building the small renderer in this tutorial teaches the mechanics of file resolution, variable scope, buffering, layouts, and escaping. It is not a recommendation to replace a maintained template engine.
The problem a template layer solves is real either way: keep PHP logic from melting into the HTML until every page is a pile of echo statements and half-remembered includes.
Picture the template boundary as a service counter. The route prepares approved data behind the counter, the renderer passes it through one controlled opening, and the view arranges the HTML. Database work, authorization, and raw request input stay behind the counter.
The Small Version
Start with a deliberately boring directory shape:
public/
index.php
src/
view.php
views/
layout.php
articles/
show.php The controller or route prepares data, the view renders it, and the template does not query the database, read $_POST, or decide whether the current user is allowed to see the page.
<?php
require __DIR__ . "/../src/view.php";
$article = [
"title" => "Template Systems",
"body" => "A view should be boring enough to debug at 11:30 p.m.",
];
echo view("articles/show.php", [
"article" => $article,
]); That is enough structure to start because the page code owns the data while the view file owns the HTML. The counter has one clear handoff instead of request logic leaking into every template.
Render a View Safely
PHP's include expression evaluates another file, and when that file is included, it inherits the variable scope at the include line. That behavior is why a small renderer can pass data into a view file. Output buffering then lets the renderer capture the view output as a string instead of sending it immediately.
<?php
function view(string $template, array $data = []): string
{
$base = realpath(__DIR__ . "/../views");
if ($base === false) {
throw new RuntimeException("Views directory not found.");
}
$path = realpath($base . "/" . ltrim($template, "/"));
if ($path === false) {
throw new RuntimeException("Template not found.");
}
if (! str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
throw new RuntimeException("Template path is outside the views directory.");
}
extract($data, EXTR_SKIP);
$bufferLevel = ob_get_level();
ob_start();
try {
include $path;
return ob_get_clean();
} catch (Throwable $error) {
while (ob_get_level() > $bufferLevel) {
ob_end_clean();
}
throw $error;
}
} There are three important details in that small function.
First, the template name is resolved under one known views/ directory. Do not let a request parameter choose an arbitrary file to include. That is not a template feature; it is a file-inclusion bug waiting for a quiet afternoon.
Second, output buffering is used so the caller gets a string. The renderer can finish the handoff before the response leaves the service counter, making it possible to apply a layout, test a fragment, or handle an error without sending half a document.
Third, extract() is used only on data controlled by the application. PHP's own manual warns against using extract() on untrusted data. If the array came from $_GET, $_POST, or decoded JSON, validate it first and pass only the view variables the template needs.
With this implementation, data keys named template, data, base, or path are dropped because those variables already exist in the renderer's scope.
Escape by Default
The renderer is only half the system; the other half is escaping output every time a value crosses into HTML.
<?php
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
} With that helper in place, the template can stay plain:
<article>
<h1><?= e($article["title"]) ?></h1>
<p><?= e($article["body"]) ?></p>
</article> htmlspecialchars() is the ordinary escape function for text going into HTML. It turns characters such as <, >, &, and quotes into HTML entities.
That does not make every context safe. A URL, a JavaScript string, a CSS value, and HTML body text are different contexts. For a small PHP template system, the right first rule is simple: escape all normal text, and do not put untrusted values into JavaScript or CSS templates.
If you need raw HTML, make it explicit:
<div class="article-body">
<?= $trustedHtml ?>
</div> That variable name should make you uncomfortable enough to ask where the HTML was sanitized, and that discomfort is the point.
Add a Layout
Most pages need the same outer document: doctype, head, navigation, and footer. Render the page content first, then pass it into the layout.
<?php
function page(string $template, array $data = []): string
{
$content = view($template, $data);
return view("layout.php", [
"title" => $data["title"] ?? "CodeWalkers",
"content" => $content,
]);
} The layout can then own the shell:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= e($title) ?></title>
</head>
<body>
<main>
<?= $content ?>
</main>
</body>
</html> Notice the difference between $title and $content. The title is text, so it is escaped. The content is already rendered HTML from a trusted template, so it is not escaped again.
View Logic Limits
Templates are allowed to contain small display logic: a loop over comments, a condition for an empty state, or date formatting through one clear project helper.
This is the kind of display logic that can stay in the view:
<?php if ($comments === []): ?>
<p>No comments yet.</p>
<?php else: ?>
<ol>
<?php foreach ($comments as $comment): ?>
<li><?= e($comment["body"]) ?></li>
<?php endforeach; ?>
</ol>
<?php endif; ?> This is where the view boundary has started to fail:
<?php
$db = new PDO($dsn, $user, $password);
$comments = $db->query("SELECT * FROM comments")->fetchAll();
?> The template is not the place to open a database connection. Put that work in the route, repository, or service layer, then pass the result into the view. Read PHP and MySQL with PDO for the database boundary and Beginning Object-Oriented Programming in PHP for the object boundary.
When It's Enough
A tiny renderer is usually enough under a narrow set of conditions:
- The template authors are trusted developers.
- The app has a small number of pages.
- You do not need inheritance, macros, filters, translation tooling, or sandboxing.
- You can enforce escaping habits in code review.
It stops being enough when the template language becomes a project inside the project. The usual sequence starts with replacing {title}, then adds loops, conditions, includes, caching, filters, and custom debugging. At that point, use a maintained template engine or the framework's view layer.
Common Pitfalls & Debugging
The Template Path Escapes the Views Directory
Symptom: a crafted template name includes a file outside views/. Cause: the renderer joins strings without resolving and checking the final path. Fix: resolve the base and candidate with realpath(), then require the candidate to start with the base plus DIRECTORY_SEPARATOR.
An Exception Leaves an Output Buffer Open
Symptom: later output disappears or lands in the wrong buffer after a template throws. Cause: the renderer starts buffering but cleans it only after a successful include. Fix: record ob_get_level() before the include, unwind every buffer above that level on failure, and rethrow the exception.
Trusted HTML Bypasses Escaping
Symptom: stored text renders active markup or script. Cause: a value was labelled trusted without a defined sanitizer or ownership boundary. Fix: escape ordinary text with e() and allow raw HTML only from a reviewed, sanitized source.
Frequently Asked Questions
Should you build your own PHP template engine?
Build this small renderer to learn file resolution, scope, buffering, layouts, and escaping. For untrusted template authors, no general-purpose PHP template layer is safe by default. Twig's sandbox needs an explicit allowlist and still does not bound CPU or memory, so treat untrusted templates as a separate hard problem.
Can a view read $_SESSION or $_POST directly?
Technically yes, because superglobals are visible everywhere, and doing so defeats the point of this renderer. A view reaching into $_POST is no longer receiving only the data the controller approved.
Does this renderer cache the compiled template?
No. It re-reads and re-includes the file on every call. A maintained engine such as Twig compiles a template to PHP once and caches that output, which is a real argument against hand-rolling this part of the stack.
Can a view call the renderer again for a partial?
Yes. Because the render function returns a string, one view can render a header or a list item and drop the result into its own output, which is the same mechanism the layout uses to nest a view inside itself.
Should a view template be allowed to query the database directly?
A view should only ever receive prepared data. Letting a template query the database hides data access inside rendering, makes queries hard to test, and risks running a query for every loop iteration in a list. Fetch everything the view needs beforehand and pass it in as plain variables.
The Modern Decision
For a Laravel app, use Blade; for a Symfony app, use Twig; and for a small framework or custom app, a plain PHP renderer can be perfectly reasonable if the team understands the escape boundary.
What you should not do is rebuild a full template language casually. Template engines look small from the outside because the hard parts are hidden: escaping, context rules, inheritance, caching, file loading, error messages, and developer ergonomics.
The lesson underneath all of it: separate the data work from the display work. The sharper version is to make escaping boring, keep file paths locked down, and stop before your "simple" template system becomes the least-tested framework in the app.
For adjacent PHP boundaries, read PHP security fundamentals, Build your own API with PHP, and Building a small PHP database app.
This example has been updated from the original CodeWalkers template-system tutorial, which was written before auto-escaping engines were the norm. Twig and Blade escape output by default now, so the hand-rolled e() helper here is a discipline to learn from rather than a system to ship.
Sources
-
[1]
PHP include(php.net)
-
[2]
PHP ob_start(php.net)
-
[3]
PHP htmlspecialchars(php.net)
-
[4]
PHP extract(php.net)
-
[5]
Laravel Blade Templates(laravel.com)
-
[6]
Twig Documentation(twig.symfony.com)
Read Next
Compare Laravel, Symfony, CakePHP, Slim, and Fat-Free Framework for modern PHP projects.
Learn the PHP object-oriented programming shape that still matters in 2026: classes, methods, constructors, repositories, and the limits of inheritance.
Secure ordinary PHP surfaces with current password hashing, sessions, CSRF tokens, output escaping, prepared statements, and upload controls.