Skip to content

Template Rendering

samuelgfeller edited this page Apr 22, 2024 · 18 revisions

Introduction

A template renderer fills the placeholders in the template files with data and generates the final HTML displayed in the browser.

There are different approaches to rendering HTML. One is to generate the HTML on the server and send it to the browser (SSR).

Another approach is to let the client handle rendering and managing the user interface (e.g. SPA).

Single Page Application (SPA) vs Server Side Rendering (SSR)

Single Page Application (SPA)

A single page application is a web application that loads a single HTML page and dynamically updates that page with JavaScript as the user interacts with the application.

The frontend interacts with the backend via API Ajax calls.

Authentication can be done via Bearer-Token in the Authorization header with JWT to respect the stateless RESTful API principle, but when sessions are involved like in a typical web application with login/logout functionality, stateless tokens are not suited. Server side session are better for this.

Server Side Rendering (SSR)

Server-side rendering (SSR) is the process of rendering web pages on a server and passing them to the browser (client-side), instead of rendering them in the browser.

Comparison

Both have their advantages and disadvantages, and the choice depends on the use-case.

For a simple website that should load fast for optimal SEO, SSR is the way to go.

Progressive Web Apps (PWA) run on the client and can be cached for offline use, which means that client-side rendering and communication via API calls is more suited.

SPAs are big frontend applications that are typically slower to load and more challenging to develop and maintain. Due to their size and many responsibilities, they require a JavaScript framework which adds overhead and complexity to the project.

But solely relying on SSR is not optimal either for interactive web applications. The user experience can benefit greatly from JavaScript DOM manipulation and Ajax calls.

A hybrid approach is the best solution for most web apps where the backend renders the pages while also enabling actions via Ajax calls without the need for constant page reloads.

Choosing the template renderer

The most popular template renderers for PHP projects are Twig used by the Symfony framework and Blade used by the Laravel framework.

When I chose libraries for the slim-example-project, the main criteria was that they are lightweight and as "native" as possible.
This means that the template renderer ideally uses PHP templates and doesn't require a custom syntax.

PHP-View is a lightning fast and simple template renderer that uses native PHP templates.
It comes with fewer features than other template renderers, but they can be added via small helper functions and middlewares.

The template renderer is easily replaceable with another renderer like Twig and Twig-View.

Setup

Configuration

The only configuration needed is the path to the template files.

File: config/defaults.php

$settings['renderer'] = [
    // Template path
    'path' => $settings['root_dir'] . '/templates',
];

Container instantiation

To use the render with the configured template path via Dependency Injection, it has to be added to the container.

File: config/container.php

use Psr\Container\ContainerInterface;
use Slim\Views\PhpRenderer;

return [
    // ...
    PhpRenderer::class => function (ContainerInterface $container) {
        $settings = $container->get('settings')['renderer'];
        return new PhpRenderer($settings['path']);
    },
];

Helper functions

Output escaping

The most important feature missing from PHP-View is the automatic escaping of HTML entities. Extra care has to be taken to make sure that all outputs are escaped to prevent XSS attacks.

Through composer autoloading, a file with globally available functions can be created which is handy for this task.

File: config/functions.php

function html(?string $text = null): string
{
    return htmlspecialchars($text ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

The function html() can now be used in the templates to escape HTML entities.

<?= html($value) ?>

Translations

The __() function is used to translate strings which is an alias for the gettext() function.

The Translations chapter describes how to set up and use translations with PHP templates.

PHP-View

Before looking at how the template renderer is used in the example project, let's look at how PHP-View works.

Template files

PHP-View uses native PHP templates, meaning they are simple PHP files with HTML and PHP code. The template files are stored in the configured template path templates/.
To differentiate them from other PHP files, they have the .html.php extension.

Inside the template files, PHP code can be used to access variables passed to the renderer and display them in the desired format.

For the IDE to recognize the variables, the template files can be annotated with the PHPDoc @var annotation at the top of the page.

Layouts

Oftentimes HTML content is repeated on multiple pages of the application like the header, footer, and navigation.
It wouldn't make sense to put this code in every template file.

Layouts allow us to define this HTML code once in a layout file and then include it in the template. Below is an example.

Note that the $content variable in the layout file is defined by PHP-View to insert the content of the template file that uses the layout.
This variable name should therefore not be used in any template file.

File: templates/layout.html.php

<?php
/**
 * @var \Slim\Views\PhpRenderer $this PhpRenderer instance
 * @var string $content PHP-View var page content
 * @var string $appName
 * @var string $pageTitle
 */
?>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="assets/css/style.css">
    <title><?= $appName ?> <?= $pageTitle ? " - $pageTitle" : '' ?></title>
</head>
<body>
    <main><?= $content ?></main>
</body>
</html>

Attributes

Attributes are variables that are passed to the template renderer. These attributes can then be accessed and used within the template (or layout) files.

The following example shows how to render a template file with the above layout and some attributes.

File: src/Application/Action/HomeAction.php

<?php

namespace App\Application\Action;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Views\PhpRenderer;

final readonly class HomeAction
{
    public function __construct(
        private PhpRenderer $renderer,
    ) {}

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface 
    {
        // Attributes array passed to the template renderer via the third parameter of the render method
        $attributes = [
            // Attributes that will be accessible as variables in the template with the key being the variable name
            'pageTitle' => 'Home', // $pageTitle -> 'Home'
            'name' => 'John Doe'
        ];
        // Attributes can also be added individually with the addAttribute() method
        $this->renderer->addAttribute('appName', 'Slim App');
        
        // Rendering the home.html.php template
        return $this->renderer->render($response, 'home/home.html.php', $attributes);
    }
}

File: templates/home/home.html.php

<?php
/**
 * @var \Slim\Views\PhpRenderer $this PhpRenderer instance
 * @var string $pageTitle
 * @var string $appName
 * @var string $name
 */
 
 // Set layout
$this->setLayout('layout.html.php')
?>

<h1><?= $pageTitle ?></h1>

<p>Welcome to the <?= $appName ?>, <?= $name ?>!</p>

The rendered HTML will look like this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="assets/css/style.css">
    <title>Slim App - Home</title>
</head>
<body>
<main>
    <h1>Home</h1>
    <p>Welcome to the Slim App, John Doe!</p>
</main>
</body>
</html>

Nested templates

To simplify the structure of layouts or templates, partial code can be extracted into separate template files.
They can be included anywhere in the template via the fetch() method.

<?= $this->fetch('header.html.php', ['additionalAttribute' => 'Value']) ?>

The attributes available in the template calling the fetch() method are also available inside the fetched template.

Asset handling

Asset versioning

Hard-coding the asset paths in templates is not practical mainly because of the versioning issue.
Browser cache assets, so they don't have to be re-downloaded on every page load.
Therefore, when a JS or CSS file is updated, it is important that the browser fetches the latest version on the next page load.
This can be achieved by appending a GET parameter to the asset link (e.g. ?v=1.0.0).

The current version is specified in the config file config/default.php and has to be updated each time a new version is deployed.

To keep the templates lean, the asset paths are added to the PhpRenderer as attributes within the template, and the version is appended to the path in another template file templates/layout/assets.html.php.

Include JS and CSS files

At the top of each template file, the list of required stylesheets, JS scripts and JS modules are added as attributes to the PhpRenderer instance which can be accessed via $this in the template files.

Libraries can be downloaded and their minified version added like the other JS / CSS assets.

File: templates/module/template.html.php

<?php $this->setLayout('layout.php');

// CSS files
$this->addAttribute('css', ['assets/dashboard/dashboard.css', 'assets/lib/tailwind.min.css',]);  
// JS files
$this->addAttribute('js', ['assets/path/to/file.js', 'assets/lib/library.min.js',]);  
// JS modules
$this->addAttribute('jsModules', ['assets/dashboard/dashboard-main.js',]);  
?>

<h1>Page content</h1>

Render JS and CSS links

The assets from the templates are now available via the $css, $js and $jsModules variables set above.

The layout file fetches templates/layout/assets.html.php which is responsible for rendering the asset paths and adding the version GET parameter in the <head> section.

If all templates using a certain layout rely on a JS or CSS file or library, they can be added to the $layoutCss and $layoutJs arrays in the layout file.

File: templates/layout.html.php

<?php 
/**
 * @var \Slim\Views\PhpRenderer $this PhpRenderer instance
 * @var string $content PHP-View var page content
 * @var array $css CSS files added in the template
 * @var array $js JS files added in the template
 * @var array $jsModules JS modules added in the template
 */ 
?>
<!DOCTYPE html>
<html>
<head>
    <!-- ... -->
    <?php
    // Layout assets that are loaded for all templates with this layout
    $layoutCss = ['assets/general/general-css/layout.css', 'assets/lib/library.min.css'];
    $layoutJs = ['assets/navbar/navbar.js', 'assets/lib/library.min.js'];
    $layoutJsModules = ['assets/general/general-js/default.js',];

    // Include template that renders the asset paths
    echo $this->fetch(
        'layout/assets.html.php', 
        [ // Merge layout assets and assets required by templates (added via $this->addAttribute())
            'stylesheets' => array_merge($layoutCss, $css ?? []),
            'scripts' => array_merge($layoutJs, $js ?? []),
            // The type="module" allows the use of import and export inside a JS file.
            'jsModules' => array_merge($layoutJsModules, $jsModules ?? []),
        ]
    );
    ?>
</head>
<body>
    <!-- ... -->
</body>
</html>

File: templates/layout/assets.html.php

<?php
/**
 * @var $stylesheets array stylesheet paths
 * @var $scripts array script paths
 * @var $version null|string app version
 */

// CSS stylesheets
foreach ($stylesheets ?? [] as $stylesheet) {
    echo '<link rel="stylesheet" type="text/css" href="' . $stylesheet . ($version ? '?v='. $version : '') . '">';
}

// JavaScript files
foreach ($scripts ?? [] as $script) {
    // With "defer" the script is downloaded in parallel to parsing the page and executed after the page has finished parsing
    echo '<script defer src="' . $script . ($version ? '?v='. $version : '') . '"></script>';
}

// JavaScript module files
foreach ($jsModules ?? [] as $modulePath) {
    echo '<script defer type="module" src="' . $modulePath . ($version ? '?v='. $version : '') . '"></script>';
}

JS import cache busting

One of the remarkable aspects of ES6 is the import statement, as it simplifies the utilization of code from other JavaScript files without the need for explicit requirement in the template.
Since the files are imported in the JS files and not in the template, the versioning solution described above does not work for JS modules.
The version number has to be updated in the import statement of the JS module.

Usually this problem is solved by an asset bundler like Webpack and I considered using one mainly for this reason.
But that would add quite some complexity to the project and a heavy dependency, so I tried to find a simpler solution.

Appending a version number to import paths can be done with a simple PHP script and the right regex pattern. This removes the need for an asset bundler for cache busting.
Although there are other reasons to use an asset bundler, such as the minification of JS files, this isn't a priority for the slim-example-project, which is designed to keep things as simple as possible.

The PHP script JsImportCacheBuster.php traverses through all JavaScript files and updates the version GET parameter in the import statements.

After a version bump in the config file, any page on the development machine must be loaded once before pushing / deploying in order for the JS modules to be updated.

File: config/defaults.php

$settings['deployment'] = [
    // ...
    // Application version number
    'version' => 'x.x.x',
];

Enable the JS import cache buster in the development environment.

File: config/env/env.dev.php

// ...

// Enable JS import cache busting
$settings['deployment']['update_js_imports_version'] = true;

The cache buster should be disabled in production.

File: config/env/env.prod.php

// ...
// Disable JS import cache busting
$settings['deployment']['update_js_imports_version'] = false;
File: src/Infrastructure/Utility/JsImportCacheBuster.php
<?php

namespace App\Infrastructure\Utility;

use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * Adds version number to js imports to break cache on version change.
 */
final class JsImportCacheBuster
{
    private ?string $version;
    private string $assetPath;

    public function __construct(Settings $settings)
    {
        $deploymentSettings = $settings->get('deployment');
        $this->version = $deploymentSettings['version'];
        $this->assetPath = $deploymentSettings['asset_path'];
    }

    /**
     * All js files inside the given directory that contain ES6 imports
     * are modified so that the imports have the version number at the
     * end of the file name as query parameters to break cache on
     * version change.
     * This function is called in PhpViewMiddleware only on dev env.
     * Performance wise, this function takes between 10 and 20ms when content
     * is unchanged and between 30 and 50ms when content is replaced.
     *
     * @return void
     */
    public function addVersionToJsImports(): void
    {
        if (is_dir($this->assetPath)) {
            $rii = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($this->assetPath, FilesystemIterator::SKIP_DOTS)
            );

            foreach ($rii as $file) {
                $fileInfo = pathinfo($file->getPathname());

                if (isset($fileInfo['extension']) && $fileInfo['extension'] === 'js') {
                    $content = file_get_contents($file->getPathname()) ?: '';
                    $originalContent = $content;
                    // Matches lines that have 'import ' then any string then ' from ' and single or double quote opening then
                    // any string (path) then '.js' and optionally v GET param '?v=234' and '";' at the end with single or double quotes
                    preg_match_all('/import (.|\n|\r|\t)*? from ("|\')(.*?)\.js(\?v=.*?)?("|\');/', $content, $matches);
                    // $matches is an array that contains all matches. In this case, the content is the following:
                    // Key [0] is the entire matching string including the search
                    // Key [1] first variable unknown string after the 'import ' word (e.g. '{requestDropdownOptions}', '{createModal}')
                    // Key [2] single or double quotes of path opening after "from"
                    // Key [3] variable unknown string after the opening single or double quotes after from (only path) e.g.
                    // '../general/js/requestUtil/fail-handler'
                    // Key [4] optional '?v=2' GET param and [5] closing quotes
                    // Loop over import paths
                    foreach ($matches[3] as $key => $importPath) {
                        $oldFullImport = $matches[0][$key];
                        // Remove query params if version is null
                        if ($this->version === null) {
                            $newImportPath = $importPath . '.js';
                        } else {
                            $newImportPath = $importPath . '.js?v=' . $this->version;
                        }
                        // Old import path potentially with GET param
                        $existingImportPath = $importPath . '.js' . $matches[4][$key];
                        // Search for old import path and replace with new one
                        $newFullImport = str_replace($existingImportPath, $newImportPath, $oldFullImport);
                        // Replace in file content
                        $content = str_replace($oldFullImport, $newFullImport, $content);
                    }
                    // Replace file contents with modified one if there are changes
                    if ($originalContent !== $content) {
                        file_put_contents($file->getPathname(), $content);
                    }
                }
            }
        }
    }
}

Other assets

Image and other paths are directly linked in the templates' HTML tags (e.g. <img src="assets/module/images/image.png">). In certain IDEs like PHPStorm, the public/ directory can be marked as Resource Root, enabling automatic path completion.
The base path is always the public directory.

When an asset is refactored (renamed or moved), the path is automatically updated wherever the IDE recognizes the asset path. This functionality works when linking to assets directly in the HTML src or href tags but not for JS and CSS file paths added via the PhpRenderer css, js and jsModule attributes. These have to be updated manually.

Template renderer middleware

Some attributes, such as the route parser required to generate URLs and other values that the layout may need, should be available for every page.

These are added to the PhpRenderer instance with the PhpViewMiddleware.

This middleware also calls the JsImportCacheBuster (if it's enabled) to update the version GET parameters in the import statements of the JS modules.

File: src/Application/Middleware/PhpViewMiddleware.php

<?php

namespace App\Application\Middleware;

use App\Infrastructure\Utility\JsImportCacheBuster;
use App\Infrastructure\Utility\Settings;
use Odan\Session\SessionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\App;
use Slim\Interfaces\RouteParserInterface;
use Slim\Routing\RouteContext;
use Slim\Views\PhpRenderer;

final readonly class PhpViewMiddleware implements MiddlewareInterface
{
    /** @var array<string, mixed> */
    private array $publicSettings;
    /** @var array<string, mixed> */
    private array $deploymentSettings;

    public function __construct(
        private App $app,
        private PhpRenderer $phpRenderer,
        private SessionInterface $session,
        private JsImportCacheBuster $jsImportCacheBuster,
        Settings $settings,
        private RouteParserInterface $routeParser
    ) {
        $this->publicSettings = $settings->get('public');
        $this->deploymentSettings = $settings->get('deployment');
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $loggedInUserId = $this->session->get('user_id');
        // The following has to work even with no connection to mysql to display the error page (layout needs those attr)
        $this->phpRenderer->setAttributes([
            'version' => $this->deploymentSettings['version'],
            'uri' => $request->getUri(),
            'basePath' => $this->app->getBasePath(),
            'route' => $this->routeParser,
            'currRouteName' => RouteContext::fromRequest($request)->getRoute()?->getName(),
            'flash' => $this->session->getFlash(),
            // Used for public values used by view like company email address
            'config' => $this->publicSettings,
            'authenticatedUser' => $loggedInUserId,
        ]);

        // Add version number to js imports
        if ($this->deploymentSettings['update_js_imports_version'] === true) {
            $this->jsImportCacheBuster->addVersionToJsImports();
        }

        return $handler->handle($request);
    }
}

Handling various response formats

The backend does not only respond with rendered PHP templates.
Ajax calls, for instance, expect JSON responses. Other actions might require a redirection to another page.

The folder src/Application/Responder contains classes that handle the different response types.

  • Template renderer
  • JSON encoder
  • Redirect handler

Template renderer

The TemplateRenderer class is a wrapper around the PhpRenderer and provides methods used by the Action controllers to render the templates.

The class contains the render() method as well as functions to help render validation errors and security exceptions.

File: src/Application/Responder/TemplateRenderer.php
<?php

namespace App\Application\Responder;

use App\Domain\Security\Exception\SecurityException;
use App\Domain\Validation\ValidationException;
use Psr\Http\Message\ResponseInterface;
use Slim\Views\PhpRenderer;

final readonly class TemplateRenderer
{
    public function __construct(private PhpRenderer $phpRenderer) 
    {
    }

    public function render(ResponseInterface $response, string $template, array $data = []): ResponseInterface
    {
        return $this->phpRenderer->render($response, $template, $data);
    }

    public function addPhpViewAttribute(string $key, mixed $value): void
    {
        $this->phpRenderer->addAttribute($key, $value);
    }

    public function renderOnValidationError(
        ResponseInterface $response,
        string $template,
        ValidationException $validationException,
        array $queryParams = [],
        ?array $preloadValues = null,
    ): ResponseInterface {
        $this->phpRenderer->addAttribute('preloadValues', $preloadValues);

        // Add the validation errors to phpRender attributes
        $this->phpRenderer->addAttribute('validation', $validationException->validationErrors);
        $this->phpRenderer->addAttribute('formError', true);
        // Provide same query params passed to page to be added again after validation error (e.g. redirect)
        $this->phpRenderer->addAttribute('queryParams', $queryParams);

        // Render template with status code
        return $this->render($response->withStatus(422), $template);
    }

    public function respondWithFormThrottle(
        ResponseInterface $response,
        string $template,
        SecurityException $securityException,
        array $queryParams = [],
        ?array $preloadValues = null,
    ): ResponseInterface {
        $this->phpRenderer->addAttribute('throttleDelay', $securityException->getRemainingDelay());
        $this->phpRenderer->addAttribute('formErrorMessage', $securityException->getPublicMessage());
        $this->phpRenderer->addAttribute('preloadValues', $preloadValues);
        $this->phpRenderer->addAttribute('formError', true);

        // Provide same query params passed to page to be added again after validation error (e.g. redirect)
        $this->phpRenderer->addAttribute('queryParams', $queryParams);

        return $this->render($response->withStatus(422), $template);
    }
}

JSON encoder

When Actions should return a JSON response, the JsonResponder can be used to encode the data and add it to the response.

File: src/Application/Responder/JsonResponder.php
<?php

namespace App\Application\Responder;

use Psr\Http\Message\ResponseInterface;

final readonly class JsonResponder
{

    public function encodeAndAddToResponse(
        ResponseInterface $response,
        mixed $data = null,
        int $status = 200
    ): ResponseInterface {
        $response->getBody()->write((string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR));
        $response = $response->withStatus($status);

        return $response->withHeader('Content-Type', 'application/json');
    }
}

Redirect handler

The RedirectHandler class adds the Location header to the response with the given URL or route name which redirects the client to the given destination.

File: src/Application/Responder/RedirectHandler.php
<?php

namespace App\Application\Responder;

use Psr\Http\Message\ResponseInterface;
use Slim\Interfaces\RouteParserInterface;

final readonly class RedirectHandler
{

    public function __construct(private RouteParserInterface $routeParser)
    {
    }

    public function redirectToUrl(
        ResponseInterface $response,
        string $destination,
        array $queryParams = []
    ): ResponseInterface {
        if ($queryParams) {
            $destination = sprintf('%s?%s', $destination, http_build_query($queryParams));
        }

        return $response->withStatus(302)->withHeader('Location', $destination);
    }

    public function redirectToRouteName(
        ResponseInterface $response,
        string $routeName,
        array $data = [],
        array $queryParams = []
    ): ResponseInterface {
        return $this->redirectToUrl($response, $this->routeParser->urlFor($routeName, $data, $queryParams));
    }
}
Clone this wiki locally