The framework uses pure PHP class-based templates instead of a templating engine like Twig. Every view is a PHP class that extends AbstractView and implements a single show() method. This page explains how views work, how they compose, what helpers are available, and why this approach was chosen.
This is a deliberate design decision, not an oversight. Here's the reasoning:
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 triggers E_USER_ERROR via __get() on AbstractView, which is caught by the error handler and turned into a meaningful exception 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 use case it's overhead that buys nothing over plain PHP.
Twig is still available — if you want Twig for a specific page or use case, nothing stops you. Twig is installed and configured in templates_twig/. Use it for whatever makes sense. The framework's view system and Twig coexist without conflict — framework routes use PHP class views, Symfony routes can use Twig templates. Pick the right tool per situation.
// 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 triggers E_USER_ERROR via __get():
// AbstractView::__get()
public function __get( string $name ): mixed
{
if ( array_key_exists( $name, $this->data ) )
return $this->data[ $name ];
trigger_error( "Undefined Property `$name` in view: " . static::class, E_USER_ERROR );
}
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' ]
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:
$kernel = ( new PHP_SF\Kernel )
->setHeaderTemplateClassName( header::class )
->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.
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.
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 providedcheckbox(
string $name,
bool $checked = false,
string $id = null,
bool $isRequired = false
): string
Generates a styled checkbox input with a hidden native checkbox and a visible span for custom styling:
<?= checkbox( 'accept_terms', isRequired: true ) ?>
<?= checkbox( '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">
numFormat(
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:
<?= numFormat( 1234567 ) ?> // "1 234 567"
<?= numFormat( 1234.567, 2 ) ?> // "1 234,57"
<?= numFormat( 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 $stringName, ...$values ): string
_tt( string $stringName, string $translateTo, ...$values ): string
Translate a string to the current locale, or to a specific locale:
<h1><?= _t( 'Welcome to the game' ) ?></h1>
<p><?= _t( 'Hello, %s!', $player->getLogin() ) ?></p>
// Translate to a specific locale regardless of current session locale
<span><?= _tt( '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 will trigger E_USER_ERROR. 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.
Mixing Twig and framework views in the same response — framework view responses render the full page (header + view + footer) and call exit(). You cannot render a Twig template inside a framework view or vice versa within the same response. Use one system per response — framework views for framework controller routes, Twig for Symfony controller routes.