Documentation

Everything PurePlate does, in the order you meet it. One rule runs through all of it: what you read is what runs. A template is PHP with a lighter syntax, so PHP's own rules apply.

1. Install

$ composer require michel/pure-plate
RequirementVersion
PHP7.4 or higher
Extensiontokenizer (enabled in standard PHP builds)
DependenciesNone

Enable OPcache in production: compiled templates are plain PHP files, and OPcache keeps them in memory.

2. Quick start

Create templates/page.tpl:

<h1>Hello, {{ user.name|strtoupper }}!</h1>

{% if items is not empty %}
<ul>
{% foreach items as item %}
  <li>{{ item.title }} — {{ item.price|number_format(2) }} €</li>
{% endforeach %}
</ul>
{% endif %}

Then render it:

<?php
use Michel\PurePlate\Engine;

$plate = new Engine(__DIR__ . '/templates');

echo $plate->render('page.tpl', [
    'user'  => (object) ['name' => 'fady'],
    'items' => [
        (object) ['title' => 'Item A', 'price' => 19.90],
        (object) ['title' => 'Item B', 'price' => 42.00],
    ],
]);

The engine takes four arguments:

new Engine(
    string  $templateDir,
    bool    $devMode = false,   // true: recompile a template when it changes
    ?string $cacheDir = null,   // default: <system temp dir>/pp_cache
    array   $globals = []       // variables available in every template
);

Template names are relative to $templateDir, sub-folders included: $plate->render('emails/welcome.tpl'). The extension is free (.tpl, .html, .txt…), except .php, which is rendered as plain PHP.

3. Writing templates

A template is text with three kinds of tags:

TagRole
{{ expression }}Prints the value, escaped.
{% statement %}Controls: if, foreach, set, extends, block, include…
{# comment #}Removed from the output.

Inside a tag, you write a PHP expression with three shortcuts:

You writePHP runsRule
title$titleA bare word is a variable.
user.name$user->nameThe dot is the object operator.
price|round(2)round($price, 2)The pipe passes the value to a function.

Everything else is written as in PHP: strings, numbers, operators, [], ? :, ??, function, method and static calls. A word followed by ( is a function, so count(items) is count($items). A word followed by :: is a class, so Order::PAID stays Order::PAID. true, false and null keep their meaning.

{{ }} and {% %} end on the line where they start. Otherwise PurePlate stops with Unclosed '{{' at that line, instead of printing the tag as text. Comments {# … #} can span lines: their content is never run, and line numbers stay right. To print a literal {{, write {{ '{{' }}.
A tag ends at the first }} or %}, even inside a string, and a comment is removed even inside a string. {{ "}}" }} and {{ '{# x #}' }} do not work: build such strings in PHP and pass them as variables.

PHP's precedence applies: nested ternaries need parentheses, a ? b : (c ? d : e), or PHP 8 refuses them. The syntax stays the template one: PHP 8 named arguments (f(name: value)) are not supported. | is the filter, not the bitwise OR, and a bare word is a variable, not a global constant: combine flags such as JSON_HEX_TAG | JSON_HEX_AMP in a PHP function (see Escaping in other contexts).

PHP removes the line break right after a statement that ends a line, so {% if %} and {% endif %} on their own lines do not leave empty lines. The line break after a {{ … }} that ends a line is kept, so text templates (emails, CSV) keep their layout.

4. Printing values

{{ … }} prints any PHP expression, always through htmlspecialchars(…, ENT_QUOTES). The value is cast to a string first: null and false print nothing, true prints 1, an array cannot be printed.

Variables, properties, methods

{{ title }}                              {# $title #}
{{ user.name }}                          {# $user->name #}
{{ user.profile.address.city }}          {# $user->profile->address->city #}
{{ user.getName() }}                     {# $user->getName() #}
{{ user.hasRole('admin') }}              {# $user->hasRole('admin') #}
{{ user.getProfile().city }}             {# $user->getProfile()->city #}

Arrays

{{ config['env'] }}                      {# $config['env'] #}
{{ config['db']['host'] }}               {# $config['db']['host'] #}
{{ tags[0] }}                            {# $tags[0] #}
{{ items[count - 1] }}                   {# $items[$count - 1] #}
{{ tags[0].name }}                       {# $tags[0]->name #}
{{ config['db'].host }}                  {# $config['db']->host #}

Literals and operators

{{ 'Hello' }}   {{ 42 }}   {{ 3.14 }}
{{ "Hi $name" }}                         {# PHP string interpolation #}
{{ price * quantity }}
{{ (price + shipping) * 1.2 }}
{{ count % 2 }}
{{ -balance }}

Conditions and fallbacks

{{ isAdmin ? 'admin' : 'guest' }}
{{ not isAdmin ? 'user' : 'admin' }}
{{ items is empty ? 'No items' : 'Items' }}
{{ nickname ?: 'anonymous' }}            {# when nickname is empty #}
{{ nickname ?? 'anonymous' }}            {# when nickname is not set or null #}
{{ user.middleName ?? '' }}              {# a missing property, no error #}
{{ config['debug'] ?? false }}           {# a missing key, no error #}

Functions and filters

{{ count(items) }}                       {# a PHP function #}
{{ items|count }}                         {# the same, as a filter #}
{{ name|trim|strtoupper }}                {# strtoupper(trim($name)) #}
{{ user.getName()|ucfirst }}
{{ items|count > 1 ? 'many' : 'one' }}
{{ sprintf('%s (%d)', name, count) }}
{{ max(1, count, 3) }}
{{ json_encode(config) }}
{{ date('Y') }}

See Filters and functions for the rules.

Classes and constants

{{ Order::STATUS_PAID }}                  {# a class constant #}
{{ Money::format(price) }}                {# a static method #}
{{ \App\Model\Role::ADMIN }}             {# a namespaced class #}
{{ order.status == Order::STATUS_PAID ? 'Paid' : 'Pending' }}
{{ constant('PHP_VERSION') }}            {# a global constant #}

A word followed by :: is a class name. A global constant such as PHP_VERSION is a bare word, so it would be read as a variable: use constant('PHP_VERSION'), or pass the value to the template.

Objects and arrays

The dot is for objects, brackets are for arrays, exactly as -> and [] in PHP. Chain them freely: config['db'].host, user.tags[1].name.

user.profile reads the property, it never calls getProfile(). To use a getter, write it: user.getProfile(). A private property works only if the class defines __get(). On an array, user.name fails like $user->name would: write user['name'].

Since the dot reads objects, PHP's . concatenation is not available. Print values side by side, or use a function: {{ sprintf('%s %s', first, last) }}.

Escaped and raw output

Two ways to print, the same as in PHP:

TemplatePHPEscaped
{{ article.html }}echo htmlspecialchars(…)Yes
{% echo article.html %}echo $article->htmlNo

{{ … }} is the safe default. {% echo … %} is PHP's own echo: use it only for HTML you trust, such as content you sanitized yourself. Filters work with both:

{% echo article.html|strip_tags('<p><a>') %}
{% echo %} prints exactly what it receives. Never use it on text that comes from a user without cleaning it first: that is how XSS happens.

More generally, a statement tag that is not one of PurePlate's keywords runs as a PHP statement: {% user.count++ %}.

Escaping in other contexts

{{ … }} escapes for HTML text and quoted attributes. A value placed in JavaScript or in a URL needs another escaping, as in plain PHP, Twig or Blade: the template engine cannot guess it. Declare two small PHP functions once, in your own helpers:

<?php
// Any value, as a JavaScript literal that cannot break out of a <script> or an attribute.
function js($value): string
{
    return json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}

// A link typed by a user: only http(s), never javascript:
function safe_url(string $url): string
{
    return preg_match('~^https?://~i', $url) ? $url : '#';
}

Then use the right form for each place:

Where the value goesWrite
HTML text, quoted attribute<p title="{{ title }}">{{ name }}</p>
Inside <script><script>var user = {% echo js(user) %};</script>
JavaScript in an attribute<button onclick="select({{ js(name) }})">
Part of a URL<a href="/search?q={{ rawurlencode(q) }}">
A whole URL from a user<a href="{{ safe_url(user.website) }}">
In <script>, {% echo %}; in an attribute, {{ }}. Inside a script, {{ }} would turn the JSON quotes into &quot; and break the code. Inside an attribute, {% echo %} would let the first " of the JSON close the attribute. With these two forms, a value like ');alert(1);// or </script> stays plain data.

5. Filters and functions

Every PHP function, and every function you declare, is a filter. The value becomes the first argument:

You writePHP runs
{{ name|strtoupper }}strtoupper($name)
{{ price|number_format(2, ',', ' ') }}number_format($price, 2, ',', ' ')
{{ text|substr(0, 100) }}substr($text, 0, 100)
{{ items|count }}count($items)

When the value is not the first argument, call the function directly:

{{ date('Y-m-d', timestamp) }}
{{ implode(', ', tags) }}

A filter applies to the value just before it, wherever it stands: in a ternary, in function arguments, in an array, after =. Any operator ends the value; parentheses widen it.

You writePHP runs
{{ isAdmin ? name|strtoupper : 'guest' }}$isAdmin ? strtoupper($name) : 'guest'
{{ sprintf('%s!', name|ucfirst) }}sprintf('%s!', ucfirst($name))
{% set title = name|strtoupper %}$title = strtoupper($name)
{{ items|count > 1 }}count($items) > 1
{{ user.getName()|ucfirst }}ucfirst($user->getName())
{{ price * quantity|round }}$price * round($quantity)
{{ (price * quantity)|round }}round(($price * $quantity))

is empty and is not empty follow the same rule: {% if user.active and cart is empty %} is $user->active and empty($cart).

6. Conditions and loops

{% if user.isAdmin %} … {% elseif user.isEditor %} … {% else %} … {% endif %}

{% foreach items as item %} … {% endforeach %}
{% foreach items as key => item %} … {% endforeach %}

{% for i = 0; i < 10; i++ %} … {% endfor %}

{% while queue %} … {% endwhile %}

switch

{% switch user.role %}
    {% case 'admin' %}
        <span>Administrator</span>
    {% break %}
    {% case 'editor' %}
    {% case 'author' %}
        <span>Writer</span>
    {% break %}
    {% default %}
        <span>Guest</span>
{% endswitch %}

It is PHP's switch, with PHP's rules: values are compared with ==, and a case without {% break %} runs into the next one. That is how 'editor' and 'author' share the same output above. A case can be any expression: {% case Order::STATUS_PAID %}.

Indent the cases as you like: PurePlate opens the switch at its first case, so the spaces in between are not a problem.

Do not forget {% break %}. Without it, the next case is printed too, exactly as in PHP.

Conditions are PHP expressions, with readable words:

You writePHP runs
items is emptyempty($items)
items is not empty!empty($items)
not user.active!$user->active
(age >= 18 and hasPermit) or role == 'admin'($age >= 18 and $hasPermit) or $role == 'admin'

Each end… must close the structure opened last. A mistake stops the compilation with a clear message, see Errors.

7. Variables and globals

{% set total = price * quantity %}
{{ total }}
{% set user.name = 'Fady' %}
and and or are PHP's. They bind weaker than =: {% set ok = a and b %} sets ok to a. In an assignment, write && and ||.

Globals

Globals are available in every template, without passing them to each render:

$plate = new Engine(__DIR__ . '/templates', false, null, [
    'siteTitle' => 'My Site',
    'version'   => '1.0',
]);

Global names must be strings. A render that passes a variable with the same name as a global and a different value stops with Context key "siteTitle" conflicts with a global variable, so a template never shows the wrong one silently. The name _plate is reserved: it holds the engine.

Names follow PHP: a bare word that PHP reads as a keyword (list, default, class, new…) is not a variable. Through an object it works: page.list.

8. Layouts and blocks

{# layout.tpl #}
<html>
<head><title>{% block title %}My site{% endblock %}</title></head>
<body>
  {% block content %}{% endblock %}
</body>
</html>
{# page.tpl #}
{% extends "layout.tpl" %}

{% block title %}Home{% endblock %}

{% block content %}
  <h1>Welcome, {{ user.name }}</h1>
{% endblock %}

The page is rendered first, then its layout. Rules:

  1. The layout receives the same variables as the page.
  2. The deepest template wins: a block defined by the page replaces the layout's.
  3. A block the page does not define keeps the layout's default content.
  4. A layout can extend another layout, on any number of levels, and blocks can be nested.
  5. In a template that extends, text outside the blocks is ignored.
  6. In a template that does not extend, a block is printed where it stands.

Block content is trimmed. The block name can be written with or without quotes: {% block content %} or {% block "content" %}.

No parent(). A block replaces the layout's block entirely; it cannot append to it. Split the layout into smaller blocks instead.

A layout inside a layout

A section layout fills a block of the base layout, and opens a new block inside it for its pages. This is how an admin area, a blog or an account section shares one HTML shell with the rest of the site.

{# base.tpl: the HTML shell #}
<html>
<head><title>{% block title %}My site{% endblock %}</title></head>
<body>
{% block body %}{% endblock %}
</body>
</html>
{# admin.tpl: the admin section, inside the shell #}
{% extends "base.tpl" %}

{% block body %}
<nav>Admin menu</nav>
<main>
    {% block content %}{% endblock %}
</main>
{% endblock %}
{# users.tpl: one admin page #}
{% extends "admin.tpl" %}

{% block title %}Users{% endblock %}

{% block content %}
<h1>Users</h1>
{% endblock %}

Rendering users.tpl gives the shell, the admin menu, and the page inside <main>:

<html>
<head><title>Users</title></head>
<body>
<nav>Admin menu</nav>
<main>
    <h1>Users</h1></main></body>
</html>

Each page of the section chooses which block it fills:

A page that extends admin.tpl…Gets
defines contentThe admin menu, with its content in <main>.
defines titleIts own title. Without it, My site from the base.
defines bodyIts own body only: the admin menu and content are replaced too. Useful for a login page without the menu.
defines nothingThe admin menu with an empty <main>.

It works because the page is rendered first: when admin.tpl reaches {% block content %}, the page's content already exists and is printed inside body. Then base.tpl receives that whole body.

9. Includes

{% include "partials/header.tpl" %}

The path is relative to the template folder, not to the current template. The included template sees every current variable, including those created with {% set %} or by a foreach. Variables it changes do not come back: an include cannot break its caller. An include can itself extend a layout and use blocks; they stay separate from the caller's.

The name can be any expression: {% include sprintf('cards/%s.tpl', type) %}.

10. Plain PHP templates

A file ending in .php is not compiled: it is rendered as plain PHP, with the same layout helpers on $this.

<?php $this->extend('layout.php') ?>

<?php $this->startBlock('content') ?>
  <h1><?= htmlspecialchars($title) ?></h1>
<?php $this->endBlock() ?>
<!-- layout.php -->
<main><?= $this->block('content') ?></main>
MethodBehavior
$this->extend('layout.php')Renders this layout after the current file, with the same variables.
$this->startBlock('name')Starts capturing a block.
$this->endBlock()Ends the block. The first definition wins, then it is printed.
$this->block('name')Returns the content of a block, or an empty string.

In plain PHP templates, escaping is yours, and a .php file can only extend another .php file.

11. Errors

Line N of a compiled file is line N of its template, always. When something fails, PurePlate throws an ErrorException whose getFile() and getLine() are the template file and line, even inside a layout or an include:

PurePlate Error: Undefined variable: username [At: templates/page.tpl:14]
WhenMessage
An end… without an opening tagUnexpected 'endif' (missing opening tag)
An end… that closes the wrong structureUnexpected 'endif', expected 'endforeach' (opened at line 9)
A structure never closedUnclosed 'if', at the line that opened it
A case or default outside a switchUnexpected 'case' outside of a 'switch'
A switch with no case'switch' without 'case' (opened at line 4)
A block never closed, or crossing an ifUnclosed 'block' / Unexpected 'endblock', expected 'endif' (opened at line 3)
break or continue outside a loop'break' outside of a loop or switch, 'break 2' needs 2 nested loops or switch
A tag not closed on its lineUnclosed '{{' (a tag must end on the line where it starts)
An expression that is not valid PHPPHP's syntax error, for example syntax error, unexpected ')'
A PHP warning or notice while renderingPHP's message, for example Undefined variable: username
An exception thrown by your codeIts message
An included template that does not existTemplate not found: …, at the line of the include

Syntax mistakes are found when the template is compiled, before anything is printed. Warnings and notices follow your error_reporting setting: those it excludes are ignored.

Your exceptions are wrapped. An exception thrown by your code during a render reaches you as an ErrorException pointing at the template. The original is in getPrevious().

Two errors are thrown before any template is involved: RuntimeException: Template not found: … when the template given to render() does not exist, and RuntimeException: Unable to write cache file: … when the cache folder is not writable.

12. Cache: dev and production

Each template is compiled once to a PHP file in the cache folder, named after the template and a hash of its full path. The file is written to a temporary name, then renamed, so a request never reads a half-written file.

ModeBehavior
devMode = trueRecompiles a template when it is newer than its compiled file. Edit, refresh.
devMode = false (default)Compiles a template the first time it is used, then trusts the compiled file. No check on the template at each render.
$plate = new Engine(__DIR__ . '/templates', true);                                // dev
$plate = new Engine(__DIR__ . '/templates', false, __DIR__ . '/var/cache/plate'); // production
In production, clear the cache folder when you deploy new templates. Without devMode, a changed template is never recompiled on its own.

Set your own cache folder in production. The default, <system temp dir>/pp_cache, is shared with other programs and may be cleaned by the system. PurePlate creates the folder if it does not exist.

13. Performance

The benchmark/ folder renders the same page with PurePlate and Twig: a layout with two blocks, a loop over 100 objects, a ternary, two filters and HTML escaping. It checks that both produce the same HTML before measuring.

PurePlateTwig 3.30
Render82 µs194 µs2.4× faster
New engine + render (≈ one request)83 µs221 µs2.7× faster

PHP 8.2.29, OPcache on, Xdebug off, Debian VM, local disk. Best of 5 rounds of 2,000 renders. Run it on your machine:

$ composer install
$ php -d xdebug.mode=off -d opcache.enable_cli=1 benchmark/run.php   # first run compiles the templates
$ php -d xdebug.mode=off -d opcache.enable_cli=1 benchmark/run.php   # measures

Why it is fast: user.name compiles to $user->name, a filter to a function call, and nothing is resolved at runtime. A function that looks for a getter before each property read costs about 20× more per access, which is most of the gap with Twig.