setono/tag-bag-bundle
Symfony bundle for centralized tag management. Collect and render analytics, tracking, and marketing tags such as Google Analytics or Facebook Pixel from one place in your templates, keeping scripts organized and easy to control.
Check platform requirements first. As of v3.3.0 the bundle requires PHP >= 8.2 and Symfony ^6.4 || ^7.4. That is the entire content of the release — no new API, no new features, just a raised floor. If you are on PHP 8.1, Symfony 6.0–6.3, or Symfony 7.0–7.3, composer require will refuse and you stay on 3.2.0 (which still works; nothing in this assessment depends on 3.3.0).
composer require setono/tag-bag-bundle
If Flex does not register it (there is no official recipe), add it to config/bundles.php:
return [
// ...
Setono\TagBagBundle\SetonoTagBagBundle::class => ['all' => true],
];
No configuration is required to get going. The bundle's job is small: it registers the tag bag as a shared service and exposes a Twig function that renders everything collected during the request. Put that call into your base layout — once in <head>, and optionally a second one before </body> if you use sections:
<head>
{{ tag_bag() }}
</head>
<body>
...
{{ tag_bag('body_end') }}
</body>
Then add a tag from anywhere:
use Setono\TagBag\Tag\Script;
use Setono\TagBag\TagBagInterface;
final class DashboardController extends AbstractController
{
public function __invoke(TagBagInterface $tagBag): Response
{
$tagBag->add(new Script('console.log("dashboard")'));
return $this->render('dashboard.html.twig');
}
}
Load the page, view source, and confirm the <script> landed in <head>. That is the whole loop.
Where to look first, in order:
vendor/setono/tag-bag-bundle/src/ — the bundle is tiny (a DI extension, a Twig extension, maybe a small config tree). Reading it end-to-end takes ten minutes and answers more than any documentation will.bin/console config:dump-reference setono_tag_bag — the authoritative list of config keys for your installed version.bin/console debug:container tag_bag — confirm the service id, class, and that it is shared.vendor/setono/tag-bag/README.md — the underlying library is where the real API lives (TagInterface, the concrete tag classes, priority/section/key semantics). The bundle is a thin adapter over it; the library is the thing you are actually programming against.Best first use case: something that is currently duplicated or awkwardly threaded through templates — a JSON-LD block, a per-page meta tag, or a single tracking snippet that several services want to contribute to. Do not start by migrating your whole asset pipeline; this package manages tag output, not assets.
Autowire the bag, add tags from services. TagBagInterface is aliased to the bundle's implementation, so any controller, listener, or domain service can type-hint it. This is the primary integration point: business services (ProductSeoData, CheckoutTracking) describe what tags are needed; the template decides where they go.
Use keys to make tags idempotent. Tags implement a key concept; two tags with the same key collapse to one instead of rendering twice. This is the single most valuable feature in practice — it means a script can be added defensively by three different code paths without you auditing call sites:
$tagBag->add((new Script($snippet))->setKey('analytics'));
If you find yourself writing if (!$this->alreadyAdded) flags, you want a key instead.
Separate head from body_end with sections. Assign a section on the tag and render that section in the matching place in the layout. Scripts that must run after the DOM (widgets, chat loaders) belong at the bottom; meta/JSON-LD/title belong in the head. Keep the number of sections to two or three — every extra section is another render call someone will forget to add to a layout.
Collect in a kernel.controller listener, not kernel.response. Listeners are the natural home for cross-cutting tags (canonical links, hreflang, tracking pixels keyed off the route). But the tag bag is rendered during Twig rendering, so anything added after the response body exists is invisible. kernel.controller (or kernel.request / a controller argument resolver) runs comfortably before rendering.
Provider-service pattern for structured data. For anything non-trivial, define small services that return tag objects and iterate over them in one listener. A tagged-iterator (!tagged_iterator app.tag_provider) plus a kernel.controller listener keeps SEO/tracking logic out of controllers and makes each provider unit-testable:
foreach ($this->providers as $provider) {
foreach ($provider->tags($request->attributes->get('_route')) as $tag) {
$this->tagBag->add($tag);
}
}
Gate on consent before add(), not after render. The bag is a dumb container; it has no concept of consent. If you use a consent manager, wrap tag construction in the consent check so the HTML is never emitted at all. This is the intended pairing with Setono's consent tooling, and it is much easier to audit than filtering output.
Test at two levels. Unit-test a provider by asserting on the tag objects it returns (tag name, content, key, section). Functional-test the rendered page with assertStringContainsString('application/ld+json', $client->getResponse()->getContent()). For the middle layer, fetch tag_bag from the test container and assert on render() output directly — that catches ordering and dedupe bugs without a full HTML parse.
Extend by subclassing a concrete tag, not by fighting the renderer. If you need a <link rel="preload"> variant or a custom wrapper element, extend the base tag class, override the element/attributes it emits, and add it like any other tag. Custom tags are the supported extension point; keep them in your app, not in the vendor tree. Nothing in 3.3.0 changes this surface, so existing custom tags carry over untouched.
v3.3.0 is a platform bump, not a feature release. Upgrade only if your app is already on PHP >= 8.2 and Symfony ^6.4 || ^7.4. The 7.x floor being stated as ^7.4 (rather than ^7.0) is worth verifying literally: run composer why-not setono/tag-bag-bundle 3.3.0 before planning an upgrade, and read the require block in vendor/setono/tag-bag-bundle/composer.json rather than trusting the release notes. If you are on Symfony 7.0–7.3, expect to be blocked and pin 3.2.0.
Upgrading is low-risk but not zero-risk. The library API, tag classes, service ids, and Twig function are unchanged between 3.2.0 and 3.3.0 — the diff is composer.json constraints plus dev tooling (code-quality-pack ^3.2, which never reaches your app). Still: composer update, then bin/console cache:clear (the bundle's config tree is compiled into the container), then click through one page that renders each of your sections. Regressions here come from your app's own tag code interacting with a new Symfony minor, not from the bundle.
Do not upgrade to chase features. There are none in this release. Stay on 3.2.0 for as long as your platform requires and lose nothing; move when you move PHP or Symfony for other reasons.
Render once, or accept duplicates. The render call emits what is in the bag; calling it twice (a layout included from another layout, an override template that forgets to drop the parent block) emits everything twice. Treat the render call as a single, deliberate line in the base layout, and grep for it whenever markup looks doubled.
Additions after rendering are silently lost. By far the most common bug report: "my tag doesn't appear." Almost always the tag was added in a kernel.response listener or an kernel.terminate handler, i.e. after Twig already ran. Move collection to kernel.controller. If you genuinely must post-process, you are no longer using this package — you are doing string manipulation on the response.
State leaks in long-running workers. The bag is a shared, mutable, request-scoped service. Under PHP-FPM that is fine because the container dies each request. Under Messenger workers, RoadRunner, Swoole, or FrankenPHP in worker mode, tags accumulate across requests unless the service is reset between them. Check that it implements ResetInterface (or reset it yourself in a worker loop) before running this in any resident-runtime setup.
Sub-requests and ESI fragments do not merge into the parent. Each request (and each ESI/fragment render) has its own bag. Tags added inside a fragment render inside that fragment's output only. If you use render(controller(...)) for a block, tags emitted there land inside the block, not the page head.
Watch priority direction. Tags are ordered by priority, and it is easy to assume the wrong direction. Write a deliberate test that adds three tags with priorities and asserts the resulting order, then never guess again.
Same key means one winner. Dedupe is a feature until two different components pick the same key string and one silently replaces the other's content. Namespace keys (seo.canonical, tracking.ga4) and log when a key is reused during debugging.
JSON-LD escaping is on you. Build structured data as an array, json_encode with JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP, and make sure the tag emits the content unescaped. Double-escaped " inside a ld+json block is the classic symptom of a template escaping what the tag already produced.
No CSP nonce support out of the box. If you have a Content-Security-Policy, every script the bag emits needs the nonce. Do not hardcode it — inject the nonce into a small factory that builds your tags, or subclass the script tag to stamp the attribute. Retrofitting this later means touching every call site. Nothing in 3.3.0 adds this.
Missing sections fail silently. If a tag has a section that no layout renders, nothing errors — the tag just vanishes. Keep section names in a single constant or enum and reference it from both the PHP side and the Twig side.
Config changes need a cache clear. Standard Symfony, but worth stating: the bundle's config tree is compiled into the container, so bin/console cache:clear after editing, and check config:dump-reference if a key appears to be ignored (it may not exist in your installed version).
Budget for the maturity level. Five stars and zero dependents means no community to fall back on: pin the version (now with an eye on the PHP 8.2 / Symfony 6.4|7.4 floor), expect the bundle's source (not docs) to be the reference, and verify composer.json constraints against your app before upgrading majors. On the plus side, the surface area is so small that vendoring a fork is a realistic option if you need a fix — and the 3.3.0 changelog being a single dependency-bump PR tells you the maintenance cadence is alive but conservative. If your needs grow into asset management, bundling, or nonce orchestration, this is the wrong tool — keep it scoped to "collect small HTML tags from many places and render them in a few places."
How can I help you explore Laravel packages today?