Pop PHP
The Basics

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:

PHP
$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:

PHPapp/view/orders.phtml
<!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:

PHPapp/src/Http/Controller/OrdersController.php
<?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:

PHP
$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:

PHP
$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:

HTMLapp/view/summary.html
<!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>
PHP
$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.

HTMLapp/view/header.html
<header>
    <h1>[{title}]</h1>
</header>
HTML
<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:

HTML
[{orders}]
    <tr class="row-[{i}]"><td>[{reference}]</td><td>[{total}]</td></tr>
[{/orders}]
PHP
$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:

HTML
[{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:

HTML
[{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:

HTML
[{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.

HTMLapp/view/layout.html
<!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>
HTMLapp/view/orders.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:

PHP
$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.

PHP
$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:

PHP
$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 &lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;.

The third constructor argument excludes named keys, which is how one field keeps its markup while the rest of the data is filtered:

PHP
$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#