slim/php-view
slim/php-view is a lightweight view renderer for Slim Framework 4 that uses plain PHP templates with no compilation step or custom syntax. It supports layouts and partials through the PhpRenderer class, making server-side rendering simple and fast for developers who prefer native PHP over template e...
Install and wire it up (Slim 4)
composer require slim/php-view
// public/index.php
use Slim\Views\PhpRenderer;
$container->set(PhpRenderer::class, function () {
return new PhpRenderer(__DIR__ . '/../templates', [
'siteName' => 'Acme',
]);
});
// templates/home.php
<h1><?= htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') ?></h1>
<p>Hello, <?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?></p>
// src/Application/Actions/HomeAction.php
public function __invoke(Request $request, Response $response): Response
{
return $this->view->render($response, 'home', ['name' => 'Josh']);
}
First things to read
Slim\Views\PhpRenderer itself — it is roughly 100 lines. Reading it end to end (constructor, render(), getTemplateFile(), fetch()) tells you everything the package does, including exactly how data becomes variables.templates/ directory layout. There is no convention imposed, so decide one now (flat files vs. pages/, partials/, layouts/ subfolders).First real use case: replacing hand-rolled ob_start(); include ...; ob_get_clean() in a controller with $view->render($response, 'template', $data). The win is that the renderer holds the template path and shared attributes, so controllers stop caring about filesystem paths.
Slim 3 variant (for older apps): register $container['view'] with the same class and call $this->view->render($response, 'home.phtml', $data) from a route callable. Slim 3's default slim/twig-view-style ViewInterface contract is satisfied by __invoke()/render(), so the wiring is drop-in.
Constructor injection beats container lookups. Bind PhpRenderer as a service and type-hint it in action constructors. If you use PHP-DI with autowiring, define the binding once with the template path so no action ever needs __DIR__ gymnastics.
Global attributes for cross-cutting view data. Anything every template needs — current user, CSRF token, flash messages, app version, locale — belongs in addAttribute()/setAttributes(), populated from a middleware rather than repeated in each action:
$app->add(function (Request $request, Handler $handler) {
$this->view->addAttribute('currentUser', $request->getAttribute('user'));
$this->view->addAttribute('flash', $this->flash->getMessages());
return $handler->handle($request);
});
Note the merge order: attributes are merged first, then route data, so per-render data wins — a good default, but it means an action can accidentally shadow siteName.
Layouts via include. The package has no layout/inheritance support. The idiomatic pattern is a two-file split where the layout includes the page:
// templates/layout.php
<!doctype html><html><body>
<?php include $templatePath . '/partials/header.php'; ?>
<?= $content ?>
</body></html>
In practice most people invert this: each page template does include $this->templatePath . '/partials/header.php' at the top and footer.php at the bottom, with templatePath exposed as an attribute. Exposing templatePath as a global attribute is the single most useful trick with this package.
Partials. include $templatePath . '/partials/card.php' inside a loop works because include inherits the calling scope's variables — so $item from your foreach is visible. Keep partials small and document which variables they require in a comment at the top; there's no compiler to catch a missing variable.
Escaping helper. Since nothing is escaped automatically, define one function and use it religiously:
function e(?string $v): string {
return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
Autoload it via composer.json files, or expose it as a closure attribute. For raw HTML fragments use <?= $body ?> deliberately and only on trusted, sanitized content.
Setting the content type yourself. PhpRenderer writes the body and returns the response — it does not touch headers. Add a Content-Type: text/html; charset=utf-8 header either in the action or, better, in a response-header middleware so you never forget it.
Rendering outside Slim. It only depends on Psr\Http\Message\ResponseInterface, so it works with Nyholm, Laminas Diactoros, or Guzzle PSR-7:
$response = (new \Nyholm\Psr7\Response())->withHeader('Content-Type', 'text/html');
$response = $view->render($response, 'report', ['rows' => $rows]);
Testing. Templates are PHP, so you can assert on rendered output without a browser:
$response = $view->render(new \Slim\Psr7\Response(), 'home', ['name' => 'Josh']);
$this->assertStringContainsString('Hello, Josh', (string) $response->getBody());
Golden-file tests over a templates/ fixtures directory catch accidental Undefined variable warnings before users do. Run the suite with error_reporting(E_ALL) and convert warnings to exceptions so a typo'd variable name fails the test instead of rendering an empty string.
Custom extension. For .phtml templates pass the third constructor argument: new PhpRenderer($path, [], 'phtml').
render($response, 'home') → templates/home.php. Passing 'home.php' yields home.php.php on most versions, because getTemplateFile() blindly concatenates path . DIRECTORY_SEPARATOR . template . '.' . extension. Always pass the bare name.<?= $foo ?> is an XSS hole if $foo is user data. This is the number-one source of bugs with this package. Audit templates with a grep for <?= and confirm each one is either wrapped in e() or provably safe.extract() collisions. fetch() runs extract($data) after ob_start() in a scope that already has $templateFile and $data. A data key named templateFile or data silently clobbers those locals. Also, extract() skips keys that aren't valid PHP identifiers — 'user-name' or 'user.name' simply won't exist as a variable. Use underscore keys.write() appends, it doesn't replace. If anything upstream already wrote to the response body (streaming middleware, a debug dumper, a previous render() call), your HTML is concatenated onto it. Return a fresh response or use $response->getBody()->rewind()/->truncate(0) before rendering.RuntimeException, not a 404. In production with displayErrorDetails => false you get an opaque 500. If template names ever come from data, translate a missing-file exception into a 404 explicitly.'../../etc/passwd' resolves happily. Whitelist template names against a known array.fetch() visibility varies by release. In some versions it's public and you can render a partial to a string; in others it's protected. If you need a renderToString(), subclass and expose a method, or just use ob_start() + include in your own helper — don't build architecture around an inherited method whose visibility has changed.paths array. Organize with subdirectories ('admin/users') rather than expecting fallback directories. Trailing slashes in the constructor path produce double separators — harmless on Linux/macOS but sloppy; normalize with rtrim($path, '/\\').DIRECTORY_SEPARATOR, a template name written with forward slashes still works (Windows accepts both), but a path built in a test with forward slashes and compared as a string will fail on Windows. Use DIRECTORY_SEPARATOR in assertions or normalize.require plus output buffering. That's cheap, but pair it with OPcache (opcache.validate_timestamps=0 in prod after deploy) and be aware there is no fragment caching — if a partial is expensive, cache the data, not the template.Undefined variable warnings is easier if you dump the merged data first: var_dump(array_merge($view->getAttributes(), $data)) in a dev-only middleware. Nine times out of ten the key you set in the action doesn't match the variable name in the template.setTemplatePath(), setTemplateExtension(), addAttribute()/setAttributes(), and the protected fetch()/getTemplateFile(). If you need filters, sections, or auto-escaping, you've outgrown this package — that's the signal to move to Twig or Blade, not to bolt features onto PhpRenderer.How can I help you explore Laravel packages today?