Symfony UX 3.5 is out, with close to ninety pull requests merged since 3.4, enough to make it one of the largest releases the project has shipped. It brings a brand new Pagination package, hands component attribute rendering over to Twig itself, lets you document component props with the Twig 3.29 comment syntax, and adds twenty new Shadcn recipes to the UX Toolkit.
UX Pagination, At Last
For years, whenever Fabien Potencier teased a new Symfony component, someone in the community guessed "Pagination". It finally exists, though in Symfony UX rather than in Symfony itself.
symfony/ux-pagination paginates arrays, Doctrine ORM and DBAL queries, and
any custom data source behind a single request-aware PaginatorInterface.
PHP owns the query, Twig renders accessible navigation, and the browser follows
ordinary links: no JavaScript, no Stimulus, no Turbo required.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
#[Route('/products', name: 'product_index')]
public function __invoke(
ProductRepository $repository,
PaginatorInterface $paginator,
): Response {
$products = $paginator
->cursor($repository->createQueryBuilder('product'))
->orderBy(['createdAt', 'id'], 'DESC')
->perPage(20)
->paginate();
return $this->render('product/index.html.twig', ['products' => $products]);
}
1 2 3 4 5 6
{# templates/product/index.html.twig #}
{% for product in products %}
<article>{{ product.name }}</article>
{% endfor %}
{{ ux_pagination(products) }}
Three strategies are available: numbered pagination for exact totals,
lookahead when you only need a reliable next link and want to skip the
COUNT query, and bidirectional cursor pagination when rows can move while
somebody is browsing.
The <twig:ux:pagination> component ships default, Bootstrap and Tailwind
themes, translations in ten locales, JSON serialization, and an optional
LiveComponent integration through ComponentWithPaginationTrait. The bundle
is experimental, but its own page on the UX website is already live.
Component Attributes Are Now Rendered by Twig
ComponentAttributes used to hand-roll its own attribute rendering: it threw
on null, dropped aria-* set to false, rendered a boolean true
bare, and threw on arrays, iterables and enums. That was a partial, in places
invalid, reimplementation of what Twig has rendered natively since 3.24, when
html_attr() landed.
Both {{ attributes }} and attributes.render() now resolve values through
that same logic, so a component and a plain Twig template render attributes
identically. Twig 3.29 is what made the reuse possible: it exposed the per-value
building block behind html_attr().
1 2 3 4 5 6 7 8 9
{# null omits the attribute instead of throwing #}
<twig:Button :title="null" /> {# <button> #}
{# aria-* booleans render "true"/"false", like in Vue and React #}
<twig:Button :aria-expanded="false" /> {# <button aria-expanded="false"> #}
{# arrays, iterables and backed enums are now accepted #}
<twig:Button :class="['btn', 'btn-lg']" :style="{ color: 'red' }" />
<twig:Button :type="buttonType" /> {# a BackedEnum renders its backing value #}
ComponentAttributes#defaults() also understands MergeableInterface from
twig/html-extra, which is how the Toolkit now merges Tailwind classes. This
needs twig/html-extra ^3.29 and twig/twig ^3.24, and it may
break your tests if you assert on the rendered HTML of a component's
attributes: boolean, null, aria-* and non-scalar values render
differently now.
UX Icons and UX Map Follow the Same Rules
ux_icon(), <twig:ux:icon>, ux_map() and <twig:ux:map> render
their attributes through the same code path, so the typed values of
html_attr_type() and tailwind_classes now work there too. The same
warning applies: if you assert on the <svg> or map <div> attributes,
regenerate those assertions.
The same change fixes a separate bug in UX Icons: an omitted aria-label,
aria-labelledby or title used to suppress the automatic
aria-hidden="true", leaving the icon neither labelled nor hidden from
assistive technology.
Documenting Props and Blocks with Twig Comments
Twig 3.29 introduced documentation comments, and TwigComponent now reads
them. Write a ## comment above a prop inside {% props %}, or a
{## ... ##} comment above a block, and the description is captured at
compile time.
1 2 3 4 5 6 7 8
{%- props
## 'default'|'sm' Size variant of the card.
size = 'default'
-%}
<div {{ attributes }}>
{##- The card content, typically includes `Card:Header` and `Card:Footer`. -#}
{%- block content %}{% endblock -%}
</div>
PropsNode::getPropDocumentation() exposes it per prop. The UX Toolkit is
both the reason this syntax exists and its first consumer: wanting to document
the props and blocks of every kit recipe is what pushed for an official Twig
syntax instead of inventing another convention. Every kit recipe then dropped
its old {# @prop #} and {# @block #} docblocks for it, and the prop
tables on the UX website are generated from it.
Dynamic Component Names in the HTML Syntax
{% component %} has accepted a dynamic expression since 3.4. The HTML syntax
now does too, through <twig:component> and its is attribute:
1 2 3 4 5 6 7 8 9 10
<twig:component is="Alert" type="success" />
{# Dynamic component name #}
<twig:component :is="componentName" type="success" />
{# Dynamic in a loop #}
{% set prefix = 'DynamicNameComponent' %}
{% for i in 1..2 %}
<twig:component :is="prefix ~ i" />
{% endfor %}
Twig 4 Support
Autocomplete, Cropper.js, Dropzone, Icons, LiveComponent, Map, StimulusBundle, Toolkit, Turbo and TwigComponent now run on Twig 4 as well as Twig 3.
Turbo gets a behavior change worth knowing about: on Twig 4, a fully qualified
class name written with single backslashes in turbo_stream_from() or
<twig:Turbo:Stream:From> resolves to the class it names. Twig 3 dropped the
backslashes and turned 'App\Entity\Book' into the unusable topic
AppEntityBook. Templates already using the documented 'App\Entity\Book'
form are unaffected.
Autocomplete Without Doctrine
UX Autocomplete was tied to Doctrine ORM. Backing a field with an API, a search
engine or an in-memory list meant writing a custom endpoint. It now accepts any
data source: implement AutocompleterInterface, tag the service with an
alias, and the new ux_autocomplete route serves it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
#[AutoconfigureTag('ux.autocompleter', ['alias' => 'food'])]
class FoodAutocompleter implements AutocompleterInterface
{
public function fetchResults(string $query, int $page): AutocompleteResults
{
// ... fetch from wherever your data lives
return new AutocompleteResults($results, $hasNextPage);
}
public function isGranted(Security $security): bool
{
return true;
}
}
1
{{ path('ux_autocomplete', { alias: 'food' }) }}
The Doctrine API stays fully supported: EntityAutocompleterInterface and the
ux.entity_autocompleter tag work exactly as before. The
ux_entity_autocomplete route is now an alias of ux_autocomplete, and is
deprecated.
Removing a Component, and Downloading a File, from a LiveAction
A LiveAction can now end its own component. LiveResponse::remove()
performs one final render, so emitted events still reach other components, then
disconnects the Stimulus controller and takes the element off the page:
1 2 3 4 5 6 7 8
#[LiveAction]
public function dismiss(NotificationRepository $repository): LiveResponse
{
$repository->markAsRead($this->notification);
$this->emit('notificationDismissed', ['id' => $this->notification->getId()]);
return LiveResponse::remove();
}
The element carries a data-live-removing attribute until any animation on
it finishes, then is dropped: nothing is deleted server-side, only the
component leaves the page. LiveResponse::downloadUrl() and
LiveResponse::downloadFile() trigger a file download while the component
keeps its state and re-renders. Prefer downloadUrl() whenever the file can
be served from its own route: the browser downloads it natively, so nothing
sits in memory on either side, progress is reported, and range requests and
resuming work.
Dropzone Accepts Several Files
Pass the standard multiple option inherited from FileType, and the
Dropzone accumulates files across successive selections instead of replacing the
previous one, previews each of them, and lets the user remove them individually
before submitting:
1 2 3 4
->add('photos', DropzoneType::class, [
'multiple' => true,
'remove_label' => 'Delete', // labels the per-file remove button
])
In multiple mode, dropzone:change carries a FileList rather than a
single File, a new dropzone:remove event is dispatched with the removed
file, and dropzone:clear is not dispatched since there is no clear button.
The same release fixes the drop zone appearing empty when the very same file is
dropped twice in a row, which Chrome reports without firing a change event.
Cropper.js Runs on Intervention Image 3 and 4
Server-side cropping is powered by Intervention Image, and the package was pinned to version 2, which triggers PHP deprecations. Versions 3 and 4 are now supported, and version 2 keeps working as the lowest supported version.
The image driver is configurable too, which was not possible before:
1 2 3
# config/packages/cropperjs.yaml
cropperjs:
driver: gd # "gd" (default), "imagick" or "vips"
gd and imagick ship with intervention/image. vips also needs
the intervention/image-driver-vips package, the libvips system library and
ext-ffi. If you need full control, register your own service implementing
DriverInterface and point cropperjs.driver_service at it. Both options
require intervention/image 3 or higher.
Custom Icon Finders
ux:icons:lock scans your Twig templates, which means it cannot find an icon
whose name is built at runtime. IconFinderInterface lets you declare those
names from any source, a database, an API or a PHP enum:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Icons/AppIconFinder.php
final class AppIconFinder implements IconFinderInterface
{
public function icons(): array
{
$icons = ['tabler:mail'];
foreach (Status::cases() as $status) {
$icons[] = $status->icon();
}
return $icons;
}
}
Thanks to autoconfiguration, the finder is registered for you. Its icons are
merged with the ones found in your templates, and are used both by
ux:icons:lock and when warming the icon cache.
UX Icons Parses SVG with the PHP 8.4 Dom API
SVG parsing moved from \DOMDocument to the \Dom\XMLDocument API
introduced in PHP 8.4. Beyond being the modern API, it fixes a real bug:
\DOMDocument dropped the xmlns attribute when rebuilding an icon's
attributes, so an icon rendered from a local file came out without it.
That is a markup change. If you assert on the output of ux_icon() or
<twig:ux:icon>, regenerate those assertions and clear your icon cache after
upgrading.
StimulusHelper Is Autowirable
The Stimulus attributes the Twig helpers build are also available from PHP,
through the StimulusHelper service, which you can now autowire. Reach for it
when the element you want to decorate is not written in a template, a form field
for instance:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
public function __construct(private StimulusHelper $stimulusHelper)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$attributes = $this->stimulusHelper->createStimulusAttributes();
$attributes->addController('country-picker', ['locale' => 'fr']);
$attributes->addAction('country-picker', 'refresh', 'change');
$builder->add('country', CountryType::class, [
'attr' => $attributes->toArray(),
]);
}
The field then renders with data-controller, data-action and the value
attributes the Twig helpers would have produced.
UX Toolkit: Blocks
A recipe is a pack of files and dependencies for one component. A block is a
whole page section built from a kit's components, installed and previewed as a
unit. The Shadcn kit ships the first two, login-01 and login-02:
1
$ php bin/console ux:install login-01 --kit=shadcn
Installing it pulls in the recipes it is built from, here button, card,
input and label, and writes the LoginForm component into your
templates. Dashboards, sidebars and other ready-to-use sections are where this
goes next.
UX Toolkit: Twenty New Shadcn Recipes
The Shadcn kit gained twenty recipes: attachment, bubble, calendar,
carousel, date-picker, drawer, dropdown-menu, form,
input-otp, marker, menubar, message, native-select,
navigation-menu, popover, questionnaire, scroll-area, sheet,
sidebar and slider. The typography recipe was removed.
Both Tailwind kits also changed how they merge classes: component variants now
go through attributes.defaults({ class: '...'|tailwind_classes }) instead of
tailwind_merge, so the classes you pass to a component override its own.
This requires symfony/ux-twig-component 3.5.
Browse them all, with a live preview, on ux.symfony.com/toolkit.
UX Toolkit: Accessibility, Documented on Every Recipe
Every one of the sixty Shadcn recipes now carries an ## Accessibility
section in its README.md, visible on the UX website. It says what the
recipe already handles, what it expects from your markup, and what you still
have to do yourself.
Writing them surfaced real bugs, now fixed. alert-dialog, dialog,
drawer and sheet rendered aria-labelledby and aria-describedby
on a roleless wrapper rather than on the <dialog> element, which left the
modal without an accessible name. Right-to-left layouts were broken by physical
spacing and border utilities, now replaced with their logical counterparts. A
dialog focuses its first form field, or the [autofocus] element, when it
opens. And button no longer renders type="button" when as is not a
button.
The Standalone AssetMapper Bundle
Symfony 8.2 moves the AssetMapper configuration out of FrameworkBundle and into a standalone AssetMapperBundle. Every UX package that registers assets detects it, so nothing changes for you whether you run Symfony 7.4, 8.0 or 8.2.
A Performance Pass
A dozen pull requests went after hot paths across the monorepo, each one measured before and after. TwigComponent absorbed most of the work, on two fronts: its render pipeline and its pre-lexer.
Its render pipeline no longer rebuilds a ComponentMetadata on every call, no
longer allocates and dispatches five events per render when nothing listens to
them, and now builds its render variables in a single pass. That pipeline was
profiled against an EasyAdmin index page, the kind of page that renders hundreds
of small components. Its pre-lexer used to copy the whole remaining template on
every call to consume(), several times per character, which made pre-lexing
quadratic in the size of the template. It now scans the template in place
instead.
The rest is smaller and spread out. LiveComponent memoizes its attribute-method
lookups per class instead of rebuilding a ReflectionClass on every render,
and skips the bracket pipeline for plain model names. UX Icons cuts allocations
in IconRenderer::renderIcon(), and UX Map renders each icon once instead of
once per marker. In the browser, StimulusBundle stops watching the DOM once
every lazy controller is loaded and normalizes controller names faster, while
the Translator caches the regular expression compiled by strtr(). The
Toolkit stops re-walking the filesystem on every Recipe::getFiles().
Full Changelog
- #3890 [Toolkit][Shadcn] Drop the dead height option from recipe previews (@Kocal)
- #3889 [Toolkit][Shadcn] Add message recipe (@ker0x)
- #3872 [Toolkit][Shadcn] Add questionnaire recipe (@ker0x)
- #3883 [Toolkit][Shadcn] Add date-picker recipe (@ker0x)
- #3888 [Map] Make the Google Maps browser test resilient (@smnandre)
- #3855 [Toolkit][Shadcn] Add calendar recipe (@ker0x)
- #3796 [Toolkit] Read prop and block docs from Twig documentation comments (@Kocal)
- #3814 [Autocomplete][Cropperjs][Dropzone][LiveComponent][StimulusBundle][Toolkit][TwigComponent] Add support for Twig 4.x (@Kocal)
- #3821 [Icons][Map] Render attributes through twig/html-extra's html_attr() logic (@Kocal)
- #3820 [TwigComponent] Render component attributes through twig/html-extra's
html_attr()(@Kocal) - #3870 [Toolkit][Shadcn] Add bubble recipe (@ker0x)
- #3880 [LiveComponent] Handle paths beginning with double slash in LiveUrlSubscriber (@mbuliard)
- #3876 [Icons] Add
IconFinderInterfaceto create custom finders to lock icons (@pierredup) - #3887 [Toolkit][Shadcn] Document accessibility, fix
<dialog>-based recipies accessibility, and improve right-to-left layouts (@Kocal) - #3871 [Toolkit][Shadcn] Add marker recipe (@ker0x)
- #3869 [Toolkit][Shadcn] Add attachment recipe (@ker0x)
- #3884 [Toolkit][Shadcn] Add carousel recipe (@Kocal)
- #3878 [Toolkit][Shadcn] Fix the rich colors of Sonner recipe (@Kocal)
- #3868 [LiveComponent] Upgrade Idiomorph to 0.7.4 (@smnandre)
- #3858 [Autocomplete] Add a max_options option to control how many options the dropdown displays (@Kocal)
- #3882 [Autocomplete][Chartjs][Cropperjs][Dropzone][LiveComponent][Map][Notify][React][StimulusBundle][Turbo][Vue] Update TypeScript's
targettoes2022(@Kocal) - #3881 [Autocomplete] Make search test fixtures deterministic (fix flakky) (@smnandre)
- #3867 [StimulusBundle] Detect the standalone AssetMapper bundle (@smnandre)
- #3851 [Toolkit] Fix
ux:installreporting the source path of the installed files (@ker0x) - #3860 [Toolkit] Restore the "available since" note on recipe install steps (@Kocal)
- #3849 [StimulusBundle] Make StimulusHelper autowirable and document PHP usage (@Kocal)
- #3847 [Turbo] Fix broadcasting an entity whose identifier is made of associations (@Kocal)
- #3846 [Translator] Cover the parent locale fallback in the dumper tests (@Kocal)
- #3843 [Dropzone] Fix the drop zone appearing empty when the same file is dropped again (@Kocal)
- #3842 [Toolkit][Flowbite] Rename the modal Stimulus controller to avoid a collision with Flowbite (@Kocal)
- #3518 [TwigComponent] Fix PreLexer confusing
{#inside output expressions with comments (@Amoifr) - #3441 [Autocomplete] Decouple from Doctrine ORM - support any data source (@Prometee)
- #3679 [Cropperjs] Upgrade to Intervention Image v4 and make the image driver configurable (@deluxetom)
- #3684 Allow for multiple file uploads at once (@Dooij)
- #3763 [Autocomplete] Translate the optgroup labels returned by the AJAX endpoint (@kira0269)
- #3836 [TwigComponent] Fix null-safe operator in component tag props (@alireza-aminzadeh)
- #3827 [TwigComponent] Treat a prop explicitly passed as
nullas defined (@Kocal) - #3823 [LiveComponent] Use test stubs instead of mocks (@smnandre)
- #3832 Remove vulnerable extract-zip dependency (@smnandre)
- #3831 [CI] Pin DOCtor-RST container image (@smnandre)
- #3829 [React] Update E2E dependencies (@smnandre)
- #3753 [Pagination] Add new UX Pagination component (@smnandre)
- #3773 [LiveComponent] Add LiveResponse::remove() to trigger component deletion (@smnandre)
- #3761 [LiveComponent] Add file downloads from a LiveAction (@smnandre)
- #3817 [CalendarLink] Derive a stable ICS UID from the event content (@Kocal)
- #3816 [CalendarLink] Make
DTSTAMPdeterministic via the Clock component (@Kocal) - #3818 [CalendarLink] Anchor timed events to their time zone with TZID and VTIMEZONE (@Kocal)
- #3826 [CalendarLink] Make
IcsBuilderinternal (@Kocal) - #3819 [Autocomplete] Translated 'Add placeholder' to Italian (@luigif)
- #3469 [Toolkit][Shadcn] Add sidebar recipe (@Amoifr)
- #3804 [Toolkit][Shadcn] Add form recipe (@Kocal)
- #3806 [Toolkit][Shadcn] Add drawer recipe (@Kocal)
- #3805 [Toolkit][Shadcn] Add sheet recipe (@Kocal)
- #3807 [Toolkit][Shadcn] popover: honor [autofocus] and skip hidden inputs when focusing content (@Kocal)
- #3803 [Toolkit][Shadcn] Focus the first form field when a dialog opens (@Kocal)
- #3481 [Toolkit][Shadcn] Add slider recipe (@Kocal)
- #3480 [Toolkit][Shadcn] Add scroll-area recipe (@Kocal)
- #3802 [Toolkit][Shadcn] Remove typography recipe (@Kocal)
- #3479 [Toolkit][Shadcn] Add native-select recipe (@Amoifr)
- #3801 [Toolkit][Shadcn] Add navigation-menu recipe (@Kocal)
- #3232 [Toolkit][Shadcn] Add DropdownMenu component (@Kocal)
- #3487 [Toolkit][Shadcn] Add popover recipe (@Kocal)
- #3791 [LiveComponent] Fix proxy turning "toJSON" and "then" protocol probes into server actions (@Amoifr)
- #3798 [Toolkit][Shadcn] Fix the hover-card usage example crashing on a plain HTML trigger (@Amoifr)
- #3795 [TwigComponent] Capture prop documentation comments (@Kocal)
- #3486 [Toolkit][Shadcn] Add menubar recipe (@Kocal)
- #3483 [Toolkit][Shadcn] Add input-otp recipe (@Kocal)
- #3777 [LiveComponent] Cache the attribute-method lookups per component class (@Kocal)
- #3762 [TwigComponent] Fix twig:blockquote is not a block (@smnandre)
- #3779 [Map] Render each UX icon once instead of once per marker (@Kocal)
- #3783 [StimulusBundle] Stop watching the DOM once every lazy controller is loaded (@Kocal)
- #3776 [TwigComponent] Reduce per-character work in the pre-lexer scan loops (@Kocal)
- #3784 [Translator] Cache the RegExp compiled by strtr() (@Kocal)
- #3786 [LiveComponent] Skip the bracket pipeline for plain model names (@Kocal)
- #3781 [StimulusBundle] Speed up Stimulus name normalization (@Kocal)
- #3841 [TwigComponent] Apply review remarks from #3692 (@Kocal)
- #3692 [TwigComponent] Reduce per-render CPU cost of static components (@javiereguiluz)
- #3775 [TwigComponent] Fix quadratic scanning in TwigPreLexer::consume() (@Kocal)
- #3780 [Toolkit] Stop re-walking the filesystem on every Recipe::getFiles() (@Kocal)
- #3785 [TwigComponent] Build the exposed properties without a generator (@Kocal)
- #3782 [Icons] Cut per-render allocations in IconRenderer::renderIcon() (@Kocal)
- #3764 [TwigComponent] Fix { verbatim } pre-lexing (@smnandre)
- #3771 [Icons] Migrate to modern
\Dom\XMLDocumentAPI (@Kocal) - #3699 [TwigComponent] Add dynamic component support for HTML-syntax (@seb-jean)
- #3596 [Toolkit] Finalize support for Blocks, add Shadcn's login-01 and login-02 blocks (@seb-jean)
- #3760 [Toolkit] Migrate Tailwind kits to the
tailwind_classesmergeable idiom (@Kocal) - #3756 [TwigComponent] Support
MergeableInterfaceinComponentAttributes(@Kocal) - #3758 Upgrade pnpm to 11.21.0 (@Kocal)
- #3749 [Toolkit] Restore the filename above installation code blocks (
filenamecode options) (@Kocal)
Symfony UX 3.5.1 followed a few hours later, with two fixes for the standalone AssetMapper bundle that 3.5.0 had missed:
- #3892 [Map] Add missing support for Symfony 8.2's standalone AssetMapperBundle (@Kocal)
- #3891 [Pagination] Add missing support for Symfony 8.2's standalone AssetMapperBundle (@Kocal)
UX Pagination is experimental, which means its API can still change before a stable release. That is exactly when your feedback is worth the most, so try it and open an issue if something does not fit. The same goes for the attribute rendering change: if upgrading broke an assertion in a way this post did not warn you about, we want to hear about it.