Templates and rendering
Once an action has run and its view has decided what to show, a renderer turns a template into the bytes of the response. This page covers that last leg: the renderer, the template it runs, and how layouts and layers compose a page from nested pieces.
A few terms up front:
- Renderer — the object that takes a template plus the view’s data and produces output. Quiote’s default runs plain PHP templates.
- Template — the file the renderer runs (a
.phpfile for the default renderer). - Layout / layer — a page is usually a layout built from one or more layers (an outer shell wrapping inner content), rather than a single template file.
Nothing here is opinionated about your front end — the default is PHP files, and swapping in another renderer is a config change, not a code change.
How it fits in a request
Section titled “How it fits in a request”Rendering is the last real work in a request. It happens in the action phase of the middleware pipeline — you never call the renderer yourself; the framework does, once your view has run.
How the framework finds the renderer and template. Each output type (html, json, …) names a renderer in its output_types config. OutputTypeConfigHandler reads that config into an OutputType, and OutputType::getRenderer() instantiates the class you named (checking it extends Quiote\Renderer\Renderer). The template file is found by name: the action name plus the view name resolve to Modules/<Module>/Templates/<Action><View>.php (see Template location).
The path a request takes through it:
DispatchMiddlewareruns the action throughActionExecutor→ the action returns a view name →ActionExecutorresolves the view class and calls itsexecute<OutputType>()method (e.g.executeHtml()) → the view loads its layout and layers → each layer hands its template to the configured renderer → the rendered string is written to the PSR-7 response body.
For the complete picture see The request lifecycle and The middleware pipeline.
Renderers
Section titled “Renderers”A renderer extends the abstract Quiote\Renderer\Renderer base class (its one required method is render()). The kernel ships only the plain-PHP renderer; the rest are opt-in packages — install the package, then name its class in the output type:
| Renderer | Templates | Ships in |
|---|---|---|
Quiote\Renderer\PhpRenderer | Plain PHP (.php) — the default | the kernel |
Quiote\Renderer\Phptal\PhptalRenderer | PHPTAL | quioteframework/phptal |
Quiote\Renderer\Xslt\XsltRenderer | XSLT | quioteframework/xslt |
Quiote\Renderer\Twig\TwigRenderer | Twig | quioteframework/twig |
You select the renderer per output type in output_types (see Output types):
// Config/output_types.php — inside the output type's array'default_renderer' => 'php','renderers' => [ 'php' => ['class' => \Quiote\Renderer\PhpRenderer::class],],# Config/output_types.yaml — inside the output typedefault_renderer: phprenderers: php: class: Quiote\Renderer\PhpRenderer<!-- Config/output_types.xml — inside the <output_type> --><renderers default="php"> <renderer name="php" class="Quiote\Renderer\PhpRenderer" /></renderers>Template variables
Section titled “Template variables”The PhpRenderer exposes the view’s attributes to the template as a single array — $template by default:
<!DOCTYPE html><html lang="en"><head> <title><?php echo htmlspecialchars($template['title'] ?? '', ENT_QUOTES, 'UTF-8'); ?></title></head><body> <h1><?php echo htmlspecialchars($template['post']['heading'], ENT_QUOTES, 'UTF-8'); ?></h1></body></html>Where do those values come from? Everything the action set with setAttribute(), plus everything the view set, is visible in $template. The attribute the action set as post is $template['post'] in the template.
The variable name is configurable per renderer (var_name, default template), as are extract_vars (extract attributes into individual variables), slots_var_name, and default_extension.
Three things about that array are worth knowing:
- It is the attributes array itself, not a wrapper around it. Reading a key the action never set is an ordinary undefined-key warning — which is how a typo in a template gets found, so the renderer does not soften it. Use
?? ''where a value is genuinely optional, as thetitleabove does. - It is bound by reference, so a template writing to
$template['x']writes back to the attributes the view passed in — visible to the layers rendered after it. moduleNameandactionNameare filled in from the layer being rendered, since the attributes carry neither. An attribute the action set under either name wins; this only fills a gap.
Layouts and layers
Section titled “Layouts and layers”A view rarely renders a single file. A page is usually a layout made of layers — for example, an outer HTML shell wrapping an inner content layer. Layouts and their layers are declared in the output type:
// Config/output_types.php — inside the output type's array'default_layout' => 'default','layouts' => [ 'default' => [ 'layers' => [ 'content' => [], ], ],],# Config/output_types.yaml — inside the output typedefault_layout: defaultlayouts: default: layers: content: {}<!-- Config/output_types.xml — inside the <output_type> --><layouts default="default"> <layout name="default"> <layer name="content" /> </layout></layouts>In the view, loadLayout() reads this layout for the current output type and prepares its layers:
public function executeHtml(WebRequest $rd){ $this->loadLayout(); // prepare the "content" layer $this->setAttribute('title', 'Home'); // returning null renders the prepared layers}When executeHtml() returns nothing but layers are loaded, the framework renders each layer in order. The output of an inner layer is passed to the next as $inner — so an outer shell layer can wrap the content:
<!-- shell layer template --><!DOCTYPE html><html><body> <?php echo $inner; ?> <!-- rendered content layer --></body></html>This is why loadLayout() matters: without it, executeHtml() returning nothing produces an empty body, because there are no layers to render.
Template location
Section titled “Template location”Templates live in the module’s Templates/ directory, named after the action and view. An Index action returning the Success view renders Modules/Default/Templates/IndexSuccess.php. Keeping the names aligned is what keeps the wiring implicit — see Actions and views.
Slots: embedding one action in another
Section titled “Slots: embedding one action in another”A view can render the output of another action inline — a “slot”. Use it for shared fragments like a navigation bar or a sidebar widget that has its own action and view. Two entry points on View, both canonical — one just wraps the other:
public function renderSlot(string $moduleName, string $actionName, ?array $arguments = null, ?string $outputType = null): string{ $slotContent = $this->createSlotContent($moduleName, $actionName, $arguments, $outputType); return $slotContent->getContent();}Pick based on what you need next:
-
renderSlot($module, $action, $arguments, $outputType)returns the rendered string immediately. Use this when you just want the HTML now, e.g. to store as a regular view attribute:public function executeHtml(WebRequest $rd){$this->loadLayout();$this->setAttribute('nav', $this->renderSlot('Default', 'Navigation'));} -
createSlotContent($module, $action, $arguments, $outputType)returns aSlotRenderablevalue object without rendering it immediately. Use this to attach the slot to a named layer slot, letting the renderer decide when/how to insert it into the template:$this->getLayer('content')->setSlot('Attachments',$this->createSlotContent('Item', 'Attachment', ['item_id' => $item->getId()]));
Both are prepared by SlotMiddleware in the pipeline, so slots participate in the normal execution model rather than being a side channel.
Arguments and who can read them
Section titled “Arguments and who can read them”$arguments is applied via setParameter() onto the shared request before the slot’s action/view runs, then restored afterward — explicit, developer-supplied values, not raw HTTP input, and (like any setParameter() call) auto-whitelisted for strict-access reads. What actually runs to read them depends on whether the slot action is isSimple():
- The slot action is
isSimple()— the common case; most slots are static or purely presentational.execute*()never runs at all, so only the view can read the passed arguments, via$rd->getParameter('currentPage')inexecute()/executeHtml()/etc. - The slot action needs real business logic against those arguments — it must not be
isSimple(). Give it realregister{Method}Validators()for the parameter names it needs, the same as any other action;setParameter()’s auto-whitelisting already makes the arguments technically readable, but anisSimple()action has no code path left to read them from at all, and skipping real validators on a non-simple action forfeits the type/shape checking a validator would otherwise give you.
In short: renderSlot()/createSlotContent() for passing values in, isSimple() for whether any action code ever gets to see them once they’re there. A slot’s action/view runs as its own separate Action/View instance with its own private attributes, though — a slot can’t set an attribute that the top-level view or layout template will see. That isolation is exactly right for the slot’s own output, but wrong for one specific thing a slot-nested view often needs to do: register page-level CSS/JS. That’s what AssetRegistry is for.
Page assets: CSS and JavaScript
Section titled “Page assets: CSS and JavaScript”A view — top-level or nested inside a slot — registers assets for the page’s <head> through two methods on View:
class ChartSuccessView extends View{ public function executeHtml(WebRequest $rd) { $this->addCss('css/chart.css'); $this->addJavascript('js/d3.min.js'); $this->addJavascript('js/my-chart.js'); }}Unlike setAttribute(), there’s no immutability or isolation footgun here — call it and move on, no reassignment needed. addCss()/addJavascript() reach the request-scoped Quiote\Asset\AssetRegistry in the container directly rather than going through the view’s own attribute holder, so they work identically whether the view is the page’s top-level view or something rendered inside a slot: every node in the render tree resolves the same registry. The registry deduplicates at insertion time and preserves first-seen order — two different slots both needing d3.min.js render it once, at the position it was first needed.
Wire the registry into a renderer’s assigns to read it from a template, the same way the request is exposed as $rq:
// Config/output_types.php — inside the renderer's config'assigns' => [ 'request' => 'rq', 'asset_registry' => 'assets',],# Config/output_types.yaml — inside the renderer's configassigns: request: rq asset_registry: assets<!-- Config/output_types.xml — inside the <renderer> --><ae:parameter name="assigns"> <ae:parameter name="request">rq</ae:parameter> <ae:parameter name="asset_registry">assets</ae:parameter></ae:parameter>Then, typically in the layout’s shell template:
<?php foreach ($assets->css() as $href): ?> <link rel="stylesheet" type="text/css" href="<?= $href ?>"><?php endforeach; ?>
<?php foreach ($assets->javascript() as $src): ?> <script type="text/javascript" src="<?= $src ?>"></script><?php endforeach; ?>AssetRegistry has no priority/ordering beyond insertion order and no per-asset metadata (media queries, defer/async, integrity hashes) — reach for a small value object in place of the plain string href/src if you need those; there’s no built-in support for inline <script> blocks either. It’s request-scoped and cleared between requests in worker mode, the same as everything else covered in the worker-mode note.
Choosing a different renderer
Section titled “Choosing a different renderer”Because the renderer is per output type, you can render HTML with PHP templates and, say, a document export with XSLT — in the same app — by declaring each output type with its own renderer. Nothing in the action or view changes; they set attributes and return content, and the configured renderer decides how that becomes bytes.