Building Your Own Templating Engine in PHP
AI generated
8.4
PHP · Templating · Engine
Building Your Own Templating Engine in PHP
From the language itself to controlled output

PHP was originally invented as a templating language, long before it became the general purpose programming language it is today. That very origin makes plain PHP templating dangerously uncontrolled, since neither sandboxing nor automatic escaping are built in. We build a minimal engine with variable interpolation and auto-escaping, and show what mechanism actually powers Twig under the surface.

15 min read Auto-Escaping Template Compiler Sandboxing Twig Comparison

1. Why PHP itself is already a templating language

PHP started in 1995 as a collection of scripts for processing form data, the abbreviation PHP originally stood for Personal Home Page Tools. The opening tags were designed from the very start to switch back and forth between plain HTML text and executable code, which is exactly the core principle of any templating language.

A simple PHP template like

Hello

is therefore technically already a complete template engine, without any additional library. That closeness to the actual programming language is both its strength and its weakness: it allows maximum flexibility because any PHP function is directly available inside the template, but it also skips every protective layer a dedicated template engine typically brings along.

2. Limits of plain PHP templating

The biggest problem with plain PHP templates is the lack of automatic escaping. Writing instead of lets any HTML or script code contained in $comment end up unfiltered in the output, a classic cross site scripting entry point. Since PHP does not enforce this escaping, an application's security depends entirely on every single developer manually remembering it at every single output point.

The second problem is missing sandboxing. A PHP template can call any function without restriction, include files, or even run exec(), because it is ultimately ordinary PHP code. For templates fully controlled by developers, that is unproblematic, but as soon as parts of a template come from user input or from editors without technical trust, that unrestricted power becomes a serious security risk.

3. Architecture of a minimal own engine

A minimal templating engine needs two core building blocks: a parser that scans a template file for special placeholders, and a compiler that turns them into valid, safe PHP code. Instead of a full tokenizer, a simple variable system can get by with a regular expression that recognizes placeholders like {{ variable }} and replaces them with corresponding PHP code.

The critical architectural decision is that the engine never executes raw PHP straight out of the template, it instead controls itself which PHP code gets generated for which placeholder. That keeps its power deliberately limited: only explicitly supported constructs like variable output, simple conditions, and loops get translated, arbitrary PHP code from the template is excluded from the start.

4. Implementing variable interpolation

The first step is finding {{ variable }} placeholders in the template text and replacing them with real PHP code. A regular expression extracts the variable name between the double curly braces, then the compiler inserts a statement with the matching variable access at that spot.

It is important to write the generated PHP code into a separate, compiled file instead of executing the template itself as PHP. That later allows caching the compilation step and limiting the generated code to pure output statements, so an editor working on the template could never enter real, potentially dangerous PHP code directly.


<?php

declare(strict_types=1);

namespace App\Templating;

final class TemplateCompiler
{
    public function compileVariables(string $template): string
    {
        // {{ name }} becomes <?= $engine->escape($name) ?>
        return preg_replace_callback(
            '/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/',
            static function (array $matches): string {
                $variable = $matches[1];
                return "<?= \$engine->escape(\$context['{$variable}'] ?? '') ?>";
            },
            $template,
        );
    }
}

5. Implementing auto-escaping

The core of security lies in the escape() method that every variable output automatically passes through before landing in the HTML response. By default it uses htmlspecialchars() with the ENT_QUOTES flag, which encodes both double and single quotes, and explicitly UTF-8 as the character set to prevent encoding based escaping bypasses.

For a template to still deliberately output unfiltered HTML, for example editorially approved rich text, it needs an explicit exception like {{{ variable }}} or a named raw() function. What matters is that this exception always stays visibly marked in the template code, so a review immediately spots exactly where automatic escaping is intentionally bypassed.


<?php

declare(strict_types=1);

namespace App\Templating;

final class TemplateEngine
{
    public function escape(mixed $value): string
    {
        if ($value === null) {
            return '';
        }

        return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
    }

    /**
     * Deliberate exception for already vetted HTML content,
     * MUST be explicitly marked in the template
     */
    public function raw(mixed $value): string
    {
        return (string) $value;
    }
}

6. Adding minimal control structures

Beyond plain variable output, a usable engine needs at least simple conditions and loops. A placeholder like {% if condition %} can be recognized analogously to variable interpolation via another regular expression and translated into , with a matching {% endif %} translated into .

The pragmatic compromise here is not parsing the expression inside {% if ... %} itself, but passing it directly through as a PHP condition, as long as it consists exclusively of variables already present in the context. Anyone who needs genuine sandboxing guarantees, for example because editors without technical trust edit templates, would need to build a custom, restricted expression parser here instead of passing PHP syntax straight through.

7. Compiling to PHP code and caching

Since every compilation of a template consists of regular expressions and string replacements, it pays off not to recompute the result on every request. The engine therefore first checks whether a compiled version already exists on the filesystem and whether its modification time is newer than the source template's, before even invoking the compiler.

This pattern matches exactly what Twig does under the hood: templates get compiled into regular, highly optimized PHP code on first invocation and stored in a cache directory, every subsequent call loads the already compiled PHP file directly without repeating the compilation step, reducing the performance difference against direct PHP templating to practically zero.


<?php

declare(strict_types=1);

namespace App\Templating;

final class TemplateLoader
{
    public function __construct(
        private readonly string $templateDir,
        private readonly string $cacheDir,
        private readonly TemplateCompiler $compiler,
    ) {
    }

    public function load(string $name): string
    {
        $sourcePath = $this->templateDir . '/' . $name;
        $cachePath = $this->cacheDir . '/' . md5($name) . '.php';

        $needsCompile = !is_file($cachePath)
            || filemtime($cachePath) < filemtime($sourcePath);

        if ($needsCompile) {
            $compiled = $this->compiler->compileVariables(
                file_get_contents($sourcePath),
            );
            file_put_contents($cachePath, $compiled);
        }

        return $cachePath;
    }
}

8. Security considerations: avoiding template injection

Template injection happens when user input gets interpreted not just as a variable value but as part of the template structure itself, for example when a username gets inserted unchecked into a template string before it is compiled. The engine from this article is structurally protected against that, as long as user input lands exclusively as values in the context array and is never itself treated as template source code.

The cachePath in the TemplateLoader class stays a critical spot: if the file name were built directly from user input instead of hashed via md5(), a path traversal risk would arise. It is equally important to place the cache directory outside the publicly reachable web root, so compiled PHP files never accidentally become directly executable via a URL.

9. Where this differs from Twig: understanding, not reinventing

Reimplementing Twig would be neither sensible nor realistic, this article's goal is solely to understand the underlying mechanism. Twig offers considerably more than the mini engine shown here: genuine sandboxing via a SecurityPolicy with configurable allowed tags, filters, and functions, template inheritance via extends and block, context aware escaping that automatically distinguishes between HTML, JavaScript, and CSS context, plus a mature, high performance compiler.

For applications with editors who lack technical trust, multilingual templates, or complex inheritance logic, Twig is almost always the right choice. Building it yourself pays off instead for very small, fully developer controlled output, such as CLI reports or email templates without external input, and above all for understanding why Twig makes the decisions it makes.

Trait Plain PHP templating Custom mini engine Twig
Auto-escaping Not present, manual effort needed Yes, enforced via escape() Yes, context aware
Sandboxing No protection, full PHP access Limited to defined constructs Complete via SecurityPolicy
Template inheritance Not built in Not built in extends and block built in
Performance Direct, no compilation step Compiled and cached Compiled and cached
Fit Developer controlled, small output Learning projects, very lean apps Production applications with editors

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

A Custom Templating Engine: The Essentials at a Glance

PHP as a language

The

Missing escaping

Plain PHP templating does not enforce escaping, every unprotected output is a potential XSS risk.

Controlled compiler

A custom engine translates only explicitly supported placeholders, arbitrary PHP code from the template is excluded.

Twig as a reference

Twig adds sandboxing, inheritance, and context aware escaping, features production editorial systems usually need.

11. FAQ: A Custom Templating Engine: The Essentials at a Glance

1Is plain PHP as a templating language fundamentally wrong?
No, for fully developer controlled output without external input it is entirely sufficient. It only becomes problematic once escaping or sandboxing are actually needed.
2Why is missing auto-escaping a security risk?
Because any unprotected output of a variable containing user input can inject HTML or script code unfiltered into the page, a classic cross site scripting scenario.
3What does sandboxing concretely mean for a templating engine?
That a template can only use explicitly allowed constructs such as variable output, filters, and control structures, but cannot call arbitrary host language functions like exec().
4How does the custom engine detect variable placeholders?
Through a regular expression that finds patterns like {{ name }} in the template text and replaces them with real PHP code for controlled, escaped output.
5Why is the compiled template cached instead of regenerated on every request?
Because compilation via regular expressions and string replacements would create unnecessary overhead on every request, a cache comparison via modification timestamps makes that unnecessary.
6How can a template still deliberately output raw HTML despite auto-escaping?
Through an explicit exception like a raw() function or a separate triple brace syntax, which must stay visibly marked in the template code.
7What fundamentally sets Twig apart from the mini engine shown here?
Twig offers genuine sandboxing via a SecurityPolicy, template inheritance via extends and block, and context aware escaping for HTML, JavaScript, and CSS.
8Is building your own templating engine worth it for a real project?
Only for very small, fully developer controlled use cases like CLI reports. For production web applications with editors, a mature engine like Twig is almost always the better choice.
9How does the engine prevent template injection?
By ensuring user input lands exclusively as values in the context array and is never itself interpreted or compiled as template source code.
10Where should the cache directory for compiled templates live?
Outside the publicly reachable web root, so compiled PHP files can never accidentally be called and executed directly via a URL.