PHP_SF controllers can render three kinds of views: classic plain-PHP class views, Twig templates, and Blade templates. The engine is chosen per render() call based on the template reference, so all three can coexist in one application — even on the same page.
This page covers the engine side. For class views see Views.
render() inspects the view argument. A class name goes through the classic class-view path; a template file name is dispatched to the first registered engine that supports it:
| Reference | Engine | Template location |
|---|---|---|
welcome_page::class |
Plain-PHP class view | templates/ |
'example/page.html.twig' |
Twig | templates_twig/ |
'example/page.blade.php' |
Blade (BladeOne) | templates_blade/ |
#[Route( url: 'dashboard', httpMethod: 'GET' )]
public function dashboard(): Response
{
return $this->render( 'dashboard/page.html.twig', [ 'user' => $user ] );
}
#[Route( url: 'settings', httpMethod: 'GET' )]
public function settings(): Response
{
return $this->render( 'settings/page.blade.php', [ 'user' => $user ] );
}
#[Route( url: 'profile', httpMethod: 'GET' )]
public function profile(): Response
{
return $this->render( player_profile_page::class, [ 'user' => $user ] );
}
Engines are optional. Built-in engines register themselves lazily on first use, and only when their backing library is installed:
symfony/twig-bundle (already in the template project) and a public container alias, which the template ships in config/services.yaml:Twig\Environment:
alias: twig
public: true
The framework fetches Symfony's configured twig service, so engine templates share the paths (config/packages/twig.yaml), extensions, and compiled cache with Twig used by Symfony controllers.eftec/bladeone (composer require eftec/bladeone) and a templates_blade/ directory in the project root. Compiled templates are cached in var/cache/bladeone/.If no engine supports the reference and it isn't a view class either, the classic path fails with the usual configuration error — typos in template names are loud, not silent.
By default an engine template renders as a fragment inside the app header/footer layout, exactly like a class view — including the wrapper div (its CSS class is derived from the template file name: dashboard/page.html.twig → <div class="page">):
// Fragment — wrapped in header/footer chrome
return $this->render( 'dashboard/page.html.twig', [ 'user' => $user ] );
When the template provides its own full document — Twig {% extends 'base.html.twig' %} or Blade @extends('layouts.app') — pass useLayout: false to skip the header/footer:
// Full page — the template owns the whole document
return $this->render( 'marketing/landing.html.twig', useLayout: false );
With useLayout: false the framework renders nothing around the engine output: no header, no footer, no wrapper div. The alternative — swapping header/footer classes globally or via middleware (blank middleware) — still works, but useLayout is per-route and needs no middleware.
API routes (#[RouteApi] — see Routing) never get header/footer regardless of useLayout.
pageTitle() keeps working for engine templates — render() stores the title before dispatching, so {{ pageTitle() }} in a template returns whatever was passed as $pageTitle.
The Symfony-configured environment, unmodified:
config/packages/twig.yaml (default path templates_twig/)asset(), path()/url(), form_* theming, trans(), dump(), twig/extra-bundle extrasvar/cache/<env>/twig, auto-reload in devPHP globals are not callable from Twig. The template project registers them as Twig functions in App\Twig\PhpSfHelpersExtension (auto-tagged via autowiring):
| Twig function | Wraps |
|---|---|
pageTitle() |
pageTitle() |
csrf_token() |
csrf_token() |
manifest_asset() |
manifest_asset() |
manifest_has() |
manifest_has() |
_t() |
_t() |
route_link() |
Router::getRouteLink() |
<h1>{{ pageTitle() }}</h1>
<input type="hidden" name="_token" value="{{ csrf_token() }}">
<link rel="stylesheet" href="{{ manifest_asset( 'app.css' ) }}">
<a href="{{ route_link( 'welcome_page' ) }}">Home</a>
Add more functions by extending that class — or register a separate Twig extension the usual Symfony way.
app.user, app.session, app.request — these read Symfony's security token and request stack, which PHP_SF routes don't populate. Expect null on pages rendered from PHP_SF controllers. app.environment and app.debug work.path() / url() — Symfony's router only knows PHP_SF routes that carry OpenAPI response attributes (they're the ones registered with Symfony). For anything else use route_link().csrf_token() here is the framework session token (validated by the csrf middleware), not Symfony's intention-based CSRF. Use the framework one in forms processed by PHP_SF routes.Blade runs on BladeOne — a single-class, dependency-free Blade compiler. Template references are paths relative to templates_blade/:
{{-- templates_blade/dashboard/page.blade.php --}}
<h2>Dashboard for {{ $user->getLogin() }}</h2>
<p>Page title: {{ pageTitle() }}</p>
@csrf
Data keys become $variables, PHP_SF global functions are callable directly (Blade compiles to plain PHP), and the usual directives work: @extends, @section/@yield, @include/@includeIf/@includeWhen/@includeFirst, @each, conditionals, loops with $loop, @php, @json, @verbatim, @push/@pushonce/@prepend/@stack, @class/@style/@checked/@selected/@disabled/@readonly/@required, classic @component/@slot.
@csrf emits the framework session token (csrf_token()), so it matches what the csrf middleware validates@error('key') reads the redirect error bag (getErrors()), first error for the key@auth / @guest / @can use BladeOne's own auth state — bridge it from PHP_SF auth by setting $blade->setAuth( ... ) if you need them (not wired by default)BladeOne is Blade-compatible, not Laravel:
<x-*> dynamic/anonymous/inline components, @props, @aware — unsupported (classic @component/@slot only)@env, @production, @once, @includeUnless — not implementedModes: MODE_AUTO in DEV_MODE (recompile when the template changes), MODE_FAST otherwise.
AbstractView::import() accepts engine templates too, so engines compose with class views in both directions:
// A plain-PHP class view importing a Twig partial and a Blade partial
final class settings_page extends AbstractView { public function show(): void { ?>
<h1>Settings</h1>
<?php $this->import( 'settings/_profile_form.html.twig' ) ?>
<?php $this->import( 'settings/_danger_zone.blade.php', [], false ) ?>
<?php } }
Imported engine templates follow the same wrapper rules as class views: <div class="..."> by default (class derived from the template file name), disabled with htmlClassTagEnabled: false. Parent view data merges into the template data, same as with class sub-views.
The template project ships a live demo of all three engines: /example/twig, /example/twig/standalone (useLayout: false), /example/blade, and /example/mixed (class view importing Twig + Blade partials).
Register any engine implementing PHP_SF\System\Interface\TemplateEngineInterface during kernel bootstrap:
interface TemplateEngineInterface
{
public function supports( string $template ): bool;
/** @param array<string, mixed> $data */
public function render( string $template, array $data = [] ): string;
}
// public/index.php
$kernel = ( new PHP_SF\Kernel() )
->addTemplateEngine( new MyLatteEngine() );
The registry resolves first-match in registration order — register a custom engine that claims .twig/.blade.php after understanding the built-ins claim those suffixes.
Each engine keeps its own compiled cache; the class-view TemplatesCache minifier does not apply to engine templates (they compile to PHP and cache themselves):
| Cache | Location | Cleared by |
|---|---|---|
| Twig compiled templates | var/cache/<env>/twig |
symfony:cache:clear |
| BladeOne compiled templates | var/cache/bladeone/ |
app:cache:clear |
| Class-view compiled templates | var/cache/templates/ |
app:cache:clear |
See DEV_MODE and caching for the full cache layout.
Passing a class name with an engine suffix — 'App\View\page' is a class reference, 'page.html.twig' is a template reference. The dispatcher only looks at the suffix; a class named like a template file would be misrouted. Keep class views as ::class constants.
Extending a layout while leaving useLayout on — the template's <html> renders inside the framework's <html>, producing a doubly wrapped document. Full-page templates need useLayout: false.
Using Symfony's csrf_token() intention semantics in PHP_SF forms — the Twig function exposed here returns the framework session token. That's deliberate: forms processed by PHP_SF routes are validated by the csrf middleware against that token.
Expecting path() to know every framework route — it only knows routes registered with Symfony (OpenAPI-annotated). Use route_link() for the rest.
show(), import(), layout systemrender() and response types