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.
Either way, a template layer keeps 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.
How Do You Organise PHP Template Files?
Keep PHP templates in a views directory, the renderer in src, and the public entry point in public:
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 controller supplies the data the template needs.
How Do You Render a PHP Template as a String?
Render a PHP template as a string by opening an output buffer, including the trusted template, then collecting the output with ob_get_clean(). The included file 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 || !is_file($path)) {
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;
while (ob_get_level() > $bufferLevel + 1) {
if (!ob_end_flush()) {
throw new RuntimeException("Template left a non-removable buffer open.");
}
}
if (ob_get_level() !== $bufferLevel + 1) {
throw new RuntimeException("Template closed the renderer buffer.");
}
return ob_get_clean();
} catch (Throwable $error) {
while (ob_get_level() > $bufferLevel) {
if (!ob_end_clean()) {
break;
}
}
throw $error;
}
} Save view() and the e() helper below in src/view.php. Templates must be trusted PHP files, must not close the renderer’s buffer, and may open only removable buffers. This is not a sandbox. For the buffer functions, see the output-buffer functions explained in this example.
First, the template name is resolved under one known views/ directory. Do not let a request parameter choose an arbitrary file to include. That turns the renderer into a file-inclusion bug.
Second, output buffering is used so the caller gets a string. Returning a string lets you wrap the output in a layout or handle a rendering error before sending HTML to the browser.
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.
How Do You Escape Text in PHP Templates?
Escape ordinary text in PHP templates with htmlspecialchars() before placing it in HTML text or quoted attributes.
<?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.
How Do You Reuse a PHP Page 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.
What Logic Belongs in a PHP Template?
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 Is a Small PHP Renderer Enough?
A small PHP renderer is enough when trusted developers maintain a few pages and need only simple layouts and partials:
- 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
A crafted template name includes a file outside views/. The renderer joins strings without resolving and checking the final path. 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
Later output disappears or lands in the wrong buffer after a template throws. The renderer starts buffering but cleans it only after a successful include. Record ob_get_level() before the include, unwind every buffer above that level on failure, and rethrow the exception.
Trusted HTML Bypasses Escaping
Stored text renders active markup or script. A value was labelled trusted without a defined sanitizer or ownership boundary. 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 a small PHP renderer to learn file resolution, scope, buffering, layouts and escaping; use a maintained engine when the application needs a full template language. 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 PHP view read $_SESSION or $_POST directly?
A PHP view can read $_SESSION or $_POST because superglobals are available inside included files, but passing validated data from the controller makes its dependencies clearer. A view reaching into $_POST is no longer receiving only the data the controller approved.
Does a plain PHP renderer cache compiled templates?
A plain PHP renderer includes and executes the view on each call, but OPcache can cache the compiled PHP bytecode. The renderer does not cache the finished HTML. Template engines such as Twig can also cache their generated PHP files.
Can a PHP view render another template as a partial?
A PHP view can call the view() renderer to include a partial. Because the 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 PHP view query the database directly?
A PHP view should receive prepared data rather than query the database itself. 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.
Choose a Renderer You Can Maintain
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 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.
Separate the data work from the display work, 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.
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.