Repository: symfony/ux
Pull request #2678 (2025-04-05 17:09:24)

[Icons] improve DX when `symfony/http-client` is not installed
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Docs?         | yes
| Issues        | Alternative to #2667
| License       | MIT

- throw exception if http-client is not available
- always enable iconify, iconify commands and on-demand registry
- display "potential" missing icons to `ux:icons:warm-cache` (addresses #2668)

Exception when rendering a template w/o http-client:
<img width="1041" alt="Screenshot 2025-04-05 at 1 13 18 PM" src="https://github.com/user-attachments/assets/c243c898-a4e8-4614-943f-bb6f138e07a4" />

Exception when running ux:icon:* command w/o http-client:
<img width="961" alt="Screenshot 2025-04-05 at 1 14 50 PM" src="https://github.com/user-attachments/assets/f584a365-5a45-4c85-acc3-251cc8d6e8b5" />

Output of `ux:icons:warm -v` when there are "potential" invalid icons:
<img width="631" alt="Screenshot 2025-04-05 at 1 38 06 PM" src="https://github.com/user-attachments/assets/a430608d-44ed-4998-9207-e9530f1019d5" />

**TODO**
- [x] update changelog
- [x] #2668?




Patch:
diff --git a/.github/workflows/release-on-npm.yaml b/.github/workflows/release-on-npm.yaml
index f2913b6d..2210a699 100644
--- a/.github/workflows/release-on-npm.yaml
+++ b/.github/workflows/release-on-npm.yaml
@@ -32,7 +32,7 @@ jobs:
           - run: yarn --immutable
 
           - name: Update version of JS packages
-            run: yarn workspaces foreach -pA exec "npm version ${{ env.VERSION }} --no-git-tag-version --no-workspaces-update"
+            run: yarn workspaces foreach -A version --immediate "${{ env.VERSION }}"
 
           - name: Commit changes
             run: |
diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md
index 48a8ba77..52b4fb26 100644
--- a/src/Icons/CHANGELOG.md
+++ b/src/Icons/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## 2.25.0
+
+-   Improve DX when `symfony/http-client` is not installed.
+
 ## 2.24.0
 
 -    Add `xmlns` attribute to icons downloaded with Iconify, to correctly render icons browser as an external file, in SVG editors, and in files explorers or text editors previews.
diff --git a/src/Icons/config/iconify.php b/src/Icons/config/iconify.php
deleted file mode 100644
index 52aa705a..00000000
--- a/src/Icons/config/iconify.php
+++ /dev/null
@@ -1,56 +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\Icons\Command\ImportIconCommand;
-use Symfony\UX\Icons\Command\LockIconsCommand;
-use Symfony\UX\Icons\Command\SearchIconCommand;
-use Symfony\UX\Icons\Iconify;
-use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry;
-
-return static function (ContainerConfigurator $container): void {
-    $container->services()
-        ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class)
-            ->args([
-                service('.ux_icons.iconify'),
-            ])
-            ->tag('ux_icons.registry')
-
-        ->set('.ux_icons.iconify', Iconify::class)
-            ->args([
-                service('.ux_icons.cache'),
-                abstract_arg('endpoint'),
-                service('http_client')->nullOnInvalid(),
-            ])
-
-        ->set('.ux_icons.command.import', ImportIconCommand::class)
-            ->args([
-                service('.ux_icons.iconify'),
-                service('.ux_icons.local_svg_icon_registry'),
-            ])
-            ->tag('console.command')
-
-        ->set('.ux_icons.command.lock', LockIconsCommand::class)
-            ->args([
-                service('.ux_icons.iconify'),
-                service('.ux_icons.local_svg_icon_registry'),
-                service('.ux_icons.icon_finder'),
-            ])
-            ->tag('console.command')
-
-        ->set('.ux_icons.command.search', SearchIconCommand::class)
-            ->args([
-                service('.ux_icons.iconify'),
-            ])
-            ->tag('console.command')
-    ;
-};
diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php
index 537c0cbb..4b29a381 100644
--- a/src/Icons/config/services.php
+++ b/src/Icons/config/services.php
@@ -11,12 +11,17 @@
 
 namespace Symfony\Component\DependencyInjection\Loader\Configurator;
 
+use Symfony\UX\Icons\Command\ImportIconCommand;
+use Symfony\UX\Icons\Command\LockIconsCommand;
+use Symfony\UX\Icons\Command\SearchIconCommand;
 use Symfony\UX\Icons\Command\WarmCacheCommand;
 use Symfony\UX\Icons\IconCacheWarmer;
+use Symfony\UX\Icons\Iconify;
 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\IconifyOnDemandRegistry;
 use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
 use Symfony\UX\Icons\Twig\IconFinder;
 use Symfony\UX\Icons\Twig\UXIconExtension;
@@ -86,5 +91,39 @@ return static function (ContainerConfigurator $container): void {
                 service('.ux_icons.cache_warmer'),
             ])
             ->tag('console.command')
+
+        ->set('.ux_icons.iconify', Iconify::class)
+            ->args([
+                service('.ux_icons.cache'),
+                abstract_arg('endpoint'),
+                service('http_client')->nullOnInvalid(),
+            ])
+
+        ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class)
+            ->args([
+                service('.ux_icons.iconify'),
+            ])
+            ->tag('ux_icons.registry', ['priority' => -10])
+
+        ->set('.ux_icons.command.import', ImportIconCommand::class)
+            ->args([
+                service('.ux_icons.iconify'),
+                service('.ux_icons.local_svg_icon_registry'),
+            ])
+            ->tag('console.command')
+
+        ->set('.ux_icons.command.lock', LockIconsCommand::class)
+            ->args([
+                service('.ux_icons.iconify'),
+                service('.ux_icons.local_svg_icon_registry'),
+                service('.ux_icons.icon_finder'),
+            ])
+            ->tag('console.command')
+
+        ->set('.ux_icons.command.search', SearchIconCommand::class)
+            ->args([
+                service('.ux_icons.iconify'),
+            ])
+            ->tag('console.command')
     ;
 };
diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst
index 5700e12a..607e0160 100644
--- a/src/Icons/doc/index.rst
+++ b/src/Icons/doc/index.rst
@@ -286,6 +286,18 @@ the report to overwrite existing icons by using the ``--force`` option:
 
     $ php bin/console ux:icons:lock --force
 
+.. caution::
+
+    The process to find icons to lock in your Twig templates is imperfect. It
+    looks for any string that matches the pattern ``something:something`` so
+    it's probable there will be false positives. This command should not be used
+    to audit the icons in your templates in an automated way. Add ``-v`` see
+    *potential* invalid icons:
+
+    .. code-block:: terminal
+
+        $ php bin/console ux:icons:lock -v
+
 Rendering Icons
 ---------------
 
@@ -472,6 +484,18 @@ In production, you can pre-warm the cache by running the following command:
 This command looks in all your Twig templates for ``ux_icon()`` calls and
 ``<twig:ux:icon>`` tags and caches the icons it finds.
 
+.. caution::
+
+    The process to find icons to cache in your Twig templates is imperfect. It
+    looks for any string that matches the pattern ``something:something`` so
+    it's probable there will be false positives. This command should not be used
+    to audit the icons in your templates in an automated way. Add ``-v`` see
+    *potential* invalid icons:
+
+    .. code-block:: terminal
+
+        $ php bin/console ux:icons:warm-cache -v
+
 .. caution::
 
     Icons that have a name built dynamically will not be cached. It's advised to
diff --git a/src/Icons/src/Command/WarmCacheCommand.php b/src/Icons/src/Command/WarmCacheCommand.php
index 1451a7cf..8263183c 100644
--- a/src/Icons/src/Command/WarmCacheCommand.php
+++ b/src/Icons/src/Command/WarmCacheCommand.php
@@ -45,6 +45,11 @@ final class WarmCacheCommand extends Command
                     $io->writeln(\sprintf(' Warmed icon <comment>%s</comment>.', $name));
                 }
             },
+            onFailure: function (string $name, \Exception $e) use ($io) {
+                if ($io->isVerbose()) {
+                    $io->writeln(\sprintf(' Failed to warm (potential) icon <error>%s</error>.', $name));
+                }
+            }
         );
 
         $io->success('Icon cache warmed.');
diff --git a/src/Icons/src/DependencyInjection/UXIconsExtension.php b/src/Icons/src/DependencyInjection/UXIconsExtension.php
index 85672aeb..b9324091 100644
--- a/src/Icons/src/DependencyInjection/UXIconsExtension.php
+++ b/src/Icons/src/DependencyInjection/UXIconsExtension.php
@@ -18,7 +18,6 @@ use Symfony\Component\Config\FileLocator;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
 use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
 use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
-use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\UX\Icons\Iconify;
 
 /**
@@ -87,7 +86,7 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
                 ->end()
                 ->arrayNode('iconify')
                     ->info('Configuration for the remote icon service.')
-                    ->{interface_exists(HttpClientInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}()
+                    ->canBeDisabled()
                     ->children()
                         ->booleanNode('on_demand')
                             ->info('Whether to download icons "on demand".')
@@ -164,26 +163,24 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
             ->setArgument(1, $mergedConfig['ignore_not_found'])
         ;
 
-        if ($mergedConfig['iconify']['enabled']) {
-            $loader->load('iconify.php');
+        $container->getDefinition('.ux_icons.iconify')
+            ->setArgument(1, $mergedConfig['iconify']['endpoint']);
 
-            $container->getDefinition('.ux_icons.iconify')
-                ->setArgument(1, $mergedConfig['iconify']['endpoint']);
+        $container->getDefinition('.ux_icons.iconify_on_demand_registry')
+            ->setArgument(1, $iconSetAliases);
 
-            $container->getDefinition('.ux_icons.iconify_on_demand_registry')
-                ->setArgument(1, $iconSetAliases);
+        $container->getDefinition('.ux_icons.command.lock')
+            ->setArgument(3, $mergedConfig['aliases'])
+            ->setArgument(4, $iconSetAliases);
 
-            $container->getDefinition('.ux_icons.command.lock')
-                ->setArgument(3, $mergedConfig['aliases'])
-                ->setArgument(4, $iconSetAliases);
-
-            if (!$mergedConfig['iconify']['on_demand']) {
-                $container->removeDefinition('.ux_icons.iconify_on_demand_registry');
-            }
+        if (!$mergedConfig['iconify']['on_demand'] || !$mergedConfig['iconify']['enabled']) {
+            $container->removeDefinition('.ux_icons.iconify_on_demand_registry');
         }
 
         if (!$container->getParameter('kernel.debug')) {
             $container->removeDefinition('.ux_icons.command.import');
+            $container->removeDefinition('.ux_icons.command.search');
+            $container->removeDefinition('.ux_icons.command.lock');
         }
     }
 }
diff --git a/src/Icons/src/Exception/HttpClientNotInstalledException.php b/src/Icons/src/Exception/HttpClientNotInstalledException.php
new file mode 100644
index 00000000..eb624ade
--- /dev/null
+++ b/src/Icons/src/Exception/HttpClientNotInstalledException.php
@@ -0,0 +1,21 @@
+<?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\Exception;
+
+/**
+ * @author Kevin Bond <kevinbond@gmail.com>
+ *
+ * @internal
+ */
+final class HttpClientNotInstalledException extends \LogicException
+{
+}
diff --git a/src/Icons/src/IconCacheWarmer.php b/src/Icons/src/IconCacheWarmer.php
index 04215ae7..2a63ad84 100644
--- a/src/Icons/src/IconCacheWarmer.php
+++ b/src/Icons/src/IconCacheWarmer.php
@@ -27,8 +27,8 @@ final class IconCacheWarmer
     }
 
     /**
-     * @param callable(string,Icon):void|null $onSuccess
-     * @param callable(string):void|null      $onFailure
+     * @param callable(string,Icon):void|null       $onSuccess
+     * @param callable(string,\Exception):void|null $onFailure
      */
     public function warm(?callable $onSuccess = null, ?callable $onFailure = null): void
     {
@@ -40,8 +40,8 @@ final class IconCacheWarmer
                 $icon = $this->registry->get($name, refresh: true);
 
                 $onSuccess($name, $icon);
-            } catch (IconNotFoundException) {
-                $onFailure($name);
+            } catch (IconNotFoundException $e) {
+                $onFailure($name, $e);
             }
         }
     }
diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php
index 55c5051d..7f7db1ea 100644
--- a/src/Icons/src/Iconify.php
+++ b/src/Icons/src/Iconify.php
@@ -16,6 +16,7 @@ use Symfony\Component\HttpClient\HttpClient;
 use Symfony\Component\HttpClient\ScopingHttpClient;
 use Symfony\Contracts\Cache\CacheInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\UX\Icons\Exception\HttpClientNotInstalledException;
 use Symfony\UX\Icons\Exception\IconNotFoundException;
 
 /**
@@ -39,15 +40,10 @@ final class Iconify
 
     public function __construct(
         private CacheInterface $cache,
-        string $endpoint = self::API_ENDPOINT,
-        ?HttpClientInterface $http = null,
+        private string $endpoint = self::API_ENDPOINT,
+        private ?HttpClientInterface $httpClient = null,
         ?int $maxIconsQueryLength = null,
     ) {
-        if (!class_exists(HttpClient::class)) {
-            throw new \LogicException('You must install "symfony/http-client" to use Iconify. Try running "composer require symfony/http-client".');
-        }
-
-        $this->http = ScopingHttpClient::forBaseUri($http ?? HttpClient::create(), $endpoint);
         $this->maxIconsQueryLength = min(self::MAX_ICONS_QUERY_LENGTH, $maxIconsQueryLength ?? self::MAX_ICONS_QUERY_LENGTH);
     }
 
@@ -62,7 +58,7 @@ final class Iconify
             throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
         }
 
-        $response = $this->http->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name));
+        $response = $this->http()->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name));
 
         if (200 !== $response->getStatusCode()) {
             throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
@@ -112,7 +108,7 @@ final class Iconify
             throw new \InvalidArgumentException('The query string is too long.');
         }
 
-        $response = $this->http->request('GET', \sprintf('/%s.json', $prefix), [
+        $response = $this->http()->request('GET', \sprintf('/%s.json', $prefix), [
             'headers' => [
                 'Accept' => 'application/json',
             ],
@@ -158,7 +154,7 @@ final class Iconify
 
     public function searchIcons(string $prefix, string $query)
     {
-        $response = $this->http->request('GET', '/search', [
+        $response = $this->http()->request('GET', '/search', [
             'query' => [
                 'query' => $query,
                 'prefix' => $prefix,
@@ -205,9 +201,22 @@ final class Iconify
     private function sets(): \ArrayObject
     {
         return $this->sets ??= $this->cache->get('iconify-sets', function () {
-            $response = $this->http->request('GET', '/collections');
+            $response = $this->http()->request('GET', '/collections');
 
             return new \ArrayObject($response->toArray());
         });
     }
+
+    private function http(): HttpClientInterface
+    {
+        if (isset($this->http)) {
+            return $this->http;
+        }
+
+        if (!class_exists(HttpClient::class)) {
+            throw new HttpClientNotInstalledException('You must install "symfony/http-client" to use icons from ux.symfony.com/icons. Try running "composer require symfony/http-client".');
+        }
+
+        return $this->http = ScopingHttpClient::forBaseUri($this->httpClient ?? HttpClient::create(), $this->endpoint);
+    }
 }
diff --git a/src/Icons/src/Registry/ChainIconRegistry.php b/src/Icons/src/Registry/ChainIconRegistry.php
index c6e882cf..d476d25c 100644
--- a/src/Icons/src/Registry/ChainIconRegistry.php
+++ b/src/Icons/src/Registry/ChainIconRegistry.php
@@ -34,10 +34,16 @@ final class ChainIconRegistry implements IconRegistryInterface
         foreach ($this->registries as $registry) {
             try {
                 return $registry->get($name);
-            } catch (IconNotFoundException) {
+            } catch (IconNotFoundException $e) {
             }
         }
 
-        throw new IconNotFoundException(\sprintf('Icon "%s" not found.', $name));
+        $message = \sprintf('Icon "%s" not found.', $name);
+
+        if (isset($e)) {
+            $message .= " {$e->getMessage()}";
+        }
+
+        throw new IconNotFoundException($message, previous: $e ?? null);
     }
 }
diff --git a/src/Icons/src/Registry/IconifyOnDemandRegistry.php b/src/Icons/src/Registry/IconifyOnDemandRegistry.php
index 5931854c..60c1b591 100644
--- a/src/Icons/src/Registry/IconifyOnDemandRegistry.php
+++ b/src/Icons/src/Registry/IconifyOnDemandRegistry.php
@@ -11,6 +11,7 @@
 
 namespace Symfony\UX\Icons\Registry;
 
+use Symfony\UX\Icons\Exception\HttpClientNotInstalledException;
 use Symfony\UX\Icons\Exception\IconNotFoundException;
 use Symfony\UX\Icons\Icon;
 use Symfony\UX\Icons\Iconify;
@@ -36,6 +37,10 @@ final class IconifyOnDemandRegistry implements IconRegistryInterface
         }
         [$prefix, $icon] = $parts;
 
-        return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon);
+        try {
+            return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon);
+        } catch (HttpClientNotInstalledException $e) {
+            throw new IconNotFoundException($e->getMessage());
+        }
     }
 }


Test patch:
diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php
index cec6172c..8516568b 100644
--- a/src/Icons/tests/Unit/IconifyTest.php
+++ b/src/Icons/tests/Unit/IconifyTest.php
@@ -29,7 +29,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -55,7 +55,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -96,7 +96,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [
                         'height' => 17,
@@ -124,7 +124,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -149,7 +149,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -168,7 +168,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -199,7 +199,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'mdi' => [],
                 ]),
@@ -239,7 +239,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -256,7 +256,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            http: new MockHttpClient([
+            httpClient: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),