Lightweight PHP template engine with hybrid static caching (no eval, no DOMDocument).
- GitHub: https://github.com/zogjs/zog-php
- Packagist: https://packagist.org/packages/zogjs/zog-php
- Install:
composer require zogjs/zog-php
Zog gives you a tiny, framework-agnostic view layer plus an optional static HTML cache in front of it. It compiles your templates to plain PHP files, never uses eval, and is designed to play nicely with modern frontend frameworks (Vue, Alpine, Livewire, etc.) by leaving their attributes untouched.
- DOM-less streaming compiler – custom HTML parser, no
DOMDocument, so attributes like@click,:class,x-data,wire:click,hx-get, etc. are preserved exactly as written. - Hybrid static cache – render a page once, save it as static HTML with a TTL, and serve the static file on future requests.
- Blade-style directives –
@section,@yield,@component,@{{ }},@raw(),@json()/@tojs(),@php(). - Attribute-based control flow –
zp-if,zp-else-if,zp-else,zp-foron normal HTML elements. - Fine-grained opt-out –
zp-nozogto disable DOM-level processing in a subtree (useful when embedding another templating system). - Safe by default – escaped output for
@{{ }}, explicit opt-in to raw HTML and raw PHP.
- PHP 8.1+ :contentReference[oaicite:0]{index=0}
No extra PHP extensions are required; Zog uses only core functions.
composer require zogjs/zog-php Then bootstrap it in your project: ```php <?php use Zog\Zog; require __DIR__ . '/vendor/autoload.php'; Zog::setViewDir(__DIR__ . '/views'); // where your .php templates live Zog::setStaticDir(__DIR__ . '/static'); // where hybrid static files are written Zog::setCompiledDir(__DIR__ . '/storage/zog_compiled'); // where compiled PHP templates are storedThe directories will be created automatically if they do not exist.
If you prefer not to use Composer, you can copy Zog.php and View.php into your project, keep the Zog namespace, and load them via your own autoloader or simple require statements.
Everything else in this README works the same way.
views/hello.php
<h1>Hello @{{ $name }}!</h1> <p>Today is @{{ $today }}.</p>public/index.php
<?php use Zog\Zog; require __DIR__ . '/../vendor/autoload.php'; Zog::setViewDir(__DIR__ . '/../views'); echo Zog::render('hello.php', [ 'name' => 'Reza', 'today' => date('Y-m-d'), ]);views/layouts/main.php
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>@{{ $title }}</title> </head> <body> <header> <h1>My Site</h1> </header> <main> @yield('content') </main> </body> </html>views/pages/home.php
@section('content') <h2>Welcome, @{{ $userName }}</h2> <p>This is the home page.</p> @endsectionpublic/index.php
<?php use Zog\Zog; require __DIR__ . '/../vendor/autoload.php'; Zog::setViewDir(__DIR__ . '/../views'); echo Zog::renderLayout( 'layouts/main.php', 'pages/home.php', [ 'title' => 'Home', 'userName' => 'Reza', ] );Zog uses a custom streaming HTML parser (not DOMDocument). It scans your HTML, rewrites special attributes and directives into plain PHP, and leaves everything else alone.
Escaped output is the default:
<p>@{{ $user->name }}</p>Compiles to:
<?php echo htmlspecialchars($user->name, ENT_QUOTES, 'UTF-8'); ?>Use raw output only when you are sure the content is safe:
<div>@raw($html)</div>Compiles to:
<?php echo $html; ?>You can inject raw PHP (enabled by default):
@php($i = 0) <ul> @php(for ($i = 0; $i < 3; $i++)): <li>@{{ $i }}</li> @php(endfor;) </ul>If you want to disable this directive for security reasons:
Zog::allowRawPhpDirective(false);Any use of @php(...) after that will throw a ZogTemplateException.
You can also check the current status:
if (!Zog::isRawPhpDirectiveAllowed()) { // ... }Both directives are equivalent and produce json_encode’d output:
<script> const items = @json($items); const user = @tojs($user); </script>Compiles roughly to:
<?php echo json_encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?> <?php echo json_encode($user, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>You can also use them inside attributes:
<div data-payload="@json($payload)"></div>Child view
@section('content') <h2>Dashboard</h2> <p>Hello @{{ $user->name }}!</p> @endsectionLayout
<body> @yield('content') </body>At runtime, Zog\View handles section buffering and rendering.
Available helpers in Zog\View:
View::startSection($name); View::endSection(); View::yieldSection($name, $default = ''); View::clearSections();You can render partials/components from within a template:
<div class="card"> @component('components/user-card.php', ['user' => $user]) </div>Or call it directly from PHP:
$html = Zog::component('components/user-card.php', [ 'user' => $user, ]);Use zp-for on an element to generate a foreach:
<ul> <li zp-for="item, index of $items"> @{{ $index }} – @{{ $item }} </li> </ul>Supported forms:
item of $itemsitem, key of $items
Behind the scenes, this becomes approximately:
<?php foreach ($items as $index => $item): ?> <li>...</li> <?php endforeach; ?>Invalid zp-for expressions cause a ZogTemplateException.
Chain conditional attributes at the same DOM level:
<p zp-if="$user->isAdmin"> You are an admin. </p> <p zp-else-if="$user->isModerator"> You are a moderator. </p> <p zp-else> You are a regular user. </p>Compiles roughly to:
<?php if ($user->isAdmin): ?> <p>You are an admin.</p> <?php elseif ($user->isModerator): ?> <p>You are a moderator.</p> <?php else: ?> <p>You are a regular user.</p> <?php endif; ?>If a zp-else-if or zp-else is found without a preceding zp-if at the same level, a ZogTemplateException is thrown.
Sometimes you want Zog to leave a part of the DOM untouched, especially when embedding the markup of another templating system or frontend framework.
Add zp-nozog to any element to disable DOM-level Zog processing for that element and all its descendants:
<div zp-nozog> <!-- Zog does NOT compile this zp-if --> <p zp-if="$user->isAdmin"> This will be rendered exactly as-is in the final HTML. </p> </div>Behavior:
- Zog does not convert
zp-if,zp-for,zp-else-if, orzp-elseinside this subtree into PHP. - The attribute
zp-nozogitself is removed from the final HTML. - Inline text directives such as
@{{ $something }}and@raw($something)still work in normal elements, because text processing is independent of DOM-level control attributes.
This is especially useful when you want to keep attributes for a frontend framework:
<div zp-nozog> <button v-if="isAdmin">Admin button</button> </div>Zog will not attempt to interpret v-if="isAdmin".
<script> and <style> contents are treated as raw text (not parsed as nested HTML):
- By default, inline Zog directives inside
<script>/<style>do work, because the inner text is passed through the same compiler as other text. - If you put
zp-nozogdirectly on the<script>or<style>tag, Zog will not process anything inside it (no inline directives, no control attributes). The contents are emitted verbatim.
This lets you choose exactly how much Zog should do inside your scripts and styles.
Zog’s hybrid cache lets you render a page once, save it as a static file, and serve that file on future requests until a TTL (time to live) expires.
Signature:
Zog::hybrid( string $view, string|array $key, array|callable|null $dataOrFactory = null, ?int $cacheTtl = null );use Zog\Zog; Zog::CACHE_NONE; // 0 Zog::CACHE_A_MINUTE; Zog::CACHE_AN_HOUR; Zog::CACHE_A_DAY; Zog::CACHE_A_WEEK;You can also override the default TTL:
Zog::setDefaultHybridCacheTtl(Zog::CACHE_A_DAY); // or disable default TTL (TTL must be explicit in hybrid calls) Zog::setDefaultHybridCacheTtl(null);Internally, static files start with an HTML comment that holds the expiry date, for example:
<!-- Automatically generated by zog: [ex:2025-12-09] -->Browsers and search engines ignore this comment; it has no impact on SEO.
$html = Zog::hybrid( 'pages/home.php', 'home', [ 'title' => 'Home', 'user' => $user, ], Zog::CACHE_A_HOUR );- Always re-renders the view.
- Writes/overwrites the static file.
- Returns the rendered HTML (including the header comment).
This mode behaves like render() plus “also save the result to a static file”.
This is the recommended mode when fetching data from a database or an API:
$html = Zog::hybrid( 'pages/home.php', 'home', function () use ($db, $userId) { // This closure is called only when: // - there is no static file, or // - it has expired. $user = $db->getUserById($userId); return [ 'title' => 'Home', 'user' => $user, ]; }, Zog::CACHE_A_HOUR );Workflow:
-
If a valid static file exists and has not expired, Zog:
- Returns its contents.
- Does not call the factory.
-
If the file is missing or expired, Zog:
- Calls the factory.
- Expects an
arrayof data. - Renders the view.
- Writes a new static file with an updated expiry comment.
- Returns the fresh HTML.
If the factory does not return an array, Zog throws a ZogException.
You can check or serve an existing static file without rendering or running any data logic:
$content = Zog::hybrid( 'pages/home.php', 'home', null // read-only mode ); if ($content === false) { // no valid cache yet – decide what to do: $content = Zog::render('pages/home.php', [ 'title' => 'Home', 'user' => $user, ]); } echo $content;Returns false if:
- The static file does not exist.
- The static file is unreadable.
- The static file is expired or has an invalid expiry comment.
Static files are stored under the static directory configured with Zog::setStaticDir().
-
If
$keyis a string, Zog creates a slug-like filename:viewName-your-key.php -
If
$keyis an array, Zog:- Normalizes the array (sorts associative keys, recursively).
- JSON-encodes it.
- Hashes the JSON.
- Uses a short
sha1prefix like:
viewName-zog-0123456789abcdef.php
This guarantees that the same logical key always maps to the same static file.
use Zog\Zog; // Change where views are loaded from Zog::setViewDir(__DIR__ . '/views'); // Change where static cache files are written Zog::setStaticDir(__DIR__ . '/static'); // Change where compiled PHP templates are stored Zog::setCompiledDir(__DIR__ . '/storage/zog_compiled');// Remove all static HTML files (does not delete the directory itself) Zog::clearStatics(); // Remove all compiled template files (does not delete the directory itself) Zog::clearCompiled();If you know the relative path to a static file under the static directory, you can read it directly:
// Equivalent: Zog::staticFile('pages/home-view-zog-abc123.php'); $content = Zog::static('pages/home-view-zog-abc123.php');This is mainly useful when you manage some static files yourself, or when you want a very thin wrapper around file_get_contents() with path-traversal protection.
Zog uses exceptions for all error conditions:
-
ZogException– base exception for general runtime issues:- bad directories
- missing view files
- I/O failures
- invalid hybrid usage
-
ZogTemplateException– template compilation errors:- invalid
zp-for/zp-ifsyntax - unmatched parentheses in directives
zp-elsewithout a precedingzp-if- misuse of section/component directives
- disabled
@php()still being used - unclosed tags in the HTML source
- invalid
Example:
try { echo Zog::render('pages/home.php', ['user' => $user]); } catch (\Zog\ZogTemplateException $e) { // render a friendly error page for template errors } catch (\Zog\ZogException $e) { // log and render a generic error page }-
Zog does not use
eval; compiled templates are normal PHP files that arerequired. -
All data passed into
render()(or viahybrid()) is available as:- Individual variables (
$user,$title, etc.). - A full array
$zogDataif you prefer to access everything as an array.
- Individual variables (
-
Zog is intentionally small and framework-agnostic – you can drop it into any PHP project or framework and wire it to your router/controller layer.
MIT
- Open issues and pull requests on GitHub.
- Ideas, bug reports, and feature suggestions are very welcome.
- Help with documentation and logo/design improvements is also appreciated.