Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Twig Bridge Laravel Package

symfony/twig-bridge

Twig Bridge integrates the Twig template engine with Symfony components, letting you render Twig templates within Symfony apps and use framework features like forms, translations, and routing directly in your templates.

View on GitHub
Deep Wiki
Context7

Getting Started

First, the honest framing: symfony/twig-bridge is not a package you composer require in a Laravel app. It is a library of Twig extensions and templates that glue Twig to Symfony components — Form, Routing, Translation, Security, Workflow, Mailer, Asset, HttpKernel, VarDumper, Yaml. It ships no service provider, no config tree, no facade. In Symfony it arrives transitively via symfony/twig-bundle, which is what actually registers the extensions and reads config/packages/twig.yaml.

For a Laravel developer the realistic entries are:

  1. You inherit it because you are maintaining a Symfony app, or a package that must render Symfony-style templates/form themes.
  2. You use plain twig/twig for templating in Laravel and only reach for the bridge when you need its template assets (Bootstrap/Tailwind form themes) or are porting Symfony templates. Even then, most extensions need Symfony services (UrlGeneratorInterface, TranslatorInterface, AuthorizationCheckerInterface) that you must wire yourself — expect to write a container binding per extension.

Where to look first (in src/):

Path What it is
Extension/ The whole public surface: FormExtension, RoutingExtension, TranslationExtension, SecurityExtension, WorkflowExtension, AssetExtension, HttpKernelExtension, HttpFoundationExtension, CodeExtension, YamlExtension, DumpExtension, CsrfExtension, Mailer/
Resources/views/Form/ The actual form themes (bootstrap_5_layout.html.twig, tailwind_2_layout.html.twig, daisyui_layout.html.twig on 8.1, …)
Form/TwigRendererEngine.php How themes are loaded/cached; source of most "why isn't my block used" bugs
AppVariable.php Everything behind app.*
Extension/UndefinedCallableHandler.php Turns "unknown function" into a "install/enable X" message
Node/, TokenParser/ Compile-time behavior of form_*, trans, stopwatch, dump

First use case — one template that proves the bridge is wired:

# config/packages/twig.yaml (twig-bundle, not the bridge)
twig:
    form_themes:
        - 'form/app_theme.html.twig'
        - 'bootstrap_5_layout.html.twig'
{# templates/post/new.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
    <h1>{{ 'post.new'|trans }}</h1>

    {{ form_start(form, {attr: {class: 'needs-validation', novalidate: 'novalidate'}}) }}
        {{ form_row(form.title) }}
        {{ form_row(form.body) }}
        <button class="btn btn-primary" type="submit">{{ 'action.save'|trans }}</button>
    {{ form_end(form) }}

    <a href="{{ path('post_index') }}">{{ 'action.cancel'|trans }}</a>
    <img src="{{ asset('img/logo.svg') }}" alt="">
{% endblock %}

Verify before debugging: bin/console debug:twig lists every function/filter/tag and the class providing it. bin/console debug:twig --filter=trans is the fastest way to confirm which bridge extension is actually active. Add bin/console lint:twig templates/ to CI — it catches compile-time errors (unknown tags, wrong form_theme paths) that only surface on page render.


Implementation Patterns

1. Theme layering — app theme wins, framework theme fills gaps. form_themes is an ordered list; the first theme containing a block wins. Always build on a shipped layout with {% use %} so you only override what you must:

{# templates/form/app_theme.html.twig #}
{% use 'bootstrap_5_layout.html.twig' %}

{% block _post_body_widget %}
    {{ block('textarea_widget') }}
    <small class="text-muted">{{ 'post.body_hint'|trans }}</small>
{% endblock %}

{% block form_errors %}
    {% if errors|length > 0 %}
        <div class="invalid-feedback d-block">{{ parent() }}</div>
    {% endif %}
{% endblock %}

Per-form override for one-off screens: {% form_theme form 'form/checkout_theme.html.twig' %} (or with ['a.html.twig', 'b.html.twig'] for a list). Prefer the app-wide list for consistency; per-template themes quietly bypass your global design system.

2. Render granularly, not form_row everywhere. Bridge functions compile to block_<prefix>_<name> calls via SearchAndRenderBlockNode, so anything you can name you can render individually:

{{ form_label(form.price) }}
{{ form_widget(form.price, {attr: {'data-currency': 'EUR'}}) }}
{{ form_help(form.price) }}
{{ form_errors(form.price) }}
{# Symfony 6.2+ FormField objects: #}
{{ field_label(form.price, {label_attr: {class: 'form-label'}}) }}
{{ field_widget(form.price) }}
{{ field_errors(form.price) }}

3. Translation keys that survive extraction. TranslationNodeVisitor scrapes templates at compile time via bin/console translation:extract. Only literal strings are extractable — this is the single biggest source of "translation missing in prod, fine in dev".

{{ 'post.count'|trans({'%count%': posts|length}) }}      {# extracted #}
{{ ('post.status.' ~ post.status)|trans }}                {# NOT extracted — build keys in a PHP enum/constant map #}
{% trans_default_domain 'admin' %}                        {# scoped to file, resets per template #}
{{ message|trans }}                                       {# TranslatableInterface objects #}

4. URLs: pick the right helper per context. path() = relative route, url() = absolute route, absolute_url(path) = absolutize an arbitrary path, asset() = asset package-aware. For HTML fragments rendered server-side:

{{ render(controller('App\\Controller\\CartController::summary')) }}
{{ render_esi(controller('App\\Controller\\CartController::summary')) }}   {# needs framework.fragments + reverse proxy #}
{{ render_ssi(controller('App\\Controller\\CartController::summary')) }}

5. Authorization and workflow reads in templates, never writes.

{% set can_edit = is_granted('EDIT', post) %}          {# cache the check — it hits the checker #}
{% if can_edit %}<a href="{{ path('post_edit', {id: post.id}) }}">edit</a>{% endif %}

{% if workflow_can(post, 'publish') %}
    {% for transition in workflow_transitions(post) %}
        <span class="badge">{{ transition.name }}</span>
    {% endfor %}
{% endif %}

workflow_can(entity, 'transition'), workflow_transitions(entity), workflow_has(entity, 'place'), workflow_marked_places(entity), workflow_metadata(entity, 'place', 'prop') — all take the subject entity, not the workflow name; the workflow is resolved from the class. workflow_transition() mutates state — call it from a controller and redirect.

6. Emails: render through the Mailer integration, not by hand. TemplatedEmail + BodyRenderer picks the Twig template from the email, injects email (a WrappedTemplatedEmail exposing email.to, email.from, email.subject, email.returnPath) and your context.

$email = (new TemplatedEmail())
    ->to($user->getEmail())
    ->subject('Order shipped')
    ->htmlTemplate('email/order_shipped.html.twig')
    ->textTemplate('email/order_shipped.txt.twig')
    ->context(['order' => $order]);
{{ email.to[0].address }}
{{ order.number }}

7. Extending: a custom Twig extension with the twig.extension tag is the sanctioned integration point — that's how you add domain functions without touching the bridge. Decorate AppVariable (or add twig.globals) for shared view data instead of passing eight variables through every controller.

8. Testing: unit-test templates with Twig\Environment + ArrayLoader and a hand-built PreloadedExtension of the specific bridge extension. For form themes, functional tests with WebTestCase + assertSelectorExists('.invalid-feedback') catch more than string matching.


Gotchas and Tips

Debug-only tags break prod. {% stopwatch %}/{% endstopwatch %} and {% dump %} are registered only when the corresponding service/kernel.debug is on. A template using them compiles in dev and throws "Unknown tag" in prod. Gate them with {% if app.debug %} around a block, or don't ship them.

app.request/app.session are request-scoped. In a Messenger worker or console command app.request is null (or throws) and app.session blows up in stateless contexts. Never read app.request.headers in a template that an email or PDF job renders.

Absolute URLs from CLI/workers point at localhost. url(), absolute_url(), asset() and render_esi() all derive from RequestContext. Rendered in a queue worker, links become http://localhost/... and _fragment URLs are signed for the wrong host. Fix at config level, not in templates: set framework.router.default_uri (and framework.assets.base_urls/base_path) so CLI-rendered emails and fragments match production.

form_end(form) already renders form_rest. Adding a manual {{ form_rest(form) }} duplicates hidden fields (and _token). Use {{ form_end(form, {render_rest: false}) }} only when you're deliberately hand-placing the remaining fields — and don't forget _token, or every submit fails CSRF validation.

Caching anything containing a CSRF token is a bug. csrf_token() and the hidden _token from form_rest are session-bound. If you cache at the fragment or reverse-proxy level, exclude the form region or render the token via ESI.

"Unknown function 'trans'/'asset'/'render'" almost never means the bridge is broken. It means the corresponding Symfony component isn't installed/enabled. UndefinedCallableHandler deliberately rewrites these messages with the package to install — read the second line of the exception, not the first.

Version matrix bites. Bridge 8.1 requires Twig ≥ 3.25 for the EscaperRuntime service definition and allows Twig 4; older 8.x/7.4 releases pinned Twig 3 and conflicted with phpdocumentor/reflection-docblock >= 6. If Composer gives you an unexplained conflict, it's probably this. Stay on the branch matching your Symfony release (7.4 is the LTS; 8.1 current) and never mix.

CodeExtension::fileExcerpt() had an XSS issue (CVE-2026-45072). Patched in 6.4.40 / 7.4.12 / 8.0.12 / 8.1.0-BETA3. It only renders in error/profiler pages, but upgrade anyway — it renders user-influenced file content into HTML.

{% trans %} tag vs |trans filter. The tag still parses and extracts, but the filter is the documented path and the only one that works with TranslatableInterface objects and inline count/parameters. transchoice is gone — use |trans({'%count%': n}) with interval or ICU messages. Never string-concatenate a translatable object; recent releases explicitly reject implicit __toString casts.

Block prefixes are cached per theme. Overriding form_row in an app theme changes every form — check the profiler's form panel before blaming your controller. There was also a bug where cached block prefixes leaked across nested collections (fixed in 6.4.32 / 7.4.4) — relevant if you render a collection inside an overridden block and see the wrong widget.

Small rendering fixes worth upgrading for: a label whose value is "0" was being skipped as falsy (#64891); Bootstrap 4 errors were rendered inside <label> and later broke horizontal layouts (#63472, #63568); Bootstrap 4 date/time widget error boxes (#65495). If you use Bootstrap 4 themes, these are visible defects.

Tailwind themes are purged. tailwind_2_layout.html.twig emits utility classes from a file your Tailwind content glob may not scan. Add the theme's path (vendor/symfony/twig-bridge/Resources/views/Form) to content, or classes vanish in production builds only.

Clear the cache when you touch a theme. Themes are Twig resources cached in TwigRendererEngine and in var/cache/<env>/twig; a stale compiled template makes you edit a file for ten minutes that nothing is loading. bin/console cache:clear after changing form_themes or {% use %} chains.

Performance: is_granted(), workflow_*(), and especially render()/controller() cost real time per call in a loop. Hoist is_granted into a {% set %}, and treat render() as a sub-request you'd never put inside a 200-row for. render_esi only pays off with a caching reverse proxy in front — without one it's just render() with extra config.

yaml_dump/yaml_encode require symfony/yaml; file_excerpt/abbr_class require nothing but are only useful in error pages; markdown_to_html is not here — that comes from twig/markdown-extra, and the Inky email extension from twig/inky-extra. Don't file bugs against the bridge for filters it never provided.

Extension points, ranked by safety: (1) custom Twig extension service, (2) custom form theme via {% use %}, (3) twig.globals/decorating AppVariable, (4) overriding TwigRendererEngine — almost never, and it will break on minor upgrades.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package