zenstruck/twig-service-bundle
Twig Service Bundle adds a service() function to Twig so templates can fetch Symfony services directly by ID, e.g. {{ service('my_service') }}, plus service_exists() for safe checks. It keeps controllers thin and avoids writing custom Twig extensions just to expose a service.
Install with Composer and register the bundle (Symfony Flex will do this automatically):
composer require zenstruck/twig-service-bundle
// config/bundles.php
return [
Zenstruck\TwigServiceBundle\ZenstruckTwigServiceBundle::class => ['all' => true],
];
Where to look first
config/packages/zenstruck_twig_service.yaml — this is where you register everything.vendor/zenstruck/twig-service-bundle/tests) if the README leaves a syntax question unanswered.First use case
Take a static helper you already have and expose it as a Twig function, replacing a full AbstractExtension subclass:
namespace App\Util;
final class Money
{
public static function format(int $cents, string $currency = 'USD'): string
{
return number_format($cents / 100, 2).' '.$currency;
}
}
# config/packages/zenstruck_twig_service.yaml
zenstruck_twig_service:
functions:
- [App\Util\Money, 'format'] # -> {{ format(1999) }}
<p>Total: {{ format(order.totalCents) }}</p>
That is the entire value proposition: you skip writing a dedicated Twig extension class, registering it as a service, and tagging it twig.extension.
Expose static helpers as functions/filters/tests
The common day-to-day pattern is a small static utility class (Money, Text, Dates, Url) with pure methods, wired once in YAML:
zenstruck_twig_service:
filters:
- [App\Util\Text, 'slugify'] # {{ post.title|slugify }}
- [App\Util\Text, 'excerpt'] # {{ body|excerpt(120) }}
functions:
- [App\Util\Money, 'format']
tests:
- [App\Util\Flags, 'isFeatureOn'] # {% if isFeatureOn('new_checkout') %}
Keep these classes pure and side-effect free. If a helper needs a dependency (clock, HTTP client, repository), it belongs in a service, not a static class.
Expose service methods for stateful or DI-backed work
For anything that needs injected dependencies — a Markdown renderer, a feature-flag client, a formatter reading config — register the service, and the bundle bridges it into Twig:
zenstruck_twig_service:
services:
App\Service\MarkdownRenderer:
- render
- renderInline
{{ page.body|markdown_rendered }}
This is the pattern that actually saves wiring: the alternative is a MarkdownTwigExtension that injects MarkdownRenderer and forwards one method. Repeat that five times and the boilerplate adds up; this bundle collapses it to YAML.
Prefer a thin presentation facade
Rather than exposing a fat domain service directly, expose a small facade whose method signatures are Twig-friendly (scalars in, strings/arrays out):
final class TemplatePresenter
{
public function __construct(private MarkdownRenderer $md, private FeatureFlags $flags) {}
public function markdown(string $text): string
{
return $this->md->render($text);
}
public function feature(string $name): bool
{
return $this->flags->isEnabled($name);
}
}
This gives you one registration point, keeps template-facing APIs narrow, and leaves room to change internals without touching Twig.
Replacing ad-hoc Twig extensions
Audit your src/Twig/ directory. Any extension that is basically "one method, one-line forwarding" is a candidate to delete and replace with a config block. Keep real extensions (ones with getFilters(), getFunctions(), custom node visitors, or runtime classes) as they are — the bundle is not a replacement for those, it is a shortcut for trivial callables.
Testing
Because the callable is just a class or a service, you can unit-test the underlying method normally. For template-level verification, render the template in a functional test and assert on output:
$this->assertStringContainsString('$19.99', $this->renderTemplate('{{ format(1999) }}'));
Integration tips
money.yaml, markdown.yaml) using imports if your config file grows.render conflicts with another exposed name), name the PHP method explicitly for Twig (renderMarkdown()) instead of relying on config-level aliasing.bin/console config:dump-reference zenstruck_twig_service to see the live schema for your installed version.date, number_format, upper) and any other extensions share one namespace. A duplicate registration throws at compile time; if a name feels generic, prefix it (app_format).Mailer::send, repositories with delete), and don't expose anything taking non-scalar objects — Twig will not have a way to construct them.string $x will throw TypeError on null. Either default your parameters string $x = '' or guard inside the method.bin/console debug:twig lists all registered functions, filters, and tests — if yours is missing, the config file isn't being imported, the bundle isn't registered, or the cache is stale.bin/console cache:clear to show up. In dev this is automatic; in a Docker/CI setup, remember to warm the cache.self:: dependencies or reading $_ENV inside a static helper, promote it to a service and register it under services: instead.composer.json, and if the bundle ever becomes a blocker, the migration path is mechanical: replace each config entry with a forwarding method on one consolidated AppTwigExtension.twigbridge extension; use this package only if the project runs the Symfony DI container.How can I help you explore Laravel packages today?