Repository: symfony/ux
Pull request #2211 (2024-09-26 11:05:46)

[TwigComponent] Cache component properties metadata
Currently if we have 50 times the same component in a page, 50 times we use reflection to analyse component class properties and methods.

This PR centralize this task in a dedicated (internal) service and add a cachewarmer to pre-compute metadata during app build.

Significant performance gains here too 
(i won't do charts for every PR but be sure i'm gonna make some before/after once i'm "done" with all this.... in Vienna 👼 )

onEXHovia: As an alternative, or even as a follow-up, I think it makes sense to replace/improve class `ComponentMetadata`. We could get all possible meta information PostMount/PreMount/ExposeInTemplate hooks etc, not just about properties. 

Structure may look like this:
`ComponentMetadata` - store metadata
`ComponentMetadataFactory` - provider metadata with methods getMetadataFor/hasMetadataFor. Factory methods return `ComponentMetadata` instance.
`ComponentCacheWarmer` - cache warmer call factory `ComponentMetadataFactory`
smnandre: Hey @onEXHovia ! You read my mind 

This is indeed my next move, and i started to extract the props metadata from template too :) 
smnandre: > Are we agree, this is happening only in prod ?

@WebMamba nothing "prod-related" 

For all the environments, during a request, there is no need to compute metadata twice, so the values are "kept".

Also, if you use `cache:warm` all the components metadata are cached in the ssytem cache.

What's maybe missing here is something to invalidate the cache locally if you change a file ? 

Do you have encountered any trouble ?
Kocal: <img width="410" alt="image" src="https://github.com/user-attachments/assets/b1ea99f9-fc14-41f1-bfb5-9190a7e823aa">

A small rebase before merging? :) 🙏🏻 

Patch:
diff --git a/src/Icons/src/Command/ImportIconCommand.php b/src/Icons/src/Command/ImportIconCommand.php
index f1c8a08b..b287231d 100644
--- a/src/Icons/src/Command/ImportIconCommand.php
+++ b/src/Icons/src/Command/ImportIconCommand.php
@@ -68,7 +68,7 @@ final class ImportIconCommand extends Command
             $io->comment(\sprintf('Importing %s...', $fullName));
 
             try {
-                $iconSvg = $this->iconify->fetchIcon($prefix, $name)->toHtml();
+                $svg = $this->iconify->fetchSvg($prefix, $name);
             } catch (IconNotFoundException $e) {
                 $io->error($e->getMessage());
                 $result = Command::FAILURE;
@@ -79,7 +79,7 @@ final class ImportIconCommand extends Command
             $cursor = new Cursor($output);
             $cursor->moveUp(2);
 
-            $this->registry->add(\sprintf('%s/%s', $prefix, $name), $iconSvg);
+            $this->registry->add(\sprintf('%s/%s', $prefix, $name), $svg);
 
             $license = $this->iconify->metadataFor($prefix)['license'];
 
diff --git a/src/Icons/src/Command/LockIconsCommand.php b/src/Icons/src/Command/LockIconsCommand.php
index c3b3d2fa..e452a7d7 100644
--- a/src/Icons/src/Command/LockIconsCommand.php
+++ b/src/Icons/src/Command/LockIconsCommand.php
@@ -81,7 +81,7 @@ final class LockIconsCommand extends Command
             }
 
             try {
-                $iconSvg = $this->iconify->fetchIcon($prefix, $name)->toHtml();
+                $svg = $this->iconify->fetchSvg($prefix, $name);
             } catch (IconNotFoundException) {
                 // icon not found on iconify
                 if ($io->isVerbose()) {
@@ -90,7 +90,7 @@ final class LockIconsCommand extends Command
                 continue;
             }
 
-            $this->registry->add(\sprintf('%s/%s', $prefix, $name), $iconSvg);
+            $this->registry->add(\sprintf('%s/%s', $prefix, $name), $svg);
 
             $license = $this->iconify->metadataFor($prefix)['license'];
             ++$count;
diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php
index 757dacb2..c948e24d 100644
--- a/src/Icons/src/Iconify.php
+++ b/src/Icons/src/Iconify.php
@@ -85,6 +85,25 @@ final class Iconify
         ]);
     }
 
+    public function fetchSvg(string $prefix, string $name): string
+    {
+        if (!isset($this->sets()[$prefix])) {
+            throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
+        }
+
+        $response = $this->http->request('GET', \sprintf('/%s/%s.svg', $prefix, $name));
+
+        if (200 !== $response->getStatusCode()) {
+            throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
+        }
+
+        if (!str_starts_with($svg = $response->getContent(), '<svg')) {
+            throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
+        }
+
+        return $svg;
+    }
+
     public function hasIconSet(string $prefix): bool
     {
         return isset($this->sets()[$prefix]);
diff --git a/src/TwigComponent/config/cache.php b/src/TwigComponent/config/cache.php
new file mode 100644
index 00000000..e8f62ee2
--- /dev/null
+++ b/src/TwigComponent/config/cache.php
@@ -0,0 +1,23 @@
+<?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\DependencyInjection\Loader\Configurator;
+
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+
+return static function (ContainerConfigurator $container): void {
+    $container->services()
+        ->set('cache.ux.twig_component')
+            ->parent('cache.system')
+            ->private()
+            ->tag('cache.pool')
+    ;
+};
diff --git a/src/TwigComponent/src/CacheWarmer/TwigComponentCacheWarmer.php b/src/TwigComponent/src/CacheWarmer/TwigComponentCacheWarmer.php
new file mode 100644
index 00000000..fad5c74c
--- /dev/null
+++ b/src/TwigComponent/src/CacheWarmer/TwigComponentCacheWarmer.php
@@ -0,0 +1,55 @@
+<?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\CacheWarmer;
+
+use Psr\Container\ContainerInterface;
+use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
+use Symfony\Contracts\Service\ServiceSubscriberInterface;
+use Symfony\UX\TwigComponent\ComponentProperties;
+
+/**
+ * Warm the TwigComponent metadata caches.
+ *
+ * @author Simon André <smn.andre@gmail.com>
+ *
+ * @internal
+ */
+final class TwigComponentCacheWarmer implements CacheWarmerInterface, ServiceSubscriberInterface
+{
+    /**
+     * As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected.
+     */
+    public function __construct(
+        private readonly ContainerInterface $container,
+    ) {
+    }
+
+    public static function getSubscribedServices(): array
+    {
+        return [
+            'ux.twig_component.component_properties' => ComponentProperties::class,
+        ];
+    }
+
+    public function warmUp(string $cacheDir, ?string $buildDir = null): array
+    {
+        $properties = $this->container->get('ux.twig_component.component_properties');
+        $properties->warmup();
+
+        return [];
+    }
+
+    public function isOptional(): bool
+    {
+        return true;
+    }
+}
diff --git a/src/TwigComponent/src/ComponentProperties.php b/src/TwigComponent/src/ComponentProperties.php
new file mode 100644
index 00000000..abb8c733
--- /dev/null
+++ b/src/TwigComponent/src/ComponentProperties.php
@@ -0,0 +1,148 @@
+<?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;
+
+use Symfony\Component\Cache\Adapter\AdapterInterface;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
+
+/**
+ * @author Simon André <smn.andre@gmail.com>
+ *
+ * @internal
+ */
+final class ComponentProperties
+{
+    private const CACHE_KEY = 'ux.twig_component.component_properties';
+
+    /**
+     * @var array<class-string, array{
+     *     properties: array<class-string, array{string, array{string, string, bool}, bool}>,
+     *     methods: array<class-string, array{string, array{string, bool}}>,
+     *  }|null>
+     */
+    private array $classMetadata;
+
+    public function __construct(
+        private readonly PropertyAccessorInterface $propertyAccessor,
+        ?array $classMetadata = [],
+        private readonly ?AdapterInterface $cache = null,
+    ) {
+        $cacheItem = $this->cache?->getItem(self::CACHE_KEY);
+
+        $this->classMetadata = $cacheItem?->isHit() ? [...$cacheItem->get(), ...$classMetadata] : $classMetadata;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getProperties(object $component, bool $publicProps = false): array
+    {
+        return iterator_to_array($this->extractProperties($component, $publicProps));
+    }
+
+    public function warmup(): void
+    {
+        if (!$this->cache) {
+            return;
+        }
+
+        foreach ($this->classMetadata as $class => $metadata) {
+            if (null === $metadata) {
+                $this->classMetadata[$class] = $this->loadClassMetadata($class);
+            }
+        }
+
+        $this->cache->save($this->cache->getItem(self::CACHE_KEY)->set($this->classMetadata));
+    }
+
+    /**
+     * @return \Generator<string, mixed>
+     */
+    private function extractProperties(object $component, bool $publicProps): \Generator
+    {
+        yield from $publicProps ? get_object_vars($component) : [];
+
+        $metadata = $this->classMetadata[$component::class] ??= $this->loadClassMetadata($component::class);
+
+        foreach ($metadata['properties'] as $propertyName => $property) {
+            $value = $property['getter'] ? $component->{$property['getter']}() : $this->propertyAccessor->getValue($component, $propertyName);
+            if ($property['destruct'] ?? false) {
+                yield from $value;
+            } else {
+                yield $property['name'] => $value;
+            }
+        }
+
+        foreach ($metadata['methods'] as $methodName => $method) {
+            if ($method['destruct'] ?? false) {
+                yield from $component->{$methodName}();
+            } else {
+                yield $method['name'] => $component->{$methodName}();
+            }
+        }
+    }
+
+    /**
+     * @param class-string $class
+     *
+     * @return array{
+     *   properties: array<string, array{
+     *     name?: string,
+     *     getter?: string,
+     *     destruct?: bool
+     *   }>,
+     *   methods: array<string, array{
+     *     name?: string,
+     *     destruct?: bool
+     *   }>,
+     * }
+     */
+    private function loadClassMetadata(string $class): array
+    {
+        $refClass = new \ReflectionClass($class);
+
+        $properties = [];
+        foreach ($refClass->getProperties() as $property) {
+            if (!$attributes = $property->getAttributes(ExposeInTemplate::class)) {
+                continue;
+            }
+            $attribute = $attributes[0]->newInstance();
+            $properties[$property->name] = [
+                'name' => $attribute->name ?? $property->name,
+                'getter' => $attribute->getter ? rtrim($attribute->getter, '()') : null,
+            ];
+            if ($attribute->destruct) {
+                unset($properties[$property->name]['name']);
+                $properties[$property->name]['destruct'] = true;
+            }
+        }
+
+        $methods = [];
+        foreach ($refClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+            if (!$attributes = $method->getAttributes(ExposeInTemplate::class)) {
+                continue;
+            }
+            if ($method->getNumberOfRequiredParameters()) {
+                throw new \LogicException(\sprintf('Cannot use "%s" on methods with required parameters (%s::%s).', ExposeInTemplate::class, $class, $method->name));
+            }
+            $attribute = $attributes[0]->newInstance();
+            $name = $attribute->name ?? (str_starts_with($method->name, 'get') ? lcfirst(substr($method->name, 3)) : $method->name);
+            $methods[$method->name] = $attribute->destruct ? ['destruct' => true] : ['name' => $name];
+        }
+
+        return [
+            'properties' => $properties,
+            'methods' => $methods,
+        ];
+    }
+}
diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php
index 3273ba51..fb6a01ff 100644
--- a/src/TwigComponent/src/ComponentRenderer.php
+++ b/src/TwigComponent/src/ComponentRenderer.php
@@ -11,9 +11,7 @@
 
 namespace Symfony\UX\TwigComponent;
 
-use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
-use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
 use Symfony\UX\TwigComponent\Event\PostRenderEvent;
 use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
 use Symfony\UX\TwigComponent\Event\PreRenderEvent;
@@ -30,7 +28,7 @@ final class ComponentRenderer implements ComponentRendererInterface
         private Environment $twig,
         private EventDispatcherInterface $dispatcher,
         private ComponentFactory $factory,
-        private PropertyAccessorInterface $propertyAccessor,
+        private ComponentProperties $componentProperties,
         private ComponentStack $componentStack,
     ) {
     }
@@ -107,9 +105,11 @@ final class ComponentRenderer implements ComponentRendererInterface
     {
         $component = $mounted->getComponent();
         $metadata = $this->factory->metadataFor($mounted->getName());
-        $isAnonymous = $mounted->getComponent() instanceof AnonymousComponent;
 
-        $classProps = $isAnonymous ? [] : iterator_to_array($this->exposedVariables($component, $metadata->isPublicPropsExposed()));
+        $classProps = [];
+        if (!$metadata->isAnonymous()) {
+            $classProps = $this->componentProperties->getProperties($component, $metadata->isPublicPropsExposed());
+        }
 
         // expose public properties and properties marked with ExposeInTemplate attribute
         $props = [...$mounted->getInputProps(), ...$classProps];
@@ -137,57 +137,4 @@ final class ComponentRenderer implements ComponentRendererInterface
 
         return $event;
     }
-
-    private function exposedVariables(object $component, bool $exposePublicProps): \Iterator
-    {
-        if ($exposePublicProps) {
-            yield from get_object_vars($component);
-        }
-
-        $class = new \ReflectionClass($component);
-
-        foreach ($class->getProperties() as $property) {
-            if (!$attribute = $property->getAttributes(ExposeInTemplate::class)[0] ?? null) {
-                continue;
-            }
-
-            $attribute = $attribute->newInstance();
-
-            /** @var ExposeInTemplate $attribute */
-            $value = $attribute->getter ? $component->{rtrim($attribute->getter, '()')}() : $this->propertyAccessor->getValue($component, $property->name);
-
-            if ($attribute->destruct) {
-                foreach ($value as $key => $destructedValue) {
-                    yield $key => $destructedValue;
-                }
-            }
-
-            yield $attribute->name ?? $property->name => $value;
-        }
-
-        foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
-            if (!$attribute = $method->getAttributes(ExposeInTemplate::class)[0] ?? null) {
-                continue;
-            }
-
-            $attribute = $attribute->newInstance();
-
-            /** @var ExposeInTemplate $attribute */
-            $name = $attribute->name ?? (str_starts_with($method->name, 'get') ? lcfirst(substr($method->name, 3)) : $method->name);
-
-            if ($method->getNumberOfRequiredParameters()) {
-                throw new \LogicException(\sprintf('Cannot use "%s" on methods with required parameters (%s::%s).', ExposeInTemplate::class, $component::class, $method->name));
-            }
-
-            if ($attribute->destruct) {
-                foreach ($component->{$method->name}() as $prop => $value) {
-                    yield $prop => $value;
-                }
-
-                return;
-            }
-
-            yield $name => $component->{$method->name}();
-        }
-    }
 }
diff --git a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php
index 2a4ec1b0..faf86afe 100644
--- a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php
+++ b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php
@@ -80,6 +80,9 @@ final class TwigComponentPass implements CompilerPassInterface
         $factoryDefinition->setArgument(4, $componentConfig);
         $factoryDefinition->setArgument(5, $componentClassMap);
 
+        $componentPropertiesDefinition = $container->findDefinition('ux.twig_component.component_properties');
+        $componentPropertiesDefinition->setArgument(1, array_fill_keys(array_keys($componentClassMap), null));
+
         $debugCommandDefinition = $container->findDefinition('ux.twig_component.command.debug');
         $debugCommandDefinition->setArgument(3, $componentClassMap);
     }
diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
index 72cd6f26..0ee72306 100644
--- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
+++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
@@ -21,14 +21,17 @@ use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
 use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
 use Symfony\Component\DependencyInjection\ChildDefinition;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\DependencyInjection\Exception\LogicException;
 use Symfony\Component\DependencyInjection\Extension\Extension;
 use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
 use Symfony\Component\DependencyInjection\Parameter;
 use Symfony\Component\DependencyInjection\Reference;
 use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+use Symfony\UX\TwigComponent\CacheWarmer\TwigComponentCacheWarmer;
 use Symfony\UX\TwigComponent\Command\TwigComponentDebugCommand;
 use Symfony\UX\TwigComponent\ComponentFactory;
+use Symfony\UX\TwigComponent\ComponentProperties;
 use Symfony\UX\TwigComponent\ComponentRenderer;
 use Symfony\UX\TwigComponent\ComponentRendererInterface;
 use Symfony\UX\TwigComponent\ComponentStack;
@@ -84,21 +87,29 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
         $container->register('ux.twig_component.component_factory', ComponentFactory::class)
             ->setArguments([
                 new Reference('ux.twig_component.component_template_finder'),
-                class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)) : null,
+                new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
                 new Reference('property_accessor'),
                 new Reference('event_dispatcher'),
-                class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)) : [],
+                new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
             ])
         ;
 
         $container->register('ux.twig_component.component_stack', ComponentStack::class);
 
+        $container->register('ux.twig_component.component_properties', ComponentProperties::class)
+            ->setArguments([
+                new Reference('property_accessor'),
+                new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
+                new Reference('cache.ux.twig_component', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
+            ])
+        ;
+
         $container->register('ux.twig_component.component_renderer', ComponentRenderer::class)
             ->setArguments([
                 new Reference('twig'),
                 new Reference('event_dispatcher'),
                 new Reference('ux.twig_component.component_factory'),
-                new Reference('property_accessor'),
+                new Reference('ux.twig_component.component_properties'),
                 new Reference('ux.twig_component.component_stack'),
             ])
         ;
@@ -107,7 +118,7 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
             ->addTag('twig.extension')
         ;
 
-        $container->register('.ux.twig_component.twig.component_runtime', ComponentRuntime::class)
+        $container->register('ux.twig_component.twig.component_runtime', ComponentRuntime::class)
             ->setArguments([
                 new Reference('ux.twig_component.component_renderer'),
                 new ServiceLocatorArgument(new TaggedIteratorArgument('ux.twig_component.twig_renderer', indexAttribute: 'key', needsIndexes: true)),
@@ -126,7 +137,7 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
                 new Parameter('twig.default_path'),
                 new Reference('ux.twig_component.component_factory'),
                 new Reference('twig'),
-                class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)) : [],
+                new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
                 $config['anonymous_template_directory'],
             ])
             ->addTag('console.command')
@@ -138,6 +149,14 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
         if ($container->getParameter('kernel.debug') && $config['profiler']) {
             $loader->load('debug.php');
         }
+
+        $loader->load('cache.php');
+
+        $container->register('ux.twig_component.cache_warmer', TwigComponentCacheWarmer::class)
+            ->setArguments([new Reference(\Psr\Container\ContainerInterface::class)])
+            ->addTag('kernel.cache_warmer')
+            ->addTag('container.service_subscriber', ['id' => 'ux.twig_component.component_properties'])
+        ;
     }
 
     public function getConfigTreeBuilder(): TreeBuilder


Test patch:
diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php
index b9406836..a3aa50bf 100644
--- a/src/Icons/tests/Unit/IconifyTest.php
+++ b/src/Icons/tests/Unit/IconifyTest.php
@@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase;
 use Symfony\Component\Cache\Adapter\NullAdapter;
 use Symfony\Component\HttpClient\MockHttpClient;
 use Symfony\Component\HttpClient\Response\JsonMockResponse;
+use Symfony\Component\HttpClient\Response\MockResponse;
 use Symfony\UX\Icons\Exception\IconNotFoundException;
 use Symfony\UX\Icons\Iconify;
 
@@ -172,6 +173,37 @@ class IconifyTest extends TestCase
         $this->assertSame('Font Awesome Solid', $metadata['name']);
     }
 
+    public function testFetchSvg(): void
+    {
+        $client = new MockHttpClient([
+            new MockResponse(file_get_contents(__DIR__.'/../Fixtures/Iconify/collections.json'), [
+                'response_headers' => ['content-type' => 'application/json'],
+            ]),
+            new MockResponse(file_get_contents(__DIR__.'/../Fixtures/Iconify/icon.svg')),
+        ]);
+        $iconify = new Iconify(new NullAdapter(), 'https://localhost', $client);
+
+        $svg = $iconify->fetchSvg('fa6-regular', 'bar');
+
+        $this->assertIsString($svg);
+        $this->stringContains('-.224l.235-.468ZM6.013 2.06c-.649-.1', $svg);
+    }
+
+    public function testFetchSvgThrowIconNotFoundExceptionWhenStatusCodeNot200(): void
+    {
+        $client = new MockHttpClient([
+            new MockResponse(file_get_contents(__DIR__.'/../Fixtures/Iconify/collections.json'), [
+                'response_headers' => ['content-type' => 'application/json'],
+            ]),
+            new MockResponse('', ['http_code' => 404]),
+        ]);
+        $iconify = new Iconify(new NullAdapter(), 'https://localhost', $client);
+
+        $this->expectException(IconNotFoundException::class);
+
+        $iconify->fetchSvg('fa6-regular', 'bar');
+    }
+
     private function createHttpClient(mixed $data, int $code = 200): MockHttpClient
     {
         $mockResponse = new JsonMockResponse($data, ['http_code' => $code]);