<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Ssr on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/ssr/</link><description>Recent content in Ssr on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.160.0</generator><language>en-us</language><lastBuildDate>Mon, 06 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/ssr/feed.xml" rel="self" type="application/rss+xml"/><item><title>Remember when server-side rendering was just rendering?</title><link>https://jonesrussell.github.io/blog/waaseyaa-ssr-remember-when-rendering-was-just-rendering/</link><pubDate>Mon, 06 Apr 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/waaseyaa-ssr-remember-when-rendering-was-just-rendering/</guid><category>php</category><category>waaseyaa</category><blog:tag>php</blog:tag><blog:tag>waaseyaa</blog:tag><blog:tag>ssr</blog:tag><blog:tag>twig</blog:tag><description>How Waaseyaa&amp;rsquo;s SSR package renders HTML the way PHP always has, with Twig templates, field formatters, and a theme chain loader, no JavaScript runtime required.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p>Somewhere around 2016, &ldquo;server-side rendering&rdquo; stopped meaning &ldquo;the server renders HTML.&rdquo; It started meaning &ldquo;run your JavaScript framework on the server so it can produce the HTML that the browser will then throw away and rebuild.&rdquo; The industry just forgot what to call it after React came along.</p>
<p><a href="https://github.com/waaseyaa/waaseyaa">Waaseyaa&rsquo;s</a> SSR package does the original thing. A request comes in. PHP resolves a template. Twig renders HTML. The server sends it back. No hydration step, no virtual DOM diffing, no 200MB <code>node_modules</code> folder for the privilege of generating a <code>&lt;div&gt;</code>.</p>
<p>This post walks through how the rendering pipeline works: from request to HTML, with the entity renderer, field formatters, and theme chain loader that make it more than <code>echo</code> statements in a <code>.php</code> file.</p>
<h2 id="what-the-rendering-pipeline-actually-does">What the rendering pipeline actually does</h2>
<p>The entry point is <code>SsrPageHandler::handleRenderPage()</code>. It takes a path, an account, and an HTTP request. It returns an array with the rendered HTML, a status code, and headers. That&rsquo;s it.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">handleRenderPage</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $path,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">AccountInterface</span> $account,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">HttpRequest</span> $httpRequest,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $requestedViewMode <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;full&#39;</span>,
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span> {
</span></span></code></pre></div><p>The method signature tells you what matters: a path to render, who&rsquo;s asking, and what view mode they want. The return type is a structured array, not a framework-specific response object. The kernel decides how to send it.</p>
<p>Between receiving the path and returning HTML, five things happen in sequence:</p>
<ol>
<li><strong>Language negotiation</strong> resolves the content language from URL prefixes and <code>Accept-Language</code> headers.</li>
<li><strong>Path alias resolution</strong> maps friendly URLs to entity references.</li>
<li><strong>Editorial visibility</strong> checks whether the current account can see the content.</li>
<li><strong>Entity rendering</strong> converts the entity into a Twig variable bag with formatted fields.</li>
<li><strong>Template resolution</strong> finds the most specific Twig template and renders it.</li>
</ol>
<p>If the path doesn&rsquo;t resolve to an entity, <code>RenderController</code> tries a path-based template instead. Visit <code>/about</code> and it looks for <code>about.html.twig</code>. Visit <code>/</code> and it looks for <code>home.html.twig</code>. No route file needed.</p>
<p>Steps 1 through 3 narrow down what to render. Step 4 is where it gets interesting.</p>
<h2 id="how-entities-become-template-variables">How entities become template variables</h2>
<p>The <code>EntityRenderer</code> is where the real work happens. It takes an entity and a view mode, and returns a flat array that Twig can consume directly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">render</span>(<span style="color:#a6e22e">EntityInterface</span> $entity, <span style="color:#a6e22e">ViewMode</span><span style="color:#f92672">|</span><span style="color:#a6e22e">string</span> $viewMode <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;full&#39;</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $mode <span style="color:#f92672">=</span> $viewMode <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">ViewMode</span> <span style="color:#f92672">?</span> $viewMode<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">name</span> <span style="color:#f92672">:</span> (<span style="color:#a6e22e">string</span>) $viewMode;
</span></span><span style="display:flex;"><span>    $entityTypeId <span style="color:#f92672">=</span> $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getEntityTypeId</span>();
</span></span><span style="display:flex;"><span>    $definition <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityTypeManager</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getDefinition</span>($entityTypeId);
</span></span><span style="display:flex;"><span>    $fieldDefinitions <span style="color:#f92672">=</span> $definition<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getFieldDefinitions</span>();
</span></span><span style="display:flex;"><span>    $display <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">viewModeConfig</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getDisplay</span>($entityTypeId, $mode);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... field formatting happens here ...
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;entity&#39;</span> <span style="color:#f92672">=&gt;</span> $entity,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;entity_type&#39;</span> <span style="color:#f92672">=&gt;</span> $entityTypeId,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;bundle&#39;</span> <span style="color:#f92672">=&gt;</span> $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">bundle</span>(),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;view_mode&#39;</span> <span style="color:#f92672">=&gt;</span> $mode,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;template_suggestions&#39;</span> <span style="color:#f92672">=&gt;</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">buildTemplateSuggestions</span>($entityTypeId, (<span style="color:#a6e22e">string</span>) $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">bundle</span>(), $mode),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;fields&#39;</span> <span style="color:#f92672">=&gt;</span> $fields,
</span></span><span style="display:flex;"><span>    ];
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The return value is a plain associative array. Every field gets three things: the raw value, a formatted string ready for output, and the field type. Your Twig template can use <code>{{ fields.body.formatted }}</code> for the processed HTML or <code>{{ fields.body.raw }}</code> when you need the original.</p>
<p>View mode configuration controls which fields appear and in what order. A <code>teaser</code> view mode might show only the title and summary. A <code>full</code> view mode shows everything. If no display configuration exists for a view mode, the renderer builds a sensible default from the entity&rsquo;s field definitions.</p>
<h2 id="field-formatters-type-safe-output-without-the-ceremony">Field formatters: type-safe output without the ceremony</h2>
<p>Each field type has a formatter that knows how to turn a raw value into safe HTML. The package ships with formatters for the common cases:</p>
<ul>
<li><code>PlainTextFormatter</code> for strings (with proper escaping)</li>
<li><code>HtmlFormatter</code> for rich text</li>
<li><code>DateFormatter</code> for timestamps</li>
<li><code>ImageFormatter</code> for image fields</li>
<li><code>BooleanFormatter</code> for flags</li>
<li><code>EntityReferenceFormatter</code> for relationships between entities</li>
</ul>
<p>The <code>FieldFormatterRegistry</code> maps field types to formatters. When the entity renderer processes a field, it asks the registry for the right formatter and calls it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$fields[$fieldName] <span style="color:#f92672">=</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;raw&#39;</span> <span style="color:#f92672">=&gt;</span> $raw,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;formatted&#39;</span> <span style="color:#f92672">=&gt;</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">formatterRegistry</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">format</span>($formatterType, $raw, $settings),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;type&#39;</span> <span style="color:#f92672">=&gt;</span> $fieldType,
</span></span><span style="display:flex;"><span>];
</span></span></code></pre></div><p>One line of code handles the dispatch. The formatter does the escaping, date formatting, or reference resolution. Your template never has to worry about whether a value is safe for output.</p>
<p>You can register custom formatters for domain-specific field types. The <code>#[AsFormatter]</code> attribute marks a class as a formatter, and the registry picks it up automatically.</p>
<h2 id="template-resolution-the-chain-loader">Template resolution: the chain loader</h2>
<p>Waaseyaa uses Twig&rsquo;s <code>ChainLoader</code> to search for templates in priority order. The <code>ThemeServiceProvider</code> builds the chain at boot:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">createTemplateChainLoader</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $projectRoot,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $activeTheme <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;</span>,
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">ChainLoader</span> {
</span></span><span style="display:flex;"><span>    $chain <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ChainLoader</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 1) App templates (highest priority)
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">addPathLoaderIfExists</span>($chain, $root <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;/templates&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 2) Active theme templates
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... discovered from composer metadata ...
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 3) Package templates
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... from packages/*/templates ...
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 4) Base SSR templates (lowest priority)
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">addPathLoaderIfExists</span>($chain, $root <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;/packages/ssr/templates&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> $chain;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Your application&rsquo;s <code>templates/</code> directory wins over everything. The active theme sits below that. Package templates come next. The base SSR package provides the fallback.</p>
<p>This means you can override any template at any level. Want a custom 404 page? Drop <code>404.html.twig</code> in your app&rsquo;s <code>templates/</code> directory. Want a theme to provide a default layout that individual apps can override? That works too.</p>
<p>Theme discovery reads <code>composer.json</code> metadata. Any package with a <code>waaseyaa.theme</code> key in its <code>extra</code> block is a theme candidate:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;extra&#34;</span>: {
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&#34;waaseyaa&#34;</span>: {
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&#34;theme&#34;</span>: {
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&#34;id&#34;</span>: <span style="color:#e6db74">&#34;my-theme&#34;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&#34;templates&#34;</span>: <span style="color:#e6db74">&#34;templates&#34;</span>
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No theme registry, no configuration file, no admin panel. Composer already knows what&rsquo;s installed. The SSR package just reads that.</p>
<h2 id="template-suggestions-specificity-without-complexity">Template suggestions: specificity without complexity</h2>
<p>When the entity renderer builds a variable bag, it also generates template suggestions, an ordered list of template filenames from most specific to least:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">buildTemplateSuggestions</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $entityTypeId,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $bundle,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $mode,
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>$entityTypeId<span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">{</span>$bundle<span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">{</span>$mode<span style="color:#e6db74">}</span><span style="color:#e6db74">.html.twig&#34;</span>,   <span style="color:#75715e">// node.article.teaser.html.twig
</span></span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>$entityTypeId<span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">{</span>$bundle<span style="color:#e6db74">}</span><span style="color:#e6db74">.full.html.twig&#34;</span>,       <span style="color:#75715e">// node.article.full.html.twig
</span></span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>$entityTypeId<span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">{</span>$mode<span style="color:#e6db74">}</span><span style="color:#e6db74">.html.twig&#34;</span>,              <span style="color:#75715e">// node.teaser.html.twig
</span></span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>$entityTypeId<span style="color:#e6db74">}</span><span style="color:#e6db74">.full.html.twig&#34;</span>,                 <span style="color:#75715e">// node.full.html.twig
</span></span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;entity.html.twig&#34;</span>,                               <span style="color:#75715e">// catch-all
</span></span></span><span style="display:flex;"><span>    ];
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>RenderController</code> walks this list and uses the first template that exists. Create <code>node.article.teaser.html.twig</code> and it renders article teasers. Remove it and the renderer falls through to the next match. You only create the templates you need.</p>
<h2 id="what-this-isnt">What this isn&rsquo;t</h2>
<p>This isn&rsquo;t PHP 4. There&rsquo;s no <code>&lt;?php echo $row['title'] ?&gt;</code> in a file that&rsquo;s also running SQL queries. The rendering layer is separate from data access, has proper escaping through Twig&rsquo;s auto-escape, supports i18n, and handles caching with surrogate keys for CDN invalidation.</p>
<p>But the fundamental model is the same one PHP has used since the beginning: the server receives a request, finds the right template, fills it with data, and sends HTML to the browser. The browser receives a fully rendered page and displays it. Nothing to hydrate. Nothing to rebuild.</p>
<p>The JavaScript ecosystem spent a decade reinventing this model and gave it a new name. Waaseyaa just kept doing it.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>