Repository: symfony/ux
Pull request #2156 (2024-09-12 05:44:32)

[Icons] Configure icon sets: path, alias & icon attributes
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | Fix #
| License       | MIT

I'm opening now if you guys have feedback, and i will add more tests the next couple days


> [!NOTE]  
> This PR brings 3 new features: iconset aliases, custom path and attributes.  So let's focus on that 😄 



```yaml
ux_icons:
    icon_dir: '%kernel.project_dir%/assets/icons'

    # Default icon set
    default_icon_attributes:
        class: 'icon'
        fill: 'currentColor'
        width: '24'
        height: '24'

    icon_sets:
        # Local icon set    (prefix mapped to a local directory)
        flags:
            path: '%kernel.project_dir%/assets/images/flags'

        # Remote icon set   (prefix mapped to an icon set identifier)
        lu:
            alias: 'lucide'

        # Icon set configuration (work for local/remote)
        oc:
            icon_attributes:
                class: 'oc-icon'       # Replace the default class
                stroke: 'none'      # Add a new attribute
                fill: false         # Use "false" to remove a default attribute
```


### Default attributes

```twig

<twig:ux:icon name="anything" />

{# Renders #}
<svg class="icon" fill="currentColor" width="24" height="24"> ... </svg>
```

## Icon set configuration

### Local icons: path

```twig

<twig:ux:icon name="flags:fr" />

{# Renders:  #}
<svg class="icon" fill="currentColor" width="24" height="24">
    <!-- file assets/images/flags/fr.svg -->
</svg>
```

### Remote icons: alias 

```twig

<twig:ux:icon name="lu:circle-check" />

{# Renders:  #}
<svg class="icon" fill="currentColor" width="24" height="24">
    <!-- file lucide:circle-check.svg from iconify -->
</svg>
```

### Icon set attributes

```twig

<twig:ux:icon name="oc:check" />

{# Renders:  #}
{# Attributes are merged into the default attributes #}
<svg class="oc-icon" stroke="none" width="24" height="24">
    <!-- file oc:check.svg from local or remote -->
</svg>
```






Patch:
diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php
index a0b8ca57..7d0dc0ab 100644
--- a/src/Icons/config/services.php
+++ b/src/Icons/config/services.php
@@ -14,6 +14,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
 use Symfony\UX\Icons\Command\WarmCacheCommand;
 use Symfony\UX\Icons\IconCacheWarmer;
 use Symfony\UX\Icons\IconRenderer;
+use Symfony\UX\Icons\IconRendererInterface;
 use Symfony\UX\Icons\Registry\CacheIconRegistry;
 use Symfony\UX\Icons\Registry\ChainIconRegistry;
 use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
@@ -60,7 +61,7 @@ return static function (ContainerConfigurator $container): void {
                 abstract_arg('icon_aliases'),
             ])
 
-        ->alias('Symfony\UX\Icons\IconRendererInterface', '.ux_icons.icon_renderer')
+        ->alias(IconRendererInterface::class, '.ux_icons.icon_renderer')
 
         ->set('.ux_icons.icon_finder', IconFinder::class)
             ->args([
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/DependencyInjection/UXIconsExtension.php b/src/Icons/src/DependencyInjection/UXIconsExtension.php
index d363de8a..618749ee 100644
--- a/src/Icons/src/DependencyInjection/UXIconsExtension.php
+++ b/src/Icons/src/DependencyInjection/UXIconsExtension.php
@@ -42,32 +42,66 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
                 ->variableNode('default_icon_attributes')
                     ->info('Default attributes to add to all icons.')
                     ->defaultValue(['fill' => 'currentColor'])
+                    ->example(['class' => 'icon'])
+                ->end()
+                ->arrayNode('icon_sets')
+                    ->info('Icon sets configuration.')
+                    ->defaultValue([])
+                    ->normalizeKeys(false)
+                    ->useAttributeAsKey('prefix')
+                    ->arrayPrototype()
+                        ->info('the icon set prefix (e.g. "acme")')
+                        ->children()
+                            ->scalarNode('path')
+                                ->info("The local icon set directory path.\n(cannot be used with 'alias')")
+                                ->example('%kernel.project_dir%/assets/svg/acme')
+                            ->end()
+                            ->scalarNode('alias')
+                                ->info("The remote icon set identifier.\n(cannot be used with 'path')")
+                                ->example('simple-icons')
+                            ->end()
+                            ->arrayNode('icon_attributes')
+                                ->info('Override default icon attributes for icons in this set.')
+                                ->example(['class' => 'icon icon-acme', 'fill' => 'none'])
+                                ->normalizeKeys(false)
+                                ->variablePrototype()
+                                ->end()
+                            ->end()
+                        ->end()
+                    ->end()
+                    ->validate()
+                        ->ifTrue(fn (array $v) => isset($v['path']) && isset($v['alias']))
+                        ->thenInvalid('You cannot define both "path" and "alias" for an icon set.')
+                    ->end()
                 ->end()
                 ->arrayNode('aliases')
-                    ->info('Icon aliases (alias => icon name).')
-                    ->example(['dots' => 'clarity:ellipsis-horizontal-line'])
+                    ->info('Icon aliases (map of alias => full name).')
+                    ->example([
+                        'dots' => 'clarity:ellipsis-horizontal-line',
+                        'privacy' => 'bi:cookie',
+                    ])
                     ->normalizeKeys(false)
                     ->scalarPrototype()
                         ->cannotBeEmpty()
                     ->end()
                 ->end()
                 ->arrayNode('iconify')
-                    ->info('Configuration for the "on demand" icons powered by Iconify.design.')
+                    ->info('Configuration for the remote icon service.')
                     ->{interface_exists(HttpClientInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}()
                     ->children()
                         ->booleanNode('on_demand')
-                            ->info('Whether to use the "on demand" icons powered by Iconify.design.')
+                            ->info('Whether to download icons "on demand".')
                             ->defaultTrue()
                         ->end()
                         ->scalarNode('endpoint')
-                            ->info('The endpoint for the Iconify API.')
+                            ->info('The endpoint for the Iconify icons API.')
                             ->defaultValue(Iconify::API_ENDPOINT)
                             ->cannotBeEmpty()
                         ->end()
                     ->end()
                 ->end()
                 ->booleanNode('ignore_not_found')
-                    ->info('Ignore error when an icon is not found.')
+                    ->info("Ignore error when an icon is not found.\nSet to 'true' to fail silently.")
                     ->defaultFalse()
                 ->end()
             ->end()
@@ -94,9 +128,25 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
             $loader->load('asset_mapper.php');
         }
 
+        $iconSetAliases = [];
+        $iconSetAttributes = [];
+        $iconSetPaths = [];
+        foreach ($mergedConfig['icon_sets'] as $prefix => $config) {
+            if (isset($config['icon_attributes'])) {
+                $iconSetAttributes[$prefix] = $config['icon_attributes'];
+            }
+            if (isset($config['alias'])) {
+                $iconSetAliases[$prefix] = $config['alias'];
+            }
+            if (isset($config['path'])) {
+                $iconSetPaths[$prefix] = $config['path'];
+            }
+        }
+
         $container->getDefinition('.ux_icons.local_svg_icon_registry')
             ->setArguments([
                 $mergedConfig['icon_dir'],
+                $iconSetPaths,
             ])
         ;
 
@@ -107,6 +157,7 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
         $container->getDefinition('.ux_icons.icon_renderer')
             ->setArgument(1, $mergedConfig['default_icon_attributes'])
             ->setArgument(2, $mergedConfig['aliases'])
+            ->setArgument(3, $iconSetAttributes)
         ;
 
         $container->getDefinition('.ux_icons.twig_icon_runtime')
@@ -117,8 +168,10 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
             $loader->load('iconify.php');
 
             $container->getDefinition('.ux_icons.iconify')
-                ->setArgument(1, $mergedConfig['iconify']['endpoint'])
-            ;
+                ->setArgument(1, $mergedConfig['iconify']['endpoint']);
+
+            $container->getDefinition('.ux_icons.iconify_on_demand_registry')
+                ->setArgument(1, $iconSetAliases);
 
             if (!$mergedConfig['iconify']['on_demand']) {
                 $container->removeDefinition('.ux_icons.iconify_on_demand_registry');
diff --git a/src/Icons/src/IconRenderer.php b/src/Icons/src/IconRenderer.php
index f58614ba..c698619d 100644
--- a/src/Icons/src/IconRenderer.php
+++ b/src/Icons/src/IconRenderer.php
@@ -18,10 +18,16 @@ namespace Symfony\UX\Icons;
  */
 final class IconRenderer implements IconRendererInterface
 {
+    /**
+     * @param array<string, mixed>                $defaultIconAttributes
+     * @param array<string, string>               $iconAliases
+     * @param array<string, array<string, mixed>> $iconSetsAttributes
+     */
     public function __construct(
         private readonly IconRegistryInterface $registry,
         private readonly array $defaultIconAttributes = [],
-        private readonly ?array $iconAliases = [],
+        private readonly array $iconAliases = [],
+        private readonly array $iconSetsAttributes = [],
     ) {
     }
 
@@ -36,11 +42,16 @@ final class IconRenderer implements IconRendererInterface
      */
     public function renderIcon(string $name, array $attributes = []): string
     {
-        $name = $this->iconAliases[$name] ?? $name;
+        $iconName = $this->iconAliases[$name] ?? $name;
 
-        $icon = $this->registry->get($name)
-            ->withAttributes($this->defaultIconAttributes)
-            ->withAttributes($attributes);
+        $icon = $this->registry->get($iconName);
+
+        if (0 < (int) $pos = strpos($name, ':')) {
+            $setAttributes = $this->iconSetsAttributes[substr($name, 0, $pos)] ?? [];
+        } elseif ($iconName !== $name && 0 < (int) $pos = strpos($iconName, ':')) {
+            $setAttributes = $this->iconSetsAttributes[substr($iconName, 0, $pos)] ?? [];
+        }
+        $icon = $icon->withAttributes([...$this->defaultIconAttributes, ...($setAttributes ?? []), ...$attributes]);
 
         foreach ($this->getPreRenderers() as $preRenderer) {
             $icon = $preRenderer($icon);
diff --git a/src/Icons/src/Registry/IconifyOnDemandRegistry.php b/src/Icons/src/Registry/IconifyOnDemandRegistry.php
index 7e9325ad..5931854c 100644
--- a/src/Icons/src/Registry/IconifyOnDemandRegistry.php
+++ b/src/Icons/src/Registry/IconifyOnDemandRegistry.php
@@ -23,8 +23,10 @@ use Symfony\UX\Icons\IconRegistryInterface;
  */
 final class IconifyOnDemandRegistry implements IconRegistryInterface
 {
-    public function __construct(private Iconify $iconify)
-    {
+    public function __construct(
+        private Iconify $iconify,
+        private ?array $prefixAliases = [],
+    ) {
     }
 
     public function get(string $name): Icon
@@ -32,7 +34,8 @@ final class IconifyOnDemandRegistry implements IconRegistryInterface
         if (2 !== \count($parts = explode(':', $name))) {
             throw new IconNotFoundException(\sprintf('The icon name "%s" is not valid.', $name));
         }
+        [$prefix, $icon] = $parts;
 
-        return $this->iconify->fetchIcon(...$parts);
+        return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon);
     }
 }
diff --git a/src/Icons/src/Registry/LocalSvgIconRegistry.php b/src/Icons/src/Registry/LocalSvgIconRegistry.php
index c12ef394..e393f358 100644
--- a/src/Icons/src/Registry/LocalSvgIconRegistry.php
+++ b/src/Icons/src/Registry/LocalSvgIconRegistry.php
@@ -23,17 +23,38 @@ use Symfony\UX\Icons\IconRegistryInterface;
  */
 final class LocalSvgIconRegistry implements IconRegistryInterface
 {
-    public function __construct(private string $iconDir)
-    {
+    /**
+     * @param array<string, string> $iconSetPaths
+     */
+    public function __construct(
+        private readonly string $iconDir,
+        private readonly array $iconSetPaths = [],
+    ) {
     }
 
     public function get(string $name): Icon
     {
-        if (!file_exists($filename = \sprintf('%s/%s.svg', $this->iconDir, str_replace(':', '/', $name)))) {
-            throw new IconNotFoundException(\sprintf('The icon "%s" (%s) does not exist.', $name, $filename));
+        if (str_contains($name, ':')) {
+            [$prefix, $icon] = explode(':', $name, 2) + ['', ''];
+            if ('' === $prefix || '' === $icon) {
+                throw new IconNotFoundException(\sprintf('The icon name "%s" is not valid.', $name));
+            }
+
+            if ($prefixPath = $this->iconSetPaths[$prefix] ?? null) {
+                if (!file_exists($filename = $prefixPath.'/'.str_replace(':', '/', $icon).'.svg')) {
+                    throw new IconNotFoundException(\sprintf('The icon "%s" (%s) does not exist.', $name, $filename));
+                }
+
+                return Icon::fromFile($filename);
+            }
+        }
+
+        $filepath = str_replace(':', '/', $name).'.svg';
+        if (file_exists($filename = $this->iconDir.'/'.$filepath)) {
+            return Icon::fromFile($filename);
         }
 
-        return Icon::fromFile($filename);
+        throw new IconNotFoundException(\sprintf('The icon "%s" (%s) does not exist.', $name, $filename));
     }
 
     public function has(string $name): bool
diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md
index d4894408..33431733 100644
--- a/src/Map/CHANGELOG.md
+++ b/src/Map/CHANGELOG.md
@@ -2,11 +2,8 @@
 
 ## 2.20
 
--   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
+-   Rename `render_map` Twig function `ux_map`
+-   Deprecate `render_map` Twig function
 
 ## 2.19
 
diff --git a/src/Map/composer.json b/src/Map/composer.json
index a24b4fd3..73d39303 100644
--- a/src/Map/composer.json
+++ b/src/Map/composer.json
@@ -39,8 +39,7 @@
         "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/ux-twig-component": "^2.18"
+        "symfony/twig-bundle": "^6.4|^7.0"
     },
     "extra": {
         "thanks": {
diff --git a/src/Map/config/services.php b/src/Map/config/services.php
index c94a9043..7dada5ec 100644
--- a/src/Map/config/services.php
+++ b/src/Map/config/services.php
@@ -15,7 +15,6 @@ 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>
@@ -27,6 +26,7 @@ return static function (ContainerConfigurator $container): void {
             ->args([
                 abstract_arg('renderers configuration'),
             ])
+            ->tag('twig.runtime')
 
         ->set('ux_map.renderer_factory.abstract', AbstractRendererFactory::class)
             ->abstract()
@@ -41,11 +41,5 @@ 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
deleted file mode 100644
index f09999e9..00000000
--- a/src/Map/config/twig_component.php
+++ /dev/null
@@ -1,32 +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\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 f98fbb69..4897b3c9 100644
--- a/src/Map/src/InfoWindow.php
+++ b/src/Map/src/InfoWindow.php
@@ -19,8 +19,7 @@ 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,
@@ -32,16 +31,6 @@ 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 [
@@ -53,25 +42,4 @@ 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 d8fdf005..834e0d3d 100644
--- a/src/Map/src/Map.php
+++ b/src/Map/src/Map.php
@@ -103,36 +103,4 @@ 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 ac0dc0e0..5c822698 100644
--- a/src/Map/src/Marker.php
+++ b/src/Map/src/Marker.php
@@ -11,8 +11,6 @@
 
 namespace Symfony\UX\Map;
 
-use Symfony\UX\Map\Exception\InvalidArgumentException;
-
 /**
  * Represents a marker on a map.
  *
@@ -21,8 +19,7 @@ use Symfony\UX\Map\Exception\InvalidArgumentException;
 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,
@@ -32,14 +29,6 @@ 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 [
@@ -49,28 +38,4 @@ 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 f34f37a2..a6d71d88 100644
--- a/src/Map/src/Point.php
+++ b/src/Map/src/Point.php
@@ -43,16 +43,4 @@ 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 6442ff1b..57807f54 100644
--- a/src/Map/src/Twig/MapExtension.php
+++ b/src/Map/src/Twig/MapExtension.php
@@ -11,6 +11,7 @@
 
 namespace Symfony\UX\Map\Twig;
 
+use Symfony\UX\Map\Renderer\Renderers;
 use Twig\DeprecatedCallableInfo;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFunction;
@@ -25,13 +26,13 @@ final class MapExtension extends AbstractExtension
     public function getFunctions(): array
     {
         return [
-            new TwigFunction('render_map', [MapRuntime::class, 'renderMap'], [
+            new TwigFunction('render_map', [Renderers::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', [MapRuntime::class, 'renderMap'], ['is_safe' => ['html']]),
+            new TwigFunction('ux_map', [Renderers::class, 'renderMap'], ['is_safe' => ['html']]),
         ];
     }
 }
diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php
deleted file mode 100644
index 62e50be8..00000000
--- a/src/Map/src/Twig/MapRuntime.php
+++ /dev/null
@@ -1,64 +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\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
deleted file mode 100644
index 94cb6407..00000000
--- a/src/Map/src/Twig/UXMapComponent.php
+++ /dev/null
@@ -1,32 +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\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
deleted file mode 100644
index 3a36e558..00000000
--- a/src/Map/src/Twig/UXMapComponentListener.php
+++ /dev/null
@@ -1,42 +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\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 13929261..52ecec41 100644
--- a/src/Map/src/UXMapBundle.php
+++ b/src/Map/src/UXMapBundle.php
@@ -19,7 +19,6 @@ 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>
@@ -56,10 +55,6 @@ 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/TestKernel.php b/src/Icons/tests/Fixtures/TestKernel.php
index acee6579..6a9d0f0b 100644
--- a/src/Icons/tests/Fixtures/TestKernel.php
+++ b/src/Icons/tests/Fixtures/TestKernel.php
@@ -50,7 +50,7 @@ final class TestKernel extends Kernel
         ]);
 
         $container->extension('twig', [
-            'default_path' => __DIR__ . '/templates',
+            'default_path' => __DIR__.'/templates',
         ]);
 
         $container->extension('twig_component', [
@@ -60,6 +60,14 @@ final class TestKernel extends Kernel
 
         $container->extension('ux_icons', [
             'icon_dir' => '%kernel.project_dir%/tests/Fixtures/icons',
+            'icon_sets' => [
+                'fla' => [
+                    'path' => '%kernel.project_dir%/tests/Fixtures/images/flags',
+                ],
+                'lu' => [
+                    'alias' => 'lucide',
+                ],
+            ],
         ]);
 
         $container->services()->set('logger', NullLogger::class);
diff --git a/src/Icons/tests/Fixtures/icons/a/icon.svg b/src/Icons/tests/Fixtures/icons/a/icon.svg
new file mode 100644
index 00000000..85878dfa
--- /dev/null
+++ b/src/Icons/tests/Fixtures/icons/a/icon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
+<circle aria-label="icons/a/" r="1" />
+</svg>
diff --git a/src/Icons/tests/Fixtures/images/a/b/icon.svg b/src/Icons/tests/Fixtures/images/a/b/icon.svg
new file mode 100644
index 00000000..e59e88ef
--- /dev/null
+++ b/src/Icons/tests/Fixtures/images/a/b/icon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
+<circle aria-label="images/a/b/" r="1" />
+</svg>
diff --git a/src/Icons/tests/Fixtures/images/a/icon.svg b/src/Icons/tests/Fixtures/images/a/icon.svg
new file mode 100644
index 00000000..da8840f9
--- /dev/null
+++ b/src/Icons/tests/Fixtures/images/a/icon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
+<circle aria-label="images/a/" r="1" />
+</svg>
diff --git a/src/Icons/tests/Fixtures/images/ab/icon.svg b/src/Icons/tests/Fixtures/images/ab/icon.svg
new file mode 100644
index 00000000..d56b0c56
--- /dev/null
+++ b/src/Icons/tests/Fixtures/images/ab/icon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
+<circle aria-label="images/ab/" r="1" />
+</svg>
diff --git a/src/Icons/tests/Fixtures/images/icon.svg b/src/Icons/tests/Fixtures/images/icon.svg
new file mode 100644
index 00000000..93c5b26d
--- /dev/null
+++ b/src/Icons/tests/Fixtures/images/icon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 2 2">
+<circle aria-label="images/" r="1" />
+</svg>
diff --git a/src/Icons/tests/Unit/IconRendererTest.php b/src/Icons/tests/Unit/IconRendererTest.php
index 75fd8566..e6834b60 100644
--- a/src/Icons/tests/Unit/IconRendererTest.php
+++ b/src/Icons/tests/Unit/IconRendererTest.php
@@ -82,81 +82,196 @@ class IconRendererTest extends TestCase
     }
 
     /**
-     * @dataProvider provideAttributesWithDefaultAttributesCases
+     * @dataProvider provideRenderIconWithAttributeCascadeCases
      */
-    public function testRenderIconWithAttributesAndDefaultAttributes($iconAttrs, $defaultAttrs, $renderAttr, $expectedTag): void
-    {
+    public function testRenderIconWithAttributeCascade(
+        array $iconAttributes,
+        array $defaultAttributes = [],
+        array $iconSetAttributes = [],
+        array $renderAttributes = [],
+        string $expectedTag = '',
+    ): void {
         $registry = $this->createRegistry([
-            'foo' => ['', $iconAttrs],
+            'ux:icon' => ['', $iconAttributes],
         ]);
-        $iconRenderer = new IconRenderer($registry, $defaultAttrs);
+        $iconRenderer = new IconRenderer($registry, $defaultAttributes, [], ['ux' => $iconSetAttributes]);
 
-        $svg = $iconRenderer->renderIcon('foo', $renderAttr);
+        $svg = $iconRenderer->renderIcon('ux:icon', $renderAttributes);
         $svg = str_replace(' aria-hidden="true"', '', $svg);
         $this->assertStringStartsWith($expectedTag, $svg);
     }
 
-    public static function provideAttributesWithDefaultAttributesCases()
+    /**
+     * @return iterable<array{
+     *     array<string, string|bool>,
+     *     array<string, string|bool>,
+     *     array<string, string|bool>,
+     *     array<string, string|bool>,
+     *     string,
+     * }>
+     */
+    public static function provideRenderIconWithAttributeCascadeCases(): iterable
     {
         yield 'no_attributes' => [
             [],
             [],
             [],
+            [],
             '<svg>',
         ];
         yield 'icon_attributes_are_used' => [
-            ['id' => 'icon'],
+            ['ico' => 'ICO'],
+            [],
             [],
             [],
-            '<svg id="icon">',
+            '<svg ico="ICO">',
         ];
         yield 'default_attributes_are_used' => [
             [],
-            ['id' => 'default'],
+            ['def' => 'DEF'],
             [],
-            '<svg id="default">',
+            [],
+            '<svg def="DEF">',
         ];
-        yield 'render_attributes_are_used' => [
+        yield 'default_attributes_overwrite_icon_attributes' => [
+            ['ico' => 'ICO'],
+            ['ico' => 'DEF'],
             [],
             [],
-            ['id' => 'render'],
-            '<svg id="render">',
+            '<svg ico="DEF">',
         ];
-        yield 'default_attributes_take_precedence_on_icon' => [
-            ['id' => 'icon'],
-            ['id' => 'default'],
+        yield 'default_attributes_removes_icon_attributes' => [
+            ['ico' => 'ICO'],
+            ['ico' => false],
             [],
-            '<svg id="default">',
+            [],
+            '<svg>',
         ];
         yield 'default_attributes_are_merged_with_icon_attributes' => [
-            ['id' => 'icon', 'foo' => 'bar'],
-            ['id' => 'default', 'baz' => 'qux'],
+            ['ico' => 'ICO', 'foo' => 'ICO'],
+            ['ico' => 'DEF', 'bar' => 'DEF'],
             [],
-            '<svg id="default" foo="bar" baz="qux">',
+            [],
+            '<svg ico="DEF" foo="ICO" bar="DEF">',
         ];
-        yield 'render_attributes_take_precedence_on_default' => [
+        yield 'icon_set_attributes_are_used' => [
+            [],
+            [],
+            ['ico' => 'SET'],
             [],
-            ['id' => 'default'],
-            ['id' => 'render'],
-            '<svg id="render">',
+            '<svg ico="SET">',
         ];
-        yield 'render_attributes_are_merged_with_default_attributes' => [
+        yield 'icon_set_attributes_overwrite_icon_attributes' => [
+            ['ico' => 'ICO'],
+            [],
+            ['ico' => 'SET'],
+            [],
+            '<svg ico="SET">',
+        ];
+        yield 'icon_set_attributes_overwrite_default_attributes' => [
+            [],
+            ['ico' => 'DEF'],
+            ['ico' => 'SET'],
+            [],
+            '<svg ico="SET">',
+        ];
+        yield 'icon_set_attributes_are_merged_with_icon_attributes' => [
+            ['ico' => 'ICO', 'foo' => 'ICO'],
+            [],
+            ['ico' => 'SET', 'bar' => 'SET'],
+            [],
+            '<svg ico="SET" foo="ICO" bar="SET">',
+        ];
+        yield 'icon_set_attributes_are_merged_with_default_attributes' => [
+            [],
+            ['ico' => 'DEF', 'foo' => 'DEF'],
+            ['ico' => 'SET', 'bar' => 'SET'],
+            [],
+            '<svg ico="SET" foo="DEF" bar="SET">',
+        ];
+        yield 'icon_set_attributes_are_merged_with_default_attributes_and_icon_attributes' => [
+            ['ico' => 'ICO', 'foo' => 'ICO'],
+            ['ico' => 'DEF', 'bar' => 'DEF'],
+            ['ico' => 'SET', 'baz' => 'SET'],
+            [],
+            '<svg ico="SET" foo="ICO" bar="DEF" baz="SET">',
+        ];
+        yield 'render_attributes_are_used' => [
+            [],
+            [],
+            [],
+            ['ren' => 'REN'],
+            '<svg ren="REN">',
+        ];
+        yield 'render_attributes_overwrite_icon_attributes' => [
+            ['ico' => 'ICO'],
+            [],
+            [],
+            ['ico' => 'REN'],
+            '<svg ico="REN">',
+        ];
+        yield 'render_attributes_overwrite_default_attributes' => [
+            [],
+            ['ico' => 'DEF'],
             [],
-            ['id' => 'default', 'foo' => 'bar'],
-            ['id' => 'render', 'baz' => 'qux'],
-            '<svg id="render" foo="bar" baz="qux">',
+            ['ico' => 'REN'],
+            '<svg ico="REN">',
         ];
-        yield 'render_attributes_take_precedence_on_icon' => [
-            ['id' => 'icon'],
+        yield 'render_attributes_overwrite_icon_set_attributes' => [
             [],
-            ['id' => 'render'],
-            '<svg id="render">',
+            [],
+            ['ico' => 'SET'],
+            ['ico' => 'REN'],
+            '<svg ico="REN">',
         ];
         yield 'render_attributes_are_merged_with_icon_attributes' => [
-            ['id' => 'icon', 'foo' => 'bar'],
+            ['ico' => 'ICO', 'foo' => 'ICO'],
+            [],
+            [],
+            ['ico' => 'REN', 'bar' => 'REN'],
+            '<svg ico="REN" foo="ICO" bar="REN">',
+        ];
+        yield 'render_attributes_are_merged_with_default_attributes' => [
+            [],
+            ['ico' => 'DEF', 'foo' => 'DEF'],
+            [],
+            ['ico' => 'REN', 'bar' => 'REN'],
+            '<svg ico="REN" foo="DEF" bar="REN">',
+        ];
+        yield 'render_attributes_are_merged_with_icon_set_attributes' => [
+            [],
             [],
-            ['id' => 'render', 'baz' => 'qux'],
-            '<svg id="render" foo="bar" baz="qux">',
+            ['ico' => 'SET', 'foo' => 'SET'],
+            ['ico' => 'REN', 'bar' => 'REN'],
+            '<svg ico="REN" foo="SET" bar="REN">',
+        ];
+        yield 'render_attributes_are_merged_with_default_attributes_and_icon_attributes' => [
+            ['ico' => 'ICO', 'foo' => 'ICO'],
+            ['ico' => 'DEF', 'bar' => 'DEF'],
+            [],
+            ['ico' => 'REN', 'baz' => 'REN'],
+            '<svg ico="REN" foo="ICO" bar="DEF" baz="REN">',
+        ];
+        yield 'render_attributes_are_merged_with_icon_set_attributes_and_default_attributes' => [
+            [],
+            ['ico' => 'DEF', 'foo' => 'DEF'],
+            ['ico' => 'SET', 'bar' => 'SET'],
+            ['ico' => 'REN', 'baz' => 'REN'],
+            '<svg ico="REN" foo="DEF" bar="SET" baz="REN">',
+        ];
+        yield 'render_attributes_are_merged_with_icon_set_attributes_and_icon_attributes' => [
+            ['ico' => 'ICO', 'foo' => 'ICO'],
+            [],
+            ['ico' => 'SET', 'bar' => 'SET'],
+            ['ico' => 'REN', 'baz' => 'REN'],
+            '<svg ico="REN" foo="ICO" bar="SET" baz="REN">',
+        ];
+        yield 'render_attributes_are_merged_with_icon_set_attributes_and_default_attributes_and_icon_attributes' => [
+            ['ico' => 'ICO', 'foo' => 'ICO'],
+            ['ico' => 'DEF', 'bar' => 'DEF'],
+            ['ico' => 'SET', 'baz' => 'SET'],
+            ['ico' => 'REN', 'qux' => 'REN'],
+            '<svg ico="REN" foo="ICO" bar="DEF" baz="SET" qux="REN">',
         ];
     }
 
@@ -249,6 +364,49 @@ class IconRendererTest extends TestCase
         $this->assertSame('<svg aria-hidden="true"><path d="M0 BAZ"/></svg>', $svg);
     }
 
+    /**
+     * @param array<string, string> $attributes
+     *
+     * @dataProvider provideRenderIconWithIconSetAttributes
+     */
+    public function testRenderIconWithIconSetAttributes(string $name, array $attributes, string $expectedSvg): void
+    {
+        $registry = $this->createRegistry([
+            'a' => '<path d="a"/>',
+            'a:b' => '<path d="a:b"/>',
+            'a:b:c' => '<path d="a:b:c"/>',
+        ]);
+        $defaultIconAttributes = ['class' => 'def', 'x' => 'def_x'];
+        $iconSetsAttributes = [
+            'a' => ['class' => 'icons_a', 'x' => 'a'],
+            'a:b' => ['class' => 'icons_ab', 'x' => 'ab'],
+        ];
+
+        $iconRenderer = new IconRenderer($registry, $defaultIconAttributes, [], $iconSetsAttributes);
+
+        $svg = $iconRenderer->renderIcon($name, $attributes);
+        $this->assertSame($expectedSvg, $svg);
+    }
+
+    public static function provideRenderIconWithIconSetAttributes(): iterable
+    {
+        yield 'icon set attributes (a:b)' => [
+            'a:b',
+            [],
+            '<svg class="icons_a" x="a" aria-hidden="true"><path d="a:b"/></svg>',
+        ];
+        yield 'icon set attributes (a:b:c)' => [
+            'a:b:c',
+            [],
+            '<svg class="icons_a" x="a" aria-hidden="true"><path d="a:b:c"/></svg>',
+        ];
+        yield 'icon set attributes and render attributes' => [
+            'a:b',
+            ['class' => 'render', 'y' => 'a'],
+            '<svg class="render" x="a" y="a" aria-hidden="true"><path d="a:b"/></svg>',
+        ];
+    }
+
     private function createRegistry(array $icons): IconRegistryInterface
     {
         $registryIcons = [];
diff --git a/src/Icons/tests/Unit/Registry/IconifyOnDemandRegistryTest.php b/src/Icons/tests/Unit/Registry/IconifyOnDemandRegistryTest.php
new file mode 100644
index 00000000..c3d1b1d6
--- /dev/null
+++ b/src/Icons/tests/Unit/Registry/IconifyOnDemandRegistryTest.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\Icons\Tests\Unit\Registry;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Cache\Adapter\NullAdapter;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\JsonMockResponse;
+use Symfony\UX\Icons\Icon;
+use Symfony\UX\Icons\Iconify;
+use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry;
+
+/**
+ * @author Simon André <smn.andre@gmail.com>
+ */
+final class IconifyOnDemandRegistryTest extends TestCase
+{
+    public function testWithIconSetAlias(): void
+    {
+        $client = new MockHttpClient([
+            new JsonMockResponse(['lucide' => []]),
+            new JsonMockResponse([
+                'icons' => [
+                    'circle' => [
+                        'body' => '<path aria-label="lucide:circle"/>',
+                        'height' => 24,
+                    ],
+                ],
+            ]),
+        ]);
+
+        $registry = new IconifyOnDemandRegistry(
+            new Iconify(new NullAdapter(), 'https://example.com', $client),
+            ['bi' => 'lucide'],
+        );
+
+        $icon = $registry->get('bi:circle');
+        $this->assertInstanceOf(Icon::class, $icon);
+        $this->assertSame('<path aria-label="lucide:circle"/>', $icon->getInnerSvg());
+    }
+}
diff --git a/src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.php b/src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.php
index 4cef5927..9404b498 100644
--- a/src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.php
+++ b/src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.php
@@ -82,6 +82,68 @@ final class LocalSvgIconRegistryTest extends TestCase
         yield ['invalid4'];
     }
 
+    /**
+     * @dataProvider provideIconSetPathsCases
+     */
+    public function testIconSetPaths(string $name, array $iconSetPaths, ?string $expectedContent): void
+    {
+        $registry = new LocalSvgIconRegistry(
+            iconDir: __DIR__.'/../../Fixtures/icons',
+            iconSetPaths: $iconSetPaths,
+        );
+
+        if (null === $expectedContent) {
+            $this->expectException(\RuntimeException::class);
+            $this->expectExceptionMessageMatches(\sprintf('/The icon "%s" \(.+\) does not exist./', $name));
+            $registry->get($name);
+        } else {
+            $icon = $registry->get($name);
+            $this->assertInstanceOf(Icon::class, $icon);
+            $this->assertSame($expectedContent, $icon->getInnerSvg());
+        }
+    }
+
+    public static function provideIconSetPathsCases(): iterable
+    {
+        yield 'no_iconset_path' => [
+            'icon',
+            [],
+            null,
+        ];
+        yield 'iconset_path' => [
+            'ux:icon',
+            ['ux' => __DIR__.'/../../Fixtures/images'],
+            '<circle aria-label="images/" r="1"></circle>',
+        ];
+        yield 'partial_iconset_path' => [
+            'a:b:icon',
+            [
+                'a' => __DIR__.'/../../Fixtures/images/a/',
+            ],
+            '<circle aria-label="images/a/b/" r="1"></circle>',
+        ];
+        yield 'root_namespace_fallback_on_local' => [
+            'a:icon',
+            [],
+            '<circle aria-label="icons/a/" r="1"></circle>',
+        ];
+        yield 'deep_namespace_refused' => [
+            'a:b:icon',
+            [
+                'a:b' => __DIR__.'/../../Fixtures/images/a/b',
+            ],
+            null,
+        ];
+        yield 'root_namespace_over_nested' => [
+            'a:b:icon',
+            [
+                'a' => __DIR__.'/../../Fixtures/images/a',
+                'a:b' => __DIR__.'/../../Fixtures/images/ab',
+            ],
+            '<circle aria-label="images/a/b/" r="1"></circle>',
+        ];
+    }
+
     private function registry(): LocalSvgIconRegistry
     {
         return new LocalSvgIconRegistry(__DIR__.'/../../Fixtures/svg');
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
deleted file mode 100644
index 7ee33360..00000000
--- a/src/Map/tests/Kernel/TwigComponentKernel.php
+++ /dev/null
@@ -1,63 +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\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
deleted file mode 100644
index a19e2d79..00000000
--- a/src/Map/tests/MapFactoryTest.php
+++ /dev/null
@@ -1,102 +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\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
deleted file mode 100644
index 031fd929..00000000
--- a/src/Map/tests/Twig/MapComponentTest.php
+++ /dev/null
@@ -1,50 +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\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
deleted file mode 100644
index e78e0ec3..00000000
--- a/src/Map/tests/Twig/MapExtensionTest.php
+++ /dev/null
@@ -1,113 +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\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]);
     }
 
     /**