Symfony UX 3.6 is out, with more than 100 pull requests merged since 3.5.1. It adds three experimental packages, UX Inspector, UX Image and UX Router, aligns the Shadcn kit of the UX Toolkit with the Nova style, and runs every Toolkit recipe in a real browser, which led to about thirty fixes in the kits.

UX Inspector

Simon André
Contributed by Simon André in #3866

Stimulus controllers, LiveComponents and Turbo frames are spread across the DOM as data-* attributes, and the browser devtools give you no overview of them. UX Inspector is a devtools panel for Symfony UX that outlines every controller, component and frame of the page.

Pick one on the page to jump to its details, which update while the application runs. Open the panel with the pull tab on the right edge of the screen, or type u then x outside an editable field.

  • Stimulus: values and their defaults, targets, CSS classes, actions and outlets, and whether the controller is connected;
  • LiveComponent: props and parent props, models and their modifiers, actions and their arguments, event listeners, loading and polling;
  • Turbo: the source URL, loading strategy and state of each frame, nested frames, and Stream actions.

The activity log lists events in the order they fire: LiveComponent renders, Turbo fetches with their status and duration, controller events. This first version only reads the page, and only runs in debug mode.

The Flex recipe, which registers the bundle in dev and connects your Stimulus application to it, is still in review (symfony/recipes#1586). The guided demos on the UX website show the Inspector at work.

UX Image

Hugo Alliaume Kevin Bond
Contributed by Hugo Alliaume and Kevin Bond in #3838 , #3937 , #3945 , #4039 and #4040

A responsive image needs several variants of the same file, a srcset that lists them and a sizes attribute that matches the layout. UX Image generates all three, but it never decodes, resizes or encodes an image itself: every variant is a URL, and an image CDN does the work.

Nothing is transformed by default. Every variant comes from an external CDN, through a bridge you install, and switching CDNs never touches a template. Four bridges ship with this release, each in its own package: Cloudflare, Cloudinary, imgix and KeyCDN.

On ux.symfony.com, served through Cloudinary, this template:

1
2
3
4
5
6
<twig:ux:image
    src="/uploads/col-de-saint-bonnet.jpg"
    alt="View from the Col de Saint-Bonnet"
    width="480"
    height="270"
/>

renders the following HTML (line breaks added for readability, the s--…-- segment signs each URL):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<img
    src="https://res.cloudinary.com/fnmfvsxv/image/fetch/s--pYfkWjrh--/w_480,h_270,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg"
    alt="View from the Col de Saint-Bonnet"
    srcset="
        https://res.cloudinary.com/fnmfvsxv/image/fetch/s--pYfkWjrh--/w_480,h_270,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 480w,
        https://res.cloudinary.com/fnmfvsxv/image/fetch/s--IP7ocXoV--/w_640,h_360,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 640w,
        https://res.cloudinary.com/fnmfvsxv/image/fetch/s--gCP1dXee--/w_750,h_422,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 750w,
        https://res.cloudinary.com/fnmfvsxv/image/fetch/s--An-4f96H--/w_828,h_466,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 828w,
        https://res.cloudinary.com/fnmfvsxv/image/fetch/s--fWWMvhjt--/w_960,h_540,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 960w"
    sizes="(min-width: 480px) 480px, 100vw"
    loading="lazy"
    width="480"
    height="270"
    style="object-fit: cover; max-width: 480px; max-height: 270px; aspect-ratio: 480 / 270; width: 100%; height: auto;"
/>

<twig:ux:picture> renders one <source> per format instead. ux_image_url() returns a single URL, for an Open Graph image or an email, and presets give a name to a set of transformations.

The UX Image page has a live demo.

UX Router

Hugo Alliaume Félix Eymonot
Contributed by Hugo Alliaume and Félix Eymonot in #1234 and #4076

Generating a Symfony URL from JavaScript has meant hard-coding paths, or installing FOSJsRoutingBundle, whose documentation covers Webpack Encore but not AssetMapper.

UX Router dumps the routes you expose when the cache warms up, and gives you path() and url() functions that return the same result as their Twig counterparts. It works with AssetMapper, Reprise (Vite and Rsbuild) and Webpack Encore, and with TypeScript, route names and parameters are type-checked.

No route is exposed by default. To expose a single route, set its expose option, the same one FOSJsRoutingBundle users already know:

1
2
3
// src/Controller/BlogController.php
#[Route('/blog/{slug}', name: 'app_blog_show', options: ['expose' => true])]
public function show(string $slug): Response

To expose many routes at once, list name patterns; a leading ! excludes routes. The expose option of a route always wins over the patterns:

1
2
3
4
5
# config/packages/ux_router.yaml
ux_router:
    routes:
        - 'app_*'
        - '!app_admin_*'

The exposed routes are dumped as JavaScript to var/routes/ when the cache warms up. assets/router.js passes them to createRouter() with the default locale and exports path() and url():

1
2
3
4
5
// assets/router.js
import { createRouter } from '@symfony/ux-router';
import { routes, defaultLocale } from '../var/routes/index.js';

export const { path, url } = createRouter({ routes, defaultLocale });

Any other JavaScript file imports them from ./router.js:

1
2
3
4
5
// assets/my_file.js
import { path, url } from './router.js';

path('app_blog_show', { slug: 'hello', _query: { page: 2 } }); // '/blog/hello?page=2'
url('app_blog_show', { slug: 'hello' });                       // 'https://example.com/blog/hello'

A localized route picks its variant from <html lang>, and falls back to the framework.default_locale of your application, dumped as defaultLocale.

The UX Router page has a live demo.

Front-End Code Checked Against Web Platform Baseline

Hugo Alliaume
Contributed by Hugo Alliaume in #3904

The Symfony backward compatibility promise covers the PHP API, but nothing said which browser features the JavaScript and CSS of Symfony UX may rely on. Following an RFC by Simon André, the code shipped by every UX package is now checked in CI against Web Platform Baseline, "Widely available" by default.

A package can declare exceptions. Notify relies on the Notifications API, which is Baseline "Limited" as long as iOS Safari only exposes it to web apps added to the home screen. LiveComponent only uses the Navigation API when the browser supports it. UX Inspector, a development tool, targets Baseline 2024 instead, with a few CSS exceptions. The build target now comes from Browserslist instead of a hard-coded es2022. The rebuilt files are byte-identical, so nothing you install changes.

UX Toolkit: The Shadcn Kit Moves to Nova

Romain Monteil
Contributed by Romain Monteil in #4061 , #3910 , #3906 and #3938

shadcn/ui introduced several visual styles in December 2025, and Nova is the compact one, with reduced padding and margins. Every recipe of the Shadcn kit was compared against the upstream Nova sources and aligned with them.

Two recipes were rewritten to match their upstream component. select is now a listbox built from Select:* sub-components and driven by a Stimulus controller; use native-select for a native <select>. combobox is now made of Combobox:* sub-components, with multiple selection. Clicking an addon of input-group now focuses its input.

1
2
3
4
5
6
7
8
9
10
11
12
<twig:Select id="fruit" name="fruit" value="banana">
    <twig:Select:Trigger>
        <twig:Select:Value placeholder="Select a fruit" />
    </twig:Select:Trigger>
    <twig:Select:Content>
        <twig:Select:Group id="fruits">
            <twig:Select:Label>Fruits</twig:Select:Label>
            <twig:Select:Item value="apple">Apple</twig:Select:Item>
            <twig:Select:Item value="banana">Banana</twig:Select:Item>
        </twig:Select:Group>
    </twig:Select:Content>
</twig:Select>

Recipes are copied into your application, so nothing changes until you install them again.

UX Toolkit: Three New Shadcn Recipes

Romain Monteil Enzo Santamaria
Contributed by Romain Monteil and Enzo Santamaria in #3902 , #4063 and #4068

The Shadcn kit gained three recipes:

  • toast stacks notifications in a corner of the screen;
  • context-menu opens a menu on right click;
  • command is the command menu Shadcn builds on top of cmdk, with a Stimulus controller in place of cmdk: it filters items as you type, and handles the keyboard shortcuts cmdk users expect.

A toast can be fired from any element with the toast#add action:

1
2
3
4
5
6
7
8
9
10
11
<twig:Toaster position="bottom-right" duration="5000" limit="3">
    <twig:Button
        data-action="click->toast#add"
        data-toast-type-param="success"
        data-toast-title-param="Event created"
        data-toast-description-param="Sunday, December 3 at 9:00 AM"
        data-toast-action-label-param="Undo"
    >
        Show Toast
    </twig:Button>
</twig:Toaster>

From JavaScript, the toast() helper exported by the controller does the same: toast.success('Event created'). To free the Toast name, the sonner recipe's component is now Sonner:Toast.

UX Toolkit: A Diff Before Overwriting a File

Hugo Alliaume
Contributed by Hugo Alliaume in #4019

ux:install asks before overwriting a file that already exists, but it never showed what would change. You either trusted the recipe or compared both files yourself.

When Git is installed, the command now prints a diff first, with the changed part of each line highlighted. When the file already matches the recipe, it no longer asks at all.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ php bin/console ux:install button --kit=shadcn

Installing recipe "button" from the Shadcn UI kit...
@@ -1,6 +1,6 @@
 {%- props
     ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
-    variant = 'outline',
+    variant = 'default',
     ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
     size = 'default',
     ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.

 File "templates/components/Button.html.twig" already exists. Do you want to overwrite it? (yes/no) [yes]:
 >

UX Toolkit: Every Recipe Tested in a Real Browser

Hugo Alliaume
Contributed by Hugo Alliaume in #3947 , #3948 and #4017

Toolkit recipes were tested through snapshots of the HTML they render. That says nothing about what a recipe looks like, or what happens when you click it. A new preview app now renders every example in isolation, and Playwright screenshots each one in light and dark mode, then compares it with a committed baseline: more than 1,800 screenshots across the four kits. Every interactive recipe also has its own interaction spec, and a test fails when a new one ships without it.

Writing those tests surfaced about thirty bugs in the Shadcn and Flowbite kits, all fixed in this release, among them:

  • a closed sheet or drawer staying reachable with the keyboard and exposed to screen readers;
  • dialog, alert-dialog, drawer, sheet and the Flowbite modal no longer being modal once moved in the DOM;
  • alert-dialog opening on page load;
  • a disabled slider still reacting to the keyboard, and slider losing the focus after a click on its track;
  • Tab skipping the panels of navigation-menu;
  • tooltip overflowing the viewport;
  • date-picker focusing "Previous month" instead of a day when it opens;
  • the Flowbite modal having no accessible name, and the inactive triggers of the Flowbite tabs being unreadable in dark mode.

Turbo and Notify Speak the Mercure Protocol 1.0

Grégoire Pineau Christopher Georg Hugo Alliaume
Contributed by Grégoire Pineau , Christopher Georg and Hugo Alliaume in #3935 , #4036 , #4056 and #4057

MercureBundle 0.6 makes the Mercure protocol 1.0 the default. That protocol dropped URI Templates, so turbo_stream_from('App\\Entity\\Book') stayed silent on such a hub, and the Notify Stimulus controller, which only knew the topic query parameter, subscribed to nothing.

UX Turbo and UX Notify now support MercureBundle 0.5 and 0.6, and speak the protocol of the hub they use. Listening to every entity of a class subscribes to a URL Pattern (:id) instead of a URI Template ({id}), and Notify subscribes with match instead of topic. On a hub that speaks the protocol 0.x, nothing changes. The deprecated turbo_stream_listen() throws an exception on a 1.0 hub: use turbo_stream_from() instead.

Searching ux.symfony.com

Hugo Alliaume
Contributed by Hugo Alliaume in #214 and #217

ux.symfony.com had no search: finding a package, a recipe or a demo meant browsing the site. It now has one, powered by Algolia DocSearch. Open it from the header, or press Ctrl+K (⌘K on macOS).

It covers the packages, the Toolkit kits and their recipes, the demos and resources such as the changelog, and groups the results into Packages, Kits, Demos and Resources.

Another Performance Pass

Nicolas Grekas Hugo Alliaume
Contributed by Nicolas Grekas and Hugo Alliaume in #3913 , #3914 , #3934 , #4047 , #4048 , #4049 and #4050

TwigComponent and UX Icons got another round of optimizations, mostly by skipping work nobody needs: walking the call stack, running mount hooks and copying render variables when no listener cares, or rendering the same icon twice.

On an admin dashboard built with the Shadcn kit, which renders about 1,800 components and 400 icons per request, a request takes 22% less CPU time with 3.6 than with 3.5.1, from 50-53 ms down to 39-41 ms, for the same HTML. These numbers are the medians of six alternating runs of 60 requests per version, in the prod environment, with PHP 8.4 and Twig 3.30. Your templates must be recompiled to benefit, which a cache clear does.

Templates that use the HTML syntax also compile faster. UX Icons now warms local icons on cache:warmup, so applications built with Webpack Encore, or that clear their cache after compiling assets, no longer start with an empty icon cache.

Full Changelog

  • #4086 Update the tooling dependencies (@Kocal)
  • #3533 [LiveComponent] Return StimulusAttributes from live_action() so it can be spread in a Twig component tag (@Amoifr)
  • #4074 [Toolkit] Fix GitHub kits pinned to a tag prefixed with "v" (@guillaume-sainthillier)
  • #3930 [LiveComponent] Export the ComponentHooks type (@Pechynho)
  • #4083 [Toolkit] Run the browser tests on 8 shards (@Kocal)
  • #4064 [Inspector] Reposition overlays when the panel opens or closes (@Kocal)
  • #4081 [Translator] Register the configured dump directory with AssetMapper (@Kocal)
  • #4082 [Router] Dump the routes when the container is built without a dump directory (@Kocal)
  • #4068 [Toolkit][Shadcn] Add command recipe (@Enz000)
  • #4073 [Translator] Dump the translations when the container is built without a dump directory (@guillaume-sainthillier)
  • #4071 [Toolkit][Shadcn] Fix the focus lost on open in dropdown-menu and combobox, and two timing-dependent tests (@Enz000)
  • #4076 [Router] Fall back to the application default locale (@feymo)
  • #4080 [TwigComponent] Fix the pre-lexer mistaking <twig: inside a {% %} tag for a component (@javiereguiluz)
  • #4079 [Pagination] Remove deprecated defaultPriorityMethod from the adapter iterator (@tacman)
  • #4040 Add the imgix bridge (@kbond)
  • #4063 [Toolkit][Shadcn] Add context-menu recipe (@Enz000)
  • #4039 [Image] Add the Cloudinary bridge (@kbond, @Kocal)
  • #4067 [Translator] Fix ux:translator:warm-cache writing empty translations (@Kocal)
  • #4066 Make release.sh handle packages npm does not have yet (@Kocal)
  • #4065 [Inspector] Drop unused version from package.json (@Kocal)
  • #1234 [Router] Add Symfony UX Router package (@Kocal)
  • #3927 [LiveComponent] Read the id attribute of elements, not the id property (@Pechynho)
  • #3928 [LiveComponent] Do not update the URL from a response that arrives after a navigation (@Pechynho)
  • #4061 [Toolkit][Shadcn] Align the kit with the upstream Shadcn Nova reference (@ker0x)
  • #4059 [Translator] Fully escape translation IDs (@tnytown)
  • #4062 [Toolkit][Shadcn] Fix the sheet width and the overlay exit transition (@ker0x)
  • #3945 [Image] Add KeyCDN Secure Token support (@kbond)
  • #4022 [LiveComponent] Reset the deterministic id counters between requests (@Pechynho)
  • #4060 [LiveComponent] Sort the template map written by the cache warmer (@nicolas-grekas)
  • #4050 [TwigComponent] Avoid copying the render variables when nobody listens to PreRenderEvent (@Kocal)
  • #4047 [Icons] Cache the rendered HTML of repeated icons in IconRenderer (@Kocal)
  • #4049 [TwigComponent] Find the host template of embedded blocks without scanning the call stack (@Kocal)
  • #4048 [TwigComponent] Skip mount hooks on anonymous components when nobody listens to mount events (@Kocal)
  • #4057 [Turbo] Add support for MercureBundle ^0.6 and Mercure ^0.9 (@Kocal)
  • #4056 [Notify] Add support for MercureBundle ^0.6 (@Kocal)
  • #4058 [Toolkit][Shadcn] Fix a caller's aria-label being ignored on pagination, breadcrumb and questionnaire (@Kocal)
  • #4036 [Notify] Add support for MercureBundle ^0.5 and Mercure ^0.8 (@Chris8934)
  • #4055 [CalendarLink] Fix UTC detection for VTIMEZONE (@tnytown)
  • #3902 [Toolkit][Shadcn] Add toast recipe (@ker0x)
  • #4043 [Autocomplete] Fix Polish translations (@TmszLBmrv)
  • #4042 [Toolkit] Fix the drawer snapshots broken by twig-tailwind-extra 1.5.0 (@Kocal)
  • #4041 [Toolkit] Fix the kit linter crashing on Twig 4 (@Kocal)
  • #4037 [Icons][LiveComponent][StimulusBundle][TwigComponent] Describe the types listed by debug:autowiring (@nicolas-grekas)
  • #3926 [LiveComponent] Finish the loading state when a request fails (@Pechynho)
  • #3924 [LiveComponent] Fix child components lost inside a data-skip-morph element (@Pechynho)
  • #3922 [LiveComponent] Add the render:started hook to the ComponentHooks type (@Pechynho)
  • #3908 [Turbo] Only trigger the TurboStreamListenRenderer deprecation when the service is used (@jmsche)
  • #3935 [Turbo] Support the Mercure protocol 1.0 in turbo_stream_from() (@lyrixx)
  • #3903 [Pagination] Fix Cursor pagination with DATETIMETZ_MUTABLE columns (@Huluti)
  • #4034 [Pagination] Add Ukrainian translations (@Menelion)
  • #4033 [Pagination] Add Russian translations (@Menelion)
  • #3900 [LiveComponent] Fix AssertDispatchedEvent::withPayloadSubset() crashing on non-scalar payload values (@Arkalo2)
  • #4027 [Toolkit][Shadcn] Fix questionnaire freeform answer losing its name once moved in the DOM (@Kocal)
  • #4031 [Toolkit][Shadcn] Fix alert-dialog no longer being modal once moved in the DOM (@Kocal)
  • #4026 [Toolkit][Shadcn] Fix navigation-menu never opening again once moved in the DOM (@Kocal)
  • #4032 [Toolkit][Flowbite] Fix modal no longer being modal once moved in the DOM (@Kocal)
  • #4030 [Toolkit][Shadcn] Fix dialog no longer being modal once moved in the DOM (@Kocal)
  • #4029 [Toolkit][Shadcn] Fix sonner losing its toasts once moved in the DOM (@Kocal)
  • #4028 [Toolkit][Shadcn] Fix resizable handle moving twice as far once moved in the DOM (@Kocal)
  • #4023 [Toolkit][Shadcn] Keep the tooltip inside the viewport and fit it to its text (@Kocal)
  • #4019 [Toolkit] Show a diff when ux:install asks to overwrite a file (@Kocal)
  • #3910 [Toolkit][Shadcn] Align select with the upstream Shadcn Select (@ker0x)
  • #3905 [Toolkit][Shadcn] Fix data-slot being shadowed when forwarded to a child component (@ker0x)
  • #3938 [Toolkit][Shadcn] Align input-group with the upstream Shadcn InputGroup (@ker0x)
  • #3906 [Toolkit][Shadcn] Align combobox with the upstream Shadcn UI component (@ker0x)
  • #3934 [TwigComponent] Copy plain text in one go when pre-lexing templates (@nicolas-grekas)
  • #3914 [Icons] Warm local icons on cache:warmup (@nicolas-grekas)
  • #3937 [Image] Add presets (@Kocal)
  • #4018 [Toolkit] Fix ux:install not overwriting a file newer than the recipe (@Kocal)
  • #3971 [Toolkit][Shadcn] Fix the tooltip of the disabled button example never opening (@Kocal)
  • #3970 [Toolkit][Shadcn] Fix the bookmark icon not filling in the toggle examples (@Kocal)
  • #3969 [Toolkit][Shadcn] Fix the close button and the action of a toast ignoring mouse clicks (@Kocal)
  • #3968 [Toolkit][Shadcn] Fix the slider losing the focus after a click on its track (@Kocal)
  • #3967 [Toolkit][Shadcn] Fix a disabled slider still reacting to the keyboard (@Kocal)
  • #3966 [Toolkit][Shadcn] Fix a closed sheet staying reachable with the keyboard (@Kocal)
  • #3965 [Toolkit][Shadcn] Fix the resizable handle missing its separator state (@Kocal)
  • #3964 [Toolkit][Shadcn] Fix Tab skipping the panels of the navigation menu (@Kocal)
  • #3958 [Toolkit][Shadcn] Fix the dialog trigger's aria-expanded getting out of sync (@Kocal)
  • #3963 [Toolkit][Shadcn] Fix menubar submenus wrapping long labels (@Kocal)
  • #3962 [Toolkit][Shadcn] Fix the radio indicator of the menubar being a ring instead of a dot (@Kocal)
  • #3957 [Toolkit][Shadcn] Fix the date picker focusing "Previous month" instead of a day (@Kocal)
  • #3961 [Toolkit][Shadcn] Fix dropdown submenus wrapping their labels (@Kocal)
  • #3960 [Toolkit][Shadcn] Fix the radio indicator of the dropdown menu being a ring instead of a dot (@Kocal)
  • #3956 [Toolkit][Shadcn] Fix the combobox list not lining up with its trigger (@Kocal)
  • #3959 [Toolkit][Shadcn] Fix a closed drawer staying reachable with the keyboard (@Kocal)
  • #3955 [Toolkit][Shadcn] Fix alert dialogs opening on page load (@Kocal)
  • #3954 [Toolkit][Shadcn] Fix the arrow keys getting stuck before a disabled accordion item (@Kocal)
  • #3953 [Toolkit][Flowbite] Fix inactive tabs being unreadable in dark mode (@Kocal)
  • #3952 [Toolkit][Flowbite] Fix the modal having no accessible name (@Kocal)
  • #3950 [Toolkit] Shard the browser tests and keep the server logs out of the CI output (@Kocal)
  • #3949 [Toolkit][Shadcn] Fix the popover not focusing its content on a busy browser (@Kocal)
  • #3948 [Toolkit] Add visual tests for recipes (@Kocal)
  • #3947 [Toolkit] Ship kit preview assets and add a preview app (@Kocal)
  • #3838 [Image] Add the UX Image package and its Cloudflare and KeyCDN bridges (@Kocal)
  • #3866 [Inspector] Add a browser inspector for Symfony UX (@smnandre)
  • #3918 [CI] Harden the npm release workflow (@Kocal)
  • #3919 Conflict with symfony/asset-mapper 8.2 and later (@Kocal)
  • #3916 Backport release.sh (@Kocal)
  • #3917 Fix the 2.x CI (@Kocal)
  • #3913 [Icons] Fetch each icon from the cache once per request (@nicolas-grekas)
  • #3911 Refresh agent instructions and import skills from symfony/symfony (@Kocal)
  • #3907 Use a X.Y.Z version placeholder in the assets README install commands (@Kocal)
  • #3904 Check shipped assets against Web Platform Baseline (@Kocal)
  • #3896 Keep the npm releases in step with the split repositories (@Kocal)

UX Inspector, UX Image and UX Router are experimental, so their API can still change, and now is when your feedback is worth the most. Try them, and open an issue if something does not fit your application.

Published in #Releases #Symfony UX