diff --git a/docs/en/reference/tools.rst b/docs/en/reference/tools.rst
index def53e4da7e..101c51ad010 100644
--- a/docs/en/reference/tools.rst
+++ b/docs/en/reference/tools.rst
@@ -96,6 +96,10 @@ The following Commands are currently available:
- ``orm:schema-tool:update`` Processes the schema and either
update the database schema of EntityManager Storage Connection or
generate the SQL output.
+- ``orm:debug:event-manager`` Lists event listeners for an entity
+ manager, optionally filtered by event name.
+- ``orm:debug:entity-listeners`` Lists entity listeners for a given
+ entity, optionally filtered by event name.
The following alias is defined:
diff --git a/src/Tools/Console/Command/Debug/AbstractCommand.php b/src/Tools/Console/Command/Debug/AbstractCommand.php
new file mode 100644
index 00000000000..55794b81294
--- /dev/null
+++ b/src/Tools/Console/Command/Debug/AbstractCommand.php
@@ -0,0 +1,34 @@
+getManagerRegistry()->getManager($name);
+
+ assert($manager instanceof EntityManagerInterface);
+
+ return $manager;
+ }
+
+ final protected function getManagerRegistry(): ManagerRegistry
+ {
+ return $this->managerRegistry;
+ }
+}
diff --git a/src/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommand.php b/src/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommand.php
new file mode 100644
index 00000000000..ba8d19ac45c
--- /dev/null
+++ b/src/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommand.php
@@ -0,0 +1,162 @@
+setName('orm:debug:entity-listeners')
+ ->setDescription('Lists entity listeners for a given entity')
+ ->addArgument('entity', InputArgument::OPTIONAL, 'The fully-qualified entity class name')
+ ->addArgument('event', InputArgument::OPTIONAL, 'The event name to filter by (e.g. postPersist)')
+ ->setHelp(<<<'EOT'
+The %command.name% command lists all entity listeners for a given entity:
+
+ php %command.full_name% 'App\Entity\User'
+
+To show only listeners for a specific event, pass the event name:
+
+ php %command.full_name% 'App\Entity\User' postPersist
+EOT);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ /** @var class-string|null $entityName */
+ $entityName = $input->getArgument('entity');
+
+ if ($entityName === null) {
+ $choices = $this->listAllEntities();
+
+ if ($choices === []) {
+ $io->error('No entities are configured.');
+
+ return self::FAILURE;
+ }
+
+ /** @var class-string $entityName */
+ $entityName = $io->choice('Which entity do you want to list listeners for?', $choices);
+ }
+
+ $entityName = ltrim($entityName, '\\');
+ $entityManager = $this->getManagerRegistry()->getManagerForClass($entityName);
+
+ if ($entityManager === null) {
+ $io->error(sprintf('No entity manager found for class "%s".', $entityName));
+
+ return self::FAILURE;
+ }
+
+ $classMetadata = $entityManager->getClassMetadata($entityName);
+ assert($classMetadata instanceof ClassMetadata);
+
+ $eventName = $input->getArgument('event');
+
+ if ($eventName === null) {
+ $allListeners = $classMetadata->entityListeners;
+ if (! $allListeners) {
+ $io->info(sprintf('No listeners are configured for the "%s" entity.', $entityName));
+
+ return self::SUCCESS;
+ }
+
+ ksort($allListeners);
+ } else {
+ if (! isset($classMetadata->entityListeners[$eventName])) {
+ $io->info(sprintf('No listeners are configured for the "%s" event.', $eventName));
+
+ return self::SUCCESS;
+ }
+
+ $allListeners = [$eventName => $classMetadata->entityListeners[$eventName]];
+ }
+
+ $io->title(sprintf('Entity listeners for %s', $entityName));
+
+ $rows = [];
+ foreach ($allListeners as $event => $listeners) {
+ if ($rows) {
+ $rows[] = new TableSeparator();
+ }
+
+ foreach ($listeners as $order => $listener) {
+ $rows[] = [$order === 0 ? $event : '', sprintf('#%d', ++$order), sprintf('%s::%s()', $listener['class'], $listener['method'])];
+ }
+ }
+
+ $io->table(['Event', 'Order', 'Listener'], $rows);
+
+ return self::SUCCESS;
+ }
+
+ public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
+ {
+ if ($input->mustSuggestArgumentValuesFor('entity')) {
+ $suggestions->suggestValues($this->listAllEntities());
+
+ return;
+ }
+
+ if ($input->mustSuggestArgumentValuesFor('event')) {
+ $entityName = ltrim($input->getArgument('entity'), '\\');
+
+ if (! class_exists($entityName)) {
+ return;
+ }
+
+ $entityManager = $this->getManagerRegistry()->getManagerForClass($entityName);
+
+ if ($entityManager === null) {
+ return;
+ }
+
+ $classMetadata = $entityManager->getClassMetadata($entityName);
+ assert($classMetadata instanceof ClassMetadata);
+
+ $suggestions->suggestValues(array_keys($classMetadata->entityListeners));
+
+ return;
+ }
+ }
+
+ /** @return list */
+ private function listAllEntities(): array
+ {
+ $entities = [];
+ foreach (array_keys($this->getManagerRegistry()->getManagerNames()) as $managerName) {
+ $entities[] = $this->getEntityManager($managerName)->getConfiguration()->getMetadataDriverImpl()->getAllClassNames();
+ }
+
+ $entities = array_values(array_unique(array_merge(...$entities)));
+
+ sort($entities);
+
+ return $entities;
+ }
+}
diff --git a/src/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommand.php b/src/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommand.php
new file mode 100644
index 00000000000..09fccfc3c3e
--- /dev/null
+++ b/src/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommand.php
@@ -0,0 +1,111 @@
+setName('orm:debug:event-manager')
+ ->setDescription('Lists event listeners for an entity manager')
+ ->addArgument('event', InputArgument::OPTIONAL, 'The event name to filter by (e.g. postPersist)')
+ ->addOption('em', null, InputOption::VALUE_REQUIRED, 'The entity manager to use for this command')
+ ->setHelp(<<<'EOT'
+The %command.name% command lists all event listeners for the default entity manager:
+
+ php %command.full_name%
+
+You can also specify an entity manager:
+
+ php %command.full_name% --em=default
+
+To show only listeners for a specific event, pass the event name as an argument:
+
+ php %command.full_name% postPersist
+EOT);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $entityManagerName = $input->getOption('em') ?: $this->getManagerRegistry()->getDefaultManagerName();
+ $eventManager = $this->getEntityManager($entityManagerName)->getEventManager();
+
+ $eventName = $input->getArgument('event');
+
+ if ($eventName === null) {
+ $allListeners = $eventManager->getAllListeners();
+ if (! $allListeners) {
+ $io->info(sprintf('No listeners are configured for the "%s" entity manager.', $entityManagerName));
+
+ return self::SUCCESS;
+ }
+
+ ksort($allListeners);
+ } else {
+ $listeners = $eventManager->hasListeners($eventName) ? $eventManager->getListeners($eventName) : [];
+ if (! $listeners) {
+ $io->info(sprintf('No listeners are configured for the "%s" event.', $eventName));
+
+ return self::SUCCESS;
+ }
+
+ $allListeners = [$eventName => $listeners];
+ }
+
+ $io->title(sprintf('Event listeners for %s entity manager', $entityManagerName));
+
+ $rows = [];
+ foreach ($allListeners as $event => $listeners) {
+ if ($rows) {
+ $rows[] = new TableSeparator();
+ }
+
+ foreach (array_values($listeners) as $order => $listener) {
+ $method = method_exists($listener, '__invoke') ? '__invoke' : $event;
+ $rows[] = [$order === 0 ? $event : '', sprintf('#%d', ++$order), sprintf('%s::%s()', $listener::class, $method)];
+ }
+ }
+
+ $io->table(['Event', 'Order', 'Listener'], $rows);
+
+ return self::SUCCESS;
+ }
+
+ public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
+ {
+ if ($input->mustSuggestArgumentValuesFor('event')) {
+ $entityManagerName = $input->getOption('em') ?: $this->getManagerRegistry()->getDefaultManagerName();
+ $eventManager = $this->getEntityManager($entityManagerName)->getEventManager();
+
+ $suggestions->suggestValues(array_keys($eventManager->getAllListeners()));
+
+ return;
+ }
+
+ if ($input->mustSuggestOptionValuesFor('em')) {
+ $suggestions->suggestValues(array_keys($this->getManagerRegistry()->getManagerNames()));
+
+ return;
+ }
+ }
+}
diff --git a/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommandTest.php b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommandTest.php
new file mode 100644
index 00000000000..892d9e3a081
--- /dev/null
+++ b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommandTest.php
@@ -0,0 +1,152 @@
+command = new DebugEntityListenersDoctrineCommand($this->getMockManagerRegistry());
+
+ self::addCommandToApplication($application, $this->command);
+ }
+
+ public function testExecute(): void
+ {
+ $commandTester = new CommandTester($this->command);
+ $commandTester->execute(
+ ['command' => $this->command->getName(), 'entity' => self::class],
+ );
+
+ self::assertSame(<<<'TXT'
+
+Entity listeners for Doctrine\Tests\ORM\Tools\Console\Command\Debug\DebugEntityListenersDoctrineCommandTest
+===========================================================================================================
+
+ ------------- ------- ------------------------------------------------------------------------------------
+ Event Order Listener
+ ------------- ------- ------------------------------------------------------------------------------------
+ postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist()
+ ------------- ------- ------------------------------------------------------------------------------------
+ preUpdate #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\FooListener::preUpdate()
+ #2 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BarListener::__invoke()
+ ------------- ------- ------------------------------------------------------------------------------------
+
+
+TXT
+ , $commandTester->getDisplay(true));
+ }
+
+ public function testExecuteWithEvent(): void
+ {
+ $commandTester = new CommandTester($this->command);
+ $commandTester->execute(
+ ['command' => $this->command->getName(), 'entity' => self::class, 'event' => 'postPersist'],
+ );
+
+ self::assertSame(<<<'TXT'
+
+Entity listeners for Doctrine\Tests\ORM\Tools\Console\Command\Debug\DebugEntityListenersDoctrineCommandTest
+===========================================================================================================
+
+ ------------- ------- ------------------------------------------------------------------------------------
+ Event Order Listener
+ ------------- ------- ------------------------------------------------------------------------------------
+ postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist()
+ ------------- ------- ------------------------------------------------------------------------------------
+
+
+TXT
+ , $commandTester->getDisplay(true));
+ }
+
+ public function testExecuteWithMissingEvent(): void
+ {
+ $commandTester = new CommandTester($this->command);
+ $commandTester->execute(
+ ['command' => $this->command->getName(), 'entity' => self::class, 'event' => 'preRemove'],
+ );
+
+ self::assertSame(<<<'TXT'
+
+ [INFO] No listeners are configured for the "preRemove" event.
+
+
+TXT
+ , $commandTester->getDisplay(true));
+ }
+
+ /**
+ * @param list $args
+ * @param list $expectedSuggestions
+ */
+ #[TestWith([['console'], 1, [self::class]])]
+ #[TestWith([['console', self::class], 2, ['preUpdate', 'postPersist']])]
+ #[TestWith([['console', 'NonExistentEntity'], 2, []])]
+ public function testComplete(array $args, int $currentIndex, array $expectedSuggestions): void
+ {
+ $input = CompletionInput::fromTokens($args, $currentIndex);
+ $input->bind($this->command->getDefinition());
+ $suggestions = new CompletionSuggestions();
+
+ $this->command->complete($input, $suggestions);
+
+ self::assertSame($expectedSuggestions, array_map(static fn (Suggestion $suggestion) => $suggestion->getValue(), $suggestions->getValueSuggestions()));
+ }
+
+ /** @return MockObject&ManagerRegistry */
+ private function getMockManagerRegistry(): ManagerRegistry
+ {
+ $mappingDriverMock = $this->createMock(MappingDriver::class);
+ $mappingDriverMock->method('getAllClassNames')->willReturn([self::class]);
+
+ $config = new Configuration();
+ $config->setMetadataDriverImpl($mappingDriverMock);
+
+ $classMetadata = new ClassMetadata(self::class);
+ $classMetadata->addEntityListener('preUpdate', FooListener::class, 'preUpdate');
+ $classMetadata->addEntityListener('preUpdate', BarListener::class, '__invoke');
+ $classMetadata->addEntityListener('postPersist', BazListener::class, 'postPersist');
+
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $emMock->method('getConfiguration')->willReturn($config);
+ $emMock->method('getClassMetadata')->willReturn($classMetadata);
+
+ $doctrineMock = $this->createMock(ManagerRegistry::class);
+ $doctrineMock->method('getManagerNames')->willReturn(['default' => 'entity_manager.default']);
+ $doctrineMock->method('getManager')->willReturn($emMock);
+ $doctrineMock->method('getManagerForClass')->willReturn($emMock);
+
+ return $doctrineMock;
+ }
+}
diff --git a/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommandTest.php b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommandTest.php
new file mode 100644
index 00000000000..f98dfdb873f
--- /dev/null
+++ b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommandTest.php
@@ -0,0 +1,142 @@
+command = new DebugEventManagerDoctrineCommand($this->getMockManagerRegistry());
+
+ self::addCommandToApplication($application, $this->command);
+ }
+
+ public function testExecute(): void
+ {
+ $commandTester = new CommandTester($this->command);
+ $commandTester->execute(
+ ['command' => $this->command->getName()],
+ );
+
+ self::assertSame(<<<'TXT'
+
+Event listeners for default entity manager
+==========================================
+
+ ------------- ------- ------------------------------------------------------------------------------------
+ Event Order Listener
+ ------------- ------- ------------------------------------------------------------------------------------
+ postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist()
+ ------------- ------- ------------------------------------------------------------------------------------
+ preUpdate #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\FooListener::preUpdate()
+ #2 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BarListener::__invoke()
+ ------------- ------- ------------------------------------------------------------------------------------
+
+
+TXT
+ , $commandTester->getDisplay(true));
+ }
+
+ public function testExecuteWithEvent(): void
+ {
+ $commandTester = new CommandTester($this->command);
+ $commandTester->execute(
+ ['command' => $this->command->getName(), 'event' => 'postPersist'],
+ );
+
+ self::assertSame(<<<'TXT'
+
+Event listeners for default entity manager
+==========================================
+
+ ------------- ------- ------------------------------------------------------------------------------------
+ Event Order Listener
+ ------------- ------- ------------------------------------------------------------------------------------
+ postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist()
+ ------------- ------- ------------------------------------------------------------------------------------
+
+
+TXT
+ , $commandTester->getDisplay(true));
+ }
+
+ public function testExecuteWithMissingEvent(): void
+ {
+ $commandTester = new CommandTester($this->command);
+ $commandTester->execute(
+ ['command' => $this->command->getName(), 'event' => 'preRemove'],
+ );
+
+ self::assertSame(<<<'TXT'
+
+ [INFO] No listeners are configured for the "preRemove" event.
+
+
+TXT
+ , $commandTester->getDisplay(true));
+ }
+
+ /**
+ * @param list $args
+ * @param list $expectedSuggestions
+ */
+ #[TestWith([['console'], 1, ['preUpdate', 'postPersist']])]
+ #[TestWith([['console', '--em'], 1, ['default']])]
+ public function testComplete(array $args, int $currentIndex, array $expectedSuggestions): void
+ {
+ $input = CompletionInput::fromTokens($args, $currentIndex);
+ $input->bind($this->command->getDefinition());
+ $suggestions = new CompletionSuggestions();
+
+ $this->command->complete($input, $suggestions);
+
+ self::assertSame($expectedSuggestions, array_map(static fn (Suggestion $suggestion) => $suggestion->getValue(), $suggestions->getValueSuggestions()));
+ }
+
+ /** @return MockObject&ManagerRegistry */
+ private function getMockManagerRegistry(): ManagerRegistry
+ {
+ $eventManager = new EventManager();
+ $eventManager->addEventListener('preUpdate', new FooListener());
+ $eventManager->addEventListener('preUpdate', new BarListener());
+ $eventManager->addEventListener('postPersist', new BazListener());
+
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $emMock->method('getEventManager')->willReturn($eventManager);
+
+ $doctrineMock = $this->createMock(ManagerRegistry::class);
+ $doctrineMock->method('getDefaultManagerName')->willReturn('default');
+ $doctrineMock->method('getManager')->willReturn($emMock);
+ $doctrineMock->method('getManagerNames')->willReturn(['default' => 'entity_manager.default']);
+
+ return $doctrineMock;
+ }
+}
diff --git a/tests/Tests/ORM/Tools/Console/Command/Debug/Fixtures/BarListener.php b/tests/Tests/ORM/Tools/Console/Command/Debug/Fixtures/BarListener.php
new file mode 100644
index 00000000000..bb6e0e2079c
--- /dev/null
+++ b/tests/Tests/ORM/Tools/Console/Command/Debug/Fixtures/BarListener.php
@@ -0,0 +1,12 @@
+