diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md
index 06eabb9c7ee..c459142fca2 100644
--- a/src/Icons/CHANGELOG.md
+++ b/src/Icons/CHANGELOG.md
@@ -1,5 +1,8 @@
# CHANGELOG
+## 2.32
+- New console command `ux:icons:manage` to manage local icons.
+
## 2.30
- Ensure compatibility with PHP 8.5
diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php
old mode 100644
new mode 100755
index 4b29a381f7a..47e32a8c44a
--- a/src/Icons/config/services.php
+++ b/src/Icons/config/services.php
@@ -15,6 +15,7 @@
use Symfony\UX\Icons\Command\LockIconsCommand;
use Symfony\UX\Icons\Command\SearchIconCommand;
use Symfony\UX\Icons\Command\WarmCacheCommand;
+use Symfony\UX\Icons\Command\ManageIconsCommand;
use Symfony\UX\Icons\IconCacheWarmer;
use Symfony\UX\Icons\Iconify;
use Symfony\UX\Icons\IconRenderer;
@@ -119,6 +120,12 @@
service('.ux_icons.icon_finder'),
])
->tag('console.command')
+ ->set('.ux_icons.command.cleanup', ManageIconsCommand::class)
+ ->args([
+ service('.ux_icons.local_svg_icon_registry'),
+ service('.ux_icons.icon_finder'),
+ ])
+ ->tag('console.command')
->set('.ux_icons.command.search', SearchIconCommand::class)
->args([
diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst
index 8f7fc770e90..1029750c39a 100644
--- a/src/Icons/doc/index.rst
+++ b/src/Icons/doc/index.rst
@@ -443,6 +443,45 @@ Now, you can use the ``dots`` alias in your templates:
{# same as: #}
+Manage Icons
+------------
+
+The ``ux:icons:manage`` console command helps you list, group and remove local icons.
+It is especially useful for cleaning up unused icons and managing icon sets.
+
+Usages
+^^^^^^
+
+.. code-block:: terminal
+
+ # Lists all icons found in the local icon directory.Can be combined with `--table` or `--group`
+ $ php bin/console ux:icons:manage --list
+
+ # List all unused Icons in templates. Can be combined with `--table` or `--group`
+ $ php bin/console ux:icons:manage --list --unused
+
+ # Removes a specific icon by name (2 methods)
+ $ php bin/console ux:icons:manage --remove=flowbite:user-solid
+ $ php bin/console ux:icons:manage --remove user-profile
+
+ # Deletes all icons that are not detected as used in templates. (Use with caution)
+ $ php bin/console ux:icons:manage --remove --unused
+
+ # Deletes *all* local icons from the directory. (Use with extreme caution)
+ $ php bin/console ux:icons:manage --remove-all
+
+.. caution::
+
+ Dynamic icon usages like:
+
+ .. code-block:: twig
+
+ {{ ux_icon('flag-' ~ locale) }}
+
+ cannot be automatically detected by the command.
+ These may appear as *unused* even though they are in use.
+ Always review before removing unused icons.
+
Errors
------
diff --git a/src/Icons/src/Command/ManageIconsCommand.php b/src/Icons/src/Command/ManageIconsCommand.php
new file mode 100755
index 00000000000..3844b19f66b
--- /dev/null
+++ b/src/Icons/src/Command/ManageIconsCommand.php
@@ -0,0 +1,233 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Icons\Command;
+
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Finder\Finder;
+use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
+use Symfony\UX\Icons\Twig\IconFinderInterface;
+
+#[AsCommand(
+ name: 'ux:icons:manage',
+ description: 'List, group or remove icons from your local icon directory.',
+)]
+final class ManageIconsCommand extends Command
+{
+ public function __construct(
+ private readonly LocalSvgIconRegistry $localRegistry,
+ private readonly IconFinderInterface $iconFinder,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->setHelp(<<<'HELP'
+ The %command.name% command helps you manage locally stored icons.
+
+ Note:
+ Icons used dynamically, such as {{ ux_icon('flag-' ~ locale) }}, may appear as unused even if they are rendered at runtime.
+
+ Usage:
+ php %command.full_name% [options]
+
+ Options:
+ --list List all icons
+ --group Group icons by set (use with --list)
+ --unused Filter only unused icons (use with --list or --remove)
+ --table Show listing in table format (use with --list)
+ --remove[=ICON] Remove a specific icon by name, or use with --unused to delete all unused icons
+ --remove-all Delete all local icons from the directory (use with caution)
+
+ Examples:
+ php %command.full_name% --list
+ php %command.full_name% --list --group
+ php %command.full_name% --list --unused
+ php %command.full_name% --list --group --unused
+ php %command.full_name% --list --table
+ php %command.full_name% --remove bi:clock
+ php %command.full_name% --remove --unused
+ php %command.full_name% --remove-all
+ HELP
+ )
+ ->addOption('list', null, InputOption::VALUE_NONE, 'List icons.')
+ ->addOption('group', null, InputOption::VALUE_NONE, 'Group icons by set when listing.')
+ ->addOption('unused', null, InputOption::VALUE_NONE, 'Filter unused icons.')
+ ->addOption('table', null, InputOption::VALUE_NONE, 'Show listing in table format.')
+ ->addOption('remove', null, InputOption::VALUE_OPTIONAL, 'Remove a specific icon or use with --unused to delete all unused icons.')
+ ->addOption('remove-all', null, InputOption::VALUE_NONE, 'Delete all icons from the local directory.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ // Get icon directory
+ $ref = new \ReflectionProperty($this->localRegistry, 'iconDir');
+ $iconDir = str_replace('\\', '/', $ref->getValue($this->localRegistry));
+
+ if (!is_dir($iconDir)) {
+ $io->error("Icon directory not found: {$iconDir}");
+
+ return Command::FAILURE;
+ }
+
+ $list = $input->getOption('list');
+ $group = $input->getOption('group');
+ $unusedOnly = $input->getOption('unused');
+ $tableMode = $input->getOption('table');
+ $remove = $input->getOption('remove');
+ $removeAll = $input->getOption('remove-all');
+
+ // Scan local icons if needed
+ $localIcons = [];
+ if ($list || $removeAll || ($remove && $unusedOnly) || $remove) {
+ $finder = new Finder();
+ $finder->files()->in($iconDir)->name('*.svg');
+ foreach ($finder as $file) {
+ $relative = str_replace('\\', '/', $file->getRelativePathname());
+ $iconName = str_replace('/', ':', preg_replace('#\.svg$#', '', $relative));
+ $localIcons[$iconName] = $file->getRealPath();
+ }
+
+ if (empty($localIcons)) {
+ $io->warning('No local icons found.');
+
+ return Command::SUCCESS;
+ }
+ }
+
+ // Find used icons if needed
+ $usedIcons = ($list || ($remove && $unusedOnly)) ? $this->iconFinder->iconsInTemplates() : [];
+ $unusedIcons = array_diff_key($localIcons, array_flip($usedIcons));
+
+ // Listing
+ if ($list) {
+ $title = $unusedOnly ? 'Unused Icons Listing' : 'All Icons Listing';
+ $io->title($title);
+
+ $iconsToShow = $unusedOnly ? $unusedIcons : $localIcons;
+
+ if (empty($iconsToShow)) {
+ $io->success($unusedOnly ? 'No unused icons found.' : 'No icons found.');
+
+ return Command::SUCCESS;
+ }
+
+ if ($tableMode) {
+ $table = new Table($output);
+ $table->setHeaders(['Set', 'Icon Name', 'Status']);
+ foreach ($iconsToShow as $icon => $path) {
+ $parts = explode(':', $icon, 2);
+ $set = isset($parts[1]) ? $parts[0] : '-';
+ $status = isset($unusedIcons[$icon]) ? 'Unused>' : 'Used>';
+ $table->addRow([$set, $icon, $status]);
+ }
+ $table->render();
+ } else {
+ // Group
+ if ($group) {
+ $grouped = [];
+ foreach ($iconsToShow as $icon => $path) {
+ $parts = explode(':', $icon, 2);
+ $set = isset($parts[1]) ? $parts[0] : '-';
+ $grouped[$set][] = $icon;
+ }
+
+ foreach ($grouped as $set => $icons) {
+ $io->section("Set: $set");
+ foreach ($icons as $icon) {
+ $color = isset($unusedIcons[$icon]) ? 'red' : 'green';
+ $io->writeln(" - {$icon}>");
+ }
+ $io->newLine();
+ }
+ } else {
+ foreach ($iconsToShow as $icon => $path) {
+ $color = isset($unusedIcons[$icon]) ? 'red' : 'green';
+ $io->writeln(" - {$icon}>");
+ }
+ }
+ }
+
+ $io->note(\sprintf('%d %s icons found.', \count($iconsToShow), $unusedOnly ? 'unused' : 'total'));
+
+ return Command::SUCCESS;
+ }
+
+ // Remove all
+ if ($removeAll) {
+ if ($io->confirm('Are you sure you want to delete ALL icons?', false)) {
+ foreach ($localIcons as $icon => $path) {
+ @unlink($path);
+ }
+ $io->success('All icons have been deleted.');
+ } else {
+ $io->info('Operation cancelled.');
+ }
+
+ return Command::SUCCESS;
+ }
+
+ // Remove unused
+ if ($remove && $unusedOnly) {
+ if (empty($unusedIcons)) {
+ $io->success('No unused icons to delete.');
+
+ return Command::SUCCESS;
+ }
+
+ if ($io->confirm(\sprintf('Delete all %d unused icons?', \count($unusedIcons)), true)) {
+ foreach ($unusedIcons as $icon => $path) {
+ @unlink($path);
+ }
+ $io->success('All unused icons deleted.');
+ } else {
+ $io->info('Operation cancelled.');
+ }
+
+ return Command::SUCCESS;
+ }
+
+ // Remove single icon
+ if ($remove && !$unusedOnly) {
+ $target = str_replace(':', '/', $remove);
+ $iconPath = "{$iconDir}/{$target}.svg";
+
+ if (!file_exists($iconPath)) {
+ $io->warning("Icon not found: {$remove}");
+
+ return Command::SUCCESS;
+ }
+
+ if ($io->confirm("Are you sure you want to delete '{$remove}'?", false)) {
+ @unlink($iconPath);
+ $io->success("Deleted icon: {$remove}");
+ } else {
+ $io->info('Operation cancelled.');
+ }
+
+ return Command::SUCCESS;
+ }
+
+ $io->note('Use --list, --remove, or --remove-all options to manage your icons.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Icons/src/Twig/IconFinder.php b/src/Icons/src/Twig/IconFinder.php
old mode 100644
new mode 100755
index a2b6844221d..fb0ec263a66
--- a/src/Icons/src/Twig/IconFinder.php
+++ b/src/Icons/src/Twig/IconFinder.php
@@ -22,7 +22,7 @@
*
* @internal
*/
-final class IconFinder
+final class IconFinder implements IconFinderInterface
{
public function __construct(
private Environment $twig,
@@ -61,6 +61,44 @@ public function icons(): array
return array_unique($found);
}
+ /**
+ * @return string[]
+ */
+ public function iconsInTemplates(): array
+ {
+ $found = [];
+
+ // part: starts with alnum, may contain -, _, . thereafter
+ $part = '[A-Za-z0-9][A-Za-z0-9_\\-\\.]*';
+
+ // full token: either "part" or "part:part" (single optional colon)
+ $token = $part.'(?:\:'.$part.')?';
+
+ // 1) ux_icon('name' ...) or ux_icon("name" ...)
+ $patternFunction = '/ux_icon\(\s*[\'"]('.$token.')[\'"]/i';
+
+ // 2) or name='name'
+ $patternTag = '/]*\bname\s*=\s*[\'"]('.$token.')[\'"][^>]*>/i';
+
+ foreach ($this->templateFiles($this->twig->getLoader()) as $file) {
+ $contents = file_get_contents($file);
+
+ if (preg_match_all($patternFunction, $contents, $matchesFunc) && !empty($matchesFunc[1])) {
+ foreach ($matchesFunc[1] as $m) {
+ $found[] = $m;
+ }
+ }
+
+ if (preg_match_all($patternTag, $contents, $matchesTag) && !empty($matchesTag[1])) {
+ foreach ($matchesTag[1] as $m) {
+ $found[] = $m;
+ }
+ }
+ }
+
+ return array_values(array_unique($found));
+ }
+
/**
* @return string[]
*/
diff --git a/src/Icons/src/Twig/IconFinderInterface.php b/src/Icons/src/Twig/IconFinderInterface.php
new file mode 100644
index 00000000000..10613b7074d
--- /dev/null
+++ b/src/Icons/src/Twig/IconFinderInterface.php
@@ -0,0 +1,25 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Icons\Twig;
+
+interface IconFinderInterface
+{
+ /**
+ * @return array
+ */
+ public function icons(): array;
+
+ /**
+ * @return array
+ */
+ public function iconsInTemplates(): array;
+}
diff --git a/src/Icons/tests/Integration/Command/ManageIconsCommandTest.php b/src/Icons/tests/Integration/Command/ManageIconsCommandTest.php
new file mode 100755
index 00000000000..b2ccd2d27a0
--- /dev/null
+++ b/src/Icons/tests/Integration/Command/ManageIconsCommandTest.php
@@ -0,0 +1,135 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Icons\Tests\Integration\Command;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\UX\Icons\Command\ManageIconsCommand;
+use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
+use Symfony\UX\Icons\Twig\IconFinderInterface;
+
+class ManageIconsCommandTest extends KernelTestCase
+{
+ private string $iconDir;
+ private LocalSvgIconRegistry $localRegistry;
+ private IconFinderInterface $iconFinder;
+
+ protected function setUp(): void
+ {
+ $this->iconDir = sys_get_temp_dir().'/icons_test_'.uniqid();
+
+ if (!is_dir($this->iconDir.'/bi')) {
+ mkdir($this->iconDir.'/bi', 0o777, true);
+ }
+ if (!is_dir($this->iconDir.'/fa')) {
+ mkdir($this->iconDir.'/fa', 0o777, true);
+ }
+
+ file_put_contents($this->iconDir.'/bi/clock.svg', '');
+ file_put_contents($this->iconDir.'/fa/flag.svg', '');
+
+ // LocalSvgIconRegistry
+ $this->localRegistry = new LocalSvgIconRegistry($this->iconDir);
+
+ $this->iconFinder = $this->createMock(IconFinderInterface::class);
+ $this->iconFinder->method('iconsInTemplates')->willReturn([
+ 'bi:clock',
+ ]);
+ }
+
+ protected function tearDown(): void
+ {
+ array_map('unlink', glob($this->iconDir.'/*/*.svg'));
+ @rmdir($this->iconDir.'/bi');
+ @rmdir($this->iconDir.'/fa');
+ @rmdir($this->iconDir);
+ }
+
+ public function testListIcons()
+ {
+ $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder);
+ $tester = new CommandTester($command);
+
+ $tester->execute([
+ '--list' => true,
+ ]);
+
+ $output = $tester->getDisplay();
+ $this->assertStringContainsString('All Icons Listing', $output);
+ $this->assertStringContainsString('bi:clock', $output);
+ $this->assertStringContainsString('fa:flag', $output);
+ }
+
+ public function testListUnusedIcons()
+ {
+ $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder);
+ $tester = new CommandTester($command);
+
+ $tester->execute([
+ '--list' => true,
+ '--unused' => true,
+ ]);
+
+ $output = $tester->getDisplay();
+ $this->assertStringContainsString('Unused Icons Listing', $output);
+ $this->assertStringContainsString('fa:flag', $output);
+ $this->assertStringNotContainsString('bi:clock', $output);
+ }
+
+ public function testRemoveUnusedIcons()
+ {
+ $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder);
+ $tester = new CommandTester($command);
+
+ $this->assertFileExists($this->iconDir.'/fa/flag.svg');
+
+ $tester->setInputs(['yes']);
+ $tester->execute([
+ '--remove' => true,
+ '--unused' => true,
+ ]);
+
+ $this->assertStringContainsString('All unused icons deleted.', $tester->getDisplay());
+ $this->assertFileDoesNotExist($this->iconDir.'/fa/flag.svg');
+ }
+
+ public function testRemoveSingleIcon()
+ {
+ $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder);
+ $tester = new CommandTester($command);
+
+ $this->assertFileExists($this->iconDir.'/bi/clock.svg');
+
+ $tester->setInputs(['yes']);
+ $tester->execute([
+ '--remove' => 'bi:clock',
+ ]);
+
+ $this->assertStringContainsString('Deleted icon: bi:clock', $tester->getDisplay());
+ $this->assertFileDoesNotExist($this->iconDir.'/bi/clock.svg');
+ }
+
+ public function testRemoveAllIcons()
+ {
+ $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder);
+ $tester = new CommandTester($command);
+
+ $tester->setInputs(['yes']);
+ $tester->execute([
+ '--remove-all' => true,
+ ]);
+
+ $this->assertStringContainsString('All icons have been deleted.', $tester->getDisplay());
+ $this->assertFileDoesNotExist($this->iconDir.'/bi/clock.svg');
+ $this->assertFileDoesNotExist($this->iconDir.'/fa/flag.svg');
+ }
+}