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
| Requirement | Version |
|---|---|
| PHP | 7.4 or higher |
| Extension | tokenizer (enabled in standard PHP builds) |
| Dependencies | None |
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:
| Tag | Role |
|---|---|
{{ 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 write | PHP runs | Rule |
|---|---|---|
title | $title | A bare word is a variable. |
user.name | $user->name | The 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 {{ '{{' }}.}} 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:
| Template | PHP | Escaped |
|---|---|---|
{{ article.html }} | echo htmlspecialchars(…) | Yes |
{% echo article.html %} | echo $article->html | No |
{{ … }} 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 goes | Write |
|---|---|
| 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) }}"> |
<script>, {% echo %}; in an attribute, {{ }}. Inside a script, {{ }} would turn the JSON quotes into " 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 write | PHP 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 write | PHP 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.
{% break %}. Without it, the next case is printed too, exactly as in PHP.Conditions are PHP expressions, with readable words:
| You write | PHP runs |
|---|---|
items is empty | empty($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:
- The layout receives the same variables as the page.
- The deepest template wins: a block defined by the page replaces the layout's.
- A block the page does not define keeps the layout's default content.
- A layout can extend another layout, on any number of levels, and blocks can be nested.
- In a template that extends, text outside the blocks is ignored.
- 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" %}.
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 content | The admin menu, with its content in <main>. |
defines title | Its own title. Without it, My site from the base. |
defines body | Its own body only: the admin menu and content are replaced too. Useful for a login page without the menu. |
| defines nothing | The 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>
| Method | Behavior |
|---|---|
$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]
| When | Message |
|---|---|
An end… without an opening tag | Unexpected 'endif' (missing opening tag) |
An end… that closes the wrong structure | Unexpected 'endif', expected 'endforeach' (opened at line 9) |
| A structure never closed | Unclosed 'if', at the line that opened it |
A case or default outside a switch | Unexpected 'case' outside of a 'switch' |
A switch with no case | 'switch' without 'case' (opened at line 4) |
A block never closed, or crossing an if | Unclosed '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 line | Unclosed '{{' (a tag must end on the line where it starts) |
| An expression that is not valid PHP | PHP's syntax error, for example syntax error, unexpected ')' |
| A PHP warning or notice while rendering | PHP's message, for example Undefined variable: username |
| An exception thrown by your code | Its message |
| An included template that does not exist | Template 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.
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.
| Mode | Behavior |
|---|---|
devMode = true | Recompiles 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
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.
| PurePlate | Twig 3.30 | ||
|---|---|---|---|
| Render | 82 µs | 194 µs | 2.4× faster |
| New engine + render (≈ one request) | 83 µs | 221 µs | 2.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.