Templates & Rendering
No Blade. No Twig. No Mustache. PHP IS the component engine. ZealPHP components are plain .php files — loops, conditionals, expressions, classes, everything you know works. Zero learning curve, full language power.
This is the rendering reference: App::render(), streaming, fragments and the return contract. To build pages from components — stateless templates with props, or reactive components with state, actions and input binding — start at Components.
Pass data, render a component
$app->route('/users/{id}', function($id) {
$user = User::find($id);
if (!$user) return 404;
App::render('profile', [
'user' => $user,
'posts' => $user->posts(),
'isAdmin' => $user->role === 'admin',
]);
});
<h1><?= htmlspecialchars($user->name) ?></h1>
<?php if ($isAdmin): ?>
<span class="badge">Admin</span>
<?php endif; ?>
<h2>Posts (<?= count($posts) ?>)</h2>
<ul>
<?php foreach ($posts as $post): ?>
<li>
<a href="/post/<?= $post->id ?>">
<?= htmlspecialchars($post->title) ?>
</a>
<small><?= $post->created_at ?></small>
</li>
<?php endforeach; ?>
</ul>
Every key in the $args array becomes a local variable in the component via extract(). No magic syntax — just PHP.
Layouts & composition
Components can render other components. Build a layout system with a single master layout composing smaller components:
<?php use ZealPHP\App;
App::render('_master', ['title' => 'About Us', 'page' => 'about']);
<!doctype html>
<html>
<head><title><?= htmlspecialchars($title) ?></title></head>
<body>
<?= App::component('/_nav', ['active' => $page]) ?>
<main>
<?= App::component("/$page/" . basename($page)) ?> <!-- template/about/about.php -->
</main>
<?= App::component('/_footer') ?>
</body>
</html>
public/ is 3 lines that call App::render('_master', [...]). The master template renders the nav, the page content, and the footer. No template inheritance syntax needed — it's just PHP includes.
Components with slots
Reusable UI components that accept data as arguments:
<div class="card">
<div class="card-icon"><?= $icon ?></div>
<h3><?= htmlspecialchars($title) ?></h3>
<p><?= htmlspecialchars($body) ?></p>
<?php if (!empty($href)): ?>
<a href="<?= htmlspecialchars($href) ?>">Read more</a>
<?php endif; ?>
</div>
<?php foreach ($features as $f): ?>
<?= App::component('card', [
'icon' => $f['icon'],
'title' => $f['name'],
'body' => $f['desc'],
'href' => $f['url'],
]) ?>
<?php endforeach; ?>
Path resolution
render() and component() resolve a name by the same rule. In each place name.php comes before the partial _name.php; a missing template's error lists every place it looked.
| Name | Looks in, in order |
|---|---|
'/emails/welcome' | template/emails/welcome.php — a leading / is absolute from template/ |
'header' from public/users.php | Nearest first: template/users/header.php (the page's folder), template/users/components/header.php (that page's components), template/header.php, template/components/header.php |
'header' from public/admin/users.php | template/admin/users/header.php, template/admin/users/components/header.php, then (compatibility) template/users/…, then template/header.php, template/components/header.php |
'header' from a route or API | No public file, so no page folder: template/header.php, template/components/header.php |
Relative names resolve from the request's page, not from the file making the call.
Which page is a request on?
The page is the public file being served: its SCRIPT_NAME without the leading / and the .php. App::include() sets SCRIPT_NAME (with PHP_SELF and SCRIPT_FILENAME, as Apache does) before it runs the file, and every public page is served through it: the implicit /{file} routes, directory index pages, and your own App::include() calls.
| Request | Served by | Page | Relative names look in first |
|---|---|---|---|
GET / | public/index.php | index | template/index/ |
GET /invoices | public/invoices.php | invoices | template/invoices/ |
GET /admin/users | public/admin/users.php | admin/users | template/admin/users/ |
GET /docs/ | public/docs/index.php (directory index) | docs/index | template/docs/index/ |
GET /invoices.php/2024/q1 | public/invoices.php, PATH_INFO=/2024/q1 | invoices | template/invoices/ |
GET /old-page, route returns App::include('/new.php') | public/new.php | new | template/new/ |
a route, an API, /_zeal/c/… | no public file | none | template/, then template/components/ |
SCRIPT_NAME, notPHP_SELF.PHP_SELFisSCRIPT_NAMEplus the client'sPATH_INFO, so reading it let/index.php/userschoosetemplate/users/. When onlyPHP_SELFis set (code that sets it by hand), itsPATH_INFOis stripped first.- It's per request, and it's the served file. A component or layout asking for a relative name gets the page's folder, wherever its own file lives. A route that calls
App::include()switches the page for the rest of that request.
From a public file to the page
render() serves the page; everything inside the page is a component. One request, end to end:
public/invoices.php App::render('invoices', ['invoices' => $list])
→ template/invoices/invoices.php the page's own template, in the page's folder
→ App::component('row', $invoice) → template/invoices/components/row.php (this page only)
→ App::component('card', [...]) → template/components/card.php (shared)
<?php use ZealPHP\App;
App::render('invoices', [
'invoices' => Billing\Invoices::open(),
]);
<h1>Invoices</h1>
<?= App::component('card', ['title' => 'Open',
'slot' => App::component('total', ['invoices' => $invoices])]) ?>
<table>
<?php foreach ($invoices as $invoice): ?>
<?= App::component('row', ['invoice' => $invoice]) ?>
<?php endforeach; ?>
</table>
Where components can be used
| You're in | Write | Resolves |
|---|---|---|
| A page template, or a component it renders | App::component('row', $p) | page folder → its components/ → template/ → template/components/ |
A layout (_master.php) | App::component("/$page/" . basename($page)) for the body, '/_nav' for its own parts | absolute: the layout serves every page |
| A route or API (no page) | return App::component('/invoices/components/row', $p), or a shared name | absolute; or template/, template/components/ |
| An htmx endpoint | return App::component('/invoices/components/row', $p), or App::render('/invoices/invoices', $a, fragment: 'rows') | the file the page rendered, so the swap matches |
| A streaming page | yield App::component('row', $p); or yield from App::render('/invoices/rows', $a, stream: true) | as a page template |
| A reactive component | $this->view('/invoices/components/counter'); stateless parts inside by name | absolute: it re-renders from /_zeal/c/…, where there is no page |
| A page's component a route also renders | name its own children in full too | a route has no page, so a relative child isn't found |
| Shared by several pages and routes | template/components/, bare name | found from anywhere |
The rules behind this layout are in Where code goes.
The rendering methods — render() and component()
Rendering comes down to two methods. App::render() renders a page or a response; its options select a fragment, make the render htmx-aware, or return a stream. App::component() returns markup as a value and never echoes; use it to compose. Both run the template through one private core (App::executeFile()) and apply the universal return contract. App::include() runs a file from public/ the same way, and App::fragment() runs inside a template to mark a named region the framework can extract by name. See the fragments section below.
| Method | Path resolved from | Returns | Use when |
|---|---|---|---|
App::render($tpl, $args, …options) |
The template rule | mixed — full return contract. A string result is also echoed (echo: true, the default), so every App::render('_master', …) statement in public/*.php keeps working. |
A page, or a route's response. Options: fragment:, htmx:, fullPage:, stream:, echo: |
App::component($name, $props) |
The same rule as render() (a registered reactive component's name wins) |
ZealPHP\SafeHtml — markup, never echoed. Content only, by design: a status code, array or response object throws, because the route or page that owns the response returns those. Cast with (string) where a plain string is required. |
Markup inside a page (<?= App::component(…) ?>), an htmx partial, an email body |
App::include($publicPath, $args = []) |
public/ (Apache document-root convention — leading / optional) |
mixed — full return contract, never echoed (always returned so it threads through ResponseMiddleware). Auto-populates $_SERVER['PHP_SELF'], SCRIPT_NAME, SCRIPT_FILENAME for the included file (Apache mod_php parity). SCRIPT_NAME also sets the request's page, so relative template names inside it resolve from that file's folder (which page). |
Apache rewrites — $app->route('/old-page', fn() => App::include('/new.php')) serves public/new.php in-process with the URL bar still at /old-page. See Legacy Apps for the 12 rewrite recipes |
App::fragment($name, $fn) v0.2.24 |
N/A — called inside a template, not on a path | void. The closure's return rides the full return contract when the fragment is extracted (the parent App::render() propagates it back through ResponseMiddleware). |
Mark a named region inside a template so the same template can serve either the full page (no selector) or just that one region (App::render('page', $args, fragment: 'name')). The htmx-essay "one file, two responses" pattern. |
App::render() options
| Option | What it does |
|---|---|
fragment: 'name' | Renders only that App::fragment() region. A name with no matching region is a 404. |
htmx: true | Renders a fragment for htmx requests and the full page otherwise. The region is fragment:, else derived from HX-Target / HX-Trigger-Name. See htmx. |
fullPage: 'tpl' | With htmx: true only: the first template is a bare partial for htmx requests, and fullPage: is the page for normal requests. |
stream: true | Returns a \Generator and never echoes. Compose several with yield from; see SSR streaming below. |
echo: false | Returns the result without echoing it. |
App::render('_master', ['page' => 'home']); // echo the page (public/*.php)
return App::render('users/show', ['user' => $u]); // a route's response
return App::render('search', $args, htmx: true); // fragment for htmx, page otherwise
return App::render('contacts', $args, fragment: 'row-7'); // one App::fragment() region
return App::render('feed', $args, stream: true); // a Generator, flushed chunk by chunk
$html = App::component('card', ['title' => 'Hi']); // markup as a value, never echoed
Deprecated helpers
Earlier releases had a separate method for each way of rendering. They still work until 1.0, but new code should use the two methods above.
| Deprecated | Use instead |
|---|---|
App::renderToString($tpl, $args) | App::component('/tpl', $args) — write the path absolute from template/, and add (string) where a plain string is required |
App::renderStream($tpl, $args) | App::render($tpl, $args, stream: true) |
App::renderHtmx($tpl, $args, $fragmentName, $fullPageTemplate) | App::render($tpl, $args, htmx: true, fragment: $fragmentName, fullPage: $fullPageTemplate) |
App::includeFile() is the deprecated alias for App::include() — kept for backward compatibility (the WordPress showcase and existing scaffolds still call it). New code should use App::include().
Template fragments — one file, two responses
App::fragment($name, $fn) turns any template into a dual-mode file: the same template serves either the complete page (App::render('page', $args), no fragment selector → every App::fragment() block runs inline) or just one named region (App::render('page', $args, fragment: 'name') → that region's buffer is cleared, only its closure runs, the rest of the template short-circuits via HaltException). Same template, same route handler, two different responses on the same URL — the htmx-essay template-fragment pattern without separate partial files.
<ul id="contacts">
<?php foreach ($contacts as $c): ?>
<?php App::fragment("contact-{$c['id']}", function() use ($c) { ?>
<li id="contact-<?= $c['id'] ?>"><?= htmlspecialchars($c['name']) ?></li>
<?php }); ?>
<?php endforeach; ?>
</ul>
$app->route('/contacts', function($g) {
// No selector → full <ul> with every row inline.
// ?fragment=contact-2 → just that one <li> on the wire.
$fragment = is_string($g->get['fragment'] ?? null) ? $g->get['fragment'] : null;
return App::render('contacts/list', ['contacts' => Contact::all()], fragment: $fragment);
});
Inside the closure, the universal contract applies — return 404; for auth, return ['id'=>1]; for JSON, return (fn(){ yield ...; })(); for streaming. Three behaviours worth knowing:
- Missing fragment → 404 per the universal return contract. Asking for
?fragment=does-not-existdoesn't silently fall back to the full page. - First match wins when the same name appears twice — the first block extracts, the rest of the template short-circuits.
- Nested renders compose — an
App::render()called from inside a fragment closure does not inherit the parent's fragment selector. Each render's scope is saved+restored.
/demo/fragments/contacts — 4 contacts, each row swaps in place via hx-get="?fragment=contact-N". Open DevTools → Network → XHR to confirm each click is a single 200 with just the <li> in the body.
Full walk-through: Forms & htmx — Template fragments.
SSR Streaming — yield from templates
Components in a stream: yield App::component(…), not yield from. A component is one value, so it is one chunk; yield from needs a Generator (App::render(…, stream: true)).
App::render($tpl, $args, stream: true) returns a Generator and never echoes. If the template file returns a Generator (via IIFE), it delegates with yield from. If the template echoes normally, the output is captured and yielded as one chunk. Both patterns compose in the same streaming pipeline.
<?php
// Declare what data this template needs —
// framework injects by name (like route handlers)
return function($users) {
yield "<section class='users'>";
foreach ($users as $user) {
yield "<div class='card'>"
. htmlspecialchars($user->name)
. "</div>\n";
}
yield "</section>";
};
$app->route('/users', function() {
return (function() {
// Regular template → single chunk
yield from App::render(
'shell-open', ['title' => 'Users'],
stream: true
);
// Streaming template → per-user chunks
yield from App::render(
'users/stream',
['users' => User::all()],
stream: true
);
yield from App::render('shell-close', stream: true);
})();
});
The template declares function($users) — the framework injects $users from the args array by name, exactly like route parameter injection. Each yield flushes to the browser immediately.
Three streaming template styles
| Style | Template code | Best for |
|---|---|---|
| Closure (cleanest) | return function($users) { yield ...; }; |
New streaming templates — parameter injection, no use() needed |
| IIFE Generator | return (function() use ($users) { yield ...; })(); |
When you need variables from the include scope via use() |
| Regular echo | <h1><?= $title ?></h1> |
Non-streaming templates — output captured as one chunk |
All three compose in the same yield from App::render(..., stream: true) pipeline.
Yield from everywhere
Generators work in route handlers, public/ files, API handlers, and template files:
| Location | How to stream | Example |
|---|---|---|
| Route handler | Return a Generator directly | return (function() { yield "chunk"; })(); |
| Public file | Return a Generator from the file | public/feed.php → <?php return (function() { yield "..."; })(); |
| API handler | Return a Generator from $get/$post |
$get = function() { return (function() { yield ...; })(); }; |
| Template | Return a Closure, render it with stream: true |
return function($items) { yield ...; }; |
<?php
// File: public/feed.php → GET /feed
// Returns a Generator — framework streams each yield to the browser
use ZealPHP\App;
return (function() {
yield from App::render('shell-open', ['title' => 'Live Feed'], stream: true);
yield "<h1>Feed</h1>";
foreach (fetchFeedItems() as $item) {
yield "<article>{$item->title}</article>\n";
}
yield from App::render('shell-close', stream: true);
})();
<?php
// File: api/events/stream.php → GET /api/events/stream
$stream = function() {
return (function() {
yield '{"events":[';
$first = true;
foreach (Event::cursor() as $event) {
if (!$first) yield ',';
yield json_encode($event);
$first = false;
}
yield ']}';
})();
};
PHP template patterns cheat sheet
| Pattern | PHP |
|---|---|
| Output a variable | <?= $name ?> |
| Escape HTML | <?= htmlspecialchars($input) ?> |
| Conditional | <?php if ($cond): ?> ... <?php endif; ?> |
| Loop | <?php foreach ($items as $i): ?> ... <?php endforeach; ?> |
| Include component | <?= App::component('card', $args) ?> |
| Ternary default | <?= $subtitle ?? 'Default' ?> |
| Format number | <?= number_format($price, 2) ?> |
| Date format | <?= date('M j, Y', strtotime($created)) ?> |
| Raw HTML (trusted) | <?= $trusted_html ?> |
| JSON encode | <script>const data = <?= json_encode($data) ?></script> |
htmlspecialchars(). PHP templates have no auto-escaping — you get full control, which means full responsibility.
Why PHP over Blade/Twig/Mustache?
No new syntax. If you know PHP, you know the component engine.
Classes, closures, exceptions, generators — not a subset.
No cache directory. Components are interpreted directly.
Autocompletion, type checking, refactoring — all free.
Components can
yield. Progressive rendering built in.Render inside render. No "extends", no "blocks" — just function calls.