The framework's native view system is pure PHP class-based templates: every view is a PHP class that extends AbstractView and implements a single show() method. Twig and Blade templates are first-class alternatives rendered from the same render() call — see Template Engines for that side. This page explains how class views work, how they compose, what helpers are available, and why pure PHP is the default.
Class views are the default, not the only option — Twig and Blade are available per route via render() (see Template Engines). Here's the reasoning for keeping pure PHP at the centre:
Simplicity — a view is just a PHP class. There is no template language to learn, no compilation step, no cache invalidation quirks specific to the template engine. If you know PHP, you already know how to write views.
Error handling — PHP's output buffering (ob_start, ob_end_clean, ob_end_flush) gives precise, reliable error handling for template rendering. If an exception is thrown inside show(), the buffer is cleaned before the exception propagates — the client never receives a half-rendered page. Twig provides similar guarantees but requires its own error handling layer on top. With pure PHP and ob_* you're working directly with the language's built-in mechanism.
Type safety and IDE autocompletion — view classes are regular PHP classes. Properties passed as data are accessed via $this->propertyName, which your IDE understands. @property docblocks give you full autocompletion and type checking on view data. Twig templates are strings — no IDE can give you reliable autocompletion or catch type errors inside them without external plugins and configuration.
Variable existence safety — accessing an undefined property in a view makes __get() on AbstractView throw an InvalidArgumentException pointing at the exact view and property name. There's no silent null or undefined variable rendering blank.
Views are regular classes — you can add methods to a view class, use traits, implement interfaces, inject computed properties, and organise logic however makes sense. Some people will argue that putting logic in views is bad practice. That's a fair position for large teams with strict separation of concerns. For a game with complex rendering logic where the view IS the logic in many cases, having a real class is genuinely useful.
No unnecessary complexity — Twig adds a compilation step, a cache layer, a template inheritance system, filters, functions, extensions, and a sandbox. All of that has real value in certain contexts. For this framework's core use case it's overhead that buys nothing over plain PHP.
Engines are still available — none of this locks you out of a templating engine. render() accepts Twig (*.html.twig) and Blade (*.blade.php) templates alongside class views, per route, mixable in one app — including engine partials imported from class views via import(). Pick the right tool per page. See Template Engines.
// templates/dashboard_page.php
namespace App\View;
use PHP_SF\System\Classes\Abstracts\AbstractView;
// @formatter:off
final class dashboard_page extends AbstractView { public function show(): void { ?>
<!--@formatter:on-->
<h1>Dashboard</h1>
<p>Welcome back!</p>
<!--@formatter:off-->
<?php } }
The // @formatter:off and <!--@formatter:on--> comments are PhpStorm formatter directives — they tell the IDE to stop reformatting the mixed PHP/HTML content inside show(). This is a convention, not a requirement.
The one-liner class opening (final class dashboard_page extends AbstractView { public function show(): void { ?>) keeps the PHP class boilerplate on a single line so the HTML content starts immediately without indentation drift.
Data passed to $this->render() in the controller is available in the view via $this->propertyName:
// Controller
return $this->render( player_profile_page::class, [
'player' => $player,
'inventory' => $inventory,
'allies' => $allies,
] );
// templates/player_profile_page.php
/**
* @property User $player
* @property Inventory $inventory
* @property Ally[] $allies
*/
final class player_profile_page extends AbstractView { public function show(): void { ?>
<h1><?= $this->player->getLogin() ?></h1>
<h2>Inventory</h2>
<?php foreach ( $this->inventory->getItems() as $item ) : ?>
<div class="item"><?= $item->getName() ?></div>
<?php endforeach ?>
<h2>Allies</h2>
<?php foreach ( $this->allies as $ally ) : ?>
<div class="ally"><?= $ally->getName() ?></div>
<?php endforeach ?>
<?php } }
The @property docblocks are optional but strongly recommended — they give your IDE accurate type information for $this->player, $this->inventory, etc., enabling autocompletion and type checking inside the view.
Accessing a property that wasn't passed in the data array throws InvalidArgumentException from __get():
// AbstractView::__get()
public function __get( string $name ): mixed
{
if ( array_key_exists( $name, $this->data ) )
return $this->data[ $name ];
throw new InvalidArgumentException( "Undefined Property `$name` in view: " . static::class );
}
This means undefined view properties are loud failures, not silent nulls.
Checking if a property was passed uses isset() which triggers __isset():
// In a view
if ( isset( $this->optionalData ) ) {
// safe to use $this->optionalData
}
Views compose by importing other view classes via $this->import():
protected function import(
string $view,
array $data = [],
bool $htmlClassTagEnabled = true
): void
// templates/dashboard_page.php
final class dashboard_page extends AbstractView { public function show(): void { ?>
<?php $this->import( player_stats::class ) ?>
<?php $this->import( inventory_panel::class, [
'items' => $this->inventory->getItems()
] ) ?>
<?php $this->import( allies_list::class, [
'allies' => $this->allies
], false ) ?>
<?php } }
By default, import() wraps the imported view in a <div class="ViewClassName"> tag. This gives every component a predictable CSS class matching its PHP class name, which is useful for styling and debugging. Pass false as the third argument to disable the wrapper div:
// Renders: <div class="inventory_panel">...</div>
$this->import( inventory_panel::class );
// Renders: the view content directly, no wrapper div
$this->import( inventory_panel::class, [], false );
Data passed to import() is merged with the parent view's data — the imported view has access to both its own passed data and all data from the parent:
// Parent has: [ 'player' => $player, 'inventory' => $inventory ]
// Import passes: [ 'title' => 'My Items' ]
// Child view receives: [ 'player' => $player, 'inventory' => $inventory, 'title' => 'My Items' ]
import() also accepts engine template references — a class view can include Twig and Blade partials:
<?php $this->import( 'partials/stats_panel.html.twig' ) ?>
<?php $this->import( 'partials/inventory_grid.blade.php', [ 'items' => $items ], false ) ?>
The wrapper div rules are identical (CSS class derived from the template file name), and the parent view's data merges into the template data the same way. See Template Engines.
Every non-API page response renders three parts in sequence:
header template → show()
└── head.php (DOCTYPE, meta tags, CSS, JS)
└── navigation, etc.
<div class="ViewClassName">
$view->show()
└── your page content
</div>
footer template → show()
└── scripts, debug info (admin only), page load time
Header and footer classes are set during kernel bootstrap (both are static calls):
PHP_SF\Kernel::setHeaderTemplateClassName( header_unauthorized::class );
PHP_SF\Kernel::setFooterTemplateClassName( footer::class );
They can be swapped at runtime from within middleware — useful for pages that need a completely different layout or no layout at all:
// In blank middleware — strips header and footer for the welcome page
final class blank extends Middleware
{
protected function result(): bool
{
$this->changeHeaderTemplateClassName( blank_page::class );
$this->changeFooterTemplateClassName( blank_page::class );
return true;
}
}
blank_page is an empty view class that outputs nothing — effectively giving the page full control over its own HTML structure.
Engine templates (*.html.twig, *.blade.php) follow the same layout by default. For a full-page template that provides its own document, skip the chrome per route instead of swapping template classes:
return $this->render( 'marketing/landing.html.twig', useLayout: false );
See Template Engines for details.
Since views are regular PHP classes, you can add methods:
/**
* @property User[] $players
*/
final class leaderboard_page extends AbstractView
{
private function getRankClass( int $rank ): string
{
return match ( true ) {
$rank === 1 => 'gold',
$rank === 2 => 'silver',
$rank === 3 => 'bronze',
default => 'standard'
};
}
private function formatScore( int $score ): string
{
return number_format( $score, 0, '.', ',' );
}
public function show(): void { ?>
<table class="leaderboard">
<?php foreach ( $this->players as $rank => $player ) : ?>
<tr class="<?= $this->getRankClass( $rank + 1 ) ?>">
<td><?= $rank + 1 ?></td>
<td><?= $player->getLogin() ?></td>
<td><?= $this->formatScore( $player->getScore() ) ?></td>
</tr>
<?php endforeach ?>
</table>
<?php }
}
Whether to put logic in views or keep them as pure display is a team decision. The framework doesn't enforce either approach — use whatever works for your context.
The framework provides global helper functions for use inside views.
Engine templates get the same helpers: Blade compiles to plain PHP, so every function below is directly callable ({{ pageTitle() }}); Twig gets them as registered functions (pageTitle, csrf_token, manifest_asset, manifest_has, _t, route_link) via App\Twig\PhpSfHelpersExtension. See Template Engines.
pageTitle(): string
Returns the current page title set by the controller, falling back to APPLICATION_NAME:
<title><?= pageTitle() ?></title>
getErrors( string $errorType = null ): array|string|false
getMessages( string $messageType = null ): array|string|false
Retrieve flash errors and messages passed via redirect. Returns all errors/messages when called with no argument, or a specific type when a type is passed:
// Get all errors
$errors = getErrors();
// Get errors of a specific type
$danger = getErrors( RedirectResponse::ALERT_DANGER );
// Typical usage in a view
<?php if ( $errors = getErrors() ) : ?>
<?php foreach ( $errors as $error ) : ?>
<div class="alert alert-danger"><?= $error ?></div>
<?php endforeach ?>
<?php endif ?>
formValue( string $name ): string
Returns the previously submitted value for a form field, or an empty string if none. Automatically called by input() and formInput() when no default value is provided:
// Manually
<input type="text" name="email" value="<?= formValue( 'email' ) ?>">
// Automatically via input() helper
<?= input( 'email', [ 6, 50 ], 'email' ) ?>
This is not a Symfony built-in — it's framework-specific behaviour that makes form UX significantly better with no extra controller code. When a form fails validation and the user is redirected back, all their previously entered values are automatically repopulated. If you're coming from vanilla Symfony and wondering where this feature is — it's here, not there.
input(
string $name,
array $length = [ 1, 255 ],
string $type = 'text',
bool $isRequired = true,
array $minMax = [],
string|int|float $defaultValue = null,
string $placeholder = null,
int|float $step = null,
array $classes = [],
array $styles = [],
string $id = null,
array $customAttributes = []
): string
Generates an HTML <input> element and returns it as a string. Automatically populates value from formValue() when no $defaultValue is provided:
// Basic text input
<?= input( 'email', [ 6, 50 ], 'email' ) ?>
// Password input, not required
<?= input( 'password', [ 6, 50 ], 'password', false ) ?>
// Number input with min/max and step
<?= input( 'amount', [ 1, 10 ], 'number', true, [ 1, 100 ], step: 1 ) ?>
// With classes and placeholder
<?= input( 'username', [ 2, 35 ], 'text', true,
placeholder: 'Enter username',
classes: [ 'form-control', 'username-field' ]
) ?>
// With custom attributes
<?= input( 'search', customAttributes: [ 'data-autocomplete' => 'true' ] ) ?>
Throws InvalidArgumentException if:
$length is not an array of exactly two integers$minMax is not empty and not an array of exactly two integers/floats$classes contains duplicate values$classes contains non-string values$styles or $customAttributes contain non-string keys or values$type is number and $step is not providedformCheckbox(
string $name,
bool $isRequired = true,
?bool $checked = null,
array $classes = [],
array $styles = [],
string|int $id = '',
array $customAttributes = []
): void
Generates a checkbox <input> and echoes it (returns void, so call it with <?php ... ?> rather than <?= ... ?>). When $checked is null the box is auto-checked from the previously submitted form value (formValue( $name ) === 'on'), so failed-validation redirects repopulate it:
<?php formCheckbox( 'accept_terms' ) ?>
<?php formCheckbox( 'newsletter', checked: true ) ?>
asset( string $path ): string
Returns the public URL for an asset file, with existence validation. Throws FileNotFoundException if the file doesn't exist in public/:
<link rel="stylesheet" href="<?= asset( 'css/app.css' ) ?>">
<script src="<?= asset( 'js/app.js' ) ?>"></script>
<img src="<?= asset( 'images/logo.png' ) ?>" alt="Logo">
num_format(
int|float|null $number,
int $decimals = 2,
string $decimal_separator = ',',
string $thousands_separator = ' '
): string
Formats a number for display with configurable separators. Returns '0' for null input:
<?= num_format( 1234567 ) ?> // "1 234 567"
<?= num_format( 1234.567, 2 ) ?> // "1 234,57"
<?= num_format( null ) ?> // "0"
routeLink(
string $routeName,
array $pathParams = [],
array $queryParams = [],
string|null $siteUrl = null
): string
Generates a URL for a named route. Cached in Redis/APCu after first call:
<a href="<?= routeLink( 'player_profile', [ 'id' => $player->getId() ] ) ?>">
<?= $player->getLogin() ?>
</a>
<a href="<?= routeLink( 'search', [], [ 'q' => 'swords', 'page' => '1' ] ) ?>">
Search
</a>
_t( string $id, array $parameters = [] ): string
_tt( string $id, string $translateTo, array $parameters = [] ): string
Translate a key to the current locale, or to a specific locale. Parameters use {name} syntax:
<h1><?= _t( 'common.welcome' ) ?></h1>
<p><?= _t( 'common.greeting', [ 'name' => $player->getLogin() ] ) ?></p>
// Translate to a specific locale regardless of current session locale
<span><?= _tt( 'common.confirm', 'pl' ) ?></span>
See Translation for the full translation system documentation.
Response::$activeTemplates tracks every view class instantiated during a request. In DEV_MODE this is displayed in the footer for admin users — useful for understanding which components rendered and in what order:
// In footer.php — admin only
<?php if ( User::isAdmin() ) : ?>
<?php dump( Response::$activeTemplates ) ?>
<?php endif ?>
This is populated automatically by AbstractView::__construct() — no manual tracking needed.
Forgetting @property docblocks — views still work without them but you lose all IDE autocompletion and type checking for $this->data properties. Always add docblocks for data properties, especially in larger views.
Using echo outside show() — output generated outside the show() method won't be captured by the output buffer started in Response::send(). This can cause headers-already-sent errors or output appearing in the wrong place. All output must happen inside show().
Accessing undefined properties without isset() — $this->optionalProperty where optionalProperty wasn't passed in the data array throws InvalidArgumentException. Always guard optional data with isset( $this->optionalProperty ) before accessing it.
Putting heavy database queries in views — views should receive ready-to-display data from the controller. Running User::findAll() or complex queries inside show() makes performance profiling and caching difficult. Fetch data in the controller and pass it to the view.
Importing the same sub-view multiple times with different data — each import() call instantiates a new view object, which is fine. But if the imported view has expensive setup in its constructor, consider passing all needed data in a single import and letting the view handle iteration internally.
Extending a layout while the response still adds one — engine templates rendered with the default useLayout: true are fragments; a template that also {% extends %} / @extends a full-page layout produces a doubly wrapped document. Full-page templates need useLayout: false. See Template Engines.
render() and response types