slim/twig-view
Twig-View integrates the Twig templating engine with Slim Framework, enabling .twig template rendering in Slim 4 applications. It offers middleware, a configurable Twig environment, extensions, and flexible view rendering for clean, maintainable PHP apps.
Install the bridge; it pulls in twig/twig 3.x — 3.11.1 minimum as of slim/twig-view 3.4.1, which is a security-only release that raises the Twig floor to patch GHSA-6j75-5wfj-gh66 (a Twig sandbox/security fix):
composer require slim/twig-view
If you already have the package, the upgrade is:
composer update slim/twig-view twig/twig --with-dependencies
composer show twig/twig # confirm >= 3.11.1
There are no API, signature, or behavioural changes in 3.4.1 for the bridge itself — Twig::create(), render(), fetch(), and the url_for/full_url_for/is_current_url/current_url/get_uri functions are untouched. Treat this as a dependency-hygiene release, not a migration.
The code you’ll actually read is small: src/Twig.php (the renderer), src/TwigMiddleware.php (route-aware wiring), src/TwigRuntimeExtension.php (the url_for/full_url_for/is_current_url/current_url/get_uri functions). Everything else is stock Twig 3, so the Twig docs are your template reference.
Minimal wiring with Slim 4’s default container:
use DI\Container;
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
$container = new Container();
AppFactory::setContainer($container);
$app = AppFactory::create();
$twig = Twig::create(__DIR__ . '/../templates', [
'cache' => __DIR__ . '/../var/cache/twig',
'debug' => true,
'strict_variables' => true,
]);
$container->set(Twig::class, $twig);
$app->add(TwigMiddleware::createFromContainer($app)); // needs Twig::class in the container
$app->get('/hello/{name}', function ($request, $response, array $args) {
return $this->get(Twig::class)->render($response, 'hello.twig', [
'name' => $args['name'],
]);
})->setName('hello');
$app->run();
{# templates/hello.twig #}
<!doctype html>
<p>Hello {{ name }}</p>
<a href="{{ url_for('hello', {'name': name}) }}">permalink</a>
Two methods are your day-to-day interface:
render($response, 'page.twig', $data) — writes the rendered output to the response body and returns the PSR-7 response.fetch('page.twig', $data) — returns the rendered string. Use this for emails, PDFs, log messages, or JSON error bodies (fetch() then json_encode()).Your first useful swap-out: replace inline echo/heredoc HTML in a route with render(), then replace hard-coded links with url_for() so routes stay named rather than duplicated as strings.
Layouts and partials map cleanly to Blade muscle memory.
{# layout.twig #}
<html><body>{% block content %}{% endblock %}</body></html>
{# page.twig #}
{% extends 'layout.twig' %}
{% block content %}
{% include 'partials/nav.twig' with {'user': user} only %}
{% endblock %}
Use only deliberately: unlike Blade’s @include, Twig passes the parent context by default, which is convenient but makes templates implicitly coupled to every caller.
Macros are the closest thing to anonymous Blade components.
{% macro input(name, value = '', type = 'text') %}
<input type="{{ type }}" name="{{ name }}" value="{{ value }}">
{% endmacro %}
{% import 'forms.twig' as forms %}
{{ forms.input('email', user.email) }}
There are no class-based components with typed props and slots; if you need that, use {% embed %} or include with explicit variables.
Register application helpers as Twig functions/filters, usually grouped in one extension class:
$env = $twig->getEnvironment();
$env->addFunction(new \Twig\TwigFunction('asset', fn (string $p) => '/assets/' . ltrim($p, '/')));
$env->addFilter(new \Twig\TwigFilter('money', fn (int $cents) => number_format($cents / 100, 2)));
For helpers that need request-scoped services (auth user, CSRF token, translator), prefer a Twig runtime class plus a RuntimeLoaderInterface. It resolves lazily per call and avoids stuffing request data into globals. This is the same extension/runtime split Symfony uses; it feels heavier than Blade directives but pays off in long-running workers.
CSRF, flash, auth, translation are bring-your-own. Slim has no equivalent of @csrf, old(), session(), __(), @auth, or @can. The standard pattern is:
$env->addFunction(new \Twig\TwigFunction('csrf_field', function () use ($csrf) {
return sprintf(
'<input type="hidden" name="%s" value="%s">',
$csrf->getTokenName(),
$csrf->getTokenValue()
);
}, ['is_safe' => ['html']]));
Use is_safe => ['html'] instead of making templates call |raw; it keeps the trust boundary in PHP where you can see it. slim/flash pairs naturally: add a middleware that puts the flash messages into the environment (or a runtime object) before the handler runs, then {% for msg in flash.getMessages('success') %}.
Rendering errors and 404s. Either render inside the route:
return $twig->render($response->withStatus(404), 'errors/404.twig', ['path' => $request->getUri()->getPath()]);
or register a Twig-backed HTML error renderer with Slim’s ErrorHandler via registerErrorRenderer('text/html', MyTwigErrorRenderer::class). The second is what you want if you dislike Slim’s default error page.
Container and constructor injection. Closure routes are bound to the container, so $this->get(Twig::class) works. For class actions ($app->get('/', HomeAction::class)), constructor-inject Twig and let PHP-DI autowire it.
Middleware ordering. TwigMiddleware binds the Twig runtime extension to the current request URI and gives you route-aware functions. If is_current_url() / current_url() look wrong, that’s an ordering problem: Twig middleware is executing before routing has populated the route context. Slim’s middleware stack is LIFO (last added = outermost = first executed), so adjust the add() order and verify with a template that calls is_current_url() on a named route.
Testing. You don’t need a Slim app to test templates: Twig::create($fixtures, ['cache' => false]) + fetch() covers syntax, escaping, and partial inclusion. Use nyholm/psr7 or Slim’s response factory when you need to assert on render() output.
src/ that you write against; it only constrains twig/twig to >=3.11.1 to address GHSA-6j75-5wfj-gh66. The practical failure mode is not a runtime error but a Composer resolution error: if something else in your tree pins Twig lower, composer update slim/twig-view will refuse. Diagnose with composer why twig/twig and composer why-not twig/twig 3.11.1, then widen or bump the conflicting constraint (symfony/twig-bridge, twig/extra-bundle, and Twig-adjacent extensions are the usual suspects).SandboxExtension with a policy, user-editable templates, CMS snippets). If every template is a file you wrote and reviewed, the risk is lower — but there is no reason to sit on the old version, and the bump costs nothing.composer audit --locked (or --format=json for parsing) to the pipeline so the next Twig advisory surfaces as a red build rather than a release-notes read. roave/security-advisories as a dev dependency is the belt-and-braces alternative if you want the resolver to refuse vulnerable versions outright.slim/twig-view:3.4.0 to keep a twig/twig version another package demands re-opens the advisory. If a transitive dependency genuinely blocks 3.11.1, record it as a tracked risk with an owner and a deadline rather than silently downgrading.{{-- --}} is {# #}; {!! !!} is |raw; @extends/@section are {% extends %}/{% block %}; @include is {% include %}; filter is apply. There is no @php block by default, no @csrf, no @error, no @vite.{{ user.name }} tries array key, public property, name(), getName(), isName(). That means {{ user.delete }} or a getter with side effects is a real risk. Keep domain objects boring, or pass explicit view models/DTOs.strict_variables => true in development. Laravel/Blade quietly tolerates missing variables in many cases; Twig’s default is also lenient, which hides typos until production. Turn it on locally, and consider leaving it off in production to avoid 500s on optional data.dump() needs two things: 'debug' => true in the environment options and $twig->getEnvironment()->addExtension(new \Twig\Extension\DebugExtension()). It is not enabled by the Slim bridge automatically.cache path must be writable by the PHP user, same class of problem as storage/framework/views. In development 'cache' => false is fine; in production, compile once and keep the cache across requests. Clear it on deploy (the equivalent of php artisan view:clear) — symlink-based releases can otherwise leave compiled templates pointing at old absolute paths. Also clear it after this upgrade: compiled templates are keyed to the Twig version that produced them, and a stale cache makes it look like the bump didn’t take.render(..., 'index') won’t find index.twig unless you build a loader convention. Pass full filenames ('index.twig'), or register a custom loader/resolver.render() appends to the body. Calling it twice on the same response duplicates output. If you need to compose content, use fetch() and write once, or create a fresh response.url_for outside HTTP. CLI commands, queue jobs, and tests that render email templates have no Slim request, so url_for() will fail or produce unusable relative URLs. Configure a separate Twig environment for those contexts and use absolute URLs from config rather than router helpers.addGlobal() leaks into the next request. Use middleware to set/restore globals, or move request data into runtime extensions that read from the current request.html; in JS or attribute contexts use |e('js') / |e('html_attr'). This is the same discipline as Blade’s e() but Twig makes the context strategy explicit.$twig->getEnvironment()->getLoader()->addPath($path, 'admin') gives you @admin/dashboard.twig namespaces; addGlobal() for genuinely static config; ProfilerExtension + Twig\Profiler\Profile if you want per-template timings; and a custom ErrorRendererInterface if you want Twig-rendered 404/500 pages instead of Slim’s defaults.url_for('profile', {'name': name}) throws when the route name is missing or the parameter set is incomplete; that’s usually a better failure than a silently broken hard-coded URL, but it means renaming routes is a template-breaking change.How can I help you explore Laravel packages today?