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.
How a renderer fits in a request
Section titled “How a renderer fits in a request”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_typesconfig. When a view needs to render a layer,Quiote\Controller\OutputType::getRenderer($name)reads therenderersregistry for the current output type, instantiates the class you named (new $class()), and callsinitialize()on it. A layer can name a specific renderer; otherwise the output type’sdefault_rendereris used. - The path a request takes.
RoutingMiddlewareresolves the action → … →DispatchMiddlewareruns the action viaActionExecutor→ the action returns a view name → the view resolves its layers → for each layer,OutputType::getRenderer()selects your renderer → the layer callsrenderer->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.
The contract
Section titled “The contract”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:
<?phpnamespace 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.
The four inputs to render()
Section titled “The four inputs to render()”$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 otherget*/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 helperself::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:
| Property | Config key | Default | Meaning |
|---|---|---|---|
$this->varName | var_name | template | Key the whole $attributes array is exposed under (when not extracting). |
$this->slotsVarName | slots_var_name | slots | Key $slots is exposed under. |
$this->extractVars | extract_vars | false | If true, each attribute becomes its own top-level variable instead of one array under $varName. |
$this->defaultExtension | default_extension | class default | Template file extension, including the dot. |
$this->assigns | assigns | [] | 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.
Exposing framework objects with assigns
Section titled “Exposing framework objects with assigns”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:
- a
Contextmethod —correlation_idreachinggetCorrelationId(); - a container id spelled exactly as written —
request,user,routing,controllerare bound under those names; - that id camel-cased —
translation_managerreachingtranslationManager,asset_registryreachingassetRegistry.
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],# Config/output_types.yaml — inside the renderer's parametersassigns: routing: ro # $ro = the Routing request: rq # $rq = this request<!-- Config/output_types.xml — inside the <renderer> --><parameter name="assigns"> <parameter name="routing">ro</parameter> <!-- $ro = the Routing --> <parameter name="request">rq</parameter> <!-- $rq = this request --></parameter>Optional: a scaffold starter template
Section titled “Optional: a scaffold starter template”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\IReusableRendereris an empty marker interface. Implement it only when your instance is safe to reuse acrossrender()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 freshinitialize()) is constructed per render. The kernel’sPhpRendererand theXsltRendererpackage 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 callparent::reset().
If in doubt, skip the marker and accept per-render construction; it’s the safe default.
Wiring it into an output type
Section titled “Wiring it into an output type”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'], ],],# Config/output_types.yaml — inside the "html" output typedefault_renderer: mdrenderers: php: class: Quiote\Renderer\PhpRenderer md: class: App\Renderer\MarkdownRenderer parameters: var_name: data<output_type name="html"> <renderers default="md"> <renderer name="php" class="Quiote\Renderer\PhpRenderer" /> <renderer name="md" class="App\Renderer\MarkdownRenderer"> <parameter name="var_name">data</parameter> </renderer> </renderers></output_type>renderers[default]picks the renderer an output type uses when a view doesn’t name one explicitly; a layer can override with arendererattribute, so one output type can mix engines (an XSLT export beside PHP-rendered HTML).- Any
<parameter>children become the array passed toinitialize()— this is howvar_name,encoding,assigns, etc. reach your renderer. - At runtime
Quiote\Controller\OutputType::getRenderer($name = null)doesnew $class(), callsinitialize($context, $parameters), and caches the instance only if it implementsIReusableRenderer.
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 worked example
Section titled “A worked example”A renderer that runs a PHP template to produce Markdown, then converts it to HTML:
<?phpnamespace 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.
Shipping it as a package
Section titled “Shipping it as a package”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.json—type: library, andrequirethe kernel (quioteframework/quiote) plus your engine library. PSR-4 autoload a namespace likeVendor\Renderer\Engine\, mapped tosrc/.src/EngineRenderer.php— the renderer class (nothing more is required; there’s no plugin to register).README.md— theoutput_types.xmlsnippet to enable it, and any renderer-specific parameters (encoding, anenvelopetoggle, …).tests/— extendQuiote\Testing\UnitTestCase, build a realQuiote\View\FileTemplateLayerpointed 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.