Symfony UX 3.1.0 is the first feature release on the 3.x branch. It brings a brand-new Calendar Link component, provide() and inject() functions for Twig components, a modern custom element and Twig component for Turbo Mercure streams, Turbo Frame request detection, and a large alignment of the Toolkit Shadcn kit with its upstream reference. This release also ships the same security fixes as Symfony UX 2.36.0; read that announcement for the details and upgrade promptly if you use the LiveComponent or Autocomplete packages.

Zairig Imad
Contributed by Zairig Imad in #3460

Letting users add an event to their calendar usually means hand-building provider-specific URLs and an .ics file. The new Calendar Link component generates "Add to calendar" links for Google Calendar, Outlook.com, Office 365 and iCalendar (.ics), the format consumed by Apple Calendar, Outlook desktop, Thunderbird and every native calendar client.

Describe the event once with a CalendarEvent, then render links from Twig with ux_calendar_link() for a single provider or ux_calendar_links() for every registered one:

1
2
3
4
5
6
7
8
use Symfony\UX\CalendarLink\CalendarEvent;

$event = new CalendarEvent(
    title: 'Symfony Live Paris',
    start: new \DateTimeImmutable('2026-05-14 09:00'),
    end: new \DateTimeImmutable('2026-05-15 18:00'),
    location: 'Cité Universitaire Paris',
);
1
2
3
4
5
6
7
8
{# templates/event/show.html.twig #}
<a href="{{ ux_calendar_link(event, 'google').url }}">Add to Google</a>

<ul class="add-to-calendar">
    {% for link in ux_calendar_links(event) %}
        <li><a href="{{ link.url }}">{{ link.label }}</a></li>
    {% endfor %}
</ul>

The component is marked experimental, so its API may still change.

Share data across Twig components with provide() and inject()

Hugo Alliaume
Contributed by Hugo Alliaume in #3512

When a component is split into nested sub-components, passing a value from the root down to a deep descendant means forwarding it as a prop through every level in between. The new provide() and inject() Twig functions, inspired by Vue.js, remove that plumbing: a parent publishes a value once and any descendant reads it, no matter how deep.

Publish a value in the parent:

1
2
3
4
5
6
7
8
{# templates/components/InputOtp.html.twig #}
{% props maxLength = 6 %}

{% do provide('inputOtp.maxLength', maxLength) %}

<div class="input-otp">
    {% block content %}{% endblock %}
</div>

Then read it in any descendant, with a fallback if no value was provided:

1
2
3
4
5
6
{# templates/components/InputOtp/Slot.html.twig #}
{% props input %}

{% set maxLength = inject('inputOtp.maxLength', 4) %}

<input type="text" maxlength="1" data-max-length="{{ maxLength }}" />

Values flow top-down only and are dropped once the parent finishes rendering, so sibling components never share state. Several Toolkit Shadcn recipes (accordion, tabs, dialog, tooltip and more) were reworked to use this new mechanism.

A custom element for Turbo Mercure streams

Seb Jean
Contributed by Seb Jean in #3505

Subscribing to Mercure topics for Turbo Stream updates relied on the mercure-turbo-stream Stimulus controller and the turbo_stream_listen() Twig function. This release introduces a native <turbo-mercure-stream-source> custom element that manages the EventSource lifecycle on its own, plus a turbo_stream_from() Twig function and a <twig:Turbo:Stream:From> Twig component to subscribe declaratively:

1
2
<twig:Turbo:Stream:From topics="App\Entity\Book" />
<div id="books"></div>

Private topics and specific transports are first-class:

1
2
<twig:Turbo:Stream:From topics="App\Entity\Book" private />
<twig:Turbo:Stream:From topics="App\Entity\Book" transport="hub2" />

The turbo_stream_listen() Twig function is now deprecated in favor of turbo_stream_from() and the new Twig component.

Detect and render Turbo Frame requests

Seb Jean
Contributed by Seb Jean in #3462 and #3439

Inspired by Turbo Rails, the new TurboFrame service detects whether the current request was triggered by a Turbo Frame, so a controller can return a lighter partial instead of the full page:

1
2
3
4
5
6
7
8
9
10
use Symfony\UX\Turbo\TurboFrame;

public function show(Post $post, TurboFrame $turboFrame): Response
{
    if ($turboFrame->isFrameRequest()) {
        return $this->render('post/_show.html.twig', ['post' => $post]);
    }

    return $this->render('post/show.html.twig', ['post' => $post]);
}

To go with it, a minimal @Turbo/layouts/frame.html.twig layout provides a lightweight HTML skeleton with overridable head and body blocks, so frame responses can still populate the <head> without dragging in the full application layout.

Toolkit Shadcn kit aligned with its reference

Seb Jean
Contributed by Seb Jean in #3538 , #3551 , #3567 , #3590 and #3592

The Toolkit Shadcn kit received a sweeping update: more than forty components, from accordion and alert-dialog to table, tabs, tooltip and input-group, were realigned with the upstream shadcn reference, and new recipes such as hover-card and resizable were added. The Toolkit manifest also gained a version-added key so the documentation can show when each component became available.

Better LiveComponent ergonomics

Pierre Capel
Contributed by Pierre Capel in #3432 and #Amoifr

When a form submitted from a LiveAction fails validation, the resulting exception used to carry a generic "Form validation failed in component" message. It now includes the full field path and the specific violation for each error, which makes failures far easier to debug in tests and during development:

1
2
3
4
Form validation failed:
blog_post_form.title: This value should not be blank.
blog_post_form.content: This value is too short.
blog_post_form.comments.0.text: This value should not be blank.

LiveComponents also now expose their loading state through the standard aria-busy="true" attribute instead of a non-standard busy attribute, so screen readers can announce re-renders and you can style them with the [aria-busy="true"] selector.

A new translation cache command

Hugo Alliaume
Contributed by Hugo Alliaume in #3601

Mirroring ux:icons:warm-cache, the new ux:translator:warm-cache command dumps the JavaScript and TypeScript translation files on demand, without warming the whole Symfony cache:

1
$ php bin/console ux:translator:warm-cache

Full Changelog

  • #557 [LiveComponent] Require X-Requested-With header to prevent CSRF (@Kocal)
  • CVE-2026-49216 [Autocomplete] Fix XSS via unescaped AJAX response data (@Kocal)
  • CVE-2026-49208 [LiveComponent] Parse format-less date LiveProps strictly with RFC 3339 (@Kocal)
  • CVE-2026-49209 [LiveComponent] Cap the number of actions per _batch request (@Kocal)
  • CVE-2026-49210 [LiveComponent] Reject malicious child component tags (@Kocal)
  • CVE-2026-49212 [LiveComponent] Bind HMAC checksum to component name and slot (@Kocal)
  • CVE-2026-49211 [Autocomplete] Escape LIKE wildcards in the search query (@Amoifr)
  • #3601 [Translator] Add ux:translator:warm-cache command (@Kocal)
  • #3432 [LiveComponent] Improve form validation error messages in exceptions (@PierreCapel)
  • #3455 Use twig.safe_class tag and move setLexer to TwigComponentPass (@GromNaN)
  • #3566 [LiveComponent] Make LiveComponentSubscriber safe-by-default (@Kocal)
  • #3565 [Autocomplete] Use hash_equals() to compare the extra_options checksum (@Amoifr)
  • #3505 [Turbo] Add <turbo-mercure-stream-source> custom element (@seb-jean)
  • #3462 [Turbo] Add TurboFrame service to detect Turbo Frame requests (@seb-jean)
  • #3439 [Turbo] Add minimal frame layout template (@seb-jean)
  • #3531 [Toolkit] Add version-added key in toolkit manifest (@MrYamous)
  • #3460 [CalendarLink] Add component (@zairigimad)
  • #3508 [LiveComponent] Fix dynamic template resolution when using "loading" attribute (@xDeSwa)
  • #3500 [TwigComponent] Include attribute name in null value error message (@IndraGunawan)
  • #3512 [TwigComponent] Add provide() and inject() Twig functions (@Kocal)
  • #3442 [LiveComponent] Use aria-busy attribute during component re-render (@Amoifr)
  • #3459 [Toolkit][Flowbite] add Dropdown and Avatar component (@DcgRG)
  • [Toolkit][Shadcn] Align 35+ components with the shadcn reference (@seb-jean): #3538, #3539, #3551, #3552, #3553, #3554, #3555, #3556, #3557, #3558, #3559, #3560, #3561, #3563, #3564, #3567, #3568, #3569, #3570, #3573, #3574, #3576, #3577, #3581, #3583, #3584, #3585, #3586, #3587, #3589, #3590, #3591, #3592, #3593, #3594
  • [Toolkit][Shadcn] Rework recipes to use provide()/inject() (@Kocal): #3521, #3523, #3524, #3525, #3526, #3527, #3528
  • [Toolkit][Shadcn] Add new recipes: hover-card, resizable, radio-group, collapsible, typography and toggle-group (@Amoifr): #3464, #3478, #3485
Published in #Releases #Symfony UX