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.
A new Calendar Link component
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()
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
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
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
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
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
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
_batchrequest (@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-cachecommand (@Kocal) - #3432 [LiveComponent] Improve form validation error messages in exceptions (@PierreCapel)
- #3455 Use
twig.safe_classtag and movesetLexertoTwigComponentPass(@GromNaN) - #3566 [LiveComponent] Make
LiveComponentSubscribersafe-by-default (@Kocal) - #3565 [Autocomplete] Use
hash_equals()to compare theextra_optionschecksum (@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-addedkey 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()andinject()Twig functions (@Kocal) - #3442 [LiveComponent] Use
aria-busyattribute 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