Symfony UX 3.7 is out, with more than 100 pull requests merged since 3.6. It adds UX CSS, an experimental package that turns design tokens into atomic CSS, two self-hosted image servers to UX Image, and takes the UX Toolkit out of experimental. LiveComponent gets 13 bug fixes, Dropzone a round of new features, and pages with many components render faster.

UX CSS

Hugo Alliaume
Contributed by Hugo Alliaume in #3933 , #4183 , #4186 , #4195 , #4196 and #4197

Utility classes written as strings, like the ones of Tailwind CSS, are plain text: a misspelled class gets no CSS, and nothing reports it. UX CSS gives Twig a css() function inspired by Panda CSS: you pass it style properties and design tokens, and it returns class names.

Every css() call is validated. A misspelled property or token name is reported as an error on the page in debug mode, by lint:twig in CI, and by cache:clear at deploy, where it makes the command fail in prod. A css() call without dynamic values has no cost at runtime: the compiled template already holds its class names as a plain string, so nothing is computed when the page renders.

PHP reads your templates and writes one stylesheet, without Node.js. It works with AssetMapper, Symfony Reprise and Webpack Encore. On ux.symfony.com, this card:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<article class="{{ css({
    p: 'lg',
    rounded: 'md',
    bg: 'surface',
    color: 'fg',
}) }}">
    <h3 class="{{ css({color: 'fg'}) }}">symfony/ux-css</h3>
    <p class="{{ css({mt: 'sm'}) }}">Typed atomic CSS for Twig.</p>
    <a href="https://github.com/symfony/ux-css" class="{{ css({
        display: 'inline-block',
        mt: 'md',
        px: 'md',
        py: 'sm',
        rounded: 'md',
        bg: 'blue.600',
        color: 'white',
        _hover: {bg: 'blue.700'},
    }) }}">View on GitHub</a>
</article>

renders <article class="p_lg bdr_md bg_surface c_fg">, and the bundle writes the matching rules, with every token as a CSS variable (reformatted for readability):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@layer tokens, utilities;

@layer tokens {
    :where(:root, :host) {
        --colors-blue-600: #1572b6;
        /* ... */
        --colors-fg: var(--colors-gray-900);
        --colors-surface: var(--colors-white);
    }

    .dark {
        --colors-fg: var(--colors-gray-50);
        --colors-surface: var(--colors-gray-800);
    }
}

@layer utilities {
    .p_lg { padding: var(--spacing-lg); }
    .bg_surface { background: var(--colors-surface); }
    /* ... */
    .hover\:bg_blue\.700:is(:hover, [data-hover]) { background: var(--colors-blue-700); }
}

UX CSS supports every token category of Panda CSS, and the same optional CSS reset (preflight: true).

Editor support is still partial. The Symfony plugin for PhpStorm and Symfony Language Tools already complete the properties and token names of css(). Pull requests in review for the PhpStorm plugin add the value of each token to the completion (1rem next to the spacing token md for the p property), a preview of the color of color tokens, and Ctrl+click to go from a token to its declaration. Language Tools is getting the same features.

In March 2026, for its 1,000th issue, A Week of Symfony listed wishes for the next 1,000 weeks of Symfony UX, and one was an integration with Panda CSS. UX CSS is inspired by Panda CSS rather than built on it. It ships 35 weeks later, and it runs without Node.js.

UX Image: Two Self-Hosted Image Servers

Tac Tacelosky Hugo Alliaume
Contributed by Tac Tacelosky and Hugo Alliaume in #4151 and #4091

The four bridges of UX Image 3.6 send every variant to an image CDN: Cloudflare, Cloudinary, imgix or KeyCDN. 3.7 adds two bridges for image servers you run yourself, Imagor and IPX. Templates stay the same: switching provider only changes the DSN.

Imagor fetches the original image from your site, and signs every URL with the key of the server:

1
2
# .env
UX_IMAGE_DSN=imagor://images.example.com?origin=https%3A%2F%2Fexample.com&sign_key=your-secret

IPX, built on sharp, reads the images from your public/ directory:

1
2
# .env
UX_IMAGE_DSN=ipx://images.example.com

UX Toolkit Is No Longer Experimental

Hugo Alliaume
Contributed by Hugo Alliaume in #4205

The UX Toolkit now follows the Symfony backward compatibility promise. It covers the ux:install command and its options, the ux_toolkit configuration, and the format of the kit and recipe manifest.json files.

It does not cover the content of the recipes. A recipe can change from one version to the next, and installing it again gives you the new one. A recipe you already installed is your own code, and nothing updates it behind your back.

UX Toolkit: Choose Where Recipes Are Installed

Hugo Alliaume
Contributed by Hugo Alliaume in #4135

ux:install always wrote Twig components to templates/components/ and Stimulus controllers to assets/controllers/, so kit files ended up mixed with the components of your application.

The new components and controllers options each take a dir, which must match where your application already loads files from, and an optional subdirectory. With a subdirectory, ux:install rewrites the copied files so the names keep working: <twig:Button> becomes <twig:ui:Button>, and the dialog controller becomes ui--dialog.

1
2
3
4
5
6
# config/packages/ux_toolkit.yaml
ux_toolkit:
    components:
        subdirectory: 'ui'
    controllers:
        subdirectory: 'ui'

Kits opt in with the %COMPONENTS_DIR% and %CONTROLLERS_DIR% placeholders in the copy-files destinations of their recipes. Every kit shipped with the Toolkit uses them.

Missing the TogglePassword Package?

Sébastien Jean
Contributed by Sébastien Jean in #4125

UX TogglePassword was contributed by Félix Eymonot in UX 2.11 (#1000), then deprecated in 2.29 and removed in 3.0. To keep the feature, its migration guide had you copy a Stimulus controller and a form type extension into your application.

If you only need the button, the Common kit of the UX Toolkit now has a password-visibility recipe. Its Stimulus controller switches a field between password and text from a toggle button, and hides the password again when the form is submitted or reset, and when the page is left:

1
2
3
4
5
6
7
<div data-controller="password-visibility">
    <input type="password" id="password" data-password-visibility-target="input" spellcheck="false" autocapitalize="off">
    <button type="button" aria-controls="password" data-action="password-visibility#toggle">
        <span data-password-visibility-target="show">Show</span>
        <span data-password-visibility-target="hide" hidden>Hide</span>
    </button>
</div>

There is no toggle form option: the button lives in your template. The Password Visibility recipe has a live preview.

UX Toolkit: New Recipes, Blocks and Fixes

Sébastien Jean Enzo Santamaria Romain Monteil Hugo Alliaume
Contributed by Sébastien Jean , Enzo Santamaria , Romain Monteil and Hugo Alliaume in #4116 , #4101 , #4100 , #4099 , #4118 and #4121

Besides password-visibility, the kits gained four recipes:

  • sortable (Common) reorders a list, a table body or a grid with drag-and-drop, powered by SortableJS, and can save the new position of each moved item to your server;
  • message-scroller (Shadcn) is the scroll container of a chat: it follows streamed responses, loads history without jumping and jumps to any message;
  • code-block (Shadcn) displays code, terminal output and diffs, with line numbers and a copy button;
  • trace (Shadcn) shows the steps of an AI response, such as reasoning, tool calls and sources, each with its status.

The Shadcn kit also has the blocks it was missing: login-03 to login-05, signup-01 to signup-05 and sidebar-01 to sidebar-16. Porting them exposed bugs in the recipes they use, now fixed:

  • the content of sidebar not scrolling when it is taller than the sidebar, which left the last entries of a long menu out of reach;
  • the gap of floating and inset sidebars being 16px too wide, and nested sidebars exposing several landmarks with the same name;
  • dropdown-menu ignoring align when it opens on the left or the right of its trigger, so a menu in a sidebar footer could leave the screen.

The rich color toasts of sonner are now opaque in dark mode, and its empty toast list no longer catches clicks when a Content Security Policy blocks inline styles.

TwigComponent: A Shorthand for Dynamic Props

Hugo Alliaume
Contributed by Hugo Alliaume in #4207

Passing a variable to a prop of the same name meant writing the name twice: :message="message". In the HTML syntax, :message alone is now a shorthand for it, like the same-name shorthand of Vue 3.4:

1
2
3
4
<twig:Alert :message :type="level" />

{# same as #}
<twig:Alert :message="message" :type="level" />

The name must be a valid Twig variable name, so :data-message still needs a value. A bare :message used to be a syntax error, so existing templates are not affected.

LiveComponent: 13 Bug Fixes

Pascal CESCON HawkForge 0xABCD01 Mlucas44 Kévin Martins Andres Sanz Jan Pech Alejandro Borrego
Contributed by Pascal CESCON , HawkForge , 0xABCD01 , Mlucas44 , Kévin Martins , Andres Sanz , Jan Pech and Alejandro Borrego in #3537 , #3465 , #3501 , #3792 , #4169 , #4170 , #4194 , #4159 , #4098 , #2767 , #3923 , #4024 and #4044

LiveComponent has no new feature in this release, but 13 bug fixes, sent by eight contributors. Among them:

  • a #[LiveProp] array of entities with a Uuid identifier failing to dehydrate, and a LiveProp typed with a resolve_target_entities interface failing to hydrate;
  • LiveCollectionTrait reusing the index of a removed item, which brought the removed Doctrine entity back, and a collection of DTOs failing to dehydrate as soon as a row was added;
  • the queued request never being sent after an error response, which left the promise of action(), set() or render() pending forever;
  • a child component with an explicit id failing to re-render when its props changed;
  • live form updates submitting disabled choices, which browsers never submit, so validation failed on a live update but passed on a real submit;
  • the events emitted by an action that returns a redirect leaking into the next request of a long-running process, such as FrankenPHP in worker mode;
  • overflown dates such as 2026-02-31 being rolled over to another date instead of rejected.

Dropzone: Filter, Transform and Restyle Files

Romain Monteil
Contributed by Romain Monteil in #4143 , #4144 , #4145 , #4146 , #4148 , #4149 and #4153

The file picker only shows the files that match the accept attribute of the input, but drag and drop ignored it. Dropzone now leaves out the dropped files that do not match accept, and the files over the new max_files option, and dispatches dropzone:reject so you can tell the user. With the Validator component, max_files also adds a Count constraint on the server:

1
2
3
4
5
$builder->add('photos', DropzoneType::class, [
    'multiple' => true,
    'max_files' => 5,
    'attr' => ['accept' => 'image/*'],
]);

The rest of the release:

  • a dropzone:pre-add event to transform, replace or reject the files before they are added, for example to resize photos in the browser before the upload;
  • a dropzone_preview_item_template form theme block, to render the preview of each file from your own <template>;
  • a data-state attribute on the container (empty, filled, dragging, processing) to style each state;
  • a placeholder option, which replaces the placeholder passed in attr;
  • the same events in single-file and multiple modes.

Some of these change the behavior, for example when your own CSS sets display on the parts of the Dropzone, or when accept only lists MIME types. Read the Dropzone UPGRADE file before updating.

Autocomplete: Preferred Choices in Ajax Fields

Hugo Alliaume anthovallet Daner Sharifi Ruud Seberechts
Contributed by Hugo Alliaume , anthovallet , Daner Sharifi and Ruud Seberechts in #4204 , #4126 , #3901 and #4203

Ajax-powered fields ignored the preferred_choices option, so the preferred choices were mixed with the other results. The endpoint now lists them first, on every page of results and while the user searches. Like the other options of an Ajax field, set it in the field class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[AsEntityAutocompleteField]
class FoodAutocompleteField extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            // ...
            'preferred_choices' => fn (Options $options) => $this->foodRepository->findBy(['favorite' => true]),
        ]);
    }

    public function getParent(): string
    {
        return BaseEntityAutocompleteType::class;
    }
}

In a multiple field, the endpoint now excludes the selected options before paginating, so a page full of selected options no longer hides the next ones. Reopening the dropdown no longer shows the results of an old search, and the clear button only shows on a select that has an empty option.

Turbo: More Control over Mercure Stream Sources

Romain Monteil Javier Eguiluz
Contributed by Romain Monteil and Javier Eguiluz in #4174 , #4175 , #4176 , #4177 and #4160

A private stream source sets the Mercure authorization cookie, and the browser keeps one such cookie per hub. So the last page rendered decided which topics every open tab could receive. The new subscribe option grants more topics than the ones the page listens to:

1
<twig:Turbo:Stream:From topics="chat" private :subscribe="['chat', 'news']" />

<twig:Turbo:Stream:From> now renders its other HTML attributes, and a stream source marked with data-turbo-permanent keeps its connection to the hub from one page to the next. When the page and the hub do not share a domain, the new turbo.mercure.ignore_cookie_domain_errors option renders nothing and logs a warning, instead of failing the whole page. Javier Eguiluz also rewrote the start of the documentation, which now explains how Turbo works before showing code.

Faster Live Components

Javier Eguiluz Hugo Alliaume
Contributed by Javier Eguiluz and Hugo Alliaume in #4164 , #4165 , #4166 , #4167 , #4168 , #4105 , #4106 , #4107 , #4109 and #4130

TwigComponent now reads the component metadata its cache warmer saved, instead of rebuilding it with reflection on every request, and copies fewer variables and walks the call stack less often. LiveComponent no longer runs its listeners for the components that are not live, and no longer computes an id for each of them during a re-render. A computed property that returns null is now cached too, instead of running again, query included, on every access.

On the admin dashboard used for 3.6, which now also holds 28 live components among its 1,800 components, every request below takes less CPU time with 3.7, for the same HTML. The last four re-render the posts table, a live component:

Request 3.6 3.7 Change
Dashboard, 25 rows 57.1-58.9 ms 48.5-51.0 ms -15%
Dashboard, 100 rows 144.5-149.0 ms 120.0-122.6 ms -18%
Ticking 3 rows 45.0-47.0 ms 25.4-27.7 ms -43%
Ticking 3 rows, 100 rows 158.0-162.1 ms 85.5-87.5 ms -46%
Typing in the search box 51.4-51.8 ms 30.0-31.2 ms -40%
Deleting the 3 ticked rows 47.5-48.2 ms 26.4-28.2 ms -42%

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: the pre-lexer was rewritten around a single scanner, and one pass over 672 real templates takes 32% less time.

Full Changelog

  • #3537 [LiveComponent] Rebuild Component on reconnect when props changed in between (@Amoifr)
  • #4169 [LiveComponent] Fix dehydrating arrays of entities with a Uuid identifier (@HawkForge)
  • #3465 [LiveComponent] Prevent LiveCollectionTrait from reusing removed indexes (@Amoifr)
  • #4170 [LiveComponent] Fix hydrating entities typed with a resolve_target_entities interface (@HawkForge)
  • #4126 [Autocomplete] Exclude already selected options from remote results (@anthovallet)
  • #4194 [LiveComponent] Fix (de)hydration of DTO collections containing a null item (@0xABCD01)
  • #4193 [Pagination] Fix the Doctrine ORM count when a parameter is only used by the SELECT or the ORDER BY (@ker0x)
  • #4159 [LiveComponent] Reject overflown dates during date hydration (@Mlucas44)
  • #4207 [TwigComponent] Add a shorthand for dynamic props named like a variable (@Kocal)
  • #3501 [LiveComponent] Fix NoModificationAllowedError when closing the error modal twice (@Amoifr)
  • #4204 [Autocomplete] Support the preferred_choices option in Ajax-powered fields (@Kocal)
  • #4203 [Autocomplete] Only add the clear button to a select that has an empty option (@Qronicle)
  • #4177 [Turbo] Add an option to ignore the cookie domain errors of private stream sources (@ker0x)
  • #4176 [Turbo] Add a subscribe option to turbo_stream_from() and <twig:Turbo:Stream:From> (@ker0x)
  • #4175 [Turbo] Keep the EventSource of a permanent <turbo-mercure-stream-source> open across visits (@ker0x)
  • #4174 [Turbo] Render the HTML attributes of <twig:Turbo:Stream:From> (@ker0x)
  • #4153 [Dropzone] Use one code path for the single-file and multiple modes (@ker0x)
  • #4149 [Dropzone] Leave out the dropped files that do not match accept and the files over max_files, and dispatch dropzone:reject (@ker0x)
  • #4148 [Dropzone] Add a placeholder option to DropzoneType (@ker0x)
  • #4146 [Dropzone] Make the clear button, file name and image preview targets optional in single-file mode (@ker0x)
  • #4144 [Dropzone] Render the preview of each file from a template in multiple mode (@ker0x)
  • #4145 [Dropzone] Expose the state with data-state and toggle the parts with hidden instead of inline styles (@ker0x)
  • #4143 [Dropzone] Add a dropzone:pre-add event to transform files before they are added (@ker0x)
  • #4201 [Css] Stop naming shared reference types after another property (@Kocal)
  • #4200 [Css] Accept any value for a token category with no token, like Panda (@Kocal)
  • #4196 [Css] Support every Panda token category (@Kocal)
  • #4197 [Css] Accept CSS strings in values (@Kocal)
  • #4195 [Css] Add the preflight option (@Kocal)
  • #4188 [Css] Link to the documentation from the generated stylesheet (@Kocal)
  • #4186 [Css] Write the value of every token to the reference file (@Kocal)
  • #4183 [Css] Write the value of each color token to the reference file (@Kocal)
  • #3933 [Css] Add the CSS component (@Kocal)
  • #4098 [LiveComponent] Reset the LiveResponder between requests (@KevinMartinsDev)
  • #4151 [Image] Add the Imagor bridge (@tacman)
  • #4164 [TwigComponent] Use the warmed component properties cache (@javiereguiluz)
  • #4166 [TwigComponent] Run the outer blocks backtrace once per template method (@javiereguiluz)
  • #4168 [TwigComponent] Set the parent template of embedded components before the render (@javiereguiluz)
  • #4167 [TwigComponent] Copy the context only when props override it (@javiereguiluz)
  • #4165 [TwigComponent] Cache null results of computed properties (@javiereguiluz)
  • #4130 [TwigComponent] Rewrite the pre-lexer around a single scanner (@Kocal)
  • #4091 [Image] Add the IPX bridge (@Kocal)
  • #4161 [Turbo] Keep the connected attribute of <turbo-mercure-stream-source> when morphing (@nicolas-grekas)
  • #4141 [Router][Translator] Don't load the routes and translations when the container is built (@Kocal)
  • #2767 [LiveComponent] Keep the route and its defaults on batch action sub-requests (@arrabiata-asanz)
  • #4134 [Toolkit] Fix the URL of the Flowbite kit (@hifarrer)
  • #4156 [Pkg] Move the tooling devDependencies of the packages to the root package.json (@Kocal)
  • #4157 [CI] Run the tests of the bridges once their package is done (@Kocal)
  • #4155 [CI] Run only the tests of the packages a pull request affects (@Kocal)
  • #4106 [LiveComponent] Look up the Doctrine manager of an entity class once (@Kocal)
  • #4125 [Toolkit][Common] Add password-visibility recipe (@seb-jean)
  • #4140 [Toolkit] Warn about copy-files destinations that skip the installation placeholders (@Kocal)
  • #4124 [Turbo] Register the stream format on the current request instead of a new one (@nicolas-grekas)
  • #4116 [Toolkit][Common] Add sortable recipe (@seb-jean)
  • #4102 [Pagination] Support date columns as Doctrine ORM cursor fields (@lyrixx)
  • #3901 [Autocomplete] Refresh stale remote options when the dropdown reopens (@DanerSharifi-FR)
  • #4135 [Toolkit] Make the installation directories of recipes configurable (@Kocal)
  • #4107 [LiveComponent][TwigComponent] Skip live-only and data-model listeners for other components (@Kocal)
  • #4129 [TwigComponent] Fix pre-lexer only recognizing one spelling of block and embed tags (@dbu)
  • #3792 [LiveComponent] Fix disabled choices being submitted by live form updates (@Amoifr)
  • #4111 [Pagination] Support Symfony UID cursor fields (@norkunas)
  • #3923 [LiveComponent] Send the queued request after an error response (@Pechynho)
  • #4024 [LiveComponent] Keep the id of a child component with an explicit id when its props change (@Pechynho)
  • #4044 [LiveComponent] Sync the value of selects mapped via a form after re-render (@abp002)
  • #4105 [LiveComponent] Only match children fingerprints against live components (@Kocal)
  • #4109 [LiveComponent] Reset deterministic ids without a PostRenderEvent listener (@Kocal)
  • #4101 [Toolkit][Shadcn] Add message-scroller recipe (@Enz000)
  • #4123 [Toolkit] Fix update_toolkit_tests.sh stopping before the screenshots of a block (@Kocal)
  • #4122 [Toolkit][Shadcn] Fix nested and side-by-side sidebars exposing landmarks with the same name (@Kocal)
  • #4121 [Toolkit][Shadcn] Add an as prop to Sidebar:GroupLabel and size nested icons in Sidebar:MenuButton (@Kocal)
  • #4120 [Toolkit][Shadcn] Fix the gap of floating and inset sidebars being 16px too wide (@Kocal)
  • #4119 [Toolkit][Shadcn] Fix dropdown-menu ignoring align on the left and right sides (@Kocal)
  • #4118 [Toolkit][Shadcn] Add the missing login, signup and sidebar blocks (@Kocal)
  • #4100 [Toolkit][Shadcn] Add code-block recipe (@ker0x)
  • #4099 [Toolkit][Shadcn] Add trace recipe (@ker0x)
  • #4117 [Toolkit][Shadcn] Make the rich color toasts of Sonner opaque in dark mode (@Kocal)
  • #4096 [Toolkit][Shadcn] Round the position of the navigation-menu panel to a whole pixel (@Enz000)
  • #4112 [Native] Add the historical location navigation (@feymo)
  • #4021 [TwigComponent] Do not leave a component on the ComponentStack when its pre-render fails (@Pechynho)
  • #4104 [Toolkit][Shadcn] Fix the empty toast list of Sonner catching clicks under a CSP (@abp002)
  • #4089 [Translator] Fall back to the application default locale (@feymo)
  • #4115 Generate the packages .gitignore, and ignore the .vitest/ report directory (@Kocal)
  • #4097 [Turbo] Reset the broadcast listener between requests (@KevinMartinsDev)
  • #4095 [Toolkit] Fix the preview wiring tests with strict AssetMapper imports (@Enz000)

UX CSS and UX Image 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