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

Simon André
Contributed by Simon André in #3753

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

Hugo Alliaume
Contributed by Hugo Alliaume in #3820 and #3756

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

Hugo Alliaume
Contributed by Hugo Alliaume in #3821

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

Hugo Alliaume
Contributed by Hugo Alliaume in #3795 and #3796

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

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

{% 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

Hugo Alliaume
Contributed by Hugo Alliaume in #3814

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

Francis Hilaire
Contributed by Francis Hilaire in #3441

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

Simon André
Contributed by Simon André in #3773 and #3761

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

Dooji Hugo Alliaume
Contributed by Dooji and Hugo Alliaume in #3684 and #3843

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

Thomas Picquet
Contributed by Thomas Picquet in #3679

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

Pierre du Plessis
Contributed by Pierre du Plessis in #3876

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

Hugo Alliaume
Contributed by Hugo Alliaume in #3771

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

Hugo Alliaume
Contributed by Hugo Alliaume in #3849

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

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

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

Hugo Alliaume Romain Monteil Pascal CESCON
Contributed by Hugo Alliaume , Romain Monteil and Pascal CESCON in #3869 , #3870 , #3855 , #3884 , #3883 , #3806 , #3232 , #3804 , #3483 , #3871 , #3486 , #3889 , #3479 , #3801 , #3487 , #3872 , #3480 , #3805 , #3469 and #3481

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

Pascal CESCON
Contributed by Pascal CESCON in #3798
Hugo Alliaume
Contributed by Hugo Alliaume in #3887 , #3803 and #3807

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

Simon André Hugo Alliaume
Contributed by Simon André and Hugo Alliaume in #3867 , #3891 and #3892

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

Javier Eguiluz Hugo Alliaume
Contributed by Javier Eguiluz and Hugo Alliaume in #3692 , #3841 , #3775 , #3776 , #3777 , #3779 , #3780 , #3781 , #3782 , #3783 , #3784 , #3785 and #3786

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 IconFinderInterface to 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 target to es2022 (@Kocal)
  • #3881 [Autocomplete] Make search test fixtures deterministic (fix flakky) (@smnandre)
  • #3867 [StimulusBundle] Detect the standalone AssetMapper bundle (@smnandre)
  • #3851 [Toolkit] Fix ux:install reporting 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 null as 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 DTSTAMP deterministic via the Clock component (@Kocal)
  • #3818 [CalendarLink] Anchor timed events to their time zone with TZID and VTIMEZONE (@Kocal)
  • #3826 [CalendarLink] Make IcsBuilder internal (@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\XMLDocument API (@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_classes mergeable idiom (@Kocal)
  • #3756 [TwigComponent] Support MergeableInterface in ComponentAttributes (@Kocal)
  • #3758 Upgrade pnpm to 11.21.0 (@Kocal)
  • #3749 [Toolkit] Restore the filename above installation code blocks (filename code 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.

Published in #Releases #Symfony UX