Views

Gustav renders native PHP templates from View responses:

namespace App\Routes;

use GustavPHP\Gustav\Attribute\{Controller, Get};
use GustavPHP\Gustav\View;

#[Controller]
final readonly class HomeController
{
    #[Get]
    public function index(): View
    {
        return new View('home', [
            'title' => 'Gustav',
            'message' => 'Ready to build',
        ]);
    }
}

home resolves to views/home.phtml under the configured view directory. The .phtml extension is optional in logical names. Absolute paths, parent traversal, other extensions, unreadable files, and symlinks escaping the configured directory are rejected.

View responses use status 200 and Content-Type: text/html; charset=utf-8 by default. Supply response metadata only when it differs:

return new View(
    template: 'dogs/created',
    data: ['dog' => $dog],
    status: 201,
    headers: ['X-Resource-Type' => 'dog'],
);

View data

An array exposes every key as a local template variable and remains available as $model:

// Controller
return new View('home', [
    'title' => 'Gustav',
]);
<!-- views/home.phtml -->
<h1><?= $view->escape($title) ?></h1>
<p><?= $view->escape($model['title']) ?></p>

Array keys must be valid PHP variable names. Internal names such as view and model are reserved and fail rendering instead of being silently overwritten.

For non-trivial pages, prefer an immutable view model. The object is exposed as $model, preserving constructor types and IDE support:

namespace App\Views;

final readonly class HomeView
{
    public function __construct(
        public string $title,
        public string $message,
    ) {
    }
}
use App\Views\HomeView;
use GustavPHP\Gustav\View;

return new View('home', new HomeView(
    title: 'Gustav',
    message: 'Ready to build',
));
<?php
/** @var \GustavPHP\Gustav\View\Template $view */
/** @var \App\Views\HomeView $model */
?>

<h1><?= $view->escape($model->title) ?></h1>
<p><?= $view->escape($model->message) ?></p>

Escaping and trusted HTML

Native PHP cannot automatically intercept echo. Escape every dynamic value with escape() or its e() alias:

<h1><?= $view->escape($title) ?></h1>
<input value="<?= $view->e($value) ?>">

The helper supports strings, numbers, booleans, null, backed enums, and Stringable objects. It uses UTF-8 HTML escaping with quotes and invalid-byte substitution. Arrays and unsupported objects fail explicitly.

Use raw() only for HTML that the application already trusts. Never pass unvalidated request or database content to it:

<?= $view->raw('<strong>Known application markup</strong>') ?>

Static HTML written directly in a template is trusted application code. section() and partial() return rendered HTML and can be echoed directly.

Use the same escaping helper for a generated CSRF token in state-changing forms:

<input type="hidden" name="_token" value="<?= $view->escape($csrfToken) ?>">

Generate the value with the injected CsrfTokenManager and protect the target route with #[Csrf]. See Sessions and CSRF.

Layouts and sections

Declare a layout from a page template. Output from the page becomes the layout's content section:

<!-- views/home.phtml -->
<?php $view->layout('layout', ['title' => $model->title]) ?>

<h1><?= $view->escape($model->title) ?></h1>
<!-- views/layout.phtml -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title><?= $view->escape($title) ?></title>
</head>
<body>
    <?= $view->section('content') ?>
</body>
</html>

Layouts can themselves declare another layout. Circular layout chains are rejected.

Capture additional named sections with start() and end():

<?php $view->start('sidebar') ?>
    <nav>...</nav>
<?php $view->end() ?>

Render one in the layout with an escaped fallback:

<aside><?= $view->section('sidebar', 'No navigation') ?></aside>

An unclosed section or an unexpected output-buffer change fails rendering and Gustav restores the buffers it opened.

Partials

Partials receive their own data and rendering context:

<?php foreach ($dogs as $dog): ?>
    <?= $view->partial('components/dog', ['dog' => $dog]) ?>
<?php endforeach ?>
<!-- views/components/dog.phtml -->
<article><?= $view->escape($dog->name) ?></article>

Each render starts with empty section and layout state. Recursive partial chains are rejected.

Optional controller helper

Controller\Base::view() remains available when a controller already uses the base response helpers. It creates the same View object:

public function index(): View
{
    return $this->view('home', ['title' => 'Gustav']);
}

A controller may also return new View(...) directly.

Replacing the renderer

Implement ViewRendererInterface and register it as a singleton service to use Twig or another template engine:

namespace App\Services;

use GustavPHP\Gustav\Attribute\Service;
use GustavPHP\Gustav\Service\Lifetime;
use GustavPHP\Gustav\View;
use GustavPHP\Gustav\View\ViewRendererInterface;

#[Service(
    as: ViewRendererInterface::class,
    lifetime: Lifetime::Singleton,
)]
final class ProjectViewRenderer implements ViewRendererInterface
{
    public function render(View $view): string
    {
        // Delegate $view->template and $view->data to the chosen engine.
        return '';
    }
}

Keep singleton renderers free of request-specific mutable state. A renderer returns only the HTML body; Gustav applies the status, headers, request ID, and HEAD behavior from the View response.

Failure behavior

Missing templates, invalid paths, cycles, template exceptions, and renderer failures are unexpected server errors. Development mode renders the framework's debug page. Production returns the same safe JSON 500 used for other internal exceptions and never exposes template names or paths.