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 & Views

No Blade. No Twig. No Mustache. PHP IS the template engine. ZealPHP templates are plain .php files — loops, conditionals, expressions, classes, everything you know works. Zero learning curve, full language power.

Pass data, render a template

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 template via extract(). No magic syntax — just PHP.

Layouts & composition

Templates can render other templates. Build a layout system with a single master template and 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>
  <?php App::render('_nav', ['active' => $page]) ?>

  <main>
    <?php App::render("/pages/$page") ?>
  </main>

  <?php App::render('_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): ?>
  <?php App::render('/components/_card', [
      'icon'  => $f['icon'],
      'title' => $f['name'],
      'body'  => $f['desc'],
      'href'  => $f['url'],
  ]) ?>
<?php endforeach; ?>

Path resolution

CallResolves toWhen
App::render('home')template/home.phpTop-level template
App::render('/components/_card')template/components/_card.phpLeading / = absolute from template/
App::render('header') from public/users.phptemplate/users/header.phpAuto-namespaces by current public file
App::render('header') (fallback)template/header.phpIf namespaced path doesn't exist

Three render methods

MethodReturnsUse when
App::render($tpl, $args)void (echoes)Direct output in route handler or another template
App::renderToString($tpl, $args)stringNeed HTML as value — email, cache, or yield
App::renderStream($tpl, $args)GeneratorSSR streaming — works with both regular and streaming templates

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.

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

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::renderStream(...) 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 via renderStream() 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 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');
})();
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<?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>
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 template engine.
Full language power
Classes, closures, exceptions, generators — not a subset.
No compile step
No cache directory. Templates are interpreted directly.
IDE support
Autocompletion, type checking, refactoring — all free.
SSR streaming
Templates can yield. Progressive rendering built in.
Composable
Render inside render. No "extends", no "blocks" — just function calls.