Views & Templates
A view is a template plus the data it renders with. Pop\View\View holds both and hands you a string. There
are two kinds of template behind it: a .phtml file, which is plain PHP with the full language available, and
a stream template, which is any other file or string and understands a small placeholder syntax instead. You
pick per template, and the view object is the same either way.
Rendering a Template#
new View($template) takes a path. A path ending in .phtml or .php that exists on disk becomes a PHP file
template; anything else becomes a stream template. Assign data as properties and render:
$view = new Pop\View\View(__DIR__ . '/../../../view/orders.phtml');
$view->title = 'Your Orders';
$view->orders = [];
echo $view->render();
Inside a .phtml template each key of the view's data is a local variable of the same name. There's no
template language and no compilation step — the file is included with output buffering on, so anything
legal in PHP is legal here:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><?=$title; ?></title>
</head>
<body>
<h1><?=$title; ?></h1>
<ul>
<?php foreach ($orders as $order): ?>
<li><?=$order['reference']; ?> — <?=$order['total']; ?></li>
<?php endforeach; ?>
</ul>
</body>
</html>
In a controller, build the view and put the rendered string into the response:
<?php
namespace App\Http\Controller;
use Pop\Controller\AbstractController;
use Pop\Dispatch\HttpTrait;
use Pop\View\View;
class OrdersController extends AbstractController
{
use HttpTrait;
public function index(): void
{
$view = new View(__DIR__ . '/../../../view/orders.phtml');
$view->title = 'Your Orders';
$view->orders = [
['reference' => 'ORD-1001', 'total' => '$42.00'],
['reference' => 'ORD-1002', 'total' => '$18.50']
];
$this->response->setBody($view->render());
$this->response->send();
}
}
The scaffolded base controller wraps that pair of steps into prepareView() and send() — see
Application Structure.
echo $view is render() — View::__toString() calls it — and getOutput() hands back the last rendered
string without rendering again. hasTemplate(), isFile() and isStream() answer which kind of template a
view ended up with, which is worth checking when the path was assembled at runtime rather than written out.
The two kinds differ in what they can express, not in how you drive them:
| PHP file template | Stream template | |
|---|---|---|
| Recognized by | a .phtml/.php path that exists on disk |
anything else — an .html path, or a raw string |
| Data reaches it as | local variables ($title) |
placeholders ([{title}]) |
| Logic available | all of PHP | loops, conditionals, includes, inheritance |
| Missing value renders as | null, plus an undefined-variable warning |
the placeholder, verbatim |
| Compilation | none — included directly |
re-parsed per render, or compiled to a cache directory |
Reach for a PHP template when the page needs real logic or a helper call, and a stream template when the markup should stay declarative — a layout a designer edits, or a template whose source is not fully trusted.
Give setTemplate() a path that exists. A .phtml path it cannot find is treated as a stream template
whose content is the path string itself, so the page renders that string. Build the template object
yourself — new Pop\View\Template\File($path) — for a version that throws instead.
Passing Data to the View#
View extends Pop\Utils\ArrayObject, so data goes in and comes out several equivalent ways. All of these
write to the same store:
$view = new Pop\View\View(__DIR__ . '/../../../view/orders.phtml');
$view->title = 'Your Orders'; // property
$view['subtitle'] = 'Last 30 days'; // array access
$view->set('count', 12); // accessor
$view->merge(['currency' => 'USD']); // add without disturbing what is set
$view->setData(['title' => 'Orders']); // replace everything
echo $view->get('title');
print_r($view->getData());
The constructor takes the whole array as its second argument, which is the shortest form when the data is already assembled:
$view = new Pop\View\View(__DIR__ . '/../../../view/orders.phtml', [
'title' => 'Your Orders',
'orders' => []
]);
A key that the template uses but the data does not carry is an ordinary undefined variable in a .phtml
template: PHP emits Warning: Undefined variable $subtitle, the expression evaluates to null, and the page
renders with a gap where the value should be. Reach for <?=$subtitle ?? ''; ?> on anything genuinely
optional rather than relying on the warning being visible in production.
Stream Templates#
A stream template is any template that is not a PHP file — an .html file, or a string you build in code. It
has no access to PHP, which is the point: the only things it can do are substitute a value, loop over an
array, include another template, and branch. [{name}] is the substitution:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>[{title}]</title>
</head>
<body>
<h1>[{title}]</h1>
<p>Showing [{count}] orders.</p>
</body>
</html>
$view = new Pop\View\View(__DIR__ . '/../../../view/summary.html', [
'title' => 'Your Orders',
'count' => 2
]);
echo $view->render();
A value nested one level down in an array is addressed with [{name[key]}], so
[{customer[name]}] reads ['customer' => ['name' => 'Nick']] without flattening the data first.
Substitution is a string replacement and nothing more, which has one consequence worth knowing up front: a
placeholder whose key is missing from the data is left in the output verbatim. Hello [{name}]! with no
name renders as Hello [{name}]!, not as Hello !. There's no strict mode and no error.
Includes#
{{@include partial.html}} splices another template in at parse time, resolved relative to the including
file's directory. The included file is not a separate render — its content becomes part of this template
before any data is substituted, so placeholders inside it read from the same data array.
<header>
<h1>[{title}]</h1>
</header>
<body>
{{@include header.html}}
<p>Showing [{count}] orders.</p>
</body>
An @include path is validated: an absolute path, or one containing a .. segment, throws
Pop\View\Template\Stream\Exception rather than reading the file. Templates include their neighbors, not
arbitrary files on disk.
Iteration#
A data key holding an array becomes a loop when the template wraps a block in [{key}] … [{/key}]. What you
write inside depends on the shape of that array.
A list of records — a numerically indexed array of associative arrays — addresses each record's fields
directly by name, and [{i}] gives the 1-indexed position:
[{orders}]
<tr class="row-[{i}]"><td>[{reference}]</td><td>[{total}]</td></tr>
[{/orders}]
$view = new Pop\View\View(__DIR__ . '/../../../view/summary.html', [
'orders' => [
['reference' => 'ORD-1001', 'total' => '$42.00'],
['reference' => 'ORD-1002', 'total' => '$18.50']
]
]);
An associative array uses [{key}] and [{value}] instead:
[{totals}]
<div><strong>[{key}]</strong>: [{value}]</div>
[{/totals}]
An empty array renders the loop as nothing at all — the markers and the body between them are removed — so
the empty case needs no guard. A key that is missing from the data entirely is a different matter: as with any
other placeholder, the [{orders}]/[{/orders}] markers are left in the output as literal text.
Conditionals#
[{if(name)}] … [{else}] … [{/if}] branches on whether a value is non-empty, with the [{else}] half
optional. The variable can be substituted inside the block it guards:
[{if(customer)}]
<p>Welcome back, [{customer}].</p>
[{else}]
<p>Welcome, guest.</p>
[{/if}]
The same [{name[key]}] form works in the test, so [{if(customer[name])}] checks one array member. A
conditional inside a loop body is evaluated per row against that row's own fields, which is how you mark one
record in a list:
[{orders}]
<tr>[{if(expedited)}]<td>[{reference}] (expedited)</td>[{else}]<td>[{reference}]</td>[{/if}]</tr>
[{/orders}]
Test a scalar in [{if(...)}] rather than an array — pass a companion value such as
'hasOrders' => !empty($orders) and test that.
Template Inheritance#
Inheritance is a stream-template feature, built out of the block markers {{name}} … {{/name}}. A parent
template declares named blocks with default content; a child names the parent with {{@extends}} and
redeclares whichever blocks it wants to replace.
<!DOCTYPE html>
<html lang="en">
<head>
{{head}}
<title>[{title}]</title>
{{/head}}
</head>
<body>
<h1>[{title}]</h1>
{{body}}
<p>Nothing here yet.</p>
{{/body}}
</body>
</html>
{{@extends layout.html}}
{{head}}
{{parent}}
<link rel="stylesheet" href="/css/orders.css" />
{{/head}}
{{body}}
<table>
[{orders}]
<tr class="row-[{i}]"><td>[{reference}]</td><td>[{total}]</td></tr>
[{/orders}]
</table>
{{/body}}
The view is constructed from the child, and the parent is pulled in for you:
$view = new Pop\View\View(__DIR__ . '/../../../view/orders.html', [
'title' => 'Your Orders',
'orders' => [['reference' => 'ORD-1001', 'total' => '$42.00']]
]);
echo $view->render();
{{parent}} inside a child block is replaced by the parent's version of that block, which is what lets the
head block above add a stylesheet rather than throw the <title> away. A block the child does not
redeclare keeps the parent's default content, and @extends chains.
@extends paths are validated exactly as @include paths are — relative, and no .. segments.
Compiled Stream Templates#
A stream template is re-parsed on every render(). Giving Template\Stream a writable cache directory
switches it to a compiled path instead: the resolved template is compiled to plain PHP once, written to that
directory, and afterward included directly.
$view = new Pop\View\View(
new Pop\View\Template\Stream(__DIR__ . '/../../../view/orders.html', __DIR__ . '/../../../data/cache'),
['title' => 'Your Orders', 'orders' => []]
);
echo $view->render();
This is the one case where building the template object yourself buys something, since the cache directory is
its second constructor argument. setCacheDir() sets it after the fact.
The cache key is a hash of the fully resolved template — after @extends, @include and blocks have merged
— so editing any file in the chain invalidates it automatically. getContributingFiles() lists what went
into the resolution. Output is identical either way; this is a performance opt-in, not a different dialect.
Filters and Escaping#
Neither template kind escapes anything for you. <?=$title; ?> and [{title}] both emit exactly what is in
the data, so a value that came from a request needs handling before it reaches the page.
For a one-off, htmlspecialchars() in the template is the direct answer. For a whole view, View runs its
data through one or more Pop\Filter\Filter objects immediately before rendering — every value, in both
template kinds, including values nested inside arrays:
$view = new Pop\View\View(__DIR__ . '/../../../view/orders.phtml', [
'title' => 'Your Orders'
]);
$view->addFilter(new Pop\Filter\Filter('htmlspecialchars', [ENT_QUOTES, 'UTF-8']));
echo $view->render();
A filter wraps any callable and its extra arguments: the first constructor argument is the callable, the
second is the array of arguments that follow the value. strip_tags removes markup outright,
htmlspecialchars renders it inert — with htmlspecialchars a submitted
<script>alert("x")</script> reaches the page as <script>alert("x")</script>.
The third constructor argument excludes named keys, which is how one field keeps its markup while the rest of the data is filtered:
$view = new Pop\View\View(__DIR__ . '/../../../view/summary.html', [
'title' => 'Your Orders',
'body' => '<p>Rendered markup we trust.</p>'
]);
$view->addFilter(new Pop\Filter\Filter('strip_tags', null, 'body'));
Filters can also be passed as the view's third constructor argument, singly or as an array. addFilters()
takes several at once, and hasFilters(), getFilters() and clearFilters() round out the set. Filtering
happens inside render(), so a filter added after rendering has no effect on output already produced.
Filtering applies to the view's data rather than to its output, so markup the template itself contains is untouched, and a value you want rendered as HTML is excluded by name as above.
Views expose more than this page covers — the Template\File and Template\Stream objects behind the
scenes, serialization inherited from ArrayObject, and the compiler's own API — see the pop-view README.
See Also#
- Controllers — what a controller receives, and how actions resolve
- Requests & Responses — sending the rendered body, and negotiating HTML against JSON
- Forms & Validation — rendering a form, and per-field markup for a custom template
- Error Handling — the error and maintenance templates the scaffold ships
- pop-view README — the full view and template API