Repository: symfony/ux
Pull request #2117 (2024-09-01 07:29:00)

[Map] Render map from Twig with `ux_map()` and  `<twig:ux:map />` 
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | Fix #...
| License       | MIT

* ~~add an MapFactory (internal)~~
* allow `ux_map` Twig function to render map from named arguments
* add basic TwigComponent `<twig:ux:map ... />`

### Create Map from Twig

```twig

{{ ux_map(
    center: [51.5074, 0.1278],
    zoom: 3,
    markers:  [
        { position: [51.5074, 0.1278], title: 'London' },
        { position: [48.8566, 2.3522], title: 'Paris' },
        {
            position: [40.7128, -74.0060],
            title: 'New York',
            infoWindow: { content: 'Welcom to <b>New York</b>' }
        },
    ],
    attributes: {
        class: 'foo',
        style: 'height: 800px; width: 100%; border: 4px solid red; margin-block: 10vh;',
    }
) }}

```

![ux-map](https://github.com/user-attachments/assets/2d127863-c159-4024-9266-c12609460534)



smnandre: Integrated the ideas fom #2152 

Patch:
diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md
index 8ff2aad1..71eb76d0 100644
--- a/src/Icons/CHANGELOG.md
+++ b/src/Icons/CHANGELOG.md
@@ -3,7 +3,6 @@
 ## 2.20.0
 
 -   Add `aliases` configuration option to define icon alternative names.
--   Add support for `int` and `float` attribute values in `<twig:ux:icon />`.
 
 ## 2.19.0
 
diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst
index 0950e885..9ac93419 100644
--- a/src/Icons/doc/index.rst
+++ b/src/Icons/doc/index.rst
@@ -339,10 +339,6 @@ Now, all icons will have the ``fill`` attribute set to ``currentColor`` by defau
 Icon Aliases
 ~~~~~~~~~~~~
 
-.. versionadded:: 2.20
-
-    Icon Aliases feature was added in 2.20.
-
 Aliases are custom names you can define to refer to any icon. They are useful for
 creating shortcuts to icons you frequently use in your templates:
 
@@ -548,11 +544,11 @@ Full Configuration
         default_icon_attributes:
             # Default:
             fill: currentColor
-
+            
         # Icon aliases (alias => icon name).
         aliases:
             # Exemple:
-            dots: 'clarity:ellipsis-horizontal-line'
+            dots: 'clarity:ellipsis-horizontal-line'    
 
         # Configuration for the "on demand" icons powered by Iconify.design.
         iconify:
@@ -563,7 +559,7 @@ Full Configuration
 
            # The endpoint for the Iconify API.
            endpoint: 'https://api.iconify.design'
-
+           
         # Whether to ignore errors when an icon is not found.
         ignore_not_found: false
 
diff --git a/src/Icons/src/Icon.php b/src/Icons/src/Icon.php
index e7a9e73b..41883a98 100644
--- a/src/Icons/src/Icon.php
+++ b/src/Icons/src/Icon.php
@@ -176,7 +176,7 @@ final class Icon implements \Stringable
     }
 
     /**
-     * @param array<string, string|bool|int|float> $attributes
+     * @param array<string, string|bool> $attributes
      */
     public function withAttributes(array $attributes): self
     {
@@ -189,8 +189,8 @@ final class Icon implements \Stringable
                 throw new \InvalidArgumentException(\sprintf('Invalid attribute name "%s".', $name));
             }
 
-            if (!\is_string($value) && !\is_bool($value) && !\is_int($value) && !\is_float($value)) {
-                throw new \InvalidArgumentException(\sprintf('Invalid value type for attribute "%s". Boolean, string, int or float allowed, "%s" provided. ', $name, get_debug_type($value)));
+            if (!\is_string($value) && !\is_bool($value)) {
+                throw new \InvalidArgumentException(\sprintf('Invalid value type for attribute "%s". Boolean or string allowed, "%s" provided. ', $name, get_debug_type($value)));
             }
         }
 
diff --git a/src/LiveComponent/assets/dist/Backend/Backend.d.ts b/src/LiveComponent/assets/dist/Backend/Backend.d.ts
index c664be92..53e5daae 100644
--- a/src/LiveComponent/assets/dist/Backend/Backend.d.ts
+++ b/src/LiveComponent/assets/dist/Backend/Backend.d.ts
@@ -13,7 +13,6 @@ export interface BackendInterface {
     }, files: {
         [key: string]: FileList;
     }): BackendRequest;
-    updateCsrfToken(csrfToken: string): void;
 }
 export interface BackendAction {
     name: string;
@@ -29,5 +28,4 @@ export default class implements BackendInterface {
     }, files: {
         [key: string]: FileList;
     }): BackendRequest;
-    updateCsrfToken(csrfToken: string): void;
 }
diff --git a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
index 8a29af3b..2cee6c2f 100644
--- a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
+++ b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
@@ -2,7 +2,7 @@ import type { BackendAction, ChildrenFingerprints } from './Backend';
 export default class {
     private url;
     private method;
-    private csrfToken;
+    private readonly csrfToken;
     constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
     buildRequest(props: any, actions: BackendAction[], updated: {
         [key: string]: any;
@@ -15,5 +15,4 @@ export default class {
         fetchOptions: RequestInit;
     };
     private willDataFitInUrl;
-    updateCsrfToken(csrfToken: string): void;
 }
diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js
index 22b6b68f..05af8986 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -2052,9 +2052,6 @@ class Component {
                 return response;
             }
             this.processRerender(html, backendResponse);
-            if (this.element.dataset.liveCsrfValue) {
-                this.backend.updateCsrfToken(this.element.dataset.liveCsrfValue);
-            }
             this.backendRequest = null;
             thisPromiseResolve(backendResponse);
             if (this.isRequestPending) {
@@ -2328,9 +2325,6 @@ class RequestBuilder {
         const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenJson + propsFromParentJson).toString();
         return (urlEncodedJsonData + params.toString()).length < 1500;
     }
-    updateCsrfToken(csrfToken) {
-        this.csrfToken = csrfToken;
-    }
 }
 
 class Backend {
@@ -2341,9 +2335,6 @@ class Backend {
         const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files);
         return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated));
     }
-    updateCsrfToken(csrfToken) {
-        this.requestBuilder.updateCsrfToken(csrfToken);
-    }
 }
 
 class StimulusElementDriver {
diff --git a/src/LiveComponent/assets/src/Backend/Backend.ts b/src/LiveComponent/assets/src/Backend/Backend.ts
index b3d019a4..b7bb6445 100644
--- a/src/LiveComponent/assets/src/Backend/Backend.ts
+++ b/src/LiveComponent/assets/src/Backend/Backend.ts
@@ -15,7 +15,6 @@ export interface BackendInterface {
         updatedPropsFromParent: { [key: string]: any },
         files: { [key: string]: FileList }
     ): BackendRequest;
-    updateCsrfToken(csrfToken: string): void;
 }
 
 export interface BackendAction {
@@ -53,8 +52,4 @@ export default class implements BackendInterface {
             Object.keys(updated)
         );
     }
-
-    updateCsrfToken(csrfToken: string) {
-        this.requestBuilder.updateCsrfToken(csrfToken);
-    }
 }
diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
index b9bd2fc1..328f0647 100644
--- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
+++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
@@ -3,7 +3,7 @@ import type { BackendAction, ChildrenFingerprints } from './Backend';
 export default class {
     private url: string;
     private method: 'get' | 'post';
-    private csrfToken: string | null;
+    private readonly csrfToken: string | null;
 
     constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
         this.url = url;
@@ -117,8 +117,4 @@ export default class {
         // if the URL gets remotely close to 2000 chars, it may not fit
         return (urlEncodedJsonData + params.toString()).length < 1500;
     }
-
-    updateCsrfToken(csrfToken: string) {
-        this.csrfToken = csrfToken;
-    }
 }
diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts
index 18c6a5de..28807c57 100644
--- a/src/LiveComponent/assets/src/Component/index.ts
+++ b/src/LiveComponent/assets/src/Component/index.ts
@@ -329,11 +329,6 @@ export default class Component {
 
             this.processRerender(html, backendResponse);
 
-            // Store updated csrf token
-            if (this.element.dataset.liveCsrfValue) {
-                this.backend.updateCsrfToken(this.element.dataset.liveCsrfValue);
-            }
-
             // finally resolve this promise
             this.backendRequest = null;
             thisPromiseResolve(backendResponse);
diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md
index 33431733..d4894408 100644
--- a/src/Map/CHANGELOG.md
+++ b/src/Map/CHANGELOG.md
@@ -2,8 +2,11 @@
 
 ## 2.20
 
--   Rename `render_map` Twig function `ux_map`
--   Deprecate `render_map` Twig function
+-   Deprecate `render_map` Twig function (will be removed in 2.21). Use 
+    `ux_map` or the `<twig:ux:map />` Twig component instead.
+-   Add `ux_map` Twig function (replaces `render_map` with a more flexible 
+    interface)
+-   Add `<twig:ux:map />` Twig component
 
 ## 2.19
 
diff --git a/src/Map/composer.json b/src/Map/composer.json
index 73d39303..a24b4fd3 100644
--- a/src/Map/composer.json
+++ b/src/Map/composer.json
@@ -39,7 +39,8 @@
         "symfony/asset-mapper": "^6.4|^7.0",
         "symfony/framework-bundle": "^6.4|^7.0",
         "symfony/phpunit-bridge": "^6.4|^7.0",
-        "symfony/twig-bundle": "^6.4|^7.0"
+        "symfony/twig-bundle": "^6.4|^7.0",
+        "symfony/ux-twig-component": "^2.18"
     },
     "extra": {
         "thanks": {
diff --git a/src/Map/config/services.php b/src/Map/config/services.php
index 7dada5ec..c94a9043 100644
--- a/src/Map/config/services.php
+++ b/src/Map/config/services.php
@@ -15,6 +15,7 @@ use Symfony\UX\Map\Renderer\AbstractRendererFactory;
 use Symfony\UX\Map\Renderer\Renderer;
 use Symfony\UX\Map\Renderer\Renderers;
 use Symfony\UX\Map\Twig\MapExtension;
+use Symfony\UX\Map\Twig\MapRuntime;
 
 /*
  * @author Hugo Alliaume <hugo@alliau.me>
@@ -26,7 +27,6 @@ return static function (ContainerConfigurator $container): void {
             ->args([
                 abstract_arg('renderers configuration'),
             ])
-            ->tag('twig.runtime')
 
         ->set('ux_map.renderer_factory.abstract', AbstractRendererFactory::class)
             ->abstract()
@@ -41,5 +41,11 @@ return static function (ContainerConfigurator $container): void {
 
         ->set('ux_map.twig_extension', MapExtension::class)
             ->tag('twig.extension')
+
+        ->set('ux_map.twig_runtime', MapRuntime::class)
+            ->args([
+                service('ux_map.renderers'),
+            ])
+            ->tag('twig.runtime')
     ;
 };
diff --git a/src/Map/config/twig_component.php b/src/Map/config/twig_component.php
new file mode 100644
index 00000000..f09999e9
--- /dev/null
+++ b/src/Map/config/twig_component.php
@@ -0,0 +1,32 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+
+use Symfony\UX\Map\Twig\UXMapComponent;
+use Symfony\UX\Map\Twig\UXMapComponentListener;
+use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
+
+return static function (ContainerConfigurator $container): void {
+    $container->services()
+        ->set('.ux_map.twig_component_listener', UXMapComponentListener::class)
+            ->args([
+                service('ux_map.twig_runtime'),
+            ])
+            ->tag('kernel.event_listener', [
+                'event' => PreCreateForRenderEvent::class,
+                'method' => 'onPreCreateForRender',
+            ])
+
+        ->set('.ux_map.twig_component.map', UXMapComponent::class)
+            ->tag('twig.component', ['key' => 'UX:Map'])
+    ;
+};
diff --git a/src/Map/src/InfoWindow.php b/src/Map/src/InfoWindow.php
index 4897b3c9..f98fbb69 100644
--- a/src/Map/src/InfoWindow.php
+++ b/src/Map/src/InfoWindow.php
@@ -19,7 +19,8 @@ namespace Symfony\UX\Map;
 final readonly class InfoWindow
 {
     /**
-     * @param array<string, mixed> $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side
+     * @param array<string, mixed> $extra Extra data, can be used by the developer to store additional information and
+     *                                    use them later JavaScript side
      */
     public function __construct(
         private ?string $headerContent = null,
@@ -31,6 +32,16 @@ final readonly class InfoWindow
     ) {
     }
 
+    /**
+     * @return array{
+     *     headerContent: string|null,
+     *     content: string|null,
+     *     position: array{lat: float, lng: float}|null,
+     *     opened: bool,
+     *     autoClose: bool,
+     *     extra: object,
+     * }
+     */
     public function toArray(): array
     {
         return [
@@ -42,4 +53,25 @@ final readonly class InfoWindow
             'extra' => (object) $this->extra,
         ];
     }
+
+    /**
+     * @param array{
+     *     headerContent: string|null,
+     *     content: string|null,
+     *     position: array{lat: float, lng: float}|null,
+     *     opened: bool,
+     *     autoClose: bool,
+     *     extra: object,
+     * } $data
+     *
+     * @internal
+     */
+    public static function fromArray(array $data): self
+    {
+        if (isset($data['position'])) {
+            $data['position'] = Point::fromArray($data['position']);
+        }
+
+        return new self(...$data);
+    }
 }
diff --git a/src/Map/src/Map.php b/src/Map/src/Map.php
index 834e0d3d..d8fdf005 100644
--- a/src/Map/src/Map.php
+++ b/src/Map/src/Map.php
@@ -103,4 +103,36 @@ final class Map
             'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers),
         ];
     }
+
+    /**
+     * @param array{
+     *     center?: array{lat: float, lng: float},
+     *     zoom?: float,
+     *     markers?: list<array>,
+     *     fitBoundsToMarkers?: bool,
+     *     options?: object,
+     * } $map
+     *
+     * @internal
+     */
+    public static function fromArray(array $map): self
+    {
+        $map['fitBoundsToMarkers'] = true;
+
+        if (isset($map['center'])) {
+            $map['center'] = Point::fromArray($map['center']);
+        }
+
+        if (isset($map['zoom']) || isset($map['center'])) {
+            $map['fitBoundsToMarkers'] = false;
+        }
+
+        $map['markers'] ??= [];
+        if (!\is_array($map['markers'])) {
+            throw new InvalidArgumentException('The "markers" parameter must be an array.');
+        }
+        $map['markers'] = array_map(Marker::fromArray(...), $map['markers']);
+
+        return new self(...$map);
+    }
 }
diff --git a/src/Map/src/Marker.php b/src/Map/src/Marker.php
index 5c822698..ac0dc0e0 100644
--- a/src/Map/src/Marker.php
+++ b/src/Map/src/Marker.php
@@ -11,6 +11,8 @@
 
 namespace Symfony\UX\Map;
 
+use Symfony\UX\Map\Exception\InvalidArgumentException;
+
 /**
  * Represents a marker on a map.
  *
@@ -19,7 +21,8 @@ namespace Symfony\UX\Map;
 final readonly class Marker
 {
     /**
-     * @param array<string, mixed> $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side
+     * @param array<string, mixed> $extra Extra data, can be used by the developer to store additional information and
+     *                                    use them later JavaScript side
      */
     public function __construct(
         private Point $position,
@@ -29,6 +32,14 @@ final readonly class Marker
     ) {
     }
 
+    /**
+     * @return array{
+     *     position: array{lat: float, lng: float},
+     *     title: string|null,
+     *     infoWindow: array<string, mixed>|null,
+     *     extra: object,
+     * }
+     */
     public function toArray(): array
     {
         return [
@@ -38,4 +49,28 @@ final readonly class Marker
             'extra' => (object) $this->extra,
         ];
     }
+
+    /**
+     * @param array{
+     *     position: array{lat: float, lng: float},
+     *     title: string|null,
+     *     infoWindow: array<string, mixed>|null,
+     *     extra: object,
+     * } $marker
+     *
+     * @internal
+     */
+    public static function fromArray(array $marker): self
+    {
+        if (!isset($marker['position'])) {
+            throw new InvalidArgumentException('The "position" parameter is required.');
+        }
+        $marker['position'] = Point::fromArray($marker['position']);
+
+        if (isset($marker['infoWindow'])) {
+            $marker['infoWindow'] = InfoWindow::fromArray($marker['infoWindow']);
+        }
+
+        return new self(...$marker);
+    }
 }
diff --git a/src/Map/src/Point.php b/src/Map/src/Point.php
index a6d71d88..f34f37a2 100644
--- a/src/Map/src/Point.php
+++ b/src/Map/src/Point.php
@@ -43,4 +43,16 @@ final readonly class Point
             'lng' => $this->longitude,
         ];
     }
+
+    /**
+     * @param array{lat: float, lng: float}|array{0: float, 1: float} $point
+     */
+    public static function fromArray(array $point): self
+    {
+        if (isset($point['lat'], $point['lng'])) {
+            return new self($point['lat'], $point['lng']);
+        }
+
+        return new self(...$point);
+    }
 }
diff --git a/src/Map/src/Twig/MapExtension.php b/src/Map/src/Twig/MapExtension.php
index 57807f54..6442ff1b 100644
--- a/src/Map/src/Twig/MapExtension.php
+++ b/src/Map/src/Twig/MapExtension.php
@@ -11,7 +11,6 @@
 
 namespace Symfony\UX\Map\Twig;
 
-use Symfony\UX\Map\Renderer\Renderers;
 use Twig\DeprecatedCallableInfo;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFunction;
@@ -26,13 +25,13 @@ final class MapExtension extends AbstractExtension
     public function getFunctions(): array
     {
         return [
-            new TwigFunction('render_map', [Renderers::class, 'renderMap'], [
+            new TwigFunction('render_map', [MapRuntime::class, 'renderMap'], [
                 'is_safe' => ['html'],
                 ...(class_exists(DeprecatedCallableInfo::class)
                     ? ['deprecation_info' => new DeprecatedCallableInfo('symfony/ux-map', '2.20', 'ux_map')]
                     : ['deprecated' => '2.20', 'deprecating_package' => 'symfony/ux-map', 'alternative' => 'ux_map']),
             ]),
-            new TwigFunction('ux_map', [Renderers::class, 'renderMap'], ['is_safe' => ['html']]),
+            new TwigFunction('ux_map', [MapRuntime::class, 'renderMap'], ['is_safe' => ['html']]),
         ];
     }
 }
diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php
new file mode 100644
index 00000000..62e50be8
--- /dev/null
+++ b/src/Map/src/Twig/MapRuntime.php
@@ -0,0 +1,64 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Twig;
+
+use Symfony\UX\Map\Map;
+use Symfony\UX\Map\Marker;
+use Symfony\UX\Map\Point;
+use Symfony\UX\Map\Renderer\RendererInterface;
+use Twig\Extension\RuntimeExtensionInterface;
+
+/**
+ * @author Simon André <smn.andre@gmail.com>
+ *
+ * @internal
+ */
+final class MapRuntime implements RuntimeExtensionInterface
+{
+    public function __construct(
+        private readonly RendererInterface $renderer,
+    ) {
+    }
+
+    /**
+     * @param array<string, mixed> $attributes
+     * @param array<string, mixed> $markers
+     */
+    public function renderMap(
+        ?Map $map = null,
+        array $attributes = [],
+        ?array $markers = null,
+        ?array $center = null,
+        ?float $zoom = null,
+    ): string {
+        if ($map instanceof Map) {
+            if (null !== $center || null !== $zoom || $markers) {
+                throw new \InvalidArgumentException('You cannot set "center", "markers" or "zoom" on an existing Map.');
+            }
+
+            return $this->renderer->renderMap($map, $attributes);
+        }
+
+        $map = new Map();
+        foreach ($markers ?? [] as $marker) {
+            $map->addMarker(Marker::fromArray($marker));
+        }
+        if (null !== $center) {
+            $map->center(Point::fromArray($center));
+        }
+        if (null !== $zoom) {
+            $map->zoom($zoom);
+        }
+
+        return $this->renderer->renderMap($map, $attributes);
+    }
+}
diff --git a/src/Map/src/Twig/UXMapComponent.php b/src/Map/src/Twig/UXMapComponent.php
new file mode 100644
index 00000000..94cb6407
--- /dev/null
+++ b/src/Map/src/Twig/UXMapComponent.php
@@ -0,0 +1,32 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Twig;
+
+use Symfony\UX\Map\Marker;
+use Symfony\UX\Map\Point;
+
+/**
+ * @author Simon André <smn.andre@gmail.com>
+ *
+ * @internal
+ */
+final class UXMapComponent
+{
+    public ?float $zoom;
+
+    public ?Point $center;
+
+    /**
+     * @var Marker[]
+     */
+    public array $markers;
+}
diff --git a/src/Map/src/Twig/UXMapComponentListener.php b/src/Map/src/Twig/UXMapComponentListener.php
new file mode 100644
index 00000000..3a36e558
--- /dev/null
+++ b/src/Map/src/Twig/UXMapComponentListener.php
@@ -0,0 +1,42 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Twig;
+
+use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
+
+/**
+ * @author Simon André <smn.andre@gmail.com>
+ *
+ * @internal
+ */
+final class UXMapComponentListener
+{
+    public function __construct(
+        private MapRuntime $mapRuntime,
+    ) {
+    }
+
+    public function onPreCreateForRender(PreCreateForRenderEvent $event): void
+    {
+        if ('ux:map' !== strtolower($event->getName())) {
+            return;
+        }
+
+        $attributes = $event->getInputProps();
+        $map = array_intersect_key($attributes, ['markers' => 0, 'center' => 1, 'zoom' => 2]);
+        $attributes = array_diff_key($attributes, $map);
+
+        $html = $this->mapRuntime->renderMap(...$map, attributes: $attributes);
+        $event->setRenderedString($html);
+        $event->stopPropagation();
+    }
+}
diff --git a/src/Map/src/UXMapBundle.php b/src/Map/src/UXMapBundle.php
index 52ecec41..13929261 100644
--- a/src/Map/src/UXMapBundle.php
+++ b/src/Map/src/UXMapBundle.php
@@ -19,6 +19,7 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
 use Symfony\UX\Map\Bridge as MapBridge;
 use Symfony\UX\Map\Renderer\AbstractRendererFactory;
 use Symfony\UX\Map\Renderer\NullRendererFactory;
+use Symfony\UX\TwigComponent\TwigComponentBundle;
 
 /**
  * @author Hugo Alliaume <hugo@alliau.me>
@@ -55,6 +56,10 @@ final class UXMapBundle extends AbstractBundle
             $config['renderer'] = 'null://null';
         }
 
+        if (ContainerBuilder::willBeAvailable('symfony/ux-twig-component', TwigComponentBundle::class, ['symfony/ux-map'])) {
+            $container->import('../config/twig_component.php');
+        }
+
         if (str_starts_with($config['renderer'], 'null://')) {
             $container->services()
                 ->set('ux_map.renderer_factory.null', NullRendererFactory::class)
diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json
index 1e0ca5d3..77f5a324 100644
--- a/src/TwigComponent/composer.json
+++ b/src/TwigComponent/composer.json
@@ -40,6 +40,7 @@
         "symfony/framework-bundle": "^5.4|^6.0|^7.0",
         "symfony/phpunit-bridge": "^6.0|^7.0",
         "symfony/stimulus-bundle": "^2.9.1",
+        "symfony/stopwatch": "^5.4|^6.0|^7.0",
         "symfony/twig-bundle": "^5.4|^6.0|^7.0",
         "symfony/webpack-encore-bundle": "^1.15"
     },
diff --git a/src/TwigComponent/config/debug.php b/src/TwigComponent/config/debug.php
index 6afabb82..6fb748d9 100644
--- a/src/TwigComponent/config/debug.php
+++ b/src/TwigComponent/config/debug.php
@@ -21,6 +21,9 @@ return static function (ContainerConfigurator $container) {
     $container->services()
 
         ->set('ux.twig_component.component_logger_listener', TwigComponentLoggerListener::class)
+        ->args([
+            service('debug.stopwatch')->ignoreOnInvalid(),
+        ])
         ->tag('kernel.event_subscriber')
 
         ->set('ux.twig_component.data_collector', TwigComponentDataCollector::class)
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index cb6e22ad..e324db1c 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -348,7 +348,7 @@ close tag, it's passed to your component template as the block called
 
 .. code-block:: html+twig
 
-    <div {{ attributes.defaults({class: 'alert alert-' ~ type}) }}>
+    <div {{ attributes.defaults({class: 'alert alert-' ~ type}) }}">
         {% block content %}{% endblock %}
     </div>
 
@@ -1771,4 +1771,4 @@ https://symfony.com/doc/current/contributing/code/bc.html
 .. _`html_cva`: https://twig.symfony.com/doc/3.x/functions/html_cva.html
 .. _`shadcn/ui`: https://ui.shadcn.com
 .. _`tales-from-a-dev/twig-tailwind-extra`: https://github.com/tales-from-a-dev/twig-tailwind-extra
-.. _`ignore not defined options`: https://symfony.com/doc/current/components/options_resolver.html#ignore-not-defined-options
+.. _`ignore not defined options`: https://symfony.com/doc/current/components/options_resolver.html#ignore-not-defined-options
\ No newline at end of file
diff --git a/src/TwigComponent/src/DataCollector/TwigComponentDataCollector.php b/src/TwigComponent/src/DataCollector/TwigComponentDataCollector.php
index 1ee94c71..f0c1e05f 100644
--- a/src/TwigComponent/src/DataCollector/TwigComponentDataCollector.php
+++ b/src/TwigComponent/src/DataCollector/TwigComponentDataCollector.php
@@ -25,10 +25,8 @@ use Twig\Error\LoaderError;
 
 /**
  * @author Simon André <smn.andre@gmail.com>
- *
- * @internal
  */
-final class TwigComponentDataCollector extends AbstractDataCollector implements LateDataCollectorInterface
+class TwigComponentDataCollector extends AbstractDataCollector implements LateDataCollectorInterface
 {
     private bool $hasStub;
 
@@ -101,9 +99,6 @@ final class TwigComponentDataCollector extends AbstractDataCollector implements
         $renders = [];
         $ongoingRenders = [];
 
-        $classStubs = [];
-        $templatePaths = [];
-
         foreach ($this->logger->getEvents() as [$event, $profile]) {
             if ($event instanceof PreRenderEvent) {
                 $mountedComponent = $event->getMountedComponent();
@@ -115,9 +110,9 @@ final class TwigComponentDataCollector extends AbstractDataCollector implements
                 $components[$componentName] ??= [
                     'name' => $componentName,
                     'class' => $componentClass,
-                    'class_stub' => $classStubs[$componentClass] ??= ($this->hasStub ? new ClassStub($componentClass) : $componentClass),
-                    'template' => $template = $metadata->getTemplate(),
-                    'template_path' => $templatePaths[$template] ??= $this->resolveTemplatePath($template),
+                    'class_stub' => $this->hasStub ? new ClassStub($componentClass) : $componentClass,
+                    'template' => $metadata->getTemplate(),
+                    'template_path' => $this->resolveTemplatePath($metadata->getTemplate()), // defer ? lazy ?
                     'render_count' => 0,
                     'render_time' => 0,
                 ];
diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
index c0c18b0e..e69bfb62 100644
--- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
+++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
@@ -34,7 +34,6 @@ use Symfony\UX\TwigComponent\ComponentTemplateFinder;
 use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass;
 use Symfony\UX\TwigComponent\Twig\ComponentExtension;
 use Symfony\UX\TwigComponent\Twig\ComponentLexer;
-use Symfony\UX\TwigComponent\Twig\ComponentRuntime;
 use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator;
 
 /**
@@ -103,13 +102,7 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
 
         $container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)
             ->addTag('twig.extension')
-        ;
-
-        $container->register('.ux.twig_component.twig.component_runtime', ComponentRuntime::class)
-            ->setArguments([
-                new Reference('ux.twig_component.component_renderer'),
-            ])
-            ->addTag('twig.runtime')
+            ->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
         ;
 
         $container->register('ux.twig_component.twig.lexer', ComponentLexer::class);
diff --git a/src/TwigComponent/src/EventListener/TwigComponentLoggerListener.php b/src/TwigComponent/src/EventListener/TwigComponentLoggerListener.php
index 62af18d3..acabac5c 100644
--- a/src/TwigComponent/src/EventListener/TwigComponentLoggerListener.php
+++ b/src/TwigComponent/src/EventListener/TwigComponentLoggerListener.php
@@ -12,38 +12,75 @@
 namespace Symfony\UX\TwigComponent\EventListener;
 
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Stopwatch\Stopwatch;
 use Symfony\Contracts\Service\ResetInterface;
+use Symfony\UX\TwigComponent\Event\PostMountEvent;
 use Symfony\UX\TwigComponent\Event\PostRenderEvent;
+use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
+use Symfony\UX\TwigComponent\Event\PreMountEvent;
 use Symfony\UX\TwigComponent\Event\PreRenderEvent;
 
 /**
  * @author Simon André <smn.andre@gmail.com>
- *
- * @internal
  */
-final class TwigComponentLoggerListener implements EventSubscriberInterface, ResetInterface
+class TwigComponentLoggerListener implements EventSubscriberInterface, ResetInterface
 {
     private array $events = [];
 
+    public function __construct(private ?Stopwatch $stopwatch = null)
+    {
+    }
+
     public static function getSubscribedEvents(): array
     {
         return [
+            PreCreateForRenderEvent::class => [
+                // High priority: start the stopwatch as soon as possible
+                ['onPreCreateForRender', 255],
+                // Low priority: check `event::getRenderedString()` as late as possible
+                ['onPostCreateForRender', -255],
+            ],
+            PreMountEvent::class => ['onPreMount', 255],
+            PostMountEvent::class => ['onPostMount', -255],
             PreRenderEvent::class => ['onPreRender', 255],
             PostRenderEvent::class => ['onPostRender', -255],
         ];
     }
 
-    /**
-     * @return list<array{
-     *     PreRenderEvent|PostRenderEvent,
-     *     array{float, int},
-     * }>
-     */
     public function getEvents(): array
     {
         return $this->events;
     }
 
+    public function onPreCreateForRender(PreCreateForRenderEvent $event): void
+    {
+        $this->stopwatch?->start($event->getName(), 'twig_component');
+        $this->logEvent($event);
+    }
+
+    private function logEvent(object $event): void
+    {
+        $this->events[] = [$event, [microtime(true), memory_get_usage(true)]];
+    }
+
+    public function onPostCreateForRender(PreCreateForRenderEvent $event): void
+    {
+        if (\is_string($event->getRenderedString())) {
+            $this->stopwatch?->stop($event->getName());
+            $this->logEvent($event);
+        }
+    }
+
+    public function onPreMount(PreMountEvent $event): void
+    {
+        $this->logEvent($event);
+    }
+
+    public function onPostMount(PostMountEvent $event): void
+    {
+        $this->logEvent($event);
+    }
+
     public function onPreRender(PreRenderEvent $event): void
     {
         $this->logEvent($event);
@@ -51,6 +88,9 @@ final class TwigComponentLoggerListener implements EventSubscriberInterface, Res
 
     public function onPostRender(PostRenderEvent $event): void
     {
+        if ($this->stopwatch?->isStarted($name = $event->getMountedComponent()->getName())) {
+            $this->stopwatch->stop($name);
+        }
         $this->logEvent($event);
     }
 
@@ -58,9 +98,4 @@ final class TwigComponentLoggerListener implements EventSubscriberInterface, Res
     {
         $this->events = [];
     }
-
-    private function logEvent(object $event): void
-    {
-        $this->events[] = [$event, [microtime(true), memory_get_usage(true)]];
-    }
 }
diff --git a/src/TwigComponent/src/Twig/ComponentExtension.php b/src/TwigComponent/src/Twig/ComponentExtension.php
index f577ae08..eaa737b6 100644
--- a/src/TwigComponent/src/Twig/ComponentExtension.php
+++ b/src/TwigComponent/src/Twig/ComponentExtension.php
@@ -11,8 +11,13 @@
 
 namespace Symfony\UX\TwigComponent\Twig;
 
+use Psr\Container\ContainerInterface;
+use Symfony\Contracts\Service\ServiceSubscriberInterface;
+use Symfony\UX\TwigComponent\ComponentRenderer;
 use Symfony\UX\TwigComponent\CVA;
+use Symfony\UX\TwigComponent\Event\PreRenderEvent;
 use Twig\DeprecatedCallableInfo;
+use Twig\Error\RuntimeError;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFunction;
 
@@ -21,12 +26,23 @@ use Twig\TwigFunction;
  *
  * @internal
  */
-final class ComponentExtension extends AbstractExtension
+final class ComponentExtension extends AbstractExtension implements ServiceSubscriberInterface
 {
+    public function __construct(private ContainerInterface $container)
+    {
+    }
+
+    public static function getSubscribedServices(): array
+    {
+        return [
+            ComponentRenderer::class,
+        ];
+    }
+
     public function getFunctions(): array
     {
         return [
-            new TwigFunction('component', [ComponentRuntime::class, 'render'], ['is_safe' => ['all']]),
+            new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
             new TwigFunction('cva', [$this, 'cva'], [
                 ...(class_exists(DeprecatedCallableInfo::class)
                     ? ['deprecation_info' => new DeprecatedCallableInfo('symfony/ux-twig-component', '2.20', 'html_cva', 'twig/html-extra')]
@@ -43,6 +59,38 @@ final class ComponentExtension extends AbstractExtension
         ];
     }
 
+    public function render(string $name, array $props = []): string
+    {
+        try {
+            return $this->container->get(ComponentRenderer::class)->createAndRender($name, $props);
+        } catch (\Throwable $e) {
+            $this->throwRuntimeError($name, $e);
+        }
+    }
+
+    public function extensionPreCreateForRender(string $name, array $props): ?string
+    {
+        try {
+            return $this->container->get(ComponentRenderer::class)->preCreateForRender($name, $props);
+        } catch (\Throwable $e) {
+            $this->throwRuntimeError($name, $e);
+        }
+    }
+
+    public function startEmbeddedComponentRender(string $name, array $props, array $context, string $hostTemplateName, int $index): PreRenderEvent
+    {
+        try {
+            return $this->container->get(ComponentRenderer::class)->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index);
+        } catch (\Throwable $e) {
+            $this->throwRuntimeError($name, $e);
+        }
+    }
+
+    public function finishEmbeddedComponentRender(): void
+    {
+        $this->container->get(ComponentRenderer::class)->finishEmbeddedComponentRender();
+    }
+
     /**
      * Create a CVA instance.
      *
@@ -71,4 +119,14 @@ final class ComponentExtension extends AbstractExtension
             $cva['defaultVariants'] ?? [],
         );
     }
+
+    private function throwRuntimeError(string $name, \Throwable $e): void
+    {
+        // if it's already a Twig RuntimeError, just rethrow it
+        if ($e instanceof RuntimeError) {
+            throw $e;
+        }
+
+        throw new RuntimeError(\sprintf('Error rendering "%s" component: "%s"', $name, $e->getMessage()), previous: $e);
+    }
 }
diff --git a/src/TwigComponent/src/Twig/ComponentNode.php b/src/TwigComponent/src/Twig/ComponentNode.php
index 439175c5..2c0561c6 100644
--- a/src/TwigComponent/src/Twig/ComponentNode.php
+++ b/src/TwigComponent/src/Twig/ComponentNode.php
@@ -48,8 +48,6 @@ final class ComponentNode extends Node implements NodeOutputInterface
     {
         $compiler->addDebugInfo($this);
 
-        $useYield = method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield();
-
         // since twig/twig 3.9.0: Using the internal "twig_to_array" function is deprecated.
         if (method_exists(CoreExtension::class, 'toArray')) {
             $twig_to_array = 'Twig\Extension\CoreExtension::toArray';
@@ -57,13 +55,6 @@ final class ComponentNode extends Node implements NodeOutputInterface
             $twig_to_array = 'twig_to_array';
         }
 
-        $componentRuntime = $compiler->getVarName();
-
-        $compiler
-               ->write(\sprintf('$%s = $this->env->getRuntime(', $componentRuntime))
-               ->string(ComponentRuntime::class)
-               ->raw(");\n");
-
         /*
          * Block 1) PreCreateForRender handling
          *
@@ -71,7 +62,9 @@ final class ComponentNode extends Node implements NodeOutputInterface
          * a string, we return that string and skip the rest of the rendering process.
          */
         $compiler
-            ->write(\sprintf('$preRendered = $%s->preRender(', $componentRuntime))
+            ->write('$preRendered = $this->extensions[')
+            ->string(ComponentExtension::class)
+            ->raw(']->extensionPreCreateForRender(')
             ->string($this->getAttribute('component'))
             ->raw(', ')
             ->raw($twig_to_array)
@@ -103,7 +96,9 @@ final class ComponentNode extends Node implements NodeOutputInterface
          * the final template, template index & variables.
          */
         $compiler
-            ->write(\sprintf('$preRenderEvent = $%s->startEmbedComponent(', $componentRuntime))
+            ->write('$preRenderEvent = $this->extensions[')
+            ->string(ComponentExtension::class)
+            ->raw(']->startEmbeddedComponentRender(')
             ->string($this->getAttribute('component'))
             ->raw(', ')
             ->raw($twig_to_array)
@@ -116,7 +111,6 @@ final class ComponentNode extends Node implements NodeOutputInterface
             ->raw(', ')
             ->raw($this->getAttribute('embedded_index'))
             ->raw(");\n");
-
         $compiler
             ->write('$embeddedContext = $preRenderEvent->getVariables();')
             ->raw("\n")
@@ -138,11 +132,18 @@ final class ComponentNode extends Node implements NodeOutputInterface
          * We add the outerBlock to the context if it doesn't exist yet.
          * Then add them to the block stack and get the converted embedded blocks.
          */
-        $compiler
-            ->write(\sprintf('$embeddedContext["outerBlocks"] ??= new \%s();', BlockStack::class))
+        $compiler->write('if (!isset($embeddedContext["outerBlocks"])) {')
+            ->raw("\n")
+            ->indent()
+            ->write(\sprintf('$embeddedContext["outerBlocks"] = new \%s();', BlockStack::class))
+            ->raw("\n")
+            ->outdent()
+            ->write('}')
             ->raw("\n");
 
-        $compiler->write('$embeddedBlocks = $embeddedContext["outerBlocks"]->convert($blocks, ')
+        $compiler->write('$embeddedBlocks = $embeddedContext[')
+            ->string('outerBlocks')
+            ->raw(']->convert($blocks, ')
             ->raw($this->getAttribute('embedded_index'))
             ->raw(");\n");
 
@@ -151,8 +152,9 @@ final class ComponentNode extends Node implements NodeOutputInterface
          *
          * This will actually render the child component template.
          */
-        if ($useYield) {
-            $compiler->write('yield from ');
+        if (method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield()) {
+            $compiler
+                ->write('yield from ');
         }
         $compiler
             ->write('$this->loadTemplate(')
@@ -165,7 +167,7 @@ final class ComponentNode extends Node implements NodeOutputInterface
             ->string($this->getAttribute('embedded_index'))
             ->raw(')');
 
-        if ($useYield) {
+        if (method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield()) {
             $compiler->raw('->unwrap()->yield(');
         } else {
             $compiler->raw('->display(');
@@ -174,8 +176,10 @@ final class ComponentNode extends Node implements NodeOutputInterface
             ->raw('$embeddedContext, $embeddedBlocks')
             ->raw(");\n");
 
-        $compiler->write(\sprintf('$%s->finishEmbedComponent();', $componentRuntime))
-            ->raw("\n")
+        $compiler->write('$this->extensions[')
+            ->string(ComponentExtension::class)
+            ->raw(']->finishEmbeddedComponentRender()')
+            ->raw(";\n")
         ;
 
         $compiler
diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php
deleted file mode 100644
index b6157599..00000000
--- a/src/TwigComponent/src/Twig/ComponentRuntime.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\UX\TwigComponent\Twig;
-
-use Symfony\UX\TwigComponent\ComponentRenderer;
-use Symfony\UX\TwigComponent\Event\PreRenderEvent;
-
-/**
- * @author Kevin Bond <kevinbond@gmail.com>
- * @author Simon André <smn.andre@gmail.com>
- *
- * @internal
- */
-final class ComponentRuntime
-{
-    public function __construct(
-        private readonly ComponentRenderer $renderer,
-    ) {
-    }
-
-    /**
-     * @param array<string, mixed> $props
-     */
-    public function render(string $name, array $props = []): string
-    {
-        return $this->renderer->createAndRender($name, $props);
-    }
-
-    /**
-     * @param array<string, mixed> $props
-     */
-    public function preRender(string $name, array $props): ?string
-    {
-        return $this->renderer->preCreateForRender($name, $props);
-    }
-
-    /**
-     * @param array<string, mixed> $props
-     * @param array<string, mixed> $context
-     */
-    public function startEmbedComponent(string $name, array $props, array $context, string $hostTemplateName, int $index): PreRenderEvent
-    {
-        return $this->renderer->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index);
-    }
-
-    public function finishEmbedComponent(): void
-    {
-        $this->renderer->finishEmbeddedComponentRender();
-    }
-}
diff --git a/src/TwigComponent/src/Twig/PropsTokenParser.php b/src/TwigComponent/src/Twig/PropsTokenParser.php
index fc3b6640..9cc94e07 100644
--- a/src/TwigComponent/src/Twig/PropsTokenParser.php
+++ b/src/TwigComponent/src/Twig/PropsTokenParser.php
@@ -44,7 +44,7 @@ class PropsTokenParser extends AbstractTokenParser
             }
         }
 
-        return new PropsNode($names, $values, $token->getLine());
+        return new PropsNode($names, $values, $token->getLine(), $token->getValue());
     }
 
     public function getTag(): string
diff --git a/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
index 4b952a10..3fe38967 100644
--- a/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
+++ b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
@@ -30,7 +30,6 @@ class TwigEnvironmentConfigurator
     public function configure(Environment $environment): void
     {
         $this->decorated->configure($environment);
-
         $environment->setLexer(new ComponentLexer($environment));
 
         if (class_exists(EscaperRuntime::class)) {
diff --git a/ux.symfony.com/assets/styles/app.scss b/ux.symfony.com/assets/styles/app.scss
index 81a8c3d2..f96eca51 100644
--- a/ux.symfony.com/assets/styles/app.scss
+++ b/ux.symfony.com/assets/styles/app.scss
@@ -11,54 +11,12 @@
 @import "../../vendor/twbs/bootstrap/scss/variables";
 @import "../../vendor/twbs/bootstrap/scss/variables-dark";
 
-// Remove Bootstrap variables/overrides
-$theme-colors: map-remove($theme-colors, "info", "light");
-
 // Boostrap remainder of required parts
 @import "../../vendor/twbs/bootstrap/scss/maps";
 @import "../../vendor/twbs/bootstrap/scss/mixins";
 @import "../../vendor/twbs/bootstrap/scss/utilities";
 @import "../../vendor/twbs/bootstrap/scss/root";
 
-// Remove multiple utilities with a comma-separated list
-// Margin
-$utilities: map-remove(
-    $utilities,
-    // Background
-    "bg-opacity",
-    "subtle-background-color",
-
-    // Negative margin
-    "negative-margin",
-    "negative-margin-x",
-    "negative-margin-y",
-    "negative-margin-top",
-    "negative-margin-end",
-    "negative-margin-bottom",
-    "negative-margin-start",
-    // Link
-    "link-opacity",
-    "link-offset",
-    "link-underline",
-    "link-underline-opacity",
-    // Border
-    "border",
-    "border-top",
-    "border-end",
-    "border-bottom",
-    "border-start",
-    "border-color",
-    "subtle-border-color",
-    "border-width",
-    "border-opacity",
-    // Position
-    "top",
-    "bottom",
-    "start",
-    "end",
-    "translate-middle",
-);
-
 // Layout & components
 @import "../../vendor/twbs/bootstrap/scss/reboot";
 @import "../../vendor/twbs/bootstrap/scss/type";
@@ -90,23 +48,8 @@ $utilities: map-remove(
 // @import "../../vendor/twbs/bootstrap/scss/spinners";
 // @import "../../vendor/twbs/bootstrap/scss/offcanvas";
 // @import "../../vendor/twbs/bootstrap/scss/placeholders";
-
 // Helpers
-@import "../../vendor/twbs/bootstrap/scss/helpers/clearfix";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/color-bg";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/colored-links";
-@import "../../vendor/twbs/bootstrap/scss/helpers/focus-ring";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/icon-link";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/ratio";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/position";
-@import "../../vendor/twbs/bootstrap/scss/helpers/stacks";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/visually-hidden";
-@import "../../vendor/twbs/bootstrap/scss/helpers/stretched-link";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/text-truncation";
-//@import "../../vendor/twbs/bootstrap/scss/helpers/vr";
-
-
-
+@import "../../vendor/twbs/bootstrap/scss/helpers";
 // Utilities
 @import "../../vendor/twbs/bootstrap/scss/utilities/api";
 
diff --git a/ux.symfony.com/composer.json b/ux.symfony.com/composer.json
index c2c1b47b..31d53906 100644
--- a/ux.symfony.com/composer.json
+++ b/ux.symfony.com/composer.json
@@ -8,12 +8,12 @@
         "ext-ctype": "*",
         "ext-iconv": "*",
         "composer/semver": "^3.4",
-        "doctrine/doctrine-bundle": "^2.13",
-        "doctrine/doctrine-migrations-bundle": "^3.3.1",
-        "doctrine/orm": "^2.19.7",
+        "doctrine/doctrine-bundle": "^2.10",
+        "doctrine/doctrine-migrations-bundle": "^3.2.4",
+        "doctrine/orm": "^2.19.3",
         "intervention/image": "^2.7.2",
         "kornrunner/blurhash": "^1.2.2",
-        "league/commonmark": "^2.5",
+        "league/commonmark": "^2.4",
         "symfony/asset": "7.1.*",
         "symfony/asset-mapper": "7.1.*",
         "symfony/console": "7.1.*",
@@ -55,14 +55,14 @@
         "symfony/yaml": "7.1.*",
         "symfonycasts/dynamic-forms": "^0.1.2",
         "symfonycasts/sass-bundle": "^0.6.0",
-        "tempest/highlight": "^2.10.3",
+        "tempest/highlight": "^2.7.0",
         "twbs/bootstrap": "^5.3",
-        "twig/extra-bundle": "^3.13",
-        "twig/html-extra": "^3.13",
-        "twig/intl-extra": "^3.13",
-        "twig/markdown-extra": "^3.13",
-        "twig/string-extra": "^3.13",
-        "twig/twig": "^3.14"
+        "twig/extra-bundle": "^3.10",
+        "twig/html-extra": "^3.10",
+        "twig/intl-extra": "^3.10",
+        "twig/markdown-extra": "^3.10",
+        "twig/string-extra": "^3.10",
+        "twig/twig": "^3.10"
     },
     "require-dev": {
         "phpunit/phpunit": "^9.6.9",
@@ -73,9 +73,9 @@
         "symfony/phpunit-bridge": "7.1.*",
         "symfony/stopwatch": "7.1.*",
         "symfony/web-profiler-bundle": "7.1.*",
-        "vincentlanglet/twig-cs-fixer": "3.1.*",
+        "vincentlanglet/twig-cs-fixer": "^3.0",
         "zenstruck/browser": "^1.8",
-        "zenstruck/foundry": "2.0.*"
+        "zenstruck/foundry": "^1.33"
     },
     "replace": {
         "symfony/polyfill-ctype": "*",
diff --git a/ux.symfony.com/composer.lock b/ux.symfony.com/composer.lock
index db247ca1..7bde0776 100644
--- a/ux.symfony.com/composer.lock
+++ b/ux.symfony.com/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "1c5f7250ac777e4c0fb8aadfb9c4fbc4",
+    "content-hash": "d62f8ee7016044332d5d972f4bd06b5a",
     "packages": [
         {
             "name": "composer/semver",
@@ -2613,16 +2613,16 @@
         },
         {
             "name": "psr/log",
-            "version": "3.0.2",
+            "version": "3.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+                "reference": "79dff0b268932c640297f5208d6298f71855c03e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
-                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e",
+                "reference": "79dff0b268932c640297f5208d6298f71855c03e",
                 "shasum": ""
             },
             "require": {
@@ -2657,9 +2657,9 @@
                 "psr-3"
             ],
             "support": {
-                "source": "https://github.com/php-fig/log/tree/3.0.2"
+                "source": "https://github.com/php-fig/log/tree/3.0.1"
             },
-            "time": "2024-09-11T13:17:53+00:00"
+            "time": "2024-08-21T13:31:24+00:00"
         },
         {
             "name": "ralouphie/getallheaders",
@@ -7215,12 +7215,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-chartjs.git",
-                "reference": "3c07fe739f42a9c686fc64c05b494f3af4322b84"
+                "reference": "a16c8272e8e9d362c0f3b4d70742747e26d66147"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/3c07fe739f42a9c686fc64c05b494f3af4322b84",
-                "reference": "3c07fe739f42a9c686fc64c05b494f3af4322b84",
+                "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/a16c8272e8e9d362c0f3b4d70742747e26d66147",
+                "reference": "a16c8272e8e9d362c0f3b4d70742747e26d66147",
                 "shasum": ""
             },
             "require": {
@@ -7272,7 +7272,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-chartjs/tree/2.x"
+                "source": "https://github.com/symfony/ux-chartjs/tree/v2.19.3"
             },
             "funding": [
                 {
@@ -7288,7 +7288,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-10T12:38:04+00:00"
+            "time": "2024-08-14T04:55:38+00:00"
         },
         {
             "name": "symfony/ux-cropperjs",
@@ -7462,12 +7462,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-icons.git",
-                "reference": "69daedf0b4f278a6a7f326fb9aea1ab787ee7be6"
+                "reference": "2a667537d5b30fdf8989ae4b713055c019565299"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-icons/zipball/69daedf0b4f278a6a7f326fb9aea1ab787ee7be6",
-                "reference": "69daedf0b4f278a6a7f326fb9aea1ab787ee7be6",
+                "url": "https://api.github.com/repos/symfony/ux-icons/zipball/2a667537d5b30fdf8989ae4b713055c019565299",
+                "reference": "2a667537d5b30fdf8989ae4b713055c019565299",
                 "shasum": ""
             },
             "require": {
@@ -7543,7 +7543,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-14T20:31:15+00:00"
+            "time": "2024-09-09T07:33:50+00:00"
         },
         {
             "name": "symfony/ux-lazy-image",
@@ -7631,12 +7631,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-leaflet-map.git",
-                "reference": "ba957b5e04652177b00284200455610bff4d1b45"
+                "reference": "a28fe4315084c7dee8e632eea8866f34b50e62c9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-leaflet-map/zipball/ba957b5e04652177b00284200455610bff4d1b45",
-                "reference": "ba957b5e04652177b00284200455610bff4d1b45",
+                "url": "https://api.github.com/repos/symfony/ux-leaflet-map/zipball/a28fe4315084c7dee8e632eea8866f34b50e62c9",
+                "reference": "a28fe4315084c7dee8e632eea8866f34b50e62c9",
                 "shasum": ""
             },
             "require": {
@@ -7692,7 +7692,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-10T12:38:04+00:00"
+            "time": "2024-08-31T10:10:16+00:00"
         },
         {
             "name": "symfony/ux-live-component",
@@ -7700,12 +7700,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-live-component.git",
-                "reference": "ac6f0e7b99d3783a71d38d84a885a16a82cb68fe"
+                "reference": "e439f05e1513af9a163c7dad6869fc33117b7394"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/ac6f0e7b99d3783a71d38d84a885a16a82cb68fe",
-                "reference": "ac6f0e7b99d3783a71d38d84a885a16a82cb68fe",
+                "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/e439f05e1513af9a163c7dad6869fc33117b7394",
+                "reference": "e439f05e1513af9a163c7dad6869fc33117b7394",
                 "shasum": ""
             },
             "require": {
@@ -7786,7 +7786,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-12T17:42:03+00:00"
+            "time": "2024-09-06T08:18:29+00:00"
         },
         {
             "name": "symfony/ux-map",
@@ -7794,12 +7794,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-map.git",
-                "reference": "3185ecf75b0b2618b529703f8f5caab070e19ca2"
+                "reference": "8d0279685f4ebf08cb63e21e0146a8ac7d911e1a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-map/zipball/3185ecf75b0b2618b529703f8f5caab070e19ca2",
-                "reference": "3185ecf75b0b2618b529703f8f5caab070e19ca2",
+                "url": "https://api.github.com/repos/symfony/ux-map/zipball/8d0279685f4ebf08cb63e21e0146a8ac7d911e1a",
+                "reference": "8d0279685f4ebf08cb63e21e0146a8ac7d911e1a",
                 "shasum": ""
             },
             "require": {
@@ -7865,7 +7865,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-11T06:56:52+00:00"
+            "time": "2024-08-31T08:56:27+00:00"
         },
         {
             "name": "symfony/ux-notify",
@@ -7956,12 +7956,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-react.git",
-                "reference": "35a3e878e6211169ffb634b788815ecb0c5bccf4"
+                "reference": "14a35bff619eaff8f25ebeeaa2adad92534d100a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-react/zipball/35a3e878e6211169ffb634b788815ecb0c5bccf4",
-                "reference": "35a3e878e6211169ffb634b788815ecb0c5bccf4",
+                "url": "https://api.github.com/repos/symfony/ux-react/zipball/14a35bff619eaff8f25ebeeaa2adad92534d100a",
+                "reference": "14a35bff619eaff8f25ebeeaa2adad92534d100a",
                 "shasum": ""
             },
             "require": {
@@ -8025,7 +8025,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-10T12:38:04+00:00"
+            "time": "2024-08-20T15:47:42+00:00"
         },
         {
             "name": "symfony/ux-svelte",
@@ -8033,12 +8033,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-svelte.git",
-                "reference": "9893ad99cdb6653832e7478312f5e6e1e719341b"
+                "reference": "473f01392b10f92f01269e9b4d5a541fe766df59"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-svelte/zipball/9893ad99cdb6653832e7478312f5e6e1e719341b",
-                "reference": "9893ad99cdb6653832e7478312f5e6e1e719341b",
+                "url": "https://api.github.com/repos/symfony/ux-svelte/zipball/473f01392b10f92f01269e9b4d5a541fe766df59",
+                "reference": "473f01392b10f92f01269e9b4d5a541fe766df59",
                 "shasum": ""
             },
             "require": {
@@ -8106,7 +8106,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-10T12:38:04+00:00"
+            "time": "2024-08-20T15:47:42+00:00"
         },
         {
             "name": "symfony/ux-swup",
@@ -8340,12 +8340,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-turbo.git",
-                "reference": "5eaa803dea9f954aa124963f2c872b414d97cc97"
+                "reference": "53fb47391767ac12b9c7a093a0bcc7ba01fea905"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/5eaa803dea9f954aa124963f2c872b414d97cc97",
-                "reference": "5eaa803dea9f954aa124963f2c872b414d97cc97",
+                "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/53fb47391767ac12b9c7a093a0bcc7ba01fea905",
+                "reference": "53fb47391767ac12b9c7a093a0bcc7ba01fea905",
                 "shasum": ""
             },
             "require": {
@@ -8430,7 +8430,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-10T12:38:04+00:00"
+            "time": "2024-08-20T19:19:54+00:00"
         },
         {
             "name": "symfony/ux-twig-component",
@@ -8438,12 +8438,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-twig-component.git",
-                "reference": "456cb00dedf7a4f1bb92a6a9ff7a5efef8a3a332"
+                "reference": "77a68152d083f4c493e05c5503e188c229de8275"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/456cb00dedf7a4f1bb92a6a9ff7a5efef8a3a332",
-                "reference": "456cb00dedf7a4f1bb92a6a9ff7a5efef8a3a332",
+                "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/77a68152d083f4c493e05c5503e188c229de8275",
+                "reference": "77a68152d083f4c493e05c5503e188c229de8275",
                 "shasum": ""
             },
             "require": {
@@ -8515,7 +8515,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-11T06:56:52+00:00"
+            "time": "2024-09-03T20:35:17+00:00"
         },
         {
             "name": "symfony/ux-typed",
@@ -8591,12 +8591,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-vue.git",
-                "reference": "8c4bcb86fa6a2b9770e9f6c7fabeee647a359b5f"
+                "reference": "48360b79af2c68e6cdecd549eada9e9084bd2353"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-vue/zipball/8c4bcb86fa6a2b9770e9f6c7fabeee647a359b5f",
-                "reference": "8c4bcb86fa6a2b9770e9f6c7fabeee647a359b5f",
+                "url": "https://api.github.com/repos/symfony/ux-vue/zipball/48360b79af2c68e6cdecd549eada9e9084bd2353",
+                "reference": "48360b79af2c68e6cdecd549eada9e9084bd2353",
                 "shasum": ""
             },
             "require": {
@@ -8664,7 +8664,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-10T12:38:04+00:00"
+            "time": "2024-08-20T15:47:42+00:00"
         },
         {
             "name": "symfony/validator",
@@ -9187,16 +9187,16 @@
         },
         {
             "name": "tempest/highlight",
-            "version": "2.10.3",
+            "version": "2.10.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/tempestphp/highlight.git",
-                "reference": "ba50ebfe88e513b3de4f7d7c02be331d0b9035aa"
+                "reference": "fb3309cb2fb6a2353d58b4da49040ad532c21a76"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/tempestphp/highlight/zipball/ba50ebfe88e513b3de4f7d7c02be331d0b9035aa",
-                "reference": "ba50ebfe88e513b3de4f7d7c02be331d0b9035aa",
+                "url": "https://api.github.com/repos/tempestphp/highlight/zipball/fb3309cb2fb6a2353d58b4da49040ad532c21a76",
+                "reference": "fb3309cb2fb6a2353d58b4da49040ad532c21a76",
                 "shasum": ""
             },
             "require": {
@@ -9233,7 +9233,7 @@
             "description": "Fast, extensible, server-side code highlighting",
             "support": {
                 "issues": "https://github.com/tempestphp/highlight/issues",
-                "source": "https://github.com/tempestphp/highlight/tree/2.10.3"
+                "source": "https://github.com/tempestphp/highlight/tree/2.10.2"
             },
             "funding": [
                 {
@@ -9241,7 +9241,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-09-12T08:06:29+00:00"
+            "time": "2024-08-30T07:52:11+00:00"
         },
         {
             "name": "twbs/bootstrap",
@@ -12445,45 +12445,46 @@
         },
         {
             "name": "zenstruck/foundry",
-            "version": "v2.0.9",
+            "version": "v1.38.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/zenstruck/foundry.git",
-                "reference": "b0a5d3d8b43acd4b0f356c6dcfdec6c8ebff1c08"
+                "reference": "112e3a6ce1d75627a3fbfd6cd009055994167edb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/zenstruck/foundry/zipball/b0a5d3d8b43acd4b0f356c6dcfdec6c8ebff1c08",
-                "reference": "b0a5d3d8b43acd4b0f356c6dcfdec6c8ebff1c08",
+                "url": "https://api.github.com/repos/zenstruck/foundry/zipball/112e3a6ce1d75627a3fbfd6cd009055994167edb",
+                "reference": "112e3a6ce1d75627a3fbfd6cd009055994167edb",
                 "shasum": ""
             },
             "require": {
-                "doctrine/persistence": "^2.0|^3.0",
-                "fakerphp/faker": "^1.23",
-                "php": ">=8.1",
+                "doctrine/persistence": "^1.3.3|^2.0|^3.0",
+                "fakerphp/faker": "^1.10",
+                "php": ">=8.0",
                 "symfony/deprecation-contracts": "^2.2|^3.0",
-                "symfony/framework-bundle": "^6.4|^7.0",
-                "symfony/property-access": "^6.4|^7.0",
-                "symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2",
-                "zenstruck/assert": "^1.4"
+                "symfony/property-access": "^5.4|^6.0|^7.0",
+                "symfony/string": "^5.4|^6.0|^7.0",
+                "zenstruck/assert": "^1.0",
+                "zenstruck/callback": "^1.1"
+            },
+            "conflict": {
+                "doctrine/mongodb-odm": "2.5.0"
             },
             "require-dev": {
                 "bamarni/composer-bin-plugin": "^1.8",
                 "dama/doctrine-test-bundle": "^7.0|^8.0",
-                "doctrine/collections": "^1.7|^2.0",
-                "doctrine/common": "^3.2",
-                "doctrine/doctrine-bundle": "^2.10",
+                "doctrine/doctrine-bundle": "^2.5",
                 "doctrine/doctrine-migrations-bundle": "^2.2|^3.0",
-                "doctrine/mongodb-odm-bundle": "^4.6|^5.0",
-                "doctrine/orm": "^2.16|^3.0",
-                "phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0",
-                "symfony/console": "^6.4|^7.0",
-                "symfony/dotenv": "^6.4|^7.0",
-                "symfony/maker-bundle": "^1.55",
-                "symfony/phpunit-bridge": "^6.4|^7.0",
-                "symfony/translation-contracts": "^3.4",
-                "symfony/var-dumper": "^6.4|^7.0",
-                "symfony/yaml": "^6.4|^7.0"
+                "doctrine/mongodb-odm": "^2.4",
+                "doctrine/mongodb-odm-bundle": "^4.4.0|^5.0",
+                "doctrine/orm": "^2.11|^3.0",
+                "matthiasnoback/symfony-dependency-injection-test": "^4.1|^5.0",
+                "phpunit/phpunit": "^9.5.0",
+                "symfony/dotenv": "^5.4|^6.0|^7.0",
+                "symfony/framework-bundle": "^5.4|^6.0|^7.0",
+                "symfony/maker-bundle": "^1.49",
+                "symfony/phpunit-bridge": "^5.4|^6.0|^7.0",
+                "symfony/translation-contracts": "^2.5|^3.0"
             },
             "type": "library",
             "extra": {
@@ -12496,11 +12497,11 @@
             "autoload": {
                 "files": [
                     "src/functions.php",
-                    "src/Persistence/functions.php",
-                    "src/phpunit_helper.php"
+                    "src/Persistence/functions.php"
                 ],
                 "psr-4": {
-                    "Zenstruck\\Foundry\\": "src/"
+                    "Zenstruck\\Foundry\\": "src/",
+                    "Zenstruck\\Foundry\\Utils\\Rector\\": "utils/rector/src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -12511,10 +12512,6 @@
                 {
                     "name": "Kevin Bond",
                     "email": "kevinbond@gmail.com"
-                },
-                {
-                    "name": "Nicolas PHILIPPE",
-                    "email": "nikophil@gmail.com"
                 }
             ],
             "description": "A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.",
@@ -12530,7 +12527,7 @@
             ],
             "support": {
                 "issues": "https://github.com/zenstruck/foundry/issues",
-                "source": "https://github.com/zenstruck/foundry/tree/v2.0.9"
+                "source": "https://github.com/zenstruck/foundry/tree/v1.38.3"
             },
             "funding": [
                 {
@@ -12538,7 +12535,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-09-02T18:20:52+00:00"
+            "time": "2024-07-04T19:59:49+00:00"
         }
     ],
     "aliases": [],
diff --git a/ux.symfony.com/config/packages/doctrine.yaml b/ux.symfony.com/config/packages/doctrine.yaml
index 718692d7..75ec9e84 100644
--- a/ux.symfony.com/config/packages/doctrine.yaml
+++ b/ux.symfony.com/config/packages/doctrine.yaml
@@ -23,7 +23,7 @@ doctrine:
                 prefix: 'App\Entity'
                 alias: App
         controller_resolver:
-            auto_mapping: false
+            auto_mapping: true
 
 when@test:
     doctrine:
diff --git a/ux.symfony.com/config/packages/zenstruck_foundry.yaml b/ux.symfony.com/config/packages/zenstruck_foundry.yaml
index 504f7e0b..0657d2c3 100644
--- a/ux.symfony.com/config/packages/zenstruck_foundry.yaml
+++ b/ux.symfony.com/config/packages/zenstruck_foundry.yaml
@@ -1,6 +1,7 @@
 when@dev: &dev
     # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
     zenstruck_foundry:
-        global_state:         []
+        # Whether to auto-refresh proxies by default (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh)
+        auto_refresh_proxies: true
 
 when@test: *dev
diff --git a/ux.symfony.com/cookbook/component_architecture.md b/ux.symfony.com/cookbook/component_architecture.md
index d178c87d..5314d8cd 100644
--- a/ux.symfony.com/cookbook/component_architecture.md
+++ b/ux.symfony.com/cookbook/component_architecture.md
@@ -33,7 +33,7 @@ In Symfony, you can have an `Alert` component, for example, with the following t
 
 ```twig
 <div class="alert alert-{{ type }}">
-    <twig:ux:icon name="{{ icon }}" />
+    <twig:Icon name="{{ icon }}" />
     {{ message }}
 </div>
 ```
@@ -43,9 +43,9 @@ Or you can compose with the following syntax:
 
 ```twig
 <twig:Card>
-    <twig:ux:icon name="info"/>
+    <twig:Icon name="info"/>
     <twig:Button>
-        <twig:ux:icon name="close" />
+        <twig:Icon name="close" />
     </twig:Button>
 </twig:Card>
 ```
@@ -79,7 +79,7 @@ We have the following template:
 {% props type, icon, message %}
 
 <div class="alert alert-{{ type }}">
-    <twig:ux:icon name="{{ icon }}" />
+    <twig:Icon name="{{ icon }}" />
     {{ message }}
 </div>
 ```
diff --git a/ux.symfony.com/src/UX/IconRenderer.php b/ux.symfony.com/src/UX/IconRenderer.php
deleted file mode 100644
index f868a052..00000000
--- a/ux.symfony.com/src/UX/IconRenderer.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace App\UX;
-
-use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
-use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
-use Symfony\UX\Icons\IconRendererInterface;
-
-#[AsDecorator(decorates: '.ux_icons.icon_renderer')]
-class IconRenderer implements IconRendererInterface
-{
-    public function __construct(
-        #[AutowireDecorated]
-        private IconRendererInterface $inner,
-    ) {
-    }
-
-    public function renderIcon(string $name, array $attributes = []): string
-    {
-        return $this->inner->renderIcon($name, [
-            ...$attributes,
-            'class' => \sprintf('%s %s', 'Icon', $attributes['class'] ?? ''),
-        ]);
-    }
-}
diff --git a/ux.symfony.com/templates/_banner.html.twig b/ux.symfony.com/templates/_banner.html.twig
index eb02f25b..15cdcd38 100644
--- a/ux.symfony.com/templates/_banner.html.twig
+++ b/ux.symfony.com/templates/_banner.html.twig
@@ -8,7 +8,7 @@
             </div>
             <div class="BannerAction">
                 <a href="https://gofund.me/44ecdba2" class="BannerButton" rel="external">
-                    <twig:ux:icon name="heart" aria-hidden="true"/>
+                    <twig:Icon name="heart" aria-hidden="true"/>
                     Donate
                 </a>
             </div>
diff --git a/ux.symfony.com/templates/_footer.html.twig b/ux.symfony.com/templates/_footer.html.twig
index 1f114585..77a598a6 100644
--- a/ux.symfony.com/templates/_footer.html.twig
+++ b/ux.symfony.com/templates/_footer.html.twig
@@ -8,7 +8,7 @@
                        aria-label="Symfony UX on GitHub"
                        class="order-lg-1"
                     >
-                        <twig:ux:icon name="github"/>
+                        <twig:Icon name="github"/>
                     </a>
                     <a href="{{ url('app_documentation') }}" style="font-size: 11px; text-transform: uppercase;" class="order-lg-0">Docs</a>
                     <a href="{{ url('app_changelog') }}" style="font-size: 11px; text-transform: uppercase;" class="order-lg-0">Changelog</a>
@@ -17,7 +17,7 @@
                        style="font-size: 1.5rem;"
                        aria-label="Symfony website"
                     >
-                        <twig:ux:icon name="symfony"/>
+                        <twig:Icon name="symfony"/>
                     </a>
                 </p>
             </div>
diff --git a/ux.symfony.com/templates/_header.html.twig b/ux.symfony.com/templates/_header.html.twig
index 3a2eb26c..5e9abd45 100644
--- a/ux.symfony.com/templates/_header.html.twig
+++ b/ux.symfony.com/templates/_header.html.twig
@@ -13,7 +13,7 @@
                     data-action="ux-header#toggleMenu"
                     aria-label="Toggle menu"
                 >
-                    <twig:ux:icon name="menu"/>
+                    <twig:Icon name="menu"/>
                 </button>
                 <twig:ThemeSwitcher class="AppNav_item AppNav_item--icon" aria-label="Switch dark/light mode"/>
                 <a
@@ -22,7 +22,7 @@
                     rel="external me"
                     title="Symfony UX on X (Twitter)"
                 >
-                    <twig:ux:icon name="x-twitter"/>
+                    <twig:Icon name="x-twitter"/>
                 </a>
                 <a
                     class="AppNav_item AppNav_item--icon d-none d-sm-grid"
@@ -30,7 +30,7 @@
                     rel="external me"
                     title="Symfony UX on GitHub"
                 >
-                    <twig:ux:icon name="github" />
+                    <twig:Icon name="github" />
                 </a>
             </div>
 
diff --git a/ux.symfony.com/templates/components/Browser.html.twig b/ux.symfony.com/templates/components/Browser.html.twig
index d8eee9db..a18aa38f 100644
--- a/ux.symfony.com/templates/components/Browser.html.twig
+++ b/ux.symfony.com/templates/components/Browser.html.twig
@@ -15,23 +15,23 @@
                 </button>
             </div>
             <div class="Browser__url">
-                <twig:ux:icon name="lock" />
+                <twig:Icon name="lock" />
                 <span>
                     <span hidden>https://</span><em>ux.symfony.com</em><span hidden>{{ url }}</span>
                 </span>
                 <button class="Browser__action">
-                    <twig:ux:icon name="refresh" />
+                    <twig:Icon name="refresh" />
                 </button>
             </div>
             <div class="Browser__actions">
                 {% if false %}
                     <button class="Browser__action" data-bs-toggle="tooltip" title="Feedback">
-                        <twig:ux:icon name="circle-help" />
+                        <twig:Icon name="circle-help" />
                     </button>
                 {% endif %}
                 {% if githubUrl|default %}
                     <a class="Browser__action" data-bs-toggle="tooltip" title="View on Github" href="{{ githubUrl }}">
-                        <twig:ux:icon name="github" />
+                        <twig:Icon name="github" />
                     </a>
                 {% endif %}
             </div>
diff --git a/ux.symfony.com/templates/components/ChangelogItem.html.twig b/ux.symfony.com/templates/components/ChangelogItem.html.twig
index 9c40a144..1fff78b9 100644
--- a/ux.symfony.com/templates/components/ChangelogItem.html.twig
+++ b/ux.symfony.com/templates/components/ChangelogItem.html.twig
@@ -14,7 +14,7 @@
         <details {{ isOpen|default ? 'open' }}>
             <summary class="ChangelogItem__Head">
                 <h2 class="ChangelogItem__Title">{{ item.name|default(item.version) }}</h2>
-                <twig:ux:icon name="arrow-down" class="ChangelogItem__Toggle" />
+                <twig:Icon name="arrow-down" class="ChangelogItem__Toggle" />
             </summary>
             <div class="ChangelogItem__Text">
                 {{ this.getContent()|markdown_to_html }}
diff --git a/ux.symfony.com/templates/components/Code/CodeBlock.html.twig b/ux.symfony.com/templates/components/Code/CodeBlock.html.twig
index b64d2769..53e70a8c 100644
--- a/ux.symfony.com/templates/components/Code/CodeBlock.html.twig
+++ b/ux.symfony.com/templates/components/Code/CodeBlock.html.twig
@@ -46,7 +46,7 @@
                 data-code-expander-target="expandCodeButton"
                 data-action="code-expander#expandCode"
             >
-                <twig:ux:icon name="arrow-down"/>
+                <twig:Icon name="arrow-down"/>
                 <span>Expand code</span>
             </button>
         {% endblock %}
diff --git a/ux.symfony.com/templates/components/Code/CodeBlockButtons.html.twig b/ux.symfony.com/templates/components/Code/CodeBlockButtons.html.twig
index c059ec8f..4abed2b8 100644
--- a/ux.symfony.com/templates/components/Code/CodeBlockButtons.html.twig
+++ b/ux.symfony.com/templates/components/Code/CodeBlockButtons.html.twig
@@ -11,7 +11,7 @@
         data-bs-placement="bottom"
         title="View on GitHub"
     >
-        <twig:ux:icon name="github"/>
+        <twig:Icon name="github"/>
     </a>
 {% endset %}
 
@@ -32,7 +32,7 @@
             data-action="clipboarder#copy"
             data-clipboarder-target="button"
         >
-            <twig:ux:icon name="copy"/>
+            <twig:Icon name="copy"/>
         </button>
     </div>
 
diff --git a/ux.symfony.com/templates/components/Code/CodeLine.html.twig b/ux.symfony.com/templates/components/Code/CodeLine.html.twig
index b7033b2f..eb3ffb0f 100644
--- a/ux.symfony.com/templates/components/Code/CodeLine.html.twig
+++ b/ux.symfony.com/templates/components/Code/CodeLine.html.twig
@@ -16,6 +16,6 @@
     ><code>{{ _code ? _code|trim|raw : code }}</code></pre>
 
     <button class="btn btn-sm IconModalcopy" data-action="clipboarder#copy" data-clipboarder-target="button">
-        <twig:ux:icon name="copy" />
+        <twig:Icon name="copy" />
     </button>
 </div>
diff --git a/ux.symfony.com/templates/components/DocsLink.html.twig b/ux.symfony.com/templates/components/DocsLink.html.twig
index d5fb6ad9..a4e1f370 100644
--- a/ux.symfony.com/templates/components/DocsLink.html.twig
+++ b/ux.symfony.com/templates/components/DocsLink.html.twig
@@ -5,9 +5,9 @@
                rel="{{ isExternal ? 'external noopened noreferrer' }}"
             >{{ title }}</a>
             {% if icon|default %}
-                <twig:ux:icon name="{{ icon }}" class="DocsLink_arrow"/>
+                <twig:Icon name="{{ icon }}" class="DocsLink_arrow"/>
             {% elseif isExternal %}
-                <twig:ux:icon name="arrow-right" style="transform: rotate(-45deg);" class="DocsLink_arrow"/>
+                <twig:Icon name="arrow-right" style="transform: rotate(-45deg);" class="DocsLink_arrow"/>
             {% endif %}
         </p>
         <div class="DocsLink_description">
diff --git a/ux.symfony.com/templates/components/FoodVote.html.twig b/ux.symfony.com/templates/components/FoodVote.html.twig
index 022b6aab..42633b1f 100644
--- a/ux.symfony.com/templates/components/FoodVote.html.twig
+++ b/ux.symfony.com/templates/components/FoodVote.html.twig
@@ -6,7 +6,7 @@
     <td style="width: 250px;">
         {% if hasVoted %}
             <div class="alert alert-success">
-                Thanks for voting! <twig:ux:icon name="circle-check" />
+                Thanks for voting! <twig:Icon name="circle-check" />
             </div>
         {% else %}
             <button
@@ -16,7 +16,7 @@
                 data-live-action-param="vote"
                 data-live-direction-param="up"
             >
-                <twig:ux:icon name="arrow-up" />
+                <twig:Icon name="arrow-up" />
             </button>
             <button
                 type="button"
@@ -25,7 +25,7 @@
                 data-live-action-param="vote"
                 data-live-direction-param="down"
             >
-                <twig:ux:icon name="arrow-down" />
+                <twig:Icon name="arrow-down" />
             </button>
         {% endif %}
     </td>
diff --git a/ux.symfony.com/templates/components/Icon.html.twig b/ux.symfony.com/templates/components/Icon.html.twig
new file mode 100644
index 00000000..efafaae3
--- /dev/null
+++ b/ux.symfony.com/templates/components/Icon.html.twig
@@ -0,0 +1,7 @@
+{% set default_attributes = {
+    class: 'Icon Icon--' ~ name,
+    role: 'img',
+    'aria-hidden': 'true',
+    'aria-label': label ?? name|humanize,
+} %}
+<twig:ux:icon name="{{ name }}" {{ ...attributes.defaults(default_attributes) }} />
diff --git a/ux.symfony.com/templates/components/Icon/IconModal.html.twig b/ux.symfony.com/templates/components/Icon/IconModal.html.twig
index 6efaf51f..533ebb6f 100644
--- a/ux.symfony.com/templates/components/Icon/IconModal.html.twig
+++ b/ux.symfony.com/templates/components/Icon/IconModal.html.twig
@@ -21,7 +21,7 @@
                     <span>{{ icon.prefix }}</span><span>:</span><span>{{ icon.name }}</span>
                 </p>
                 <button type="button" class="btn close" data-action="icon-modal#close">
-                    <twig:ux:icon name="cross"/>
+                    <twig:Icon name="cross"/>
                 </button>
             </div>
 
diff --git a/ux.symfony.com/templates/components/Icon/IconSearch.html.twig b/ux.symfony.com/templates/components/Icon/IconSearch.html.twig
index 799f8291..d264892d 100644
--- a/ux.symfony.com/templates/components/Icon/IconSearch.html.twig
+++ b/ux.symfony.com/templates/components/Icon/IconSearch.html.twig
@@ -7,7 +7,7 @@
 
                 <div class="input-group" style="max-width: 200px;">
                     <label class="input-group-text" for="IconSearch-Set">
-                        <twig:ux:icon name="icon-sets"/>
+                        <twig:Icon name="icon-sets"/>
                     </label>
                     <select class="form-control" data-model="set" id="IconSearch-Set">
                         <option value="">All sets</option>
@@ -23,7 +23,7 @@
 
                 <div class="input-group IconSearch__Query">
                     <label class="input-group-text" for="IconSearch-Query">
-                        <twig:ux:icon name="icon-search"/>
+                        <twig:Icon name="icon-search"/>
                     </label>
                     <input
                         autofocus
@@ -41,10 +41,10 @@
                 <div class="d-flex flex-shrink-0 IconSearch__Display">
                     <div class="btn-group" role="group" data-controller="icon-size">
                         <button class="btn" data-action="icon-size#small">
-                            <twig:ux:icon name="grid-small"/>
+                            <twig:Icon name="grid-small"/>
                         </button>
                         <button class="btn" data-action="icon-size#large">
-                            <twig:ux:icon name="grid-large"/>
+                            <twig:Icon name="grid-large"/>
                         </button>
                     </div>
                 </div>
@@ -52,7 +52,7 @@
                 {% if false %}
                     <div data-live-ignore="true">
                         <button class="btn IconBag__Button" data-controller="icon-bag">
-                            <twig:ux:icon name="icon-folder"/>
+                            <twig:Icon name="icon-folder"/>
                             <span class="IconBag__Badge" data-icon-bag-target="count"></span>
                         </button>
                     </div>
diff --git a/ux.symfony.com/templates/components/InlineEditFood.html.twig b/ux.symfony.com/templates/components/InlineEditFood.html.twig
index cfca8037..914d3df5 100644
--- a/ux.symfony.com/templates/components/InlineEditFood.html.twig
+++ b/ux.symfony.com/templates/components/InlineEditFood.html.twig
@@ -41,7 +41,7 @@
                 class="btn btn-link"
                 title="Click to edit!"
             >
-                <twig:ux:icon name="pencil" />
+                <twig:Icon name="pencil" />
             </button>
         {% endif %}
     </div>
diff --git a/ux.symfony.com/templates/components/InvoiceCreator.html.twig b/ux.symfony.com/templates/components/InvoiceCreator.html.twig
index c3b52d8b..1a1b18ad 100644
--- a/ux.symfony.com/templates/components/InvoiceCreator.html.twig
+++ b/ux.symfony.com/templates/components/InvoiceCreator.html.twig
@@ -59,7 +59,7 @@
                     data-live-action-param="addLineItem"
                     class="btn btn-sm btn-secondary"
                     type="button"
-                ><twig:ux:icon name="plus" /> Add Item</button>
+                ><twig:Icon name="plus" /> Add Item</button>
             </div>
         </div>
 
@@ -101,13 +101,13 @@
             {{ areAnyLineItemsEditing ? 'disabled' : '' }}
         >
             <span data-loading="action(saveInvoice)|show">
-                <twig:ux:icon name="spinner" style="animation: spin 1s linear infinite;" />
+                <twig:Icon name="spinner" style="animation: spin 1s linear infinite;" />
             </span>
             {% if savedSuccessfully %}
-                <twig:ux:icon name="circle-check" class="text-success" />
+                <twig:Icon name="circle-check" class="text-success" />
             {% endif %}
             {% if saveFailed %}
-                <twig:ux:icon name="circle-exclamation" />
+                <twig:Icon name="circle-exclamation" />
             {% endif %}
             Save Invoice
         </button>
diff --git a/ux.symfony.com/templates/components/InvoiceCreatorLineItem.html.twig b/ux.symfony.com/templates/components/InvoiceCreatorLineItem.html.twig
index 098ceaa7..4386a91b 100644
--- a/ux.symfony.com/templates/components/InvoiceCreatorLineItem.html.twig
+++ b/ux.symfony.com/templates/components/InvoiceCreatorLineItem.html.twig
@@ -73,6 +73,6 @@
             data-live-key-param="{{ key }}"
             class="btn btn-link text-danger btn-sm ml-2"
             type="button"
-        ><twig:ux:icon name="cross" /></button>
+        ><twig:Icon name="cross" /></button>
     </td>
 </tr>
diff --git a/ux.symfony.com/templates/components/Package/PackageBox.html.twig b/ux.symfony.com/templates/components/Package/PackageBox.html.twig
index c6a5aa44..1e59da3c 100644
--- a/ux.symfony.com/templates/components/Package/PackageBox.html.twig
+++ b/ux.symfony.com/templates/components/Package/PackageBox.html.twig
@@ -9,7 +9,7 @@
         <{{ titleTag }} class="PackageBox_title">
             <a href="{{ path(package.route) }}" class="PackageBox_link">{{ package.humanName }}</a>
             <span class="PackageBox_arrow">
-                <twig:ux:icon name="arrow-right" />
+                <twig:Icon name="arrow-right" />
             </span>
         </{{ titleTag }}>
         <div class="PackageBox_description">
diff --git a/ux.symfony.com/templates/components/TerminalCommand.html.twig b/ux.symfony.com/templates/components/TerminalCommand.html.twig
index f796e109..d9f6356e 100644
--- a/ux.symfony.com/templates/components/TerminalCommand.html.twig
+++ b/ux.symfony.com/templates/components/TerminalCommand.html.twig
@@ -12,6 +12,6 @@
         aria-label="Copy command"
         data-action="clipboarder#copy"
     >
-        <twig:ux:icon name="copy"/>
+        <twig:Icon name="copy"/>
     </button>
 </div>
diff --git a/ux.symfony.com/templates/components/ThemeSwitcher.html.twig b/ux.symfony.com/templates/components/ThemeSwitcher.html.twig
index 6547b2c1..deb5491e 100644
--- a/ux.symfony.com/templates/components/ThemeSwitcher.html.twig
+++ b/ux.symfony.com/templates/components/ThemeSwitcher.html.twig
@@ -5,6 +5,6 @@
     data-controller="theme-switcher"
     data-action="theme-switcher#switch"
 >
-    <twig:ux:icon name="theme-light" class="Icon--theme-light" />
-    <twig:ux:icon name="theme-dark" class="Icon--theme-dark" />
+    <twig:Icon name="theme-light" />
+    <twig:Icon name="theme-dark" />
 </button>
diff --git a/ux.symfony.com/templates/demos/live_component/invoice.html.twig b/ux.symfony.com/templates/demos/live_component/invoice.html.twig
index b4c54257..f6238d4e 100644
--- a/ux.symfony.com/templates/demos/live_component/invoice.html.twig
+++ b/ux.symfony.com/templates/demos/live_component/invoice.html.twig
@@ -1,9 +1,7 @@
 {% extends 'demos/live_demo.html.twig' %}
 
 {% block code_block_full %}
-    <twig:Code:CodeWithExplanationRow filename="src/Twig/InvoiceCreator.php" sticky class="pt-5">
-## Main Component
-
+    <twig:Code:CodeWithExplanationRow filename="src/Twig/InvoiceCreator.php">
 This main component keeps track of the `Invoice` (which may be new) and
 a list of the "invoice items" - stored on a `LiveProp` called `$lineItems`.
 
@@ -18,9 +16,7 @@ This triggers the `removeLineItem()` method on this component, which
 removes the line item from the `$lineItems` array.
     </twig:Code:CodeWithExplanationRow>
 
-    <twig:Code:CodeWithExplanationRow filename="templates/components/InvoiceCreator.html.twig" sticky reversed class="pt-5">
-## Main Template
-
+    <twig:Code:CodeWithExplanationRow filename="templates/components/InvoiceCreator.html.twig" reversed>
 The template is fairly simple: rendering form fields with `data-model` to
 bind to writable `LiveProp`'s along with their validation errors.
 
@@ -30,9 +26,7 @@ data: `productId`, `quantity`, and `isEditing`. It also passes a `key`,
 which is needed so LiveComponents can track which row is which.
     </twig:Code:CodeWithExplanationRow>
 
-    <twig:Code:CodeWithExplanationRow filename="src/Twig/InvoiceCreatorLineItem.php" sticky class="pt-5">
-## Child Component
-
+    <twig:Code:CodeWithExplanationRow filename="src/Twig/InvoiceCreatorLineItem.php">
 The child component for each "line item". This handles validating, saving,
 and changing the "edit" state of the line item.
 
@@ -47,14 +41,7 @@ by emitting events - e.g. `line_item:save`.
 > "edit" mode.
     </twig:Code:CodeWithExplanationRow>
 
-    <twig:Code:CodeWithExplanationRow
-        filename="templates/components/InvoiceCreatorLineItem.html.twig"
-        sticky
-        reversed
-        class="pt-5"
-    >
-## Item template
-
+    <twig:Code:CodeWithExplanationRow filename="templates/components/InvoiceCreatorLineItem.html.twig" reversed>
 Nothing too fancy here: some `data-model` elements and `data-action="live#action"`
 buttons.
 
diff --git a/ux.symfony.com/templates/demos/live_component/product_form.html.twig b/ux.symfony.com/templates/demos/live_component/product_form.html.twig
index bb2a7ada..f93bf52c 100644
--- a/ux.symfony.com/templates/demos/live_component/product_form.html.twig
+++ b/ux.symfony.com/templates/demos/live_component/product_form.html.twig
@@ -5,9 +5,7 @@
 {% endblock %}
 
 {% block code_block_full %}
-    <twig:Code:CodeWithExplanationRow filename="src/Twig/NewProductForm.php" sticky>
-## New Product Form
-
+    <twig:Code:CodeWithExplanationRow filename="src/Twig/NewProductForm.php">
 Live component with a form, `ValidatableComponentTrait` and a
 `saveProduct()` LiveAction for instant validation & an AJAX submit.
 
@@ -21,20 +19,14 @@ Then, thanks to the `Category` type-hint + Symfony's standard
 Symfony uses that id to query for the `Category` object.
     </twig:Code:CodeWithExplanationRow>
 
-    <twig:Code:CodeWithExplanationRow filename="templates/components/NewProductForm.html.twig" sticky reversed>
-## New Product Template
-
+    <twig:Code:CodeWithExplanationRow filename="templates/components/NewProductForm.html.twig" reversed>
 Near the bottom, this renders the `BootstrapModal` component with another
 component - `NewCategoryForm` - inside of it. Opening the modal is done entirely
 with normal Bootstrap logic: an `a` tag with `data-bs-toggle="modal"`
 and `data-bs-target="#new-category-modal"`.
     </twig:Code:CodeWithExplanationRow>
 
-    <twig:Code:CodeWithExplanationRow
-        filename="src/Twig/NewCategoryForm.php" sticky
-    >
-## New Category Form
-
+    <twig:Code:CodeWithExplanationRow filename="src/Twig/NewCategoryForm.php">
 This component opens up in the modal! It has a `#[LiveAction]` that saves
 the new `Category` to the database and then does two important things:
 
diff --git a/ux.symfony.com/templates/demos/live_memory/components/LiveMemory/ThemeSwitch.html.twig b/ux.symfony.com/templates/demos/live_memory/components/LiveMemory/ThemeSwitch.html.twig
index 1ed088bc..69e472e0 100644
--- a/ux.symfony.com/templates/demos/live_memory/components/LiveMemory/ThemeSwitch.html.twig
+++ b/ux.symfony.com/templates/demos/live_memory/components/LiveMemory/ThemeSwitch.html.twig
@@ -7,9 +7,9 @@
     data-action="theme-switcher#switch"
 >
     <span class="LiveMemory-ThemeSwitch-Mode LiveMemory-ThemeSwitch-Dark" data-theme="dark">
-        <twig:ux:icon name="theme-dark"  />
+        <twig:Icon name="theme-dark"  />
     </span>
     <span class="LiveMemory-ThemeSwitch-Mode LiveMemory-ThemeSwitch-Light" data-theme="light">
-        <twig:ux:icon name="theme-light" />
+        <twig:Icon name="theme-light" />
     </span>
 </button>
diff --git a/ux.symfony.com/templates/demos/live_memory/index.html.twig b/ux.symfony.com/templates/demos/live_memory/index.html.twig
index 59acd43c..7530deec 100644
--- a/ux.symfony.com/templates/demos/live_memory/index.html.twig
+++ b/ux.symfony.com/templates/demos/live_memory/index.html.twig
@@ -16,7 +16,7 @@
         <header class="Header">
             <nav class="HeaderMenu">
                 <a href="{{ url('app_demos') }}" class="HeaderButton" aria-label="Back to the demos">
-                    <twig:ux:icon name="arrow-left" />
+                    <twig:Icon name="arrow-left" />
                     <span aria-hidden="true" hidden>Back to the demos</span>
                 </a>
             </nav>
diff --git a/ux.symfony.com/templates/main/_file_tree.html.twig b/ux.symfony.com/templates/main/_file_tree.html.twig
index 0e1ad131..649c962f 100644
--- a/ux.symfony.com/templates/main/_file_tree.html.twig
+++ b/ux.symfony.com/templates/main/_file_tree.html.twig
@@ -8,7 +8,7 @@
     {% if file_info.isDirectory %}
         <li class="FileTree-main FileTree-dir">
             <span {{- _self.summaryAttributes(file_info.description) -}}>
-                <twig:ux:icon name="folder-open" class="d-inline-block m-0 mr-2"/>
+                <twig:Icon name="folder-open" class="d-inline-block m-0 mr-2"/>
                 {{ file_info.filename }}
             </span>
 
@@ -21,7 +21,7 @@
     {% else %}
         <li class="FileTree-main FileTree-file">
             <span {{- _self.summaryAttributes(file_info.description) -}}>
-                <twig:ux:icon name="file" class="d-inline-block m-0 mr-2" />
+                <twig:Icon name="file" class="d-inline-block m-0 mr-2" />
                 {{ file_info.filename }}
             </span>
         </li>
diff --git a/ux.symfony.com/templates/main/homepage.html.twig b/ux.symfony.com/templates/main/homepage.html.twig
index 2de3ad10..4b5b66b6 100644
--- a/ux.symfony.com/templates/main/homepage.html.twig
+++ b/ux.symfony.com/templates/main/homepage.html.twig
@@ -54,7 +54,7 @@
         <h2 class="ubuntu pt-2 component-headlines">Write custom JavaScript inside <em class="rainbow-emphasis">Stimulus Controllers</em></h2>
         <a style="height: 100%;" class="btn btn-md btn-outline-primary my-3 my-md-0" href="https://symfony.com/doc/current/frontend/ux.html">
             Read full Documentation
-            <twig:ux:icon name="arrow-right" style="transform: rotate(-45deg);"/>
+            <twig:Icon name="arrow-right" style="transform: rotate(-45deg);"/>
         </a>
     </div>
 
@@ -108,7 +108,7 @@
         <h2 class="ubuntu pt-2 component-headlines">Install extra <em class="rainbow-emphasis">Packages</em></h2>
         <a style="height: 100%;" class="btn btn-md btn-outline-primary mt-3 mt-md-0"  href="{{ path('app_packages') }}">
             Browse all Packages
-            <twig:ux:icon name="arrow-right" style="transform: rotate(-45deg);"/>
+            <twig:Icon name="arrow-right" style="transform: rotate(-45deg);"/>
         </a>
     </div>
     <div class="row mt-3 mt-md-5">
diff --git a/ux.symfony.com/templates/support.html.twig b/ux.symfony.com/templates/support.html.twig
index 46cbf410..8bfeafb0 100644
--- a/ux.symfony.com/templates/support.html.twig
+++ b/ux.symfony.com/templates/support.html.twig
@@ -21,7 +21,7 @@
 
                 <div class="SupportBox">
                     <div class="SupportBox_logo">
-                        <twig:ux:icon name="github"  />
+                        <twig:Icon name="github"  />
                     </div>
                     <div class="SupportBox_badge">
                         <span class="Badge Badge--new">
diff --git a/ux.symfony.com/templates/ux_packages/lazy_image.html.twig b/ux.symfony.com/templates/ux_packages/lazy_image.html.twig
index 79c53eaf..dfcf31aa 100644
--- a/ux.symfony.com/templates/ux_packages/lazy_image.html.twig
+++ b/ux.symfony.com/templates/ux_packages/lazy_image.html.twig
@@ -26,11 +26,11 @@
         <twig:block name="code_content_bottom">
             <div class="pb-2 px-2">
                 <div class="eyebrows d-flex align-items-center">
-                    <twig:ux:icon name="circle-fill" style="font-size: .25rem" class="me-2" />
+                    <twig:Icon name="circle-fill" style="font-size: .25rem" class="me-2" />
                     Load a small (or blurred) image first
                 </div>
                 <div class="eyebrows d-flex align-items-center">
-                    <twig:ux:icon name="circle-fill" style="font-size: .25rem" class="me-2" />
+                    <twig:Icon name="circle-fill" style="font-size: .25rem" class="me-2" />
                     The real (large) image is downloaded after page load
                 </div>
             </div>
diff --git a/ux.symfony.com/templates/ux_packages/live_component.html.twig b/ux.symfony.com/templates/ux_packages/live_component.html.twig
index c9fb51dd..472e9534 100644
--- a/ux.symfony.com/templates/ux_packages/live_component.html.twig
+++ b/ux.symfony.com/templates/ux_packages/live_component.html.twig
@@ -31,9 +31,9 @@
 
     <div class="d-flex eyebrows pt-5 align-items-center justify-content-center">
         <div>Automatic <em>debouncing</em></div>
-        <twig:ux:icon name="circle-fill" style="font-size: .25rem" class="mx-1" />
+        <twig:Icon name="circle-fill" style="font-size: .25rem" class="mx-1" />
         <div>Loading states</div>
-        <twig:ux:icon name="circle-fill" style="font-size: .25rem" class="mx-1" />
+        <twig:Icon name="circle-fill" style="font-size: .25rem" class="mx-1" />
         <div>Required writing <em>ZERO JavaScript</em></div>
     </div>
 {% endblock %}
@@ -48,7 +48,7 @@
         <a style="height: 100%;" class="btn btn-md btn-outline-primary my-3 my-md-0"
            href="https://symfony.com/bundles/ux-live-component/current/index.html">
             Read full Documentation
-            <twig:ux:icon name="arrow-right" style="transform: rotate(-45deg);"/>
+            <twig:Icon name="arrow-right" style="transform: rotate(-45deg);"/>
         </a>
     </div>
 
diff --git a/ux.symfony.com/templates/ux_packages/swup.html.twig b/ux.symfony.com/templates/ux_packages/swup.html.twig
index 03138436..065a66da 100644
--- a/ux.symfony.com/templates/ux_packages/swup.html.twig
+++ b/ux.symfony.com/templates/ux_packages/swup.html.twig
@@ -52,9 +52,9 @@
 
     <div class="d-flex eyebrows pt-3 align-items-center justify-content-center flex-wrap">
         <div>Ajax-powered page navigation</div>
-        <twig:ux:icon name="circle-fill" style="font-size: .25rem" class="mx-2" />
+        <twig:Icon name="circle-fill" style="font-size: .25rem" class="mx-2" />
         <div>URL in address bar changes</div>
-        <twig:ux:icon name="circle-fill" style="font-size: .25rem" class="mx-2" />
+        <twig:Icon name="circle-fill" style="font-size: .25rem" class="mx-2" />
         <div>Customizable transitions</div>
     </div>
 {% endblock %}
diff --git a/ux.symfony.com/templates/ux_packages/turbo.html.twig b/ux.symfony.com/templates/ux_packages/turbo.html.twig
index f54cacf1..479b70cc 100644
--- a/ux.symfony.com/templates/ux_packages/turbo.html.twig
+++ b/ux.symfony.com/templates/ux_packages/turbo.html.twig
@@ -33,7 +33,7 @@
                     <div class="alert alert-secondary d-flex justify-content-start col-12 mt-5">
                         <img width="18px" height="18px" src="{{ asset('images/info-icon.png') }}" alt="Icon to indication information">
                         <p class="info-tooltips ms-2 mb-0">
-                            <twig:ux:icon name="arrow-left"/>
+                            <twig:Icon name="arrow-left"/>
                             Click any links on this site or submit this form.
                         Zero full page refreshes!</p>
                     </div>
@@ -47,7 +47,7 @@
                     {% block content %}
                         {% if name and animal %}
                             <div>Say hello to {{ name }} the brave & noble {{ animal }}!</div>
-                            <a href="{{ path('app_turbo') }}"><twig:ux:icon name="arrow-left" /> Back</a>
+                            <a href="{{ path('app_turbo') }}"><twig:Icon name="arrow-left" /> Back</a>
                         {% else %}
                             <div class="mb-3">Build yourself a new pet</div>
                             <form method="POST" novalidate>
@@ -83,7 +83,7 @@
                         The frame displays just part of the full page from
                         <a href="{{ path('app_turbo_todo_list') }}">{{ url('app_turbo_todo_list') }}</a>.
                         All link clicks and form submits stay *inside* the frame.
-                        <twig:ux:icon name="arrow-right" /></p>
+                        <twig:Icon name="arrow-right" /></p>
                     </div>
                 </div>
             </div>
@@ -113,10 +113,10 @@
                     </div>
                     <div class="alert alert-secondary d-flex justify-content-start col-12 mt-5">
                         <img width="18px" height="18px" src="{{ asset('images/info-icon.png') }}" alt="Icon to indication information">
-                        <p class="info-tooltips ms-2 mb-0"><twig:ux:icon name="arrow-left" />
+                        <p class="info-tooltips ms-2 mb-0"><twig:Icon name="arrow-left" />
                         Chat with someone else viewing this page (or open a
                         2nd browser tab to talk to yourself)! Submitting also
-                        updates the <a href="#message-count-header"><twig:ux:icon name="comment" /> icon</a> in the header.</p>
+                        updates the <a href="#message-count-header"><twig:Icon name="comment" /> icon</a> in the header.</p>
                     </div>
                 </div>
             </div>
diff --git a/ux.symfony.com/templates/ux_packages/turbo/_chat_message_count.html.twig b/ux.symfony.com/templates/ux_packages/turbo/_chat_message_count.html.twig
index 1ab66bfd..a142c89a 100644
--- a/ux.symfony.com/templates/ux_packages/turbo/_chat_message_count.html.twig
+++ b/ux.symfony.com/templates/ux_packages/turbo/_chat_message_count.html.twig
@@ -5,5 +5,5 @@
     id="message-count-header"
     title="The number of chat messages from Turbo Streams"
 >
-    <twig:ux:icon name="comment" /> {{ messageCount }}
+    <twig:Icon name="comment" /> {{ messageCount }}
 </a>
diff --git a/ux.symfony.com/templates/ux_packages/turbo/add_todo.html.twig b/ux.symfony.com/templates/ux_packages/turbo/add_todo.html.twig
index 1a6b968c..c43b0365 100644
--- a/ux.symfony.com/templates/ux_packages/turbo/add_todo.html.twig
+++ b/ux.symfony.com/templates/ux_packages/turbo/add_todo.html.twig
@@ -6,7 +6,7 @@
     {{ include('ux_packages/turbo/_demo_message.html.twig') }}
 
     <turbo-frame id="daily-todos">
-        <a href="{{ path('app_turbo_todo_list') }}"><twig:ux:icon name="arrow-left" /> Back to list</a>
+        <a href="{{ path('app_turbo_todo_list') }}"><twig:Icon name="arrow-left" /> Back to list</a>
         <form class="mt-4" action="{{ path('app_turbo_add_todo_item') }}" method="POST" novalidate>
             {{ form_row(form.item, {
                 attr: {
diff --git a/ux.symfony.com/templates/ux_packages/turbo/todos.html.twig b/ux.symfony.com/templates/ux_packages/turbo/todos.html.twig
index 8eece3de..72a46810 100644
--- a/ux.symfony.com/templates/ux_packages/turbo/todos.html.twig
+++ b/ux.symfony.com/templates/ux_packages/turbo/todos.html.twig
@@ -13,7 +13,7 @@
                     <form class="d-inline" action="{{ path('app_turbo_remove_todo_item', {
                         id: key
                     }) }}">
-                        <button type="submit" class="btn btn-link p-0" title="All done!"><twig:ux:icon name="circle-check" /></button>
+                        <button type="submit" class="btn btn-link p-0" title="All done!"><twig:Icon name="circle-check" /></button>
                     </form>
                 </li>
             {% endfor %}


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 49a8f3aa..15f70a23 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -81,14 +81,12 @@ jobs:
         strategy:
             fail-fast: false
             matrix:
-                php-version: ['8.1', '8.3', '8.4']
+                php-version: ['8.1', '8.3']
                 include:
                   - php-version: '8.1'
                     dependency-version: 'lowest'
                   - php-version: '8.3'
                     dependency-version: 'highest'
-                  - php-version: '8.4'
-                    dependency-version: 'highest'
                 component: ${{ fromJson(needs.tests-php-components.outputs.components )}}
                 exclude:
                   - component: Map # does not support PHP 8.1
@@ -143,5 +141,5 @@ jobs:
                   restore-keys: |
                       ${{ runner.os }}-yarn-
             - run: yarn --immutable
-            - run: yarn playwright install
+            - run: yarn playwright install  
             - run: yarn test
diff --git a/src/Icons/tests/Fixtures/templates/template1.html.twig b/src/Icons/tests/Fixtures/templates/template1.html.twig
index 096a3d53..98247030 100644
--- a/src/Icons/tests/Fixtures/templates/template1.html.twig
+++ b/src/Icons/tests/Fixtures/templates/template1.html.twig
@@ -2,10 +2,8 @@
     <li id="first">{{ ux_icon('user', {class: 'h-8 w-8'}) }}</li>
     <li id="second">{{ ux_icon('user', {"aria-label": "AriaLabel"}) }}</li>
     <li id="third">{{ ux_icon('sub:check', {'data-action': 'string "with" quotes'}) }}</li>
-    <li id="fourth">{{ ux_icon('sub:check', { height: 24, width: "24" }) }}</li>
     <li id="fifth"><twig:UX:Icon name="user" class="h-8 w-8" /></li>
     <li id="sixth"><twig:ux:icon name="sub:check" aria-labelledby="foo" /></li>
     <li id="seventh"><twig:Ux:Icon :name="'sub:'~'check'" /></li>
     <li id="eighth">{{ ux_icon('iconamoon:3d-duotone') }}</li>
-    <li id="ninth">{{ ux_icon('iconamoon:3d-duotone', { height: 24, width: "24" }) }}</li>
 </ul>
diff --git a/src/Icons/tests/Integration/RenderIconsInTwigTest.php b/src/Icons/tests/Integration/RenderIconsInTwigTest.php
index 8789f29a..0522a916 100644
--- a/src/Icons/tests/Integration/RenderIconsInTwigTest.php
+++ b/src/Icons/tests/Integration/RenderIconsInTwigTest.php
@@ -29,12 +29,10 @@ final class RenderIconsInTwigTest extends KernelTestCase
                 <li id="first"><svg viewBox="0 0 24 24" fill="currentColor" class="h-8 w-8" aria-hidden="true"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd"></path></svg></li>
                 <li id="second"><svg viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6" aria-label="AriaLabel"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd"></path></svg></li>
                 <li id="third"><svg viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6" data-action="string &quot;with&quot; quotes" aria-hidden="true"><path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path></svg></li>
-                <li id="fourth"><svg viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6" height="24" width="24" aria-hidden="true"><path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path></svg></li>
                 <li id="fifth"><svg viewBox="0 0 24 24" fill="currentColor" class="h-8 w-8" aria-hidden="true"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd"></path></svg></li>
                 <li id="sixth"><svg viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6" aria-labelledby="foo"><path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path></svg></li>
                 <li id="seventh"><svg viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6" aria-hidden="true"><path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path></svg></li>
                 <li id="eighth"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><g fill="none"><path fill="currentColor" d="m12 3l7.794 4.5v7.845a2 2 0 0 1-1 1.732L13 20.423a2 2 0 0 1-2 0l-5.794-3.346a2 2 0 0 1-1-1.732V7.5z" opacity=".16"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m12 3l7.794 4.5v7.845a2 2 0 0 1-1 1.732L13 20.423a2 2 0 0 1-2 0l-5.794-3.346a2 2 0 0 1-1-1.732V7.5z"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 7v5l-4.33 2.5M12 12l4.33 2.5"/></g></svg></li>
-                <li id="ninth"><svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" aria-hidden="true"><g fill="none"><path fill="currentColor" d="m12 3l7.794 4.5v7.845a2 2 0 0 1-1 1.732L13 20.423a2 2 0 0 1-2 0l-5.794-3.346a2 2 0 0 1-1-1.732V7.5z" opacity=".16"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m12 3l7.794 4.5v7.845a2 2 0 0 1-1 1.732L13 20.423a2 2 0 0 1-2 0l-5.794-3.346a2 2 0 0 1-1-1.732V7.5z"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 7v5l-4.33 2.5M12 12l4.33 2.5"/></g></svg></li>
             </ul>
             HTML,
             trim($output)
diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts
index 0e328a61..efa875c0 100644
--- a/src/LiveComponent/assets/test/controller/render.test.ts
+++ b/src/LiveComponent/assets/test/controller/render.test.ts
@@ -630,23 +630,4 @@ describe('LiveController rendering Tests', () => {
         // verify the selectedIndex of the select option 2 is 0
         expect(selectOption2.selectedIndex).toBe(0);
     });
-
-    it('backend will have a new csrf token', async () => {
-        const test = await createTest(
-            {},
-            (data: any) => `
-            <div ${initComponent(data)} data-live-csrf-value="${data.csrf}">
-            </div>
-        `
-        );
-
-        test.expectsAjaxCall().serverWillChangeProps((data: any) => {
-            // change csrf token
-            data.csrf = 'Hello';
-        });
-
-        await test.component.render();
-
-        expect(test.mockedBackend.csrfToken).toEqual('Hello');
-    });
 });
diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts
index 958fdcc8..4b0f6799 100644
--- a/src/LiveComponent/assets/test/tools.ts
+++ b/src/LiveComponent/assets/test/tools.ts
@@ -98,8 +98,6 @@ class FunctionalTest {
 class MockedBackend implements BackendInterface {
     private expectedMockedAjaxCalls: Array<MockedAjaxCall> = [];
 
-    public csrfToken: string | null = null;
-
     addMockedAjaxCall(mock: MockedAjaxCall) {
         this.expectedMockedAjaxCalls.push(mock);
     }
@@ -141,10 +139,6 @@ class MockedBackend implements BackendInterface {
         return matchedMock.createBackendRequest();
     }
 
-    updateCsrfToken(csrfToken: string) {
-        this.csrfToken = csrfToken;
-    }
-
     getExpectedMockedAjaxCalls(): Array<MockedAjaxCall> {
         return this.expectedMockedAjaxCalls;
     }
diff --git a/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php b/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php
index f7278ed9..b63331a3 100644
--- a/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php
+++ b/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php
@@ -25,24 +25,24 @@ final class Entity2Normalizer implements NormalizerInterface, DenormalizerInterf
     {
     }
 
-    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
+    public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed
     {
-        [, $id] = explode(':', $data);
+        [, $id] = \explode(':', $data);
 
         return $this->doctrine->getRepository(Entity2::class)->find($id);
     }
 
-    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
     {
         return Entity2::class === $type;
     }
 
-    public function normalize(mixed $object, ?string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
+    public function normalize(mixed $object, string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
     {
         return 'entity2:'.$object->id;
     }
 
-    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+    public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
     {
         return $data instanceof Entity2;
     }
diff --git a/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php b/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php
index ad52f89e..90889a47 100644
--- a/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php
+++ b/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php
@@ -17,27 +17,27 @@ use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Money;
 
 final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
 {
-    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Money
+    public function denormalize(mixed $data, string $type, string $format = null, array $context = []): Money
     {
-        return new Money(...explode('|', $data));
+        return new Money(...\explode('|', $data));
     }
 
-    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
     {
         return Money::class === $type;
     }
 
-    public function normalize(mixed $object, ?string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
+    public function normalize(mixed $object, string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
     {
-        return implode('|', [$object->amount, $object->currency]);
+        return \implode('|', [$object->amount, $object->currency]);
     }
 
-    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+    public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
     {
         return $data instanceof Money;
     }
 
-    public function getSupportedTypes(?string $format = null): array
+    public function getSupportedTypes(string $format = null): array
     {
         return [Money::class => true];
     }
diff --git a/src/Map/tests/Kernel/TwigComponentKernel.php b/src/Map/tests/Kernel/TwigComponentKernel.php
new file mode 100644
index 00000000..7ee33360
--- /dev/null
+++ b/src/Map/tests/Kernel/TwigComponentKernel.php
@@ -0,0 +1,63 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Tests\Kernel;
+
+use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
+use Symfony\Bundle\TwigBundle\TwigBundle;
+use Symfony\Component\Config\Loader\LoaderInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\HttpKernel\Kernel;
+use Symfony\UX\Map\UXMapBundle;
+use Symfony\UX\StimulusBundle\StimulusBundle;
+use Symfony\UX\TwigComponent\TwigComponentBundle;
+
+/**
+ * @internal
+ */
+class TwigComponentKernel extends Kernel
+{
+    use AppKernelTrait;
+
+    public function registerBundles(): iterable
+    {
+        return [
+            new FrameworkBundle(),
+            new StimulusBundle(),
+            new TwigBundle(),
+            new TwigComponentBundle(),
+            new UXMapBundle(),
+        ];
+    }
+
+    public function registerContainerConfiguration(LoaderInterface $loader)
+    {
+        $loader->load(function (ContainerBuilder $container) {
+            $container->loadFromExtension('framework', [
+                'secret' => '$ecret',
+                'test' => true,
+                'http_method_override' => false,
+            ]);
+            $container->loadFromExtension('twig', [
+                'default_path' => __DIR__.'/templates',
+                'strict_variables' => true,
+                'exception_controller' => null,
+            ]);
+            $container->loadFromExtension('twig_component', [
+                'defaults' => [],
+                'anonymous_template_directory' => 'components',
+            ]);
+            $container->loadFromExtension('ux_map', []);
+
+            $container->setAlias('test.ux_map.renderers', 'ux_map.renderers')->setPublic(true);
+        });
+    }
+}
diff --git a/src/Map/tests/MapFactoryTest.php b/src/Map/tests/MapFactoryTest.php
new file mode 100644
index 00000000..a19e2d79
--- /dev/null
+++ b/src/Map/tests/MapFactoryTest.php
@@ -0,0 +1,102 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\Map\Map;
+
+class MapFactoryTest extends TestCase
+{
+    public function testFromArray(): void
+    {
+        $array = self::createMapArray();
+        $map = Map::fromArray($array);
+
+        $this->assertEquals($array['center']['lat'], $map->toArray()['center']['lat']);
+        $this->assertEquals($array['center']['lng'], $map->toArray()['center']['lng']);
+
+        $this->assertEquals((float) $array['zoom'], $map->toArray()['zoom']);
+
+        $this->assertCount(1, $markers = $map->toArray()['markers']);
+        $this->assertEquals($array['markers'][0]['position']['lat'], $markers[0]['position']['lat']);
+        $this->assertEquals($array['markers'][0]['position']['lng'], $markers[0]['position']['lng']);
+        $this->assertSame($array['markers'][0]['title'], $markers[0]['title']);
+        $this->assertSame($array['markers'][0]['infoWindow']['headerContent'], $markers[0]['infoWindow']['headerContent']);
+        $this->assertSame($array['markers'][0]['infoWindow']['content'], $markers[0]['infoWindow']['content']);
+    }
+
+    public function testFromArrayWithInvalidCenter(): void
+    {
+        $array = self::createMapArray();
+        $array['center'] = 'invalid';
+
+        $this->expectException(\TypeError::class);
+        Map::fromArray($array);
+    }
+
+    public function testFromArrayWithInvalidZoom(): void
+    {
+        $array = self::createMapArray();
+        $array['zoom'] = 'invalid';
+
+        $this->expectException(\TypeError::class);
+        Map::fromArray($array);
+    }
+
+    public function testFromArrayWithInvalidMarkers(): void
+    {
+        $array = self::createMapArray();
+        $array['markers'] = 'invalid';
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('The "markers" parameter must be an array.');
+        Map::fromArray($array);
+    }
+
+    public function testFromArrayWithInvalidMarker(): void
+    {
+        $array = self::createMapArray();
+        $array['markers'] = [
+            [
+                'invalid',
+            ],
+        ];
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('The "position" parameter is required.');
+        Map::fromArray($array);
+    }
+
+    private static function createMapArray(): array
+    {
+        return [
+            'center' => [
+                'lat' => 48.8566,
+                'lng' => 2.3522,
+            ],
+            'zoom' => 12,
+            'markers' => [
+                [
+                    'position' => [
+                        'lat' => 48.8566,
+                        'lng' => 2.3522,
+                    ],
+                    'title' => 'Paris',
+                    'infoWindow' => [
+                        'headerContent' => 'Paris',
+                        'content' => 'Paris, the city of lights',
+                    ],
+                ],
+            ],
+        ];
+    }
+}
diff --git a/src/Map/tests/Twig/MapComponentTest.php b/src/Map/tests/Twig/MapComponentTest.php
new file mode 100644
index 00000000..031fd929
--- /dev/null
+++ b/src/Map/tests/Twig/MapComponentTest.php
@@ -0,0 +1,50 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Tests\Twig;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Map\Map;
+use Symfony\UX\Map\Point;
+use Symfony\UX\Map\Renderer\RendererInterface;
+use Symfony\UX\Map\Tests\Kernel\TwigComponentKernel;
+
+class MapComponentTest extends KernelTestCase
+{
+    protected static function getKernelClass(): string
+    {
+        return TwigComponentKernel::class;
+    }
+
+    public function testRenderMapComponent(): void
+    {
+        $map = (new Map())
+            ->center(new Point(latitude: 5, longitude: 10))
+            ->zoom(4);
+        $attributes = ['data-foo' => 'bar'];
+
+        $renderer = self::createMock(RendererInterface::class);
+        $renderer
+            ->method('renderMap')
+            ->with($map, $attributes)
+            ->willReturn('<div data-controller="@symfony/ux-foobar-map"></div>')
+        ;
+        self::getContainer()->set('test.ux_map.renderers', $renderer);
+
+        $twig = self::getContainer()->get('twig');
+        $template = $twig->createTemplate('<twig:ux:map center="{{ {lat: 5, lng: 10} }}" zoom="4" data-foo="bar" />');
+
+        $this->assertSame(
+            '<div data-controller="@symfony/ux-foobar-map"></div>',
+            $template->render(['attributes' => $attributes]),
+        );
+    }
+}
diff --git a/src/Map/tests/Twig/MapExtensionTest.php b/src/Map/tests/Twig/MapExtensionTest.php
new file mode 100644
index 00000000..e78e0ec3
--- /dev/null
+++ b/src/Map/tests/Twig/MapExtensionTest.php
@@ -0,0 +1,113 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Tests\Twig;
+
+use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Map\Map;
+use Symfony\UX\Map\Point;
+use Symfony\UX\Map\Renderer\RendererInterface;
+use Symfony\UX\Map\Tests\Kernel\TwigAppKernel;
+use Symfony\UX\Map\Twig\MapExtension;
+use Symfony\UX\Map\Twig\MapRuntime;
+use Twig\DeprecatedCallableInfo;
+use Twig\Environment;
+use Twig\Loader\ArrayLoader;
+use Twig\Loader\ChainLoader;
+
+class MapExtensionTest extends KernelTestCase
+{
+    use ExpectDeprecationTrait;
+
+    protected static function getKernelClass(): string
+    {
+        return TwigAppKernel::class;
+    }
+
+    public function testExtensionIsRegistered(): void
+    {
+        /** @var Environment $twig */
+        $twig = self::getContainer()->get('twig');
+
+        $this->assertTrue($twig->hasExtension(MapExtension::class));
+        $this->assertInstanceOf(MapExtension::class, $twig->getExtension(MapExtension::class));
+    }
+
+    public function testRuntimeIsRegistered(): void
+    {
+        /** @var Environment $twig */
+        $twig = self::getContainer()->get('twig');
+
+        $this->assertInstanceOf(MapRuntime::class, $twig->getRuntime(MapRuntime::class));
+    }
+
+    /**
+     * @group legacy
+     */
+    public function testRenderFunctionIsDeprecated(): void
+    {
+        $map = (new Map())
+           ->center(new Point(latitude: 5, longitude: 10))
+           ->zoom(4);
+
+        $renderer = self::createMock(RendererInterface::class);
+        $renderer
+            ->expects(self::once())
+            ->method('renderMap')
+            ->with($map, [])
+            ->willReturn('<map/>')
+        ;
+        self::getContainer()->set('test.ux_map.renderers', $renderer);
+
+        /** @var Environment $twig */
+        $twig = self::getContainer()->get('twig');
+        $twig->setLoader(new ChainLoader([
+            new ArrayLoader([
+                'test' => '{{ render_map(map) }}',
+            ]),
+            $twig->getLoader(),
+        ]));
+
+        if (class_exists(DeprecatedCallableInfo::class)) {
+            $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated; use "ux_map" instead in test at line 1.');
+        } else {
+            $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated. Use "ux_map" instead in test at line 1.');
+        }
+        $html = $twig->render('test', ['map' => $map]);
+        $this->assertSame('<map/>', $html);
+    }
+
+    public function testMapFunctionWithArray(): void
+    {
+        $map = (new Map())
+            ->center(new Point(latitude: 5, longitude: 10))
+            ->zoom(4);
+        $attributes = ['data-foo' => 'bar'];
+
+        $renderer = self::createMock(RendererInterface::class);
+        $renderer
+            ->expects(self::once())
+            ->method('renderMap')
+            ->with($map, $attributes)
+            ->willReturn('<div data-controller="@symfony/ux-foobar-map"></div>')
+        ;
+        self::getContainer()->set('test.ux_map.renderers', $renderer);
+
+        $twig = self::getContainer()->get('twig');
+        $template = $twig->createTemplate('{{ ux_map(center: {lat: 5, lng: 10}, zoom: 4, attributes: attributes) }}');
+
+        $this->assertSame(
+            '<div data-controller="@symfony/ux-foobar-map"></div>',
+            $template->render(['attributes' => $attributes]),
+        );
+    }
+}
diff --git a/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php b/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php
index cd5ab7a9..df6b81c7 100644
--- a/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php
+++ b/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php
@@ -14,7 +14,10 @@ namespace Symfony\UX\TwigComponent\Tests\Unit\EventListener;
 use PHPUnit\Framework\TestCase;
 use Symfony\UX\TwigComponent\ComponentAttributes;
 use Symfony\UX\TwigComponent\ComponentMetadata;
+use Symfony\UX\TwigComponent\Event\PostMountEvent;
 use Symfony\UX\TwigComponent\Event\PostRenderEvent;
+use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
+use Symfony\UX\TwigComponent\Event\PreMountEvent;
 use Symfony\UX\TwigComponent\Event\PreRenderEvent;
 use Symfony\UX\TwigComponent\EventListener\TwigComponentLoggerListener;
 use Symfony\UX\TwigComponent\MountedComponent;
@@ -29,20 +32,31 @@ class TwigComponentLoggerListenerTest extends TestCase
         $logger = new TwigComponentLoggerListener();
         $this->assertSame([], $logger->getEvents());
 
+        $eventA = new PreCreateForRenderEvent('a');
+        $logger->onPreCreateForRender($eventA);
+
+        $eventB = new PreCreateForRenderEvent('b');
+        $logger->onPreCreateForRender($eventB);
+
+        $eventC = new PreMountEvent(new \stdClass(), [], new ComponentMetadata([]));
+        $logger->onPreMount($eventC);
+        $eventD = new PostMountEvent(new \stdClass(), [], new ComponentMetadata([]));
+        $logger->onPostMount($eventD);
+
         $mounted = new MountedComponent('foo', new \stdClass(), new ComponentAttributes([]));
-        $eventA = new PreRenderEvent($mounted, new ComponentMetadata(['template' => 'bar']), []);
-        $logger->onPreRender($eventA);
-        $eventB = new PostRenderEvent($mounted);
-        $logger->onPostRender($eventB);
+        $eventE = new PreRenderEvent($mounted, new ComponentMetadata(['template' => 'bar']), []);
+        $logger->onPreRender($eventE);
+        $eventF = new PostRenderEvent($mounted);
+        $logger->onPostRender($eventF);
 
-        $this->assertSame([$eventA, $eventB], array_column($logger->getEvents(), 0));
+        $this->assertSame([$eventA, $eventB, $eventC, $eventD, $eventE, $eventF], array_column($logger->getEvents(), 0));
     }
 
     public function testLoggerReset(): void
     {
         $logger = new TwigComponentLoggerListener();
 
-        $logger->onPreRender(new PreRenderEvent(new MountedComponent('foo', new \stdClass(), new ComponentAttributes([])), new ComponentMetadata(['template' => 'bar']), []));
+        $logger->onPreCreateForRender(new PreCreateForRenderEvent('foo'));
         $this->assertNotSame([], $logger->getEvents());
 
         $logger->reset();
diff --git a/ux.symfony.com/tests/Functional/LiveComponentDemosTest.php b/ux.symfony.com/tests/Functional/LiveComponentDemosTest.php
index 558284e4..b395a072 100644
--- a/ux.symfony.com/tests/Functional/LiveComponentDemosTest.php
+++ b/ux.symfony.com/tests/Functional/LiveComponentDemosTest.php
@@ -19,7 +19,7 @@ use Zenstruck\Browser\Test\HasBrowser;
 use Zenstruck\Foundry\Test\Factories;
 use Zenstruck\Foundry\Test\ResetDatabase;
 
-use function Zenstruck\Foundry\Persistence\persist;
+use function Zenstruck\Foundry\Persistence\persist_proxy;
 
 class LiveComponentDemosTest extends KernelTestCase
 {
@@ -32,7 +32,7 @@ class LiveComponentDemosTest extends KernelTestCase
      */
     public function setupEntities(): void
     {
-        persist(Food::class, ['name' => 'Pizza', 'votes' => 10]);
+        persist_proxy(Food::class, ['name' => 'Pizza', 'votes' => 10]);
     }
 
     /**