Skip to content

Writing a custom renderer

A renderer turns a template into an output string. The kernel ships one — Quiote\Renderer\PhpRenderer, for plain PHP templates — and PHPTAL, XSLT, and Twig are opt-in packages. Templates and rendering covers using them. This page is about writing your own — for a template language none of those cover (Blade, Markdown, Mustache), or a bespoke output format.

Renderers are chosen per output type through a plain config registry — there is no renderer plugin class. Writing one is two steps: implement the contract, then name your class in output_types.xml.

A renderer is the last link in producing the response body. It is never called directly by your code — the view layer calls it once it has data to render.

  • How it’s found and selected. Renderers aren’t discovered by scanning; they’re looked up by name from the output_types config. When a view needs to render a layer, Quiote\Controller\OutputType::getRenderer($name) reads the renderers registry for the current output type, instantiates the class you named (new $class()), and calls initialize() on it. A layer can name a specific renderer; otherwise the output type’s default_renderer is used.
  • The path a request takes.

RoutingMiddleware resolves the action → … → DispatchMiddleware runs the action via ActionExecutor → the action returns a view name → the view resolves its layers → for each layer, OutputType::getRenderer() selects your renderer → the layer calls renderer->render($layer, $attributes, $slots, $moreAssigns) → the returned string becomes the response body.

Because render() returns a string rather than writing to output, one output type can mix engines and the same renderer instance can be safely reused across requests under worker mode (see reuse below). For the full picture see The request lifecycle and The middleware pipeline.

A renderer extends the abstract Quiote\Renderer\Renderer (which lives in the kernel and is never extracted). Only render() is abstract; everything else has a working default:

abstract class Renderer extends ParameterHolder implements ResetInterface
{
public function initialize(Context $context, array $parameters = []): void;
public function getDefaultExtension(): string;
abstract public function render(
TemplateLayer $layer,
array &$attributes = [], // template variables, by reference
array &$slots = [], // slot output, by reference
array &$moreAssigns = [], // extra assigns (e.g. 'inner'), by reference
): string;
public function reset(): void; // worker-mode reuse (ResetInterface)
}

The smallest possible renderer:

<?php
namespace App\Renderer;
use Quiote\Renderer\Renderer;
use Quiote\View\TemplateLayer;
final class MyRenderer extends Renderer
{
protected $defaultExtension = '.my';
public function render(TemplateLayer $layer, array &$attributes = [], array &$slots = [], array &$moreAssigns = []): string
{
return 'rendered: ' . $layer->getResourceStreamIdentifier();
}
}

render() returns the produced output as a string — it must never echo or exit. Returning a string rather than writing to output is what makes renderers safe under worker mode.

  • $layer — the layer being rendered. Call $layer->getResourceStreamIdentifier() for the resolved template path — already extension-resolved and existence-checked. Never do your own template-file lookup; resolution (search paths, i18n fallback) is the layer’s job, and a renderer that reimplements it will diverge from the rest of the app. The layer’s own identity is on typed methods — getName()/setName(), getModule()/setModule(), getTemplate()/setTemplate()/hasTemplate()/removeTemplate(). As of 4.1 those are real methods rather than __call() magic decomposed at runtime, so a non-string value throws instead of coming back uncast, and no other get*/set* name resolves.
  • $attributes — the view’s data (what your template renders). Respect $this->extractVars / $this->varName (below) rather than hardcoding how the data is exposed — apps configure this per renderer and expect every engine to honour the same choice.
  • $slots — already-rendered output of any embedded actions, keyed by slot name. Expose under $this->slotsVarName.
  • $moreAssigns — extra caller-injected values; $moreAssigns['inner'] is the rendered inner layer that an outer shell wraps. Filter/rename it through the protected helper self::buildMoreAssigns($moreAssigns, $this->moreAssignNames).

Configuration: what initialize() gives you

Section titled “Configuration: what initialize() gives you”

Calling parent::initialize() (or simply not overriding it) reads the <parameter> children of your <renderer> config block into these properties, so you don’t reinvent them:

PropertyConfig keyDefaultMeaning
$this->varNamevar_nametemplateKey the whole $attributes array is exposed under (when not extracting).
$this->slotsVarNameslots_var_nameslotsKey $slots is exposed under.
$this->extractVarsextract_varsfalseIf true, each attribute becomes its own top-level variable instead of one array under $varName.
$this->defaultExtensiondefault_extensionclass defaultTemplate file extension, including the dot.
$this->assignsassigns[]Maps template-variable names to framework objects (see below).

initialize() throws a QuioteException if extractVars is false and varName === slotsVarName — they would collide in the template namespace.

An assigns block maps a short template variable name to something the framework already holds. initialize() resolves each config key three ways, in order, and keeps the first that answers:

  1. a Context method — correlation_id reaching getCorrelationId();
  2. a container id spelled exactly as written — request, user, routing, controller are bound under those names;
  3. that id camel-cased — translation_manager reaching translationManager, asset_registry reaching assetRegistry.

A key that matches none of the three falls through to moreAssignNames (renaming $moreAssigns keys instead). What initialize() stores is a resolver per variable, called at render time so the value is this request’s:

foreach ($this->assigns as $variable => $resolve) {
$engine->set($variable, $resolve());
}
// Config/output_types.php — inside the renderer's 'parameters'
'assigns' => [
'routing' => 'ro', // $ro = the Routing
'request' => 'rq', // $rq = this request
],

getStarterTemplate(): ?string is an opt-in hook (default null on the base Renderer class) a renderer can override to hand back a minimal, syntactically valid stub in its own templating syntax — for a scaffolding tool that needs to generate an engine-correct starter template for whichever renderer an output type is actually configured to use, not just the kernel’s native PHP one.

public function getStarterTemplate(): ?string
{
$expr = $this->extractVars ? '$title' : ('$' . $this->varName . "['title']");
return "<p><?php echo htmlspecialchars({$expr} ?? '', ENT_QUOTES, 'UTF-8'); ?></p>\n";
}

The kernel’s PhpRenderer, and the PhptalRenderer, TwigRenderer, and XsltRenderer packages, all implement it — each rendering a title variable per its own idiom, and honoring the instance’s configured $varName/$extractVars (above) the same way render() itself does.

make:action calls it: it resolves the renderer your app actually configures for the html output type and writes that renderer’s starter, under its getDefaultExtension(). A PHPTAL/Twig/XSLT app is therefore scaffolded a .tal/.twig/.xsl template rather than a .php file its renderer would never execute. External tooling (an IDE plugin, an MCP server) can call it the same way.

Leaving this at the default null is fine: make:action then writes no template at all and warns instead, naming the file and extension to author by hand. Guessing PHP syntax for a renderer known not to be PHP would only produce a file the app can never render.

Worker-mode reuse: IReusableRenderer and reset()

Section titled “Worker-mode reuse: IReusableRenderer and reset()”

Under a persistent worker (FrankenPHP, RoadRunner) a renderer instance can outlive a single request, so state hygiene matters.

  • Quiote\Renderer\IReusableRenderer is an empty marker interface. Implement it only when your instance is safe to reuse across render() calls in the same worker — it holds no per-render mutable state, or clears it every call. OutputType::getRenderer() checks for the marker: with it, one instance is built and reused; without it, a fresh instance (and a fresh initialize()) is constructed per render. The kernel’s PhpRenderer and the XsltRenderer package implement it; PHPTAL’s does not.
  • reset() runs between requests on a reused instance. Null out anything that must not leak into the next request — a stateful engine, per-render temp arrays — and always call parent::reset().

If in doubt, skip the marker and accept per-render construction; it’s the safe default.

Renderer selection is a plain config-driven registry, unrelated to the plugin system — no registrar call. Declare your class per output type in Config/output_types.{xml,php,yaml,yml}:

// Config/output_types.php — inside the "html" output type's array
'default_renderer' => 'md',
'renderers' => [
'php' => ['class' => \Quiote\Renderer\PhpRenderer::class],
'md' => [
'class' => \App\Renderer\MarkdownRenderer::class,
'parameters' => ['var_name' => 'data'],
],
],
  • renderers[default] picks the renderer an output type uses when a view doesn’t name one explicitly; a layer can override with a renderer attribute, so one output type can mix engines (an XSLT export beside PHP-rendered HTML).
  • Any <parameter> children become the array passed to initialize() — this is how var_name, encoding, assigns, etc. reach your renderer.
  • At runtime Quiote\Controller\OutputType::getRenderer($name = null) does new $class(), calls initialize($context, $parameters), and caches the instance only if it implements IReusableRenderer.

No schema change is needed — <renderer> with arbitrary nested <parameter> blocks is already open-ended. A third-party renderer therefore needs zero core integration beyond installing its package and adding these few lines.

A renderer that runs a PHP template to produce Markdown, then converts it to HTML:

<?php
namespace App\Renderer;
use Quiote\Renderer\{Renderer, IReusableRenderer};
use Quiote\View\TemplateLayer;
final class MarkdownRenderer extends Renderer implements IReusableRenderer
{
protected $defaultExtension = '.md.php';
public function render(
TemplateLayer $layer,
array &$attributes = [],
array &$slots = [],
array &$moreAssigns = [],
): string {
$template = $layer->getResourceStreamIdentifier();
if ($template === null || $template === '') {
return '';
}
// Build the template scope, honouring extract_vars / var_name / slots.
$scope = $this->extractVars ? $attributes : [$this->varName => $attributes];
$scope[$this->slotsVarName] = $slots;
// Run the PHP template to Markdown source in an isolated scope.
$markdown = (static function () use ($template, $scope) {
extract($scope, EXTR_SKIP);
ob_start();
require $template;
return ob_get_clean();
})();
return $this->toHtml($markdown);
}
private function toHtml(string $markdown): string { /* ... */ }
}

Because it holds no per-render state, it’s safe to mark IReusableRenderer. Nothing in your actions or views changes — they set attributes and return a view name; the configured renderer decides how that becomes bytes.

The official renderers — quioteframework/phptal, quioteframework/xslt, and quioteframework/twig — are just renderer classes packaged for Composer, and yours can follow the same shape:

  • composer.jsontype: library, and require the kernel (quioteframework/quiote) plus your engine library. PSR-4 autoload a namespace like Vendor\Renderer\Engine\, mapped to src/.
  • src/EngineRenderer.php — the renderer class (nothing more is required; there’s no plugin to register).
  • README.md — the output_types.xml snippet to enable it, and any renderer-specific parameters (encoding, an envelope toggle, …).
  • tests/ — extend Quiote\Testing\UnitTestCase, build a real Quiote\View\FileTemplateLayer pointed at a temp template, $layer->setRenderer($renderer), then call $renderer->render(...) (or $layer->execute(...)) and assert on the output.

The three shipped renderers make good references: phptal wraps phptal/phptal with a compiled-template cache under <core.cache_dir>/templates/phptal/; xslt wraps ext-xsl/ext-dom with no external library and envelope-wraps the inner content plus slots into one XML document (opt out via envelope=false); twig wraps twig/twig with a small TemplateLayerLoader that adapts Twig’s loader to Quiote’s layer resolution so i18n/fallback rules still apply.