intaro/twig-injection-bundle
Symfony bundle that injects container services into Twig templates, letting views call service methods directly instead of adding controller boilerplate. Configure exposed services and reuse them as handy view helpers for routing, formatting, and other presentation logic.
Install and register
composer require intaro/twig-injection-bundle:^3.0
v3.0.0 dropped PHP 7 and Symfony 3, so the floor is now PHP 8.0+ and Symfony 4+. If the host app is still on PHP 7.4 or Symfony 3, you cannot take this release at all — cap the constraint at ~2.2 and plan the platform upgrade first. The changelog between 2.2.1 and 3.0.0 lists only the platform drops, so there is no documented API change to chase, but it is still worth re-reading src/ once after the bump.
Add it to config/bundles.php (there is no Flex recipe, so this is manual):
Intaro\TwigInjectionBundle\IntaroTwigInjectionBundle::class => ['all' => true],
Then bin/console cache:clear — the bundle is event/Twig-extension driven, so nothing renders until the container and Twig caches are rebuilt.
Where to look first
There is no hosted documentation beyond the README, so read the source in this order:
src/ — the Event / EventSubscriber classes and the Twig Extension. These define your public API surface; everything else is internals.Resources/config/services.xml (or config/services.php) — shows which services are tagged and which dispatcher events are actually fired.DependencyInjection/ — reveals whether there are configuration keys (intaro_twig_injection:) you can tune. If there are none, everything is done in PHP, not YAML.Adoption is still tiny (single-digit stars, no dependents), so treat the README as a hint and src/ as the contract. Pin the version — "intaro/twig-injection-bundle": "^3.0" — and avoid dev-master; the 2.x → 3.x jump shows the maintainer is willing to make platform-breaking cuts, so a floating constraint will eventually bite.
First use case
The realistic sweet spot is cross-cutting template fragments: a cookie-consent bar, a promo banner, a GDPR notice, an analytics snippet — markup that must appear on many templates that you do not want to edit individually and that is owned by a different team or bundle. Instead of adding {% include %} to forty templates, you register one listener and it appears everywhere the injection point is defined.
Start by injecting one trivial static template into a single base layout, confirm it renders, then widen the scope. Do not begin with business logic in the listener.
Listener-per-fragment
Keep one listener class per injected fragment, registered with a priority. This keeps debug:event-dispatcher (or a container dump) readable and lets you disable a single fragment by removing one service definition.
class CookieConsentListener
{
public function __construct(private Environment $twig) {}
public function onInject(/* bundle's injection event */ $event): void
{
$event->add('layout_footer', '@App/fragments/cookie_consent.html.twig', [
'policy_url' => '/privacy',
]);
}
}
Register with a tag or #[AutoconfigureTag] matching whatever tag the bundle's compiler pass collects, and give it priority if ordering matters (consent before analytics, etc.). Attribute-based autoconfiguration needs Symfony 5.3+, which the v3 floor already guarantees.
Gate on context, not on template name
Guard the injection with request/routing context rather than string-matching template paths, which breaks the moment someone renames a file:
if (!$this->requestStack->getMainRequest()?->isXmlHttpRequest()) { ... }
Use the template-name argument, when the event exposes one, only for coarse scoping (e.g. skip the admin area) — and prefer a route/_area attribute if available.
Rendering and escaping
Return either a rendered string or a template reference plus context, and be explicit about escaping. Twig-rendered fragments are safe; raw HTML strings concatenated in PHP are not. If you hand back a string, the injection point must render it with |raw — verify this in the bundle's placeholder function before you trust it with user-controlled data.
Data belongs in the fragment, not the listener
Do not query the database inside the listener; it runs on the render path and can execute more than once per request (sub-requests, ESI, fragments). Resolve data lazily — pass an ID or a closure into the fragment context and let a Twig function fetch it, or pre-populate via a request attribute set by a kernel.controller listener.
Test at the HTTP boundary
Unit-testing the listener only proves you called add(). The bugs live in ordering, escaping and scope, so assert on the response body:
$this->client->request('GET', '/some-page');
$this->assertSelectorExists('.cookie-consent');
$this->assertSelectorNotExists('.admin-toolbar');
bin/console cache:clear after the bump, then diff the rendered output of one or two pages — most surprises will be transitive dependency resolution (a Symfony minor bump pulled in alongside), not the bundle itself.bin/console debug:container --tag=<suspected_tag> and grep -rn "dispatch(" vendor/intaro/ to find them rather than trusting a blog post.EventDispatcher and the twig service. In a Laravel app using a Twig bridge, the kernel and dispatcher are Laravel's, so the bundle's subscriber wiring will not fire as designed. Verify with one throwaway route before you plan any integration; if it does not plug in cleanly, the escape hatch below is the answer.$twig->render(...) inside a guard, or a twig.runtime context flag) and bail if already injecting.dev automatically, but listener/scope changes and container rebuilds need cache:clear. In prod behind a deploy, always clear both var/cache and any Twig template cache directory.priority: 10 for consent, 0 for analytics.{% for %}{% include %}{% endfor %} in the base layout. If you cannot articulate why the bundle is better than that in one sentence, it probably is not — consider twig_hooks (Symfony UX), Sylius/Sonata template events, or a plain kernel.controller listener that sets a global.src/ if you must, and keep the upgrade surface small by isolating all calls into a single App\Twig\Injection facade so you can swap implementations later.How can I help you explore Laravel packages today?