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
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
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
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
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?
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
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
sidebarnot 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-menuignoringalignwhen 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
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
LiveComponent has no new feature in this release, but 13 bug fixes, sent by eight contributors. Among them:
- a
#[LiveProp]array of entities with aUuididentifier failing to dehydrate, and aLiveProptyped with aresolve_target_entitiesinterface failing to hydrate; LiveCollectionTraitreusing 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()orrender()pending forever; - a child component with an explicit
idfailing 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-31being rolled over to another date instead of rejected.
Dropzone: Filter, Transform and Restyle Files
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-addevent 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_templateform theme block, to render the preview of each file from your own<template>; - a
data-stateattribute on the container (empty,filled,dragging,processing) to style each state; - a
placeholderoption, which replaces the placeholder passed inattr; - the same events in single-file and
multiplemodes.
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
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
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
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_choicesoption 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
multiplemodes (@ker0x) - #4149 [Dropzone] Leave out the dropped files that do not match
acceptand the files overmax_files, and dispatchdropzone:reject(@ker0x) - #4148 [Dropzone] Add a
placeholderoption toDropzoneType(@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
multiplemode (@ker0x) - #4145 [Dropzone] Expose the state with
data-stateand toggle the parts withhiddeninstead of inline styles (@ker0x) - #4143 [Dropzone] Add a
dropzone:pre-addevent 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
preflightoption (@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
connectedattribute 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-modellisteners 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.
Impressive work once again from all contributors 👏 Special thanks to Hugo!