Templates

Auto-escaping output, control structures, layouts and sections, partials, and shared data.

Templates are app/templates/*.orbit.php. They compile to plain PHP once, then execute as fast as any other included file.

PHP
<?php
return $this->view->respond('articles/show', [
    'title' => 'Reading list',
    'article' => $article,
]);

Output

Template
{# Escaped. This is what you want essentially always. #}
<h1>{{ $title }}</h1>

{# Not escaped. Deliberately loud, so it stands out in review. #}
<div>{!! $renderedMarkdown !!}</div>

{# A comment. Removed at compile time; never reaches the browser. #}

{# Literal braces, for Vue, Angular, Handlebars and friends. #}
<span>@{{ notPhp }}</span>
The asymmetry is the design

{{ }} is shorter and easier to type than {!! !!}. The safe form has to be the convenient one, or people reach for the other by habit. Escaping is not something a template remembers to ask for — it is what output is.

What values are allowed

Strings, numbers, booleans, null and Stringable render. Arrays and plain objects throw:

Output
TemplateError: Cannot render a value of type array. Convert it in the handler
  before passing it to the template.

Better an error than the string Array appearing in a page, or a fatal from a bare cast.

Control structures

Template
@if($articles === [])
    <p>Nothing published yet.</p>
@elseif(count($articles) === 1)
    <p>One article.</p>
@else
    <p>{{ count($articles) }} articles.</p>
@endif

@foreach($articles as $article)
    <article>
        <h2>{{ $article['title'] }}</h2>
        <p>{{ $article['excerpt'] }}</p>
    </article>
@endforeach

@for($page = 1; $page <= $pages; $page++)
    <a href="?page={{ $page }}">{{ $page }}</a>
@endfor

@while($row = $cursor->next())
    <li>{{ $row['name'] }}</li>
@endwhile

An unrecognised @word passes through untouched — it is far more likely to be prose (an email address, a decorator in a code sample) than a typo'd directive.

Layouts and sections

Template
{# app/templates/layout.orbit.php #}
<!doctype html>
<html lang="en">
<head>
    <title>{{ $title }}</title>
    @yield('head')
</head>
<body>
    <main>@yield('content')</main>
</body>
</html>
Template
{# app/templates/articles/show.orbit.php #}
@extends('layout')

@section('head')
    <meta name="description" content="{{ $article['excerpt'] }}">
@endsection

@section('content')
    <h1>{{ $article['title'] }}</h1>
    {!! $article['html'] !!}
@endsection

The child renders first, collecting its sections; the layout renders second and pulls them out with @yield. Anything the child emits outside a section becomes the implicit content section, so a layout that yields only content works with a template that never declares one.

A section left unclosed is caught with the template and section named, rather than producing a blank page.

Partials

Template
@include('partials/pagination')

{# Extra values are merged over the current ones #}
@include('partials/button')

A partial sees the including template's data.

Shared data

Values every page needs are supplied once, at boot:

PHP
<?php
$templates = new TemplateEngine(
    $root . '/app/templates',
    $storage . '/cache/views',
    alwaysRecompile: $debug,
    shared: [
        'appUrl' => $env->string('APP_URL', 'http://localhost:8080'),
        'sapi' => PHP_SAPI,
        'phpVersion' => PHP_VERSION,
    ],
);
Template
<meta property="og:image" content="{{ $appUrl }}/assets/brand/social-card.png">
<footer>Served by {{ $sapi }} on PHP {{ $phpVersion }}.</footer>

Per-render data wins over shared data, so a page can override one value without affecting any other page.

Why constructor, not a setter

A mutable bag on the engine would be per-request state living on a process-lifetime service — one page's values leaking into the next request's render. Supplying them at construction makes that impossible.

Compilation and caching

A template is compiled to PHP and cached under storage/cache/views. It recompiles when the source is newer, or on every render when alwaysRecompile is on — which --debug sets, so edits appear without clearing anything by hand.

Compiled files are written to a temporary name and renamed into place. A worker must never require a file another process is halfway through writing.

The staleness check compares file modification times, which a deploy method that preserves them (some rsync and tarball-extraction flows do) can defeat. orbit storage:clear deletes everything under storage/cache/views unconditionally — safe at any time, since the next render just recompiles what it finds missing.

Two things templates cannot do

Raw PHP tags are neutralised. A literal <? renders as text. Every executable construct comes from a directive the compiler knows about, which keeps templates readable and stops a stray tag from becoming a surprise.

Template names are validated, not sanitised. Names may contain letters, digits, underscores, hyphens and forward slashes:

PHP
<?php
$view->render('../../../etc/passwd');
// TemplateError: Invalid template name "../../../etc/passwd". Names may contain
//   letters, digits, underscores, hyphens and forward slashes only.

A name that reached the engine from a request could otherwise compile and execute an arbitrary file.

Rendering without a response

PHP
<?php
$html = $this->view->render('emails/welcome', ['name' => $user->name]);

$this->view->exists('emails/welcome');   // true

Escaping inside a template

{{ }} escapes for HTML text. For other contexts, call the escaper explicitly:

Template
<a href="{!! PhpOrbit\Security\Escaper::urlAttribute($link) !!}">Open</a>

<button data-name="{!! PhpOrbit\Security\Escaper::attribute($name) !!}">Save</button>

<script>
    const user = {!! PhpOrbit\Security\Escaper::js($name) !!};
</script>

Note that js() includes its own quotes — do not add more. Escaping in detail →