stave.dev

Views & Responses

Every endpoint can serve HTML or JSON from the same method — the framework decides based on the request's Accept header.

View Files

Views are plain PHP files stored in modules/MyModule/view/. They have full access to $this — the module instance — so any properties you set on the controller are available directly in the template:

<?php
namespace Zero\Module;

class MyModule extends \Zero\Core\Module {

    public function index() {
        $this->title = 'Dashboard';
        $this->items = ['Alpha', 'Bravo', 'Charlie'];
        $this->respond($this->viewPath . 'index.php');
    }
}
<h1><?= $this->title ?></h1>
<ul>
    <?php foreach ($this->items as $item): ?>
        <li><?= htmlspecialchars($item) ?></li>
    <?php endforeach; ?>
</ul>
Views Are Included, Not Rendered

View files are loaded with include inside the Response class, which is why $this refers to the module instance. There is no separate template engine — it is just PHP.

Response Methods

The module's base class provides several methods for sending responses:

Method Purpose Behavior
respond($view) Primary response method JSON request → exports $this->data
HTML request → renders the view file with frame
build($view) Low-level HTML render Renders view wrapped in frame (head, header, footer, sideNav). Skipped for JSON requests.
export($data, $extra) Low-level JSON output Wraps data in the JSON envelope and outputs with Content-Type: application/json
success($msg, $data) Success after mutation JSON request → exports and exits
HTML request → returns (so redirect can follow)
error($msg, $data) Error after mutation JSON request → sends error code and exits
HTML request → returns (so form can re-render)

JSON Response Envelope

All JSON responses from Zero are wrapped in a standard envelope. This applies to respond(), export(), success(), and error():

{
    "data": { ... },
    "status": "success",
    "code": 200
}
$this->data Is Nested Under "data"

When using respond() with a dual-purpose endpoint, $this->data is wrapped inside the envelope's "data" key — it is not at the root of the JSON response. Always unwrap the envelope client-side.

When $data is a string instead of an array, it becomes a "message" key instead of "data":

{
    "message": "Article created successfully",
    "status": "success",
    "code": 200
}

Dual HTML/JSON Endpoints

The most powerful pattern in Zero is the dual-purpose endpoint. A single method serves both an HTML page and a JSON API depending on the request's Accept header:

public function index() {
    // Set $this->data — this is what JSON clients receive
    $this->data = [
        'articles' => $this->model->getAll(),
        'count'    => $this->model->count(),
    ];

    // respond() checks the Accept header:
    //   JSON → exports $this->data as {"data": {"articles": [...], "count": 5}, ...}
    //   HTML → renders the view (which can also use $this->data)
    $this->respond($this->viewPath . 'index.php');
}

Client-side, unwrap the envelope to access your data:

fetch('/article', {
    headers: { 'Accept': 'application/json' }
})
    .then(r => r.json())
    .then(res => {
        // res.data contains your $this->data payload
        // res.status and res.code are envelope metadata
        const articles = res.data.articles;
        const count = res.data.count;
    });

The Frame System

When build() renders an HTML response, it wraps the view inside a frame made up of four template files:

head.php
header.php
Your View
sideNav.php
footer.php

By default, these are loaded from app/frontend/frame/. Every page on the site shares the same layout unless a module overrides it.

Frame File Purpose
head.php Opening <html>, <head> with stylesheets, scripts, components, and <body> tag
header.php Site header and navigation
sideNav.php Optional side navigation panel
footer.php Site footer, closing </body> and </html> tags

Frame Overrides

A module can provide its own frame files by creating a frame/ directory. The framework checks the module's frame directory first, then falls back to the global frame:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= $this->title ?? 'My Module' ?></title>

    <!-- Custom head content here -->
    <link rel="stylesheet" href="/css/custom-theme.css">

    <!-- IMPORTANT: Call these to preserve auto-loading -->
    <?php $this->getStylesheets(); ?>
    <?php $this->getScripts(); ?>
    <?php $this->getComponents(); ?>
</head>
<body>
Always Call Asset Loaders in Custom Frames

If you override head.php, you must include $this->getStylesheets(), $this->getScripts(), and $this->getComponents(). Without these calls, the automatic CSS/JS/component loading for your module and its endpoints will not work.

Asset Loading Methods

Method Output
$this->getStylesheets() <link> tags for matching CSS files in assets/css/
$this->getScripts() <script> tags for matching JS files in assets/js/
$this->getComponents() ShadowComponent HTML files from global and module component directories

AJAX Requests

When a request is made with XMLHttpRequest (detected via X-Requested-With header), the frame is automatically skipped. Only the view content is returned, which makes it ideal for loading partial HTML into existing pages.

// Returns only the view HTML, no frame wrapper
const response = await fetch('/article/show/42', {
    headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const html = await response.text();
document.getElementById('content').innerHTML = html;

View-Only Endpoints

If a module has no method for a requested endpoint but has a matching view file, the framework renders it automatically. This is useful for static content pages within a module:

modules/ Docs/ Docs.php ← Only has index() method view/ index.php about.php ← Auto-rendered at /docs/about faq.php ← Auto-rendered at /docs/faq

Visiting /docs/about renders view/about.php without needing an about() method on the controller. The view still has access to $this and the full frame wrapping.