diff --git a/src/TwigHooks/config/services/command.php b/src/TwigHooks/config/services/command.php
new file mode 100644
index 000000000..0e638e700
--- /dev/null
+++ b/src/TwigHooks/config/services/command.php
@@ -0,0 +1,27 @@
+services();
+
+ $services->set('sylius_twig_hooks.command.debug', DebugTwigHooksCommand::class)
+ ->args([
+ service('sylius_twig_hooks.registry.hookables'),
+ ])
+ ->tag('console.command')
+ ;
+};
diff --git a/src/TwigHooks/src/Command/DebugTwigHooksCommand.php b/src/TwigHooks/src/Command/DebugTwigHooksCommand.php
new file mode 100644
index 000000000..0fa44d467
--- /dev/null
+++ b/src/TwigHooks/src/Command/DebugTwigHooksCommand.php
@@ -0,0 +1,254 @@
+setDefinition([
+ new InputArgument('name', InputArgument::OPTIONAL, 'A hook name or part of the hook name'),
+ new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show all hookables including disabled ones'),
+ new InputOption('config', 'c', InputOption::VALUE_NONE, 'Show hookables configuration'),
+ ])
+ ->setHelp(
+ <<<'EOF'
+The %command.name% displays all Twig hooks in your application.
+
+To list all hooks:
+
+ php %command.full_name%
+
+To filter hooks by name:
+
+ php %command.full_name% sylius_admin
+
+To get specific information about a hook:
+
+ php %command.full_name% sylius_admin.product.index
+
+To include disabled hookables:
+
+ php %command.full_name% sylius_admin.product.index --all
+
+To show hookables configuration:
+
+ php %command.full_name% sylius_admin.product.index --config
+EOF
+ );
+ }
+
+ public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
+ {
+ if ($input->mustSuggestArgumentValuesFor('name')) {
+ $suggestions->suggestValues($this->hookablesRegistry->getHookNames());
+ }
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $name = $input->getArgument('name');
+ /** @var bool $showAll */
+ $showAll = $input->getOption('all');
+ /** @var bool $showConfig */
+ $showConfig = $input->getOption('config');
+
+ $hookNames = $this->hookablesRegistry->getHookNames();
+ sort($hookNames);
+
+ if (\is_string($name)) {
+ // Exact match - show details
+ if (\in_array($name, $hookNames, true)) {
+ $this->displayHookDetails($io, $name, $showAll, $showConfig);
+
+ return Command::SUCCESS;
+ }
+
+ // Partial match - filter and show table or details (case-insensitive)
+ $filteredHooks = array_filter(
+ $hookNames,
+ static fn (string $hookName): bool => false !== stripos($hookName, $name),
+ );
+
+ if (0 === \count($filteredHooks)) {
+ $io->warning(\sprintf('No hooks found matching "%s".', $name));
+
+ return Command::SUCCESS;
+ }
+
+ if (1 === \count($filteredHooks)) {
+ $this->displayHookDetails($io, reset($filteredHooks), $showAll, $showConfig);
+
+ return Command::SUCCESS;
+ }
+
+ $this->displayHooksTable($io, $filteredHooks, $showAll);
+
+ return Command::SUCCESS;
+ }
+
+ if (0 === \count($hookNames)) {
+ $io->warning('No hooks registered.');
+
+ return Command::SUCCESS;
+ }
+
+ $this->displayHooksTable($io, $hookNames, $showAll);
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * @param array $hookNames
+ */
+ private function displayHooksTable(SymfonyStyle $io, array $hookNames, bool $showAll): void
+ {
+ $rows = [];
+
+ foreach ($hookNames as $hookName) {
+ $hookables = $this->hookablesRegistry->getAllFor($hookName);
+ $enabledCount = \count(array_filter(
+ $hookables,
+ static fn (AbstractHookable $hookable): bool => !$hookable instanceof DisabledHookable,
+ ));
+ $disabledCount = \count($hookables) - $enabledCount;
+
+ $countDisplay = $showAll && $disabledCount > 0
+ ? \sprintf('%d (%d disabled)', \count($hookables), $disabledCount)
+ : (string) $enabledCount;
+
+ $rows[] = [
+ $hookName,
+ $countDisplay,
+ ];
+ }
+
+ $io->table(['Hook', 'Hookables'], $rows);
+ $io->text(\sprintf('Total: %d hooks', \count($hookNames)));
+ }
+
+ private function displayHookDetails(SymfonyStyle $io, string $hookName, bool $showAll, bool $showConfig): void
+ {
+ $io->title($hookName);
+
+ $hookables = $this->hookablesRegistry->getAllFor($hookName);
+ if (!$showAll) {
+ $hookables = array_filter(
+ $hookables,
+ static fn (AbstractHookable $hookable): bool => !$hookable instanceof DisabledHookable,
+ );
+ }
+
+ if (0 === \count($hookables)) {
+ $io->warning('No hookables registered for this hook.');
+
+ return;
+ }
+
+ $headers = ['Name', 'Type', 'Target', 'Priority'];
+ if ($showAll) {
+ $headers[] = 'Status';
+ }
+ if ($showConfig) {
+ $headers[] = 'Configuration';
+ }
+
+ $rows = [];
+ foreach ($hookables as $hookable) {
+ $row = [
+ $hookable->name,
+ $this->getHookableType($hookable),
+ $this->getHookableTarget($hookable),
+ $hookable->priority(),
+ ];
+
+ if ($showAll) {
+ $row[] = $hookable instanceof DisabledHookable ? 'disabled' : 'enabled';
+ }
+
+ if ($showConfig) {
+ $row[] = $this->formatConfiguration($hookable->configuration);
+ }
+
+ $rows[] = $row;
+ }
+
+ $io->table($headers, $rows);
+ }
+
+ /**
+ * @param array $configuration
+ */
+ private function formatConfiguration(array $configuration): string
+ {
+ if (0 === \count($configuration)) {
+ return '-';
+ }
+
+ $parts = [];
+ foreach ($configuration as $key => $value) {
+ $parts[] = \sprintf('%s: %s', $key, $this->formatValue($value));
+ }
+
+ return implode("\n", $parts);
+ }
+
+ private function formatValue(mixed $value): string
+ {
+ return VarExporter::export($value);
+ }
+
+ private function getHookableType(AbstractHookable $hookable): string
+ {
+ return match (true) {
+ $hookable instanceof HookableTemplate => 'template',
+ $hookable instanceof HookableComponent => 'component',
+ default => '-',
+ };
+ }
+
+ private function getHookableTarget(AbstractHookable $hookable): string
+ {
+ return match (true) {
+ $hookable instanceof HookableTemplate => $hookable->template,
+ $hookable instanceof HookableComponent => $hookable->component,
+ default => '-',
+ };
+ }
+}
diff --git a/src/TwigHooks/src/Registry/HookablesRegistry.php b/src/TwigHooks/src/Registry/HookablesRegistry.php
index 09a57a249..daf3bfb09 100644
--- a/src/TwigHooks/src/Registry/HookablesRegistry.php
+++ b/src/TwigHooks/src/Registry/HookablesRegistry.php
@@ -43,6 +43,14 @@ public function __construct(
}
}
+ /**
+ * @return array
+ */
+ public function getHookNames(): array
+ {
+ return array_keys($this->hookables);
+ }
+
/**
* @param string|array $hooksNames
*
@@ -66,6 +74,24 @@ public function getEnabledFor(string|array $hooksNames): array
return $priorityQueue->toArray();
}
+ /**
+ * @param string|array $hooksNames
+ *
+ * @return array
+ */
+ public function getAllFor(string|array $hooksNames): array
+ {
+ $hooksNames = is_string($hooksNames) ? [$hooksNames] : $hooksNames;
+ $hookables = $this->mergeHookables($hooksNames);
+
+ $priorityQueue = new SplPriorityQueue();
+ foreach ($hookables as $hookable) {
+ $priorityQueue->insert($hookable, $hookable->priority());
+ }
+
+ return $priorityQueue->toArray();
+ }
+
/**
* @param array $hooksNames
*
diff --git a/src/TwigHooks/tests/Unit/Command/DebugTwigHooksCommandTest.php b/src/TwigHooks/tests/Unit/Command/DebugTwigHooksCommandTest.php
new file mode 100644
index 000000000..6d16f0dbb
--- /dev/null
+++ b/src/TwigHooks/tests/Unit/Command/DebugTwigHooksCommandTest.php
@@ -0,0 +1,377 @@
+hookablesRegistry = $this->createMock(HookablesRegistry::class);
+ }
+
+ public function testItDisplaysAllHooksSortedAlphabetically(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_shop.cart.summary', 'sylius_admin.product.index']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->willReturnMap([
+ ['sylius_admin.product.index', [
+ HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']),
+ ]],
+ ['sylius_shop.cart.summary', [
+ HookableTemplateMotherObject::with(['hookName' => 'sylius_shop.cart.summary', 'name' => 'items']),
+ ]],
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute([]);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $display = $commandTester->getDisplay();
+
+ $this->assertStringContainsString('Total: 2 hooks', $display);
+
+ $adminPosition = strpos($display, 'sylius_admin.product.index');
+ $shopPosition = strpos($display, 'sylius_shop.cart.summary');
+ $this->assertLessThan($shopPosition, $adminPosition, 'Hooks should be sorted alphabetically');
+ }
+
+ public function testItDisplaysWarningWhenNoHooksRegistered(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn([]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute([]);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $this->assertStringContainsString('No hooks registered', $commandTester->getDisplay());
+ }
+
+ public function testItDisplaysHookDetailsForExactMatchSortedByPriority(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->with('sylius_admin.product.index')
+ ->willReturn([
+ HookableTemplateMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'header',
+ 'template' => '@SyliusAdmin/product/header.html.twig',
+ 'priority' => 100,
+ ]),
+ HookableComponentMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'grid',
+ 'component' => 'sylius_admin:product:grid',
+ 'priority' => 50,
+ ]),
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'sylius_admin.product.index']);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $display = $commandTester->getDisplay();
+
+ $this->assertMatchesRegularExpression('/header\s+template\s+@SyliusAdmin\/product\/header\.html\.twig\s+100/', $display);
+ $this->assertMatchesRegularExpression('/grid\s+component\s+sylius_admin:product:grid\s+50/', $display);
+
+ $headerPosition = strpos($display, 'header');
+ $gridPosition = strpos($display, 'grid');
+ $this->assertLessThan($gridPosition, $headerPosition, 'Hookables should be sorted by priority (highest first)');
+ }
+
+ public function testItFiltersHooksByPartialNameCaseInsensitive(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index', 'sylius_admin.order.index', 'sylius_shop.cart.summary']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->willReturnMap([
+ ['sylius_admin.product.index', [
+ HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']),
+ ]],
+ ['sylius_admin.order.index', [
+ HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.order.index', 'name' => 'header']),
+ ]],
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'ADMIN']);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $display = $commandTester->getDisplay();
+
+ $this->assertStringContainsString('sylius_admin.product.index', $display);
+ $this->assertStringContainsString('sylius_admin.order.index', $display);
+ $this->assertStringNotContainsString('sylius_shop.cart.summary', $display);
+ }
+
+ public function testItDisplaysWarningWhenNoHooksMatchFilter(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index']);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'nonexistent']);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $this->assertStringContainsString('No hooks found matching "nonexistent"', $commandTester->getDisplay());
+ }
+
+ public function testItDisplaysDetailsWhenSingleHookMatchesFilter(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index', 'sylius_shop.cart.summary']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->with('sylius_admin.product.index')
+ ->willReturn([
+ HookableTemplateMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'header',
+ 'template' => '@SyliusAdmin/product/header.html.twig',
+ ]),
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'product']);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $display = $commandTester->getDisplay();
+
+ $this->assertStringContainsString('sylius_admin.product.index', $display);
+ $this->assertStringContainsString('@SyliusAdmin/product/header.html.twig', $display);
+ }
+
+ #[DataProvider('provideAllOptionCases')]
+ public function testItHandlesDisabledHookablesBasedOnAllOption(
+ bool $useAllOption,
+ bool $shouldShowDisabled,
+ bool $shouldShowStatusColumn,
+ ): void {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->with('sylius_admin.product.index')
+ ->willReturn([
+ HookableTemplateMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'header',
+ ]),
+ DisabledHookableMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'disabled_item',
+ ]),
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'sylius_admin.product.index', '--all' => $useAllOption]);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $display = $commandTester->getDisplay();
+
+ $this->assertStringContainsString('header', $display);
+
+ if ($shouldShowDisabled) {
+ $this->assertStringContainsString('disabled_item', $display);
+ } else {
+ $this->assertStringNotContainsString('disabled_item', $display);
+ }
+
+ if ($shouldShowStatusColumn) {
+ $this->assertStringContainsString('Status', $display);
+ } else {
+ $this->assertStringNotContainsString('Status', $display);
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideAllOptionCases(): iterable
+ {
+ yield 'without --all option' => [false, false, false];
+ yield 'with --all option' => [true, true, true];
+ }
+
+ public function testItShowsConfigurationWithConfigOption(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->with('sylius_admin.product.index')
+ ->willReturn([
+ HookableTemplateMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'header',
+ 'configuration' => [
+ 'string_key' => 'value',
+ 'boolean' => true,
+ 'null_value' => null,
+ ],
+ ]),
+ HookableTemplateMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'empty_config',
+ 'configuration' => [],
+ ]),
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'sylius_admin.product.index', '--config' => true]);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $display = $commandTester->getDisplay();
+
+ $this->assertStringContainsString('Configuration', $display);
+ $this->assertStringContainsString('string_key', $display);
+ $this->assertStringContainsString('true', $display);
+ $this->assertStringContainsString('null', $display);
+ $this->assertMatchesRegularExpression('/empty_config.*-/s', $display);
+ }
+
+ #[DataProvider('provideHookableCountCases')]
+ public function testItDisplaysCorrectHookableCountInTable(bool $useAllOption, string $expectedPattern): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->with('sylius_admin.product.index')
+ ->willReturn([
+ HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']),
+ HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'content']),
+ DisabledHookableMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'disabled_item']),
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['--all' => $useAllOption]);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $this->assertMatchesRegularExpression($expectedPattern, $commandTester->getDisplay());
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideHookableCountCases(): iterable
+ {
+ yield 'without --all shows only enabled count' => [false, '/sylius_admin\.product\.index\s+2\s/'];
+ yield 'with --all shows total and disabled count' => [true, '/3 \(1 disabled\)/'];
+ }
+
+ public function testItDisplaysWarningWhenHookHasNoVisibleHookables(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->with('sylius_admin.product.index')
+ ->willReturn([
+ DisabledHookableMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'disabled_item']),
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'sylius_admin.product.index']);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $this->assertStringContainsString('No hookables registered for this hook', $commandTester->getDisplay());
+ }
+
+ public function testItDisplaysDashForUnknownHookableTypeAndTarget(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index']);
+
+ $this->hookablesRegistry
+ ->method('getAllFor')
+ ->with('sylius_admin.product.index')
+ ->willReturn([
+ DisabledHookableMotherObject::with([
+ 'hookName' => 'sylius_admin.product.index',
+ 'name' => 'unknown_type',
+ ]),
+ ]);
+
+ $commandTester = $this->createCommandTester();
+ $commandTester->execute(['name' => 'sylius_admin.product.index', '--all' => true]);
+
+ $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode());
+ $this->assertMatchesRegularExpression('/unknown_type\s+-\s+-/', $commandTester->getDisplay());
+ }
+
+ public function testItProvidesAutocompletion(): void
+ {
+ $this->hookablesRegistry
+ ->method('getHookNames')
+ ->willReturn(['sylius_admin.product.index', 'sylius_shop.cart.summary']);
+
+ $command = new DebugTwigHooksCommand($this->hookablesRegistry);
+ $completionTester = new CommandCompletionTester($command);
+
+ $suggestions = $completionTester->complete(['']);
+
+ $this->assertSame(['sylius_admin.product.index', 'sylius_shop.cart.summary'], $suggestions);
+ }
+
+ private function createCommandTester(): CommandTester
+ {
+ $command = new DebugTwigHooksCommand($this->hookablesRegistry);
+
+ return new CommandTester($command);
+ }
+}