Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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

Route handler
$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',
    ]);
});
template/profile.php
<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:

public/about.php — page entry (3 lines)
<?php use ZealPHP\App;
App::render('_master', ['title' => 'About Us', 'page' => 'about']);
template/_master.php — layout wrapper
<!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>
This is exactly how the ZealPHP docs site works — every page in 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:

template/components/_card.php
<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>
Using the component in any template
<?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.

NameLooks in, in order
'/emails/welcome'template/emails/welcome.php — a leading / is absolute from template/
'header' from public/users.phpNearest 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.phptemplate/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 APINo 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.

RequestServed byPageRelative names look in first
GET /public/index.phpindextemplate/index/
GET /invoicespublic/invoices.phpinvoicestemplate/invoices/
GET /admin/userspublic/admin/users.phpadmin/userstemplate/admin/users/
GET /docs/public/docs/index.php (directory index)docs/indextemplate/docs/index/
GET /invoices.php/2024/q1public/invoices.php, PATH_INFO=/2024/q1invoicestemplate/invoices/
GET /old-page, route returns App::include('/new.php')public/new.phpnewtemplate/new/
a route, an API, /_zeal/c/…no public filenonetemplate/, then template/components/
  • SCRIPT_NAME, not PHP_SELF. PHP_SELF is SCRIPT_NAME plus the client's PATH_INFO, so reading it let /index.php/users choose template/users/. When only PHP_SELF is set (code that sets it by hand), its PATH_INFO is 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:

GET /invoices
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)
public/invoices.php
<?php use ZealPHP\App;
App::render('invoices', [
    'invoices' => Billing\Invoices::open(),
]);
template/invoices/invoices.php
<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 inWriteResolves
A page template, or a component it rendersApp::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 partsabsolute: the layout serves every page
A route or API (no page)return App::component('/invoices/components/row', $p), or a shared nameabsolute; or template/, template/components/
An htmx endpointreturn 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 pageyield 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 nameabsolute: it re-renders from /_zeal/c/…, where there is no page
A page's component a route also rendersname its own children in full tooa route has no page, so a relative child isn't found
Shared by several pages and routestemplate/components/, bare namefound 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.

MethodPath resolved fromReturnsUse 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

OptionWhat it does
fragment: 'name'Renders only that App::fragment() region. A name with no matching region is a 404.
htmx: trueRenders 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: trueReturns a \Generator and never echoes. Compose several with yield from; see SSR streaming below.
echo: falseReturns the result without echoing it.
One method, every kind of render
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.

DeprecatedUse 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.

template/contacts/list.php — one template, both responses
<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>
route handler — ONE entry, both modes
$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-exist doesn'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.
▶ Live demo — the contacts list

/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.

Streaming template (template/users/stream.php)
<?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>";
};
Route handler — compose streams
$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

StyleTemplate codeBest 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:

LocationHow to streamExample
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 ...; };
public/feed.php — a streaming public page
<?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);
})();
api/events/stream.php — a streaming API endpoint
<?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

PatternPHP
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>
Always escape user data with htmlspecialchars(). PHP templates have no auto-escaping — you get full control, which means full responsibility.

Why PHP over Blade/Twig/Mustache?

Zero learning curve
No new syntax. If you know PHP, you know the component engine.
Full language power
Classes, closures, exceptions, generators — not a subset.
No compile step
No cache directory. Components are interpreted directly.
IDE support
Autocompletion, type checking, refactoring — all free.
SSR streaming
Components can yield. Progressive rendering built in.
Composable
Render inside render. No "extends", no "blocks" — just function calls.