Components & Views
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.
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>
<?php App::render('_nav', ['active' => $page]) ?>
<main>
<?php App::render("/pages/$page") ?>
</main>
<?php App::render('_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): ?>
<?php App::render('/components/_card', [
'icon' => $f['icon'],
'title' => $f['name'],
'body' => $f['desc'],
'href' => $f['url'],
]) ?>
<?php endforeach; ?>
Path resolution
| Call | Resolves to | When |
|---|---|---|
App::render('home') | template/home.php | Top-level template |
App::render('/components/_card') | template/components/_card.php | Leading / = absolute from template/ |
App::render('header') from public/users.php | template/users/header.php | Auto-namespaces by current public file |
App::render('header') (fallback) | template/header.php | If namespaced path doesn't exist |
The file-execution family — five ways to run a PHP file through the framework
The first four share a single private core (App::executeFile()) that runs the file, captures output, and applies the universal return contract. They differ only on (a) where the path is resolved from and (b) what the wrapper does with the result. The fifth — App::fragment() — runs inside a template and marks 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) |
template/ (with .php suffix) |
mixed — full return contract. BC: if the template only echoes (no explicit return), the captured output is echoed back — keeps every App::render('_master', …) call site working unchanged. |
Direct output in a route handler or inside another template; the bread-and-butter render call |
App::renderToString($tpl, $args) |
template/ |
string — coerces every shape (Generator consumed, Closure invoked, scalar cast) |
Need the HTML as a value: email body, cache entry, or to pass into another renderer |
App::renderStream($tpl, $args) |
template/ |
\Generator — yields whatever the template returned, chunk-by-chunk |
SSR streaming. Works with regular echo templates AND streaming-Closure templates uniformly |
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). |
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 App::render('page', $args) call can serve either the full page (no selector) or just that one region ($args['fragment'] = 'name'). The htmx-essay "one file, two responses" pattern. |
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 App::render('page', $args) call serves either the complete page (no fragment selector → every App::fragment() block runs inline) or just one named region ($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) {
return App::render('contacts/list', [
'contacts' => Contact::all(),
// 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,
]);
});
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
App::renderStream() returns a Generator. 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::renderStream(
'shell-open', ['title' => 'Users']
);
// Streaming template → per-user chunks
yield from App::renderStream(
'users/stream',
['users' => User::all()]
);
yield from App::renderStream('shell-close');
})();
});
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::renderStream(...) 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 via renderStream() |
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 App::renderToString('shell-open', ['title' => 'Live Feed']);
yield "<h1>Feed</h1>";
foreach (fetchFeedItems() as $item) {
yield "<article>{$item->title}</article>\n";
}
yield App::renderToString('shell-close');
})();
<?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 | <?php App::render('/components/_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.