diff --git a/config/help/MakeDoctrineListener.txt b/config/help/MakeDoctrineListener.txt
new file mode 100644
index 000000000..1057ae65c
--- /dev/null
+++ b/config/help/MakeDoctrineListener.txt
@@ -0,0 +1,5 @@
+The %command.name% command generates a new event or entity listener class.
+
+php %command.full_name% UserListener
+
+If the argument is missing, the command will ask for the class name interactively.
diff --git a/config/makers.xml b/config/makers.xml
index ad7d45483..4592d2041 100644
--- a/config/makers.xml
+++ b/config/makers.xml
@@ -69,6 +69,12 @@
+
+
+
+
+
+
diff --git a/config/services.xml b/config/services.xml
index 8f0b2a209..12ccb9029 100644
--- a/config/services.xml
+++ b/config/services.xml
@@ -31,6 +31,8 @@
+
+
diff --git a/src/Doctrine/DoctrineEventRegistry.php b/src/Doctrine/DoctrineEventRegistry.php
new file mode 100644
index 000000000..4d482ef59
--- /dev/null
+++ b/src/Doctrine/DoctrineEventRegistry.php
@@ -0,0 +1,108 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Doctrine;
+
+use Doctrine\Migrations\Event\MigrationsEventArgs;
+use Doctrine\Migrations\Event\MigrationsVersionEventArgs;
+use Doctrine\Migrations\Events as MigrationsEvents;
+use Doctrine\ORM\Events;
+use Doctrine\ORM\Tools\ToolEvents;
+
+/**
+ * @internal
+ */
+class DoctrineEventRegistry
+{
+ private array $lifecycleEvents;
+
+ private ?array $eventsMap = null;
+
+ public function __construct()
+ {
+ $this->lifecycleEvents = [
+ Events::prePersist => true,
+ Events::postPersist => true,
+ Events::preUpdate => true,
+ Events::postUpdate => true,
+ Events::preRemove => true,
+ Events::postRemove => true,
+ Events::preFlush => true,
+ Events::postLoad => true,
+ ];
+ }
+
+ public function isLifecycleEvent(string $event): bool
+ {
+ return isset($this->lifecycleEvents[$event]);
+ }
+
+ /**
+ * Returns all known event names.
+ */
+ public function getAllEvents(): array
+ {
+ return array_keys($this->getEventsMap());
+ }
+
+ /**
+ * Attempts to get the event class for a given event.
+ */
+ public function getEventClassName(string $event): ?string
+ {
+ return $this->getEventsMap()[$event]['event_class'] ?? null;
+ }
+
+ /**
+ * Attempts to find the class that defines the given event name as a constant.
+ */
+ public function getEventConstantClassName(string $event): ?string
+ {
+ return $this->getEventsMap()[$event]['const_class'] ?? null;
+ }
+
+ private function getEventsMap(): array
+ {
+ return $this->eventsMap ??= self::findEvents();
+ }
+
+ private static function findEvents(): array
+ {
+ $eventsMap = [];
+
+ foreach ((new \ReflectionClass(Events::class))->getConstants(\ReflectionClassConstant::IS_PUBLIC) as $event) {
+ $eventsMap[$event] = [
+ 'const_class' => Events::class,
+ 'event_class' => \sprintf('Doctrine\ORM\Event\%sEventArgs', ucfirst($event)),
+ ];
+ }
+
+ foreach ((new \ReflectionClass(ToolEvents::class))->getConstants(\ReflectionClassConstant::IS_PUBLIC) as $event) {
+ $eventsMap[$event] = [
+ 'const_class' => ToolEvents::class,
+ 'event_class' => \sprintf('Doctrine\ORM\Tools\Event\%sEventArgs', substr($event, 4)),
+ ];
+ }
+
+ if (class_exists(MigrationsEvents::class)) {
+ foreach ((new \ReflectionClass(MigrationsEvents::class))->getConstants(\ReflectionClassConstant::IS_PUBLIC) as $event) {
+ $eventsMap[$event] = [
+ 'const_class' => MigrationsEvents::class,
+ 'event_class' => str_contains($event, 'Version') ? MigrationsVersionEventArgs::class : MigrationsEventArgs::class,
+ ];
+ }
+ }
+
+ ksort($eventsMap);
+
+ return $eventsMap;
+ }
+}
diff --git a/src/Maker/MakeDoctrineListener.php b/src/Maker/MakeDoctrineListener.php
new file mode 100644
index 000000000..302add3c9
--- /dev/null
+++ b/src/Maker/MakeDoctrineListener.php
@@ -0,0 +1,198 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Maker;
+
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
+use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
+use Doctrine\Common\EventArgs;
+use Symfony\Bundle\MakerBundle\ConsoleStyle;
+use Symfony\Bundle\MakerBundle\DependencyBuilder;
+use Symfony\Bundle\MakerBundle\Doctrine\DoctrineEventRegistry;
+use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
+use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
+use Symfony\Bundle\MakerBundle\Generator;
+use Symfony\Bundle\MakerBundle\InputConfiguration;
+use Symfony\Bundle\MakerBundle\Str;
+use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
+use Symfony\Bundle\MakerBundle\Validator;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+use Symfony\Component\Console\Question\Question;
+
+final class MakeDoctrineListener extends AbstractMaker
+{
+ public function __construct(
+ private readonly DoctrineEventRegistry $doctrineEventRegistry,
+ private readonly DoctrineHelper $doctrineHelper,
+ ) {
+ }
+
+ public static function getCommandName(): string
+ {
+ return 'make:doctrine:listener';
+ }
+
+ public static function getCommandDescription(): string
+ {
+ return 'Creates a new doctrine event or entity listener class';
+ }
+
+ public function configureCommand(Command $command, InputConfiguration $inputConfig): void
+ {
+ $command
+ ->addArgument('name', InputArgument::OPTIONAL, 'Choose a class name for your doctrine event or entity listener')
+ ->addArgument('event', InputArgument::OPTIONAL, 'What event do you want to listen to?')
+ ->addArgument('entity', InputArgument::OPTIONAL, 'What entity should the event be associate with?')
+ ->setHelp($this->getHelpFileContents('MakeDoctrineListener.txt'));
+
+ $inputConfig->setArgumentAsNonInteractive('event');
+ $inputConfig->setArgumentAsNonInteractive('entity');
+ }
+
+ public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
+ {
+ $io->writeln('');
+
+ $event = $input->getArgument('event');
+
+ if (!$event) {
+ $events = $this->doctrineEventRegistry->getAllEvents();
+
+ $io->writeln(' Suggested Events:>');
+ $io->listing(array_map(function (string $event): string {
+ if ($this->doctrineEventRegistry->isLifecycleEvent($event)) {
+ $event .= ' (Lifecycle)>';
+ }
+
+ return $event;
+ }, $events));
+
+ $question = new Question($command->getDefinition()->getArgument('event')->getDescription());
+ $question->setAutocompleterValues($events);
+ $question->setValidator(Validator::notBlank(...));
+
+ $input->setArgument('event', $event = $io->askQuestion($question));
+
+ if ($this->doctrineEventRegistry->isLifecycleEvent($event) && !$input->getArgument('entity')) {
+ $question = new ConfirmationQuestion(\sprintf('The "%s" event is a lifecycle event, would you like to associate it with a specific entity (entity listener)?', $event));
+
+ if ($io->askQuestion($question)) {
+ $question = new Question($command->getDefinition()->getArgument('entity')->getDescription());
+ $question->setValidator(Validator::notBlank(...));
+ $question->setAutocompleterValues($this->doctrineHelper->getEntitiesForAutocomplete());
+
+ $input->setArgument('entity', $io->askQuestion($question));
+ }
+ }
+ }
+
+ if (!$this->doctrineEventRegistry->isLifecycleEvent($event) && $input->getArgument('entity')) {
+ throw new RuntimeCommandException(\sprintf('The "%s" event is not a lifecycle event and cannot be associated with a specific entity.', $event));
+ }
+ }
+
+ public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
+ {
+ $name = $input->getArgument('name');
+ $event = $input->getArgument('event');
+
+ $eventFullClassName = $this->doctrineEventRegistry->getEventClassName($event) ?? EventArgs::class;
+ $eventClassName = Str::getShortClassName($eventFullClassName);
+
+ $useStatements = new UseStatementGenerator([
+ $eventFullClassName,
+ ]);
+
+ $eventConstFullClassName = $this->doctrineEventRegistry->getEventConstantClassName($event);
+ $eventConstClassName = $eventConstFullClassName ? Str::getShortClassName($eventConstFullClassName) : null;
+
+ if ($eventConstFullClassName) {
+ $useStatements->addUseStatement($eventConstFullClassName);
+ }
+
+ $className = $generator->createClassNameDetails(
+ $name,
+ 'EventListener\\',
+ 'Listener',
+ )->getFullName();
+
+ $templateVars = [
+ 'use_statements' => $useStatements,
+ 'method_name' => $event,
+ 'event' => $eventConstClassName ? \sprintf('%s::%s', $eventConstClassName, $event) : "'$event'",
+ 'event_arg' => \sprintf('%s $event', $eventClassName),
+ ];
+
+ if ($input->getArgument('entity')) {
+ $this->generateEntityListenerClass($useStatements, $generator, $className, $templateVars, $input->getArgument('entity'));
+ } else {
+ $this->generateEventListenerClass($useStatements, $generator, $className, $templateVars);
+ }
+
+ $generator->writeChanges();
+
+ $this->writeSuccessMessage($io);
+
+ $io->text([
+ 'Next: Open your new listener class and start customizing it.',
+ 'Find the documentation at https://symfony.com/doc/current/doctrine/events.html>',
+ ]);
+ }
+
+ public function configureDependencies(DependencyBuilder $dependencies): void
+ {
+ $dependencies->addClassDependency(
+ DoctrineBundle::class,
+ 'doctrine/doctrine-bundle',
+ );
+ }
+
+ /**
+ * @param array $templateVars
+ */
+ private function generateEntityListenerClass(UseStatementGenerator $useStatements, Generator $generator, string $className, array $templateVars, string $entityClassName): void
+ {
+ $entityClassDetails = $generator->createClassNameDetails(
+ $entityClassName,
+ 'Entity\\',
+ );
+
+ $useStatements->addUseStatement(AsEntityListener::class);
+ $useStatements->addUseStatement($entityClassDetails->getFullName());
+
+ $generator->generateClass(
+ $className,
+ 'doctrine/EntityListener.tpl.php',
+ $templateVars + [
+ 'entity' => $entityClassName,
+ 'entity_arg' => \sprintf('%s $entity', $entityClassName),
+ ],
+ );
+ }
+
+ /**
+ * @param array $templateVars
+ */
+ private function generateEventListenerClass(UseStatementGenerator $useStatements, Generator $generator, string $className, array $templateVars): void
+ {
+ $useStatements->addUseStatement(AsDoctrineListener::class);
+
+ $generator->generateClass(
+ $className,
+ 'doctrine/EventListener.tpl.php',
+ $templateVars,
+ );
+ }
+}
diff --git a/templates/doctrine/EntityListener.tpl.php b/templates/doctrine/EntityListener.tpl.php
new file mode 100644
index 000000000..82e36fdf9
--- /dev/null
+++ b/templates/doctrine/EntityListener.tpl.php
@@ -0,0 +1,14 @@
+= "
+
+namespace = $namespace; ?>;
+
+= $use_statements; ?>
+
+#[AsEntityListener(event: = $event ?>, entity: = $entity ?>::class)]
+final class = $class_name."\n" ?>
+{
+ public function __invoke(= $entity_arg ?>, = $event_arg ?>): void
+ {
+ // ...
+ }
+}
diff --git a/templates/doctrine/EventListener.tpl.php b/templates/doctrine/EventListener.tpl.php
new file mode 100644
index 000000000..5ed67de6c
--- /dev/null
+++ b/templates/doctrine/EventListener.tpl.php
@@ -0,0 +1,14 @@
+= "
+
+namespace = $namespace; ?>;
+
+= $use_statements; ?>
+
+#[AsDoctrineListener(event: = $event ?>)]
+final class = $class_name."\n" ?>
+{
+ public function = $method_name ?>(= $event_arg ?>): void
+ {
+ // ...
+ }
+}
diff --git a/tests/Doctrine/DoctrineEventRegistryTest.php b/tests/Doctrine/DoctrineEventRegistryTest.php
new file mode 100644
index 000000000..8a0f533f7
--- /dev/null
+++ b/tests/Doctrine/DoctrineEventRegistryTest.php
@@ -0,0 +1,69 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Tests\Doctrine;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\MakerBundle\Doctrine\DoctrineEventRegistry;
+
+class DoctrineEventRegistryTest extends TestCase
+{
+ private static ?DoctrineEventRegistry $doctrineEventRegistry = null;
+
+ public static function setUpBeforeClass(): void
+ {
+ self::$doctrineEventRegistry = new DoctrineEventRegistry();
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ self::$doctrineEventRegistry = null;
+ }
+
+ /**
+ * @testWith ["prePersist", true]
+ * ["preUpdate", true]
+ * ["preFlush", true]
+ * ["loadClassMetadata", false]
+ * ["onFlush", false]
+ * ["postFlush", false]
+ */
+ public function testIsLifecycleEvent(string $event, bool $expected)
+ {
+ self::assertSame($expected, self::$doctrineEventRegistry->isLifecycleEvent($event));
+ }
+
+ /**
+ * @testWith ["preUpdate", "Doctrine\\ORM\\Event\\PreUpdateEventArgs"]
+ * ["preFlush", "Doctrine\\ORM\\Event\\PreFlushEventArgs"]
+ * ["onFlush", "Doctrine\\ORM\\Event\\OnFlushEventArgs"]
+ * ["postGenerateSchemaTable", "Doctrine\\ORM\\Tools\\Event\\GenerateSchemaTableEventArgs"]
+ * ["foo", null]
+ * ["bar", null]
+ */
+ public function testGetEventClassName(string $event, ?string $expected)
+ {
+ self::assertSame($expected, self::$doctrineEventRegistry->getEventClassName($event));
+ }
+
+ /**
+ * @testWith ["preUpdate", "Doctrine\\ORM\\Events"]
+ * ["preFlush", "Doctrine\\ORM\\Events"]
+ * ["onFlush", "Doctrine\\ORM\\Events"]
+ * ["postGenerateSchemaTable", "Doctrine\\ORM\\Tools\\ToolEvents"]
+ * ["foo", null]
+ * ["bar", null]
+ */
+ public function testGetEventConstantClassName(string $event, ?string $expected)
+ {
+ self::assertSame($expected, self::$doctrineEventRegistry->getEventConstantClassName($event));
+ }
+}
diff --git a/tests/Maker/MakeDoctrineListenerTest.php b/tests/Maker/MakeDoctrineListenerTest.php
new file mode 100644
index 000000000..3c68d3af2
--- /dev/null
+++ b/tests/Maker/MakeDoctrineListenerTest.php
@@ -0,0 +1,183 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Tests\Maker;
+
+use Symfony\Bundle\MakerBundle\Maker\MakeDoctrineListener;
+use Symfony\Bundle\MakerBundle\Test\MakerTestCase;
+use Symfony\Bundle\MakerBundle\Test\MakerTestDetails;
+use Symfony\Bundle\MakerBundle\Test\MakerTestRunner;
+
+class MakeDoctrineListenerTest extends MakerTestCase
+{
+ private const EXPECTED_LISTENER_PATH = __DIR__.'/../../tests/fixtures/make-doctrine-listener/tests/EventListener/';
+
+ private function createMakeDoctrineListenerTest(): MakerTestDetails
+ {
+ return $this->createMakerTest()
+ ->addExtraDependencies('doctrine/orm')
+ ;
+ }
+
+ public function getTestDetails(): \Generator
+ {
+ yield 'it_make_event_listener_without_conventional_name' => [$this->createMakeDoctrineListenerTest()
+ ->run(function (MakerTestRunner $runner) {
+ $runner->runMaker(
+ [
+ 'foo',
+ // event name
+ 'preUpdate',
+ // associate with entity?
+ 'n',
+ ],
+ );
+
+ self::assertFileEquals(
+ self::EXPECTED_LISTENER_PATH.'FooListener.php',
+ $runner->getPath('src/EventListener/FooListener.php'),
+ );
+ }),
+ ];
+
+ yield 'it_make_entity_listener_without_conventional_name' => [$this->createMakeDoctrineListenerTest()
+ ->run(function (MakerTestRunner $runner) {
+ $runner->runMaker(
+ [
+ 'fooEntity',
+ // event name
+ 'preUpdate',
+ // associate with entity?
+ 'y',
+ // entity name
+ 'User',
+ ],
+ );
+
+ self::assertFileEquals(
+ self::EXPECTED_LISTENER_PATH.'FooEntityListener.php',
+ $runner->getPath('src/EventListener/FooEntityListener.php'),
+ );
+ }),
+ ];
+
+ yield 'it_makes_event_listener_for_known_event' => [$this->createMakeDoctrineListenerTest()
+ ->run(function (MakerTestRunner $runner) {
+ $runner->runMaker(
+ [
+ // listener name
+ 'FooListener',
+ // event name
+ 'preUpdate',
+ // associate with entity?
+ 'n',
+ ],
+ );
+
+ self::assertFileEquals(
+ self::EXPECTED_LISTENER_PATH.'FooListener.php',
+ $runner->getPath('src/EventListener/FooListener.php'),
+ );
+ }),
+ ];
+
+ yield 'it_makes_entity_listener_for_known_event' => [$this->createMakeDoctrineListenerTest()
+ ->run(function (MakerTestRunner $runner) {
+ $runner->runMaker(
+ [
+ // listener name
+ 'FooEntityListener',
+ // event name
+ 'preUpdate',
+ // associate with entity?
+ 'y',
+ // entity name
+ 'User',
+ ],
+ );
+
+ self::assertFileEquals(
+ self::EXPECTED_LISTENER_PATH.'FooEntityListener.php',
+ $runner->getPath('src/EventListener/FooEntityListener.php'),
+ );
+ }),
+ ];
+
+ yield 'it_does_not_make_entity_listener_for_non_lifecycle_event' => [$this->createMakeDoctrineListenerTest()
+ ->run(function (MakerTestRunner $runner) {
+ $runner->runMaker(
+ [
+ // listener name
+ 'BarListener',
+ // event name
+ 'postFlush',
+ // associate with entity?
+ 'y',
+ // entity name
+ 'User',
+ ],
+ );
+
+ self::assertFileEquals(
+ self::EXPECTED_LISTENER_PATH.'BarListener.php',
+ $runner->getPath('src/EventListener/BarListener.php'),
+ );
+ }),
+ ];
+
+ yield 'it_makes_event_listener_for_custom_event' => [$this->createMakeDoctrineListenerTest()
+ ->run(function (MakerTestRunner $runner) {
+ $runner->runMaker(
+ [
+ // listener name
+ 'BazListener',
+ // event name
+ 'onFoo',
+ // associate with entity?
+ 'n',
+ ],
+ );
+
+ self::assertFileEquals(
+ self::EXPECTED_LISTENER_PATH.'BazListener.php',
+ $runner->getPath('src/EventListener/BazListener.php'),
+ );
+ }),
+ ];
+
+ yield 'it_does_not_make_entity_listener_for_custom_event' => [$this->createMakeDoctrineListenerTest()
+ ->run(function (MakerTestRunner $runner) {
+ $runner->runMaker(
+ [
+ // listener name
+ 'BazListener',
+ // event name
+ 'onFoo',
+ // associate with entity?
+ 'y',
+ // entity name
+ 'User',
+ ],
+ );
+
+ self::assertFileEquals(
+ self::EXPECTED_LISTENER_PATH.'BazListener.php',
+ $runner->getPath('src/EventListener/BazListener.php'),
+ );
+ }),
+ ];
+ }
+
+ protected function getMakerClass(): string
+ {
+ return MakeDoctrineListener::class;
+ }
+}
diff --git a/tests/fixtures/make-doctrine-listener/entities/attributes/User.php b/tests/fixtures/make-doctrine-listener/entities/attributes/User.php
new file mode 100644
index 000000000..53b10d49a
--- /dev/null
+++ b/tests/fixtures/make-doctrine-listener/entities/attributes/User.php
@@ -0,0 +1,54 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace App\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity]
+class User
+{
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ private ?int $id = null;
+
+ #[ORM\Column(length: 255, nullable: true)]
+ private ?string $firstName = null;
+
+ #[ORM\Column(length: 255, nullable: true)]
+ private ?string $lastName = null;
+
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ public function getFirstName(): string
+ {
+ return $this->firstName;
+ }
+
+ public function setFirstName(?string $firstName)
+ {
+ $this->firstName = $firstName;
+ }
+
+ public function getLastName(): string
+ {
+ return $this->lastName;
+ }
+
+ public function setLastName(?string $lastName)
+ {
+ $this->lastName = $lastName;
+ }
+}
diff --git a/tests/fixtures/make-doctrine-listener/tests/EventListener/BarListener.php b/tests/fixtures/make-doctrine-listener/tests/EventListener/BarListener.php
new file mode 100644
index 000000000..f9e6f3358
--- /dev/null
+++ b/tests/fixtures/make-doctrine-listener/tests/EventListener/BarListener.php
@@ -0,0 +1,16 @@
+