From 0360ef3b4a9f34e4441b95f6d9071bb49ae1f432 Mon Sep 17 00:00:00 2001
From: Benjamin Franzke <bfr@qbus.de>
Date: Thu, 5 Sep 2019 13:44:09 +0200
Subject: [PATCH] [FEATURE] Add dependency injection support for console
 commands

Transform CommandRegistry into a symfony CommandLoader which
allows console commands to be created on demand.
That means commands are lazy loaded in order to avoid creating
all commands with all their dependencies in every console
invocation. Command will now be loaded when they are either
executed or when command metadata is required (e.g. for
the command listing)

The `site:list` command is adapted to make use of dependency injection.

Releases: master
Resolves: #89139
Change-Id: I64256bf2dc21f0f3fe434aa5dff6176f0fe22233
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61630
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Achim Fritz <af@achimfritz.de>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Achim Fritz <af@achimfritz.de>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../sysext/backend/Configuration/Commands.php |  20 --
 .../backend/Configuration/Services.yaml       |  12 ++
 .../core/Classes/Command/SiteListCommand.php  |  14 +-
 .../Classes/Console/CommandApplication.php    |  15 +-
 .../core/Classes/Console/CommandRegistry.php  | 133 ++++++++++++-
 .../ConsoleCommandPass.php                    |  67 +++++++
 typo3/sysext/core/Classes/ServiceProvider.php |  11 +-
 typo3/sysext/core/Configuration/Commands.php  |  23 ---
 typo3/sysext/core/Configuration/Services.php  |   1 +
 typo3/sysext/core/Configuration/Services.yaml |  42 ++++
 ...figurationMigratedToSymfonyServiceTags.rst |  73 +++++++
 ...encyInjectionSupportForConsoleCommands.rst |  73 +++++++
 .../Unit/Console/CommandRegistryTest.php      | 126 ++++++------
 .../Console/CommandRegistryTest.php           | 181 ++++++++++++++++++
 .../Configuration/Commands.php                |  17 --
 .../Configuration/Services.yaml               |  28 +++
 .../sysext/impexp/Configuration/Commands.php  |  12 --
 .../sysext/impexp/Configuration/Services.yaml |   5 +
 .../install/Classes/ServiceProvider.php       |  34 ++++
 .../sysext/install/Configuration/Commands.php |  20 --
 .../Php/MethodCallMatcher.php                 |   8 +
 .../lowlevel/Configuration/Commands.php       |  33 ----
 .../lowlevel/Configuration/Services.yaml      |  40 ++++
 .../redirects/Configuration/Commands.php      |  13 --
 .../redirects/Configuration/Services.yaml     |   5 +
 .../scheduler/Configuration/Commands.php      |  14 --
 .../scheduler/Configuration/Services.yaml     |   6 +
 .../workspaces/Configuration/Commands.php     |  18 --
 .../workspaces/Configuration/Services.yaml    |  15 ++
 29 files changed, 806 insertions(+), 253 deletions(-)
 delete mode 100644 typo3/sysext/backend/Configuration/Commands.php
 create mode 100644 typo3/sysext/core/Classes/DependencyInjection/ConsoleCommandPass.php
 delete mode 100644 typo3/sysext/core/Configuration/Commands.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Deprecation-89139-ConsoleCommandsConfigurationMigratedToSymfonyServiceTags.rst
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst
 create mode 100644 typo3/sysext/core/Tests/UnitDeprecated/Console/CommandRegistryTest.php
 delete mode 100644 typo3/sysext/extensionmanager/Configuration/Commands.php
 delete mode 100644 typo3/sysext/impexp/Configuration/Commands.php
 delete mode 100644 typo3/sysext/install/Configuration/Commands.php
 delete mode 100644 typo3/sysext/lowlevel/Configuration/Commands.php
 delete mode 100644 typo3/sysext/redirects/Configuration/Commands.php
 delete mode 100644 typo3/sysext/scheduler/Configuration/Commands.php
 delete mode 100644 typo3/sysext/workspaces/Configuration/Commands.php

diff --git a/typo3/sysext/backend/Configuration/Commands.php b/typo3/sysext/backend/Configuration/Commands.php
deleted file mode 100644
index 7a7a9420e93b..000000000000
--- a/typo3/sysext/backend/Configuration/Commands.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-/**
- * Commands to be executed by typo3, where the key of the array
- * is the name of the command (to be called as the first argument after typo3).
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- *
- * example: bin/typo3 backend:lock
- */
-return [
-    'backend:lock' => [
-        'class' => \TYPO3\CMS\Backend\Command\LockBackendCommand::class
-    ],
-    'backend:unlock' => [
-        'class' => \TYPO3\CMS\Backend\Command\UnlockBackendCommand::class
-    ],
-    'referenceindex:update' => [
-        'class' => \TYPO3\CMS\Backend\Command\ReferenceIndexUpdateCommand::class
-    ]
-];
diff --git a/typo3/sysext/backend/Configuration/Services.yaml b/typo3/sysext/backend/Configuration/Services.yaml
index 3dd913461758..14d7049a1805 100644
--- a/typo3/sysext/backend/Configuration/Services.yaml
+++ b/typo3/sysext/backend/Configuration/Services.yaml
@@ -7,6 +7,18 @@ services:
   TYPO3\CMS\Backend\:
     resource: '../Classes/*'
 
+  TYPO3\CMS\Backend\Command\LockBackendCommand:
+    tags:
+      - { name: 'console.command', command: 'backend:lock' }
+
+  TYPO3\CMS\Backend\Command\UnlockBackendCommand:
+    tags:
+      - { name: 'console.command', command: 'backend:unlock' }
+
+  TYPO3\CMS\Backend\Command\ReferenceIndexUpdateCommand:
+    tags:
+      - { name: 'console.command', command: 'referenceindex:update' }
+
   # Temporary workaround until testing framework loads EXT:fluid in functional tests
   # @todo: Fix typo3/testing-framework and remove this
   TYPO3\CMS\Backend\View\BackendTemplateView:
diff --git a/typo3/sysext/core/Classes/Command/SiteListCommand.php b/typo3/sysext/core/Classes/Command/SiteListCommand.php
index c0a51d4156e0..c2fd02ea21b8 100644
--- a/typo3/sysext/core/Classes/Command/SiteListCommand.php
+++ b/typo3/sysext/core/Classes/Command/SiteListCommand.php
@@ -28,6 +28,17 @@ use TYPO3\CMS\Core\Site\SiteFinder;
  */
 class SiteListCommand extends Command
 {
+    /**
+     * @var SiteFinder
+     */
+    protected $siteFinder;
+
+    public function __construct(SiteFinder $siteFinder)
+    {
+        $this->siteFinder = $siteFinder;
+        parent::__construct();
+    }
+
     /**
      * Defines the allowed options for this command
      */
@@ -44,8 +55,7 @@ class SiteListCommand extends Command
     protected function execute(InputInterface $input, OutputInterface $output)
     {
         $io = new SymfonyStyle($input, $output);
-        $siteFinder = new SiteFinder();
-        $sites = $siteFinder->getAllSites();
+        $sites = $this->siteFinder->getAllSites();
 
         if (empty($sites)) {
             $io->title('No sites configured');
diff --git a/typo3/sysext/core/Classes/Console/CommandApplication.php b/typo3/sysext/core/Classes/Console/CommandApplication.php
index 3492572060ba..8b8a86c0dcb1 100644
--- a/typo3/sysext/core/Classes/Console/CommandApplication.php
+++ b/typo3/sysext/core/Classes/Console/CommandApplication.php
@@ -29,7 +29,6 @@ use TYPO3\CMS\Core\Core\Bootstrap;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Information\Typo3Version;
 use TYPO3\CMS\Core\Localization\LanguageService;
-use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
  * Entry point for the TYPO3 Command Line for Commands
@@ -42,15 +41,21 @@ class CommandApplication implements ApplicationInterface
      */
     protected $context;
 
+    /**
+     * @var CommandRegistry
+     */
+    protected $commandRegistry;
+
     /**
      * Instance of the symfony application
      * @var Application
      */
     protected $application;
 
-    public function __construct(Context $context)
+    public function __construct(Context $context, CommandRegistry $commandRegistry)
     {
         $this->context = $context;
+        $this->commandRegistry = $commandRegistry;
         $this->checkEnvironmentOrDie();
         $this->application = new Application('TYPO3 CMS', sprintf(
             '%s (Application Context: <comment>%s</comment>)',
@@ -58,6 +63,7 @@ class CommandApplication implements ApplicationInterface
             Environment::getContext()
         ));
         $this->application->setAutoExit(false);
+        $this->application->setCommandLoader($commandRegistry);
     }
 
     /**
@@ -113,11 +119,12 @@ class CommandApplication implements ApplicationInterface
 
     /**
      * Put all available commands inside the application
+     *
+     * Note: This method will be removed in TYPO3 v11 when support for Configuration/Commands.php is dropped.
      */
     protected function populateAvailableCommands(): void
     {
-        $commands = GeneralUtility::makeInstance(CommandRegistry::class);
-        foreach ($commands as $commandName => $command) {
+        foreach ($this->commandRegistry->getLegacyCommands() as $commandName => $command) {
             /** @var Command $command */
             $this->application->add($command);
         }
diff --git a/typo3/sysext/core/Classes/Console/CommandRegistry.php b/typo3/sysext/core/Classes/Console/CommandRegistry.php
index ff60a1561c8c..969a1be4f09e 100644
--- a/typo3/sysext/core/Classes/Console/CommandRegistry.php
+++ b/typo3/sysext/core/Classes/Console/CommandRegistry.php
@@ -15,7 +15,10 @@ namespace TYPO3\CMS\Core\Console;
  * The TYPO3 project - inspiring people to share!
  */
 
+use Psr\Container\ContainerInterface;
 use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
+use Symfony\Component\Console\Exception\CommandNotFoundException;
 use TYPO3\CMS\Core\Package\PackageManager;
 use TYPO3\CMS\Core\SingletonInterface;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -23,17 +26,22 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
 /**
  * Registry for Symfony commands, populated from extensions
  */
-class CommandRegistry implements \IteratorAggregate, SingletonInterface
+class CommandRegistry implements CommandLoaderInterface, \IteratorAggregate, SingletonInterface
 {
     /**
      * @var PackageManager
      */
     protected $packageManager;
 
+    /**
+     * @var ContainerInterface
+     */
+    protected $container;
+
     /**
      * Map of commands
      *
-     * @var Command[]
+     * @var array
      */
     protected $commands = [];
 
@@ -44,21 +52,68 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
      */
     protected $commandConfigurations = [];
 
+    /**
+     * Map of lazy (DI-managed) command configurations with the command name as key
+     *
+     * @var array
+     */
+    protected $lazyCommandConfigurations = [];
+
     /**
      * @param PackageManager $packageManager
+     * @param ContainerInterface $container
+     */
+    public function __construct(PackageManager $packageManager, ContainerInterface $container)
+    {
+        $this->packageManager = $packageManager;
+        $this->container = $container;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function has($name)
+    {
+        $this->populateCommandsFromPackages();
+
+        return array_key_exists($name, $this->commands);
+    }
+
+    /**
+     * {@inheritdoc}
      */
-    public function __construct(PackageManager $packageManager = null)
+    public function get($name)
     {
-        $this->packageManager = $packageManager ?: GeneralUtility::makeInstance(PackageManager::class);
+        try {
+            return $this->getCommandByIdentifier($name);
+        } catch (UnknownCommandException $e) {
+            throw new CommandNotFoundException($e->getMessage(), [], 1567969355, $e);
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getNames()
+    {
+        $this->populateCommandsFromPackages();
+
+        return array_keys($this->commands);
     }
 
     /**
      * @return \Generator
+     * @deprecated will be removed in TYPO3 v11.0 when support for Configuration/Commands.php is dropped.
      */
     public function getIterator(): \Generator
     {
+        trigger_error('Using ' . self::class . ' as iterable has been deprecated and will stop working in TYPO3 11.0.', E_USER_DEPRECATED);
+
         $this->populateCommandsFromPackages();
         foreach ($this->commands as $commandName => $command) {
+            if (is_string($command)) {
+                $command = $this->getInstance($command, $commandName);
+            }
             yield $commandName => $command;
         }
     }
@@ -73,11 +128,30 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
         $this->populateCommandsFromPackages();
         foreach ($this->commands as $commandName => $command) {
             if ($this->commandConfigurations[$commandName]['schedulable'] ?? true) {
+                if (is_string($command)) {
+                    $command = $this->getInstance($command, $commandName);
+                }
                 yield $commandName => $command;
             }
         }
     }
 
+    /**
+     * @return \Generator
+     * @internal This method will be removed in TYPO3 v11 when support for Configuration/Commands.php is dropped.
+     */
+    public function getLegacyCommands(): \Generator
+    {
+        $this->populateCommandsFromPackages();
+        foreach ($this->commands as $commandName => $command) {
+            // Type string indicates lazy loading
+            if (is_string($command)) {
+                continue;
+            }
+            yield $commandName => $command;
+        }
+    }
+
     /**
      * @param string $identifier
      * @throws CommandNameAlreadyInUseException
@@ -95,7 +169,12 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
             );
         }
 
-        return $this->commands[$identifier] ?? null;
+        $command = $this->commands[$identifier] ?? null;
+        if (is_string($command)) {
+            $command = $this->getInstance($command, $identifier);
+        }
+
+        return $command;
     }
 
     /**
@@ -118,6 +197,13 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
         if ($this->commands) {
             return;
         }
+
+        foreach ($this->lazyCommandConfigurations as $commandName => $commandConfig) {
+            // Lazy commands shall be loaded from the Container on demand, store the command as string to indicate lazy loading
+            $this->commands[$commandName] = $commandConfig['class'];
+            $this->commandConfigurations[$commandName] = $commandConfig;
+        }
+
         foreach ($this->packageManager->getActivePackages() as $package) {
             $commandsOfExtension = $package->getPackagePath() . 'Configuration/Commands.php';
             if (@is_file($commandsOfExtension)) {
@@ -129,6 +215,14 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
                 $commands = require $commandsOfExtension;
                 if (is_array($commands)) {
                     foreach ($commands as $commandName => $commandConfig) {
+                        if (array_key_exists($commandName, $this->lazyCommandConfigurations)) {
+                            // Lazy (DI managed) commands override classic commands from Configuration/Commands.php
+                            // Skip this case to allow extensions to provide commands via DI config and to allow
+                            // TYPO3 v9 backwards compatibile confguration via Configuration/Commands.php.
+                            // Note: Also the deprecation error is skipped on-demand as the extension has been
+                            // adapted and the configuration will be ignored as of TYPO3 v11.
+                            continue;
+                        }
                         if (array_key_exists($commandName, $this->commands)) {
                             throw new CommandNameAlreadyInUseException(
                                 'Command "' . $commandName . '" registered by "' . $package->getPackageKey() . '" is already in use',
@@ -137,9 +231,38 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
                         }
                         $this->commands[$commandName] = GeneralUtility::makeInstance($commandConfig['class'], $commandName);
                         $this->commandConfigurations[$commandName] = $commandConfig;
+
+                        trigger_error(
+                            'Registering console commands in Configuration/Commands.php has been deprecated and will stop working in TYPO3 v11.0.',
+                            E_USER_DEPRECATED
+                        );
                     }
                 }
             }
         }
     }
+
+    protected function getInstance(string $class, string $commandName): Command
+    {
+        $command = $this->container->get($class);
+
+        if ($command instanceof Command) {
+            $command->setName($commandName);
+            return $command;
+        }
+
+        throw new \InvalidArgumentException('Registered console command class ' . get_class($command) . ' does not inherit from ' . Command::class, 1567966448);
+    }
+
+    /**
+     * @internal
+     */
+    public function addLazyCommand(string $commandName, string $serviceName, bool $alias = false, bool $schedulable = true): void
+    {
+        $this->lazyCommandConfigurations[$commandName] = [
+            'class' => $serviceName,
+            'alias' => $alias,
+            'schedulable' => $schedulable,
+        ];
+    }
 }
diff --git a/typo3/sysext/core/Classes/DependencyInjection/ConsoleCommandPass.php b/typo3/sysext/core/Classes/DependencyInjection/ConsoleCommandPass.php
new file mode 100644
index 000000000000..f35cc5cd9d4c
--- /dev/null
+++ b/typo3/sysext/core/Classes/DependencyInjection/ConsoleCommandPass.php
@@ -0,0 +1,67 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\DependencyInjection;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use TYPO3\CMS\Core\Console\CommandRegistry;
+
+/**
+ * @internal
+ */
+final class ConsoleCommandPass implements CompilerPassInterface
+{
+    /**
+     * @var string
+     */
+    private $tagName;
+
+    /**
+     * @param string $tagName
+     */
+    public function __construct(string $tagName)
+    {
+        $this->tagName = $tagName;
+    }
+
+    /**
+     * @param ContainerBuilder $container
+     */
+    public function process(ContainerBuilder $container)
+    {
+        $commandRegistryDefinition = $container->findDefinition(CommandRegistry::class);
+        if (!$commandRegistryDefinition) {
+            return;
+        }
+
+        $unorderedEventListeners = [];
+        foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) {
+            $container->findDefinition($serviceName)->setPublic(true);
+            foreach ($tags as $attributes) {
+                if (!isset($attributes['command'])) {
+                    continue;
+                }
+
+                $commandRegistryDefinition->addMethodCall('addLazyCommand', [
+                    $attributes['command'],
+                    $serviceName,
+                    (bool)($attributes['alias'] ?? false),
+                    (bool)($attributes['schedulable'] ?? true)
+                ]);
+            }
+        }
+    }
+}
diff --git a/typo3/sysext/core/Classes/ServiceProvider.php b/typo3/sysext/core/Classes/ServiceProvider.php
index 481067386562..959b20feaa44 100644
--- a/typo3/sysext/core/Classes/ServiceProvider.php
+++ b/typo3/sysext/core/Classes/ServiceProvider.php
@@ -35,6 +35,7 @@ class ServiceProvider extends AbstractServiceProvider
         return [
             Cache\CacheManager::class => [ static::class, 'getCacheManager' ],
             Console\CommandApplication::class => [ static::class, 'getConsoleCommandApplication' ],
+            Console\CommandRegistry::class => [ static::class, 'getConsoleCommandRegistry' ],
             Context\Context::class => [ static::class, 'getContext' ],
             EventDispatcher\EventDispatcher::class => [ static::class, 'getEventDispatcher' ],
             EventDispatcher\ListenerProvider::class => [ static::class, 'getEventListenerProvider' ],
@@ -77,7 +78,15 @@ class ServiceProvider extends AbstractServiceProvider
 
     public static function getConsoleCommandApplication(ContainerInterface $container): Console\CommandApplication
     {
-        return new Console\CommandApplication($container->get(Context\Context::class));
+        return new Console\CommandApplication(
+            $container->get(Context\Context::class),
+            $container->get(Console\CommandRegistry::class)
+        );
+    }
+
+    public static function getConsoleCommandRegistry(ContainerInterface $container): Console\CommandRegistry
+    {
+        return new Console\CommandRegistry($container->get(Package\PackageManager::class), $container);
     }
 
     public static function getEventDispatcher(ContainerInterface $container): EventDispatcher\EventDispatcher
diff --git a/typo3/sysext/core/Configuration/Commands.php b/typo3/sysext/core/Configuration/Commands.php
deleted file mode 100644
index 15028c8ccf4e..000000000000
--- a/typo3/sysext/core/Configuration/Commands.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-return [
-    'dumpautoload' => [
-        'class' => \TYPO3\CMS\Core\Command\DumpAutoloadCommand::class,
-        'schedulable' => false,
-    ],
-    'mailer:spool:send' => [
-        'class' => \TYPO3\CMS\Core\Command\SendEmailCommand::class,
-    ],
-    'extension:list' => [
-        'class' => \TYPO3\CMS\Core\Command\ExtensionListCommand::class,
-        'schedulable' => false
-    ],
-    'site:list' => [
-        'class' => \TYPO3\CMS\Core\Command\SiteListCommand::class,
-        'schedulable' => false
-    ],
-    'site:show' => [
-        'class' => \TYPO3\CMS\Core\Command\SiteShowCommand::class,
-        'schedulable' => false
-    ]
-];
diff --git a/typo3/sysext/core/Configuration/Services.php b/typo3/sysext/core/Configuration/Services.php
index dc3ef1b92d43..0c5420a9d092 100644
--- a/typo3/sysext/core/Configuration/Services.php
+++ b/typo3/sysext/core/Configuration/Services.php
@@ -21,5 +21,6 @@ return function (ContainerConfigurator $container, ContainerBuilder $containerBu
     $containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener'));
     $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware'));
     $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler'));
+    $containerBuilder->addCompilerPass(new DependencyInjection\ConsoleCommandPass('console.command'));
     $containerBuilder->addCompilerPass(new DependencyInjection\AutowireInjectMethodsPass());
 };
diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml
index bd6e57342068..5ac89f353670 100644
--- a/typo3/sysext/core/Configuration/Services.yaml
+++ b/typo3/sysext/core/Configuration/Services.yaml
@@ -10,6 +10,48 @@ services:
   TYPO3\CMS\Core\DependencyInjection\EnvVarProcessor:
     tags: ['container.env_var_processor']
 
+  TYPO3\CMS\Core\Command\DumpAutoloadCommand:
+    tags:
+      - name: 'console.command'
+        command: 'dumpautoload'
+        schedulable: false
+      - name: 'console.command'
+        command: 'extensionmanager:extension:dumpclassloadinginformation'
+        alias: true
+        schedulable: false
+      - name: 'console.command'
+        command: 'extension:dumpclassloadinginformation'
+        alias: true
+        schedulable: false
+
+  TYPO3\CMS\Core\Command\ExtensionListCommand:
+    tags:
+      - name: 'console.command'
+        command: 'extension:list'
+        schedulable: false
+
+  TYPO3\CMS\Core\Command\SendEmailCommand:
+    tags:
+      - name: 'console.command'
+        command: 'mailer:spool:send'
+      - name: 'console.command'
+        command: 'swiftmailer:spool:send'
+        alias: true
+        schedulable: false
+
+
+  TYPO3\CMS\Core\Command\SiteListCommand:
+    tags:
+      - name: 'console.command'
+        command: 'site:list'
+        schedulable: false
+
+  TYPO3\CMS\Core\Command\SiteShowCommand:
+    tags:
+      - name: 'console.command'
+        command: 'site:show'
+        schedulable: false
+
   TYPO3\CMS\Core\Configuration\SiteConfiguration:
     arguments:
       $configPath: "%env(TYPO3:configPath)%/sites"
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Deprecation-89139-ConsoleCommandsConfigurationMigratedToSymfonyServiceTags.rst b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-89139-ConsoleCommandsConfigurationMigratedToSymfonyServiceTags.rst
new file mode 100644
index 000000000000..4e373e7d45d6
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-89139-ConsoleCommandsConfigurationMigratedToSymfonyServiceTags.rst
@@ -0,0 +1,73 @@
+.. include:: ../../Includes.txt
+
+=====================================================================================
+Deprecation: #89139 - Console Commands configuration migrated to Symfony service tags
+=====================================================================================
+
+See :issue:`89139`
+
+Description
+===========
+
+The console command configuration file format :php:`Configuration/Commands.php`
+has been deprecated in favor of the dependency injection service tag
+`console.command`. The tag allows to configure dependency injection and
+command registration in one single location.
+
+
+Impact
+======
+
+Providing a command configuration in :php:`Configuration/Commands.php` will
+trigger a deprecation warning when the respective commands have not already
+been defined via dependency injection service tags.
+
+Extensions that provide both, the deprecated configuration file and service
+tags, will not trigger a deprecation message in order to allow extensions to
+support multiple TYPO3 major versions.
+
+
+Affected Installations
+======================
+
+TYPO3 installations with custom extensions that configure symfony console commands
+via :php:`Configuration/Commands.php` and have not been migrated to add symfony
+service tags.
+
+
+Migration
+=========
+
+Add the `console.command` tag to command classes. Use the tag attribute `command`
+to specify the command name. The optional tag attribute `schedulable` may be set
+to false to exclude the command from the TYPO3 scheduler.
+
+.. code-block:: yaml
+
+    services:
+      _defaults:
+        autowire: true
+        autoconfigure: true
+        public: false
+
+      MyVendor\MyExt\Commands\FooCommand
+        tags:
+          - name: 'console.command',
+            command: 'my:command'
+            schedulable: false
+
+Command aliases are to be configured as separate tags.
+The optonal tag attribute `alias` should be set to true for alias commands.
+
+.. code-block:: yaml
+
+      MyVendor\MyExt\Commands\BarCommand
+        tags:
+          - name: 'console.command'
+            command: 'my:bar' }
+          - name: 'console.command'
+            command: 'my:old-bar-command'
+            alias: true
+            schedulable: false
+
+.. index:: CLI, PHP-API, PartiallyScanned, ext:core
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst
new file mode 100644
index 000000000000..3a981cd0cf2b
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst
@@ -0,0 +1,73 @@
+.. include:: ../../Includes.txt
+
+=======================================================================
+Feature: #89139 - Add dependency injection support for console commands
+=======================================================================
+
+See :issue:`89139`
+
+Description
+===========
+
+Support for dependency injection in console commands has been added.
+
+Command dependencies can now be injected via constructor or other injection techniques.
+Therefore a new dependency injection tag `console.command` has been added.
+Commands tagged with `console.command` are lazy loaded. That means they will only be
+instantiated when they are actually executed, when the `help` subcommand is executed,
+or when available schedulable commands are iterated.
+
+The legacy command definition format :php:`Confguration/Commands.php` has been deprecated.
+
+
+Impact
+======
+
+It is recommended to configure dependency injection tags for all commands, as the legacy command
+definition format :php:`Confguration/Commands.php` has been deprecated.
+
+Commands that have been configured via `console.command` tag  override legacy commands from
+:php:`Confguration/Commands.php` without throwing a deprecation error for those commands.
+Backwards compatibility with older TYPO3 version can be achieved by specifying both variants,
+legacy configuration in :php:`Confguration/Commands.php` and new configuration via
+`console.command` tag.
+
+
+Usage
+=====
+
+Add the `console.command` tag to command classes.
+Use the tag attribute `command` to specify the command name.
+The optional tag attribute `schedulable` may be set to false
+to exclude the command from the TYPO3 scheduler.
+
+.. code-block:: yaml
+
+    services:
+      _defaults:
+        autowire: true
+        autoconfigure: true
+        public: false
+
+      MyVendor\MyExt\Commands\FooCommand
+        tags:
+          - name: 'console.command'
+            command: 'my:command'
+            schedulable: false
+
+Command aliases are to be configured as separate tags.
+The optonal tag attribute `alias` should be set to true for alias commands.
+
+.. code-block:: yaml
+
+      MyVendor\MyExt\Commands\BarCommand
+        tags:
+          - name: 'console.command'
+            command: 'my:bar'
+          - name: 'console.command'
+            command: 'my:old-bar-command'
+            alias: true
+            schedulable: false
+
+
+.. index:: CLI, PHP-API, ext:core
diff --git a/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php b/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php
index 0d57be61dcaf..aa301cb9b658 100644
--- a/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php
+++ b/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php
@@ -16,11 +16,10 @@ namespace TYPO3\CMS\Core\Tests\Unit\Console;
  */
 
 use org\bovigo\vfs\vfsStream;
-use Prophecy\Prophecy\ObjectProphecy;
+use Psr\Container\ContainerInterface;
 use Symfony\Component\Console\Command\Command;
-use TYPO3\CMS\Core\Console\CommandNameAlreadyInUseException;
+use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
 use TYPO3\CMS\Core\Console\CommandRegistry;
-use TYPO3\CMS\Core\Console\UnknownCommandException;
 use TYPO3\CMS\Core\Package\PackageInterface;
 use TYPO3\CMS\Core\Package\PackageManager;
 use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
@@ -40,112 +39,97 @@ class CommandRegistryTest extends UnitTestCase
      */
     protected $packageManagerProphecy;
 
+    /**
+     * @var ContainerInterface|\Prophecy\Prophecy\ObjectProphecy
+     */
+    protected $containerProphecy;
+
     /**
      * Set up this testcase
      */
     protected function setUp(): void
     {
         parent::setUp();
-        $commandMockClass = $this->getMockClass(Command::class, ['dummy']);
-        $this->rootDirectory = vfsStream::setup('root', null, [
-            'package1' => [
-                'Configuration' => [
-                    'Commands.php' => '<?php return ["first:command" => [ "class" => "' . $commandMockClass . '" ]];',
-                ],
-            ],
-            'package2' => [
-                'Configuration' => [
-                    'Commands.php' => '<?php return ["second:command" => [ "class" => "' . $commandMockClass . '" ]];',
-                ],
-            ],
-            'package3' => [
-                'Configuration' => [
-                    'Commands.php' => '<?php return ["third:command" => [ "class" => "' . $commandMockClass . '" ]];',
-                ],
-            ],
-            'package4' => [
-                'Configuration' => [
-                    'Commands.php' => '<?php return ["third:command" => [ "class" => "' . $commandMockClass . '" ]];',
-                ],
-            ],
-        ]);
 
         /** @var PackageManager */
         $this->packageManagerProphecy = $this->prophesize(PackageManager::class);
+        $this->packageManagerProphecy->getActivePackages()->willReturn([]);
+
+        /** @var ContainerInterface */
+        $this->containerProphecy = $this->prophesize(ContainerInterface::class);
     }
 
     /**
      * @test
      */
-    public function iteratesCommandsOfActivePackages()
+    public function implementsCommandLoaderInterface()
     {
-        /** @var PackageInterface */
-        $package1 = $this->prophesize(PackageInterface::class);
-        $package1->getPackagePath()->willReturn($this->rootDirectory->getChild('package1')->url() . '/');
-        /** @var PackageInterface */
-        $package2 = $this->prophesize(PackageInterface::class);
-        $package2->getPackagePath()->willReturn($this->rootDirectory->getChild('package2')->url() . '/');
-
-        $this->packageManagerProphecy->getActivePackages()->willReturn([$package1->reveal(), $package2->reveal()]);
-
-        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal());
-        $commands = iterator_to_array($commandRegistry);
-
-        self::assertCount(2, $commands);
-        self::assertContainsOnlyInstancesOf(Command::class, $commands);
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        self::assertInstanceof(CommandLoaderInterface::class, $commandRegistry);
     }
 
     /**
      * @test
      */
-    public function throwsExceptionOnDuplicateCommand()
+    public function iteratesLazyCommandsOfActivePackages()
     {
-        /** @var PackageInterface */
-        $package3 = $this->prophesize(PackageInterface::class);
-        $package3->getPackagePath()->willReturn($this->rootDirectory->getChild('package3')->url() . '/');
-        /** @var PackageInterface */
-        $package4 = $this->prophesize(PackageInterface::class);
-        $package4->getPackagePath()->willReturn($this->rootDirectory->getChild('package4')->url() . '/');
-        $package4->getPackageKey()->willReturn('package4');
+        $command1MockClass = $this->getMockClass(Command::class, ['dummy']);
+        $command2MockClass = $this->getMockClass(Command::class, ['dummy']);
+
+        $this->containerProphecy->get('command1')->willReturn(new $command1MockClass);
+        $this->containerProphecy->get('command2')->willReturn(new $command2MockClass);
 
-        $this->packageManagerProphecy->getActivePackages()->willReturn([$package3->reveal(), $package4->reveal()]);
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        $commandRegistry->addLazyCommand('test:command', 'command1');
+        $commandRegistry->addLazyCommand('test:command2', 'command2');
 
-        $this->expectException(CommandNameAlreadyInUseException::class);
-        $this->expectExceptionCode(1484486383);
+        $commandNames = $commandRegistry->getNames();
 
-        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal());
-        iterator_to_array($commandRegistry);
+        self::assertCount(2, $commandNames);
+        self::assertInstanceOf($command1MockClass, $commandRegistry->get('test:command'));
+        self::assertInstanceOf($command1MockClass, $commandRegistry->get('test:command2'));
     }
 
     /**
      * @test
      */
-    public function getCommandByIdentifierReturnsRegisteredCommand()
+    public function iteratesLegacyCommandsOfActivePackages()
     {
-        /** @var PackageInterface|ObjectProphecy $package */
-        $package = $this->prophesize(PackageInterface::class);
-        $package->getPackagePath()->willReturn($this->rootDirectory->getChild('package1')->url() . '/');
-        $package->getPackageKey()->willReturn('package1');
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        $commands = iterator_to_array($commandRegistry->getLegacyCommands());
 
-        $this->packageManagerProphecy->getActivePackages()->willReturn([$package->reveal()]);
-
-        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal());
-        $command = $commandRegistry->getCommandByIdentifier('first:command');
-
-        self::assertInstanceOf(Command::class, $command);
+        self::assertCount(0, $commands);
+        self::assertContainsOnlyInstancesOf(Command::class, $commands);
     }
 
     /**
      * @test
      */
-    public function throwsUnknowCommandExceptionIfUnregisteredCommandIsRequested()
+    public function lazyCommandOverridesLegacyCommandsWithoutDeprecationError()
     {
-        $this->packageManagerProphecy->getActivePackages()->willReturn([]);
+        $commandMockClass = $this->getMockClass(Command::class, ['dummy']);
+        $this->rootDirectory = vfsStream::setup('root', null, [
+            'package1' => [
+                'Configuration' => [
+                    'Commands.php' => '<?php return ["first:command" => [ "class" => "' . $commandMockClass . '" ]];',
+                ],
+            ],
+        ]);
+        /** @var PackageInterface */
+        $package = $this->prophesize(PackageInterface::class);
+        $package->getPackagePath()->willReturn($this->rootDirectory->getChild('package1')->url() . '/');
+
+        $this->packageManagerProphecy->getActivePackages()->willReturn([$package->reveal()]);
+
+        $this->containerProphecy->get($commandMockClass)->willReturn(new $commandMockClass);
+
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        $commandRegistry->addLazyCommand('first:command', $commandMockClass);
 
-        $this->expectException(UnknownCommandException::class);
-        $this->expectExceptionCode(1510906768);
+        $nonLazyCommands = iterator_to_array($commandRegistry->getLegacyCommands());
+        $lazyCommands = $commandRegistry->getNames();
 
-        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal());
-        $commandRegistry->getCommandByIdentifier('foo');
+        self::assertCount(0, $nonLazyCommands);
+        self::assertCount(1, $lazyCommands);
     }
 }
diff --git a/typo3/sysext/core/Tests/UnitDeprecated/Console/CommandRegistryTest.php b/typo3/sysext/core/Tests/UnitDeprecated/Console/CommandRegistryTest.php
new file mode 100644
index 000000000000..777ea211d055
--- /dev/null
+++ b/typo3/sysext/core/Tests/UnitDeprecated/Console/CommandRegistryTest.php
@@ -0,0 +1,181 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\UnitDeprecated\Console;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use org\bovigo\vfs\vfsStream;
+use Prophecy\Prophecy\ObjectProphecy;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\Console\Command\Command;
+use TYPO3\CMS\Core\Console\CommandNameAlreadyInUseException;
+use TYPO3\CMS\Core\Console\CommandRegistry;
+use TYPO3\CMS\Core\Console\UnknownCommandException;
+use TYPO3\CMS\Core\Package\PackageInterface;
+use TYPO3\CMS\Core\Package\PackageManager;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Testcase for CommandRegistry
+ */
+class CommandRegistryTest extends UnitTestCase
+{
+    /**
+     * @var \org\bovigo\vfs\vfsStreamDirectory
+     */
+    protected $rootDirectory;
+
+    /**
+     * @var PackageManager|\Prophecy\Prophecy\ObjectProphecy
+     */
+    protected $packageManagerProphecy;
+
+    /**
+     * @var ContainerInterface|\Prophecy\Prophecy\ObjectProphecy
+     */
+    protected $containerProphecy;
+
+    /**
+     * Set up this testcase
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $commandMockClass = $this->getMockClass(Command::class, ['dummy']);
+        $this->rootDirectory = vfsStream::setup('root', null, [
+            'package1' => [
+                'Configuration' => [
+                    'Commands.php' => '<?php return ["first:command" => [ "class" => "' . $commandMockClass . '" ]];',
+                ],
+            ],
+            'package2' => [
+                'Configuration' => [
+                    'Commands.php' => '<?php return ["second:command" => [ "class" => "' . $commandMockClass . '" ]];',
+                ],
+            ],
+            'package3' => [
+                'Configuration' => [
+                    'Commands.php' => '<?php return ["third:command" => [ "class" => "' . $commandMockClass . '" ]];',
+                ],
+            ],
+            'package4' => [
+                'Configuration' => [
+                    'Commands.php' => '<?php return ["third:command" => [ "class" => "' . $commandMockClass . '" ]];',
+                ],
+            ],
+        ]);
+
+        /** @var PackageManager */
+        $this->packageManagerProphecy = $this->prophesize(PackageManager::class);
+
+        /** @var ContainerInterface */
+        $this->containerProphecy = $this->prophesize(ContainerInterface::class);
+    }
+
+    /**
+     * @test
+     */
+    public function iteratesCommandsOfActivePackages()
+    {
+        /** @var PackageInterface */
+        $package1 = $this->prophesize(PackageInterface::class);
+        $package1->getPackagePath()->willReturn($this->rootDirectory->getChild('package1')->url() . '/');
+        /** @var PackageInterface */
+        $package2 = $this->prophesize(PackageInterface::class);
+        $package2->getPackagePath()->willReturn($this->rootDirectory->getChild('package2')->url() . '/');
+
+        $this->packageManagerProphecy->getActivePackages()->willReturn([$package1->reveal(), $package2->reveal()]);
+
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        $commands = iterator_to_array($commandRegistry);
+
+        self::assertCount(2, $commands);
+        self::assertContainsOnlyInstancesOf(Command::class, $commands);
+    }
+
+    /**
+     * @test
+     */
+    public function iteratesLegacyCommandsOfActivePackages()
+    {
+        /** @var PackageInterface */
+        $package1 = $this->prophesize(PackageInterface::class);
+        $package1->getPackagePath()->willReturn($this->rootDirectory->getChild('package1')->url() . '/');
+        /** @var PackageInterface */
+        $package2 = $this->prophesize(PackageInterface::class);
+        $package2->getPackagePath()->willReturn($this->rootDirectory->getChild('package2')->url() . '/');
+
+        $this->packageManagerProphecy->getActivePackages()->willReturn([$package1->reveal(), $package2->reveal()]);
+
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        $commands = iterator_to_array($commandRegistry->getLegacyCommands());
+
+        self::assertCount(2, $commands);
+        self::assertContainsOnlyInstancesOf(Command::class, $commands);
+    }
+
+    /**
+     * @test
+     */
+    public function throwsExceptionOnDuplicateCommand()
+    {
+        /** @var PackageInterface */
+        $package3 = $this->prophesize(PackageInterface::class);
+        $package3->getPackagePath()->willReturn($this->rootDirectory->getChild('package3')->url() . '/');
+        /** @var PackageInterface */
+        $package4 = $this->prophesize(PackageInterface::class);
+        $package4->getPackagePath()->willReturn($this->rootDirectory->getChild('package4')->url() . '/');
+        $package4->getPackageKey()->willReturn('package4');
+
+        $this->packageManagerProphecy->getActivePackages()->willReturn([$package3->reveal(), $package4->reveal()]);
+
+        $this->expectException(CommandNameAlreadyInUseException::class);
+        $this->expectExceptionCode(1484486383);
+
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        iterator_to_array($commandRegistry);
+    }
+
+    /**
+     * @test
+     */
+    public function getCommandByIdentifierReturnsRegisteredCommand()
+    {
+        /** @var PackageInterface|ObjectProphecy $package */
+        $package = $this->prophesize(PackageInterface::class);
+        $package->getPackagePath()->willReturn($this->rootDirectory->getChild('package1')->url() . '/');
+        $package->getPackageKey()->willReturn('package1');
+
+        $this->packageManagerProphecy->getActivePackages()->willReturn([$package->reveal()]);
+
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        $command = $commandRegistry->getCommandByIdentifier('first:command');
+
+        self::assertInstanceOf(Command::class, $command);
+    }
+
+    /**
+     * @test
+     */
+    public function throwsUnknowCommandExceptionIfUnregisteredCommandIsRequested()
+    {
+        $this->packageManagerProphecy->getActivePackages()->willReturn([]);
+
+        $this->expectException(UnknownCommandException::class);
+        $this->expectExceptionCode(1510906768);
+
+        $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal(), $this->containerProphecy->reveal());
+        $commandRegistry->getCommandByIdentifier('foo');
+    }
+}
diff --git a/typo3/sysext/extensionmanager/Configuration/Commands.php b/typo3/sysext/extensionmanager/Configuration/Commands.php
deleted file mode 100644
index 22dd86b0cf21..000000000000
--- a/typo3/sysext/extensionmanager/Configuration/Commands.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-/**
- * Commands to be executed by typo3, where the key of the array
- * is the name of the command (to be called as the first argument after typo3).
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- */
-return [
-    'extension:activate' => [
-        'class' => \TYPO3\CMS\Extensionmanager\Command\ActivateExtensionCommand::class,
-        'schedulable' => false,
-    ],
-    'extension:deactivate' => [
-        'class' => \TYPO3\CMS\Extensionmanager\Command\DeactivateExtensionCommand::class,
-        'schedulable' => false,
-    ],
-];
diff --git a/typo3/sysext/extensionmanager/Configuration/Services.yaml b/typo3/sysext/extensionmanager/Configuration/Services.yaml
index c94629a9cd35..9c547ab8306a 100644
--- a/typo3/sysext/extensionmanager/Configuration/Services.yaml
+++ b/typo3/sysext/extensionmanager/Configuration/Services.yaml
@@ -40,3 +40,31 @@ services:
         identifier: 'legacy-slot'
         method: 'emitProcessActionsSignal'
         event: TYPO3\CMS\Extensionmanager\Event\AvailableActionsForExtensionEvent
+
+  TYPO3\CMS\Extensionmanager\Command\ActivateExtensionCommand:
+    tags:
+      - name: 'console.command'
+        command: 'extension:activate'
+        schedulable: false
+      - name: 'console.command'
+        command: 'extensionmanager:extension:install'
+        alias: true
+        schedulable: false
+      - name: 'console.command'
+        command: 'extension:install'
+        alias: true
+        schedulable: false
+
+  TYPO3\CMS\Extensionmanager\Command\DeactivateExtensionCommand:
+    tags:
+      - name: 'console.command'
+        command: 'extension:deactivate'
+        schedulable: false
+      - name: 'console.command'
+        command: 'extensionmanager:extension:uninstall'
+        alias: true
+        schedulable: false
+      - name: 'console.command'
+        command: 'extension:uninstall'
+        alias: true
+        schedulable: false
diff --git a/typo3/sysext/impexp/Configuration/Commands.php b/typo3/sysext/impexp/Configuration/Commands.php
deleted file mode 100644
index cecbaea9dec4..000000000000
--- a/typo3/sysext/impexp/Configuration/Commands.php
+++ /dev/null
@@ -1,12 +0,0 @@
-<?php
-/**
- * Commands to be executed by typo3, where the key of the array
- * is the name of the command (to be called as the first argument after typo3).
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- */
-return [
-    'impexp:import' => [
-        'class' => \TYPO3\CMS\Impexp\Command\ImportCommand::class
-    ]
-];
diff --git a/typo3/sysext/impexp/Configuration/Services.yaml b/typo3/sysext/impexp/Configuration/Services.yaml
index 4099126b0450..321ccd27139c 100644
--- a/typo3/sysext/impexp/Configuration/Services.yaml
+++ b/typo3/sysext/impexp/Configuration/Services.yaml
@@ -10,6 +10,11 @@ services:
   TYPO3\CMS\Impexp\Utility\ImportExportUtility:
     public: true
 
+  TYPO3\CMS\Impexp\Command\ImportCommand:
+    tags:
+      - name: 'console.command'
+        command: 'impexp:import'
+
   # Listener for old Signal Slots
   TYPO3\CMS\Impexp\Compatibility\SlotReplacement:
     tags:
diff --git a/typo3/sysext/install/Classes/ServiceProvider.php b/typo3/sysext/install/Classes/ServiceProvider.php
index 0f9475d2248b..a1874db01e6f 100644
--- a/typo3/sysext/install/Classes/ServiceProvider.php
+++ b/typo3/sysext/install/Classes/ServiceProvider.php
@@ -17,6 +17,7 @@ namespace TYPO3\CMS\Install;
 
 use Psr\Container\ContainerInterface;
 use TYPO3\CMS\Core\Configuration\ConfigurationManager;
+use TYPO3\CMS\Core\Console\CommandRegistry;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
 use TYPO3\CMS\Core\DependencyInjection\ContainerBuilder;
@@ -45,6 +46,16 @@ class ServiceProvider extends AbstractServiceProvider
             Service\LoadTcaService::class => [ static::class, 'getLoadTcaService' ],
             Middleware\Maintenance::class => [ static::class, 'getMaintenanceMiddleware' ],
             Controller\UpgradeController::class => [ static::class, 'getUpgradeController' ],
+            Command\LanguagePackCommand::class => [ static::class, 'getLanguagePackCommand' ],
+            Command\UpgradeWizardRunCommand::class => [ static::class, 'getUpgradeWizardRunCommand' ],
+            Command\UpgradeWizardListCommand::class => [ static::class, 'getUpgradeWizardListCommand' ],
+        ];
+    }
+
+    public function getExtensions(): array
+    {
+        return [
+            CommandRegistry::class => [ static::class, 'configureCommands' ],
         ];
     }
 
@@ -101,4 +112,27 @@ class ServiceProvider extends AbstractServiceProvider
             $container->get(Service\LateBootService::class)
         );
     }
+
+    public static function getLanguagePackCommand(ContainerInterface $container): Command\LanguagePackCommand
+    {
+        return new Command\LanguagePackCommand;
+    }
+
+    public static function getUpgradeWizardRunCommand(ContainerInterface $container): Command\UpgradeWizardRunCommand
+    {
+        return new Command\UpgradeWizardRunCommand;
+    }
+
+    public static function getUpgradeWizardListCommand(ContainerInterface $container): Command\UpgradeWizardListCommand
+    {
+        return new Command\UpgradeWizardListCommand;
+    }
+
+    public static function configureCommands(ContainerInterface $container, CommandRegistry $commandRegistry): CommandRegistry
+    {
+        $commandRegistry->addLazyCommand('language:update', Command\LanguagePackCommand::class);
+        $commandRegistry->addLazyCommand('upgrade:run', Command\UpgradeWizardRunCommand::class);
+        $commandRegistry->addLazyCommand('upgrade:list', Command\UpgradeWizardListCommand::class);
+        return $commandRegistry;
+    }
 }
diff --git a/typo3/sysext/install/Configuration/Commands.php b/typo3/sysext/install/Configuration/Commands.php
deleted file mode 100644
index 872a35613cee..000000000000
--- a/typo3/sysext/install/Configuration/Commands.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-/**
- * Commands to be executed by typo3, where the key of the array
- * is the name of the command (to be called as the first argument after typo3).
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- *
- * example: bin/typo3 language:update
- */
-return [
-    'language:update' => [
-        'class' => \TYPO3\CMS\Install\Command\LanguagePackCommand::class
-    ],
-    'upgrade:run' => [
-        'class' => \TYPO3\CMS\Install\Command\UpgradeWizardRunCommand::class
-    ],
-    'upgrade:list' => [
-        'class' => \TYPO3\CMS\Install\Command\UpgradeWizardListCommand::class
-    ]
-];
diff --git a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php
index f909a31af43f..1f0cf4973a1a 100644
--- a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php
+++ b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php
@@ -4420,4 +4420,12 @@ return [
             'Deprecation-90258-SimplifiedRTEParserAPI.rst',
         ],
     ],
+    'TYPO3\CMS\Core\Console\CommandRegistry->getIterator' => [
+        'numberOfMandatoryArguments' => 0,
+        'maximumNumberOfArguments' => 0,
+        'restFiles' => [
+            'Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst',
+            'Deprecation-89139-ConsoleCommandsConfigurationMigratedToSymfonyServiceTags.rst',
+        ],
+    ],
 ];
diff --git a/typo3/sysext/lowlevel/Configuration/Commands.php b/typo3/sysext/lowlevel/Configuration/Commands.php
deleted file mode 100644
index 3b468e21a263..000000000000
--- a/typo3/sysext/lowlevel/Configuration/Commands.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-/**
- * Commands to be executed by the typo3 CLI binary, where the key of the array
- * is the name of the command (to be called as the first argument after "bin/typo3").
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- */
-return [
-    'syslog:list' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\ListSysLogCommand::class
-    ],
-    'cleanup:missingfiles' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\MissingFilesCommand::class
-    ],
-    'cleanup:lostfiles' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\LostFilesCommand::class
-    ],
-    'cleanup:multiplereferencedfiles' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\FilesWithMultipleReferencesCommand::class
-    ],
-    'cleanup:missingrelations' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\MissingRelationsCommand::class
-    ],
-    'cleanup:deletedrecords' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand::class
-    ],
-    'cleanup:orphanrecords' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand::class
-    ],
-    'cleanup:flexforms' => [
-        'class' => \TYPO3\CMS\Lowlevel\Command\CleanFlexFormsCommand::class,
-    ]
-];
diff --git a/typo3/sysext/lowlevel/Configuration/Services.yaml b/typo3/sysext/lowlevel/Configuration/Services.yaml
index 6ded070b54e9..a3f390874b98 100644
--- a/typo3/sysext/lowlevel/Configuration/Services.yaml
+++ b/typo3/sysext/lowlevel/Configuration/Services.yaml
@@ -6,3 +6,43 @@ services:
 
   TYPO3\CMS\Lowlevel\:
     resource: '../Classes/*'
+
+  TYPO3\CMS\Lowlevel\Command\ListSysLogCommand:
+    tags:
+      - name: 'console.command'
+        command: 'syslog:list'
+
+  TYPO3\CMS\Lowlevel\Command\MissingFilesCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:missingfiles'
+
+  TYPO3\CMS\Lowlevel\Command\LostFilesCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:lostfiles'
+
+  TYPO3\CMS\Lowlevel\Command\FilesWithMultipleReferencesCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:multiplereferencedfiles'
+
+  TYPO3\CMS\Lowlevel\Command\MissingRelationsCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:missingrelations'
+
+  TYPO3\CMS\Lowlevel\Command\DeletedRecordsCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:deletedrecords'
+
+  TYPO3\CMS\Lowlevel\Command\OrphanRecordsCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:orphanrecords'
+
+  TYPO3\CMS\Lowlevel\Command\CleanFlexFormsCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:flexforms'
diff --git a/typo3/sysext/redirects/Configuration/Commands.php b/typo3/sysext/redirects/Configuration/Commands.php
deleted file mode 100644
index b9687f07e55f..000000000000
--- a/typo3/sysext/redirects/Configuration/Commands.php
+++ /dev/null
@@ -1,13 +0,0 @@
-<?php
-/**
- * Commands to be executed by typo3, where the key of the array
- * is the name of the command (to be called as the first argument after typo3).
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- */
-return [
-    'redirects:checkintegrity' => [
-        'class' => \TYPO3\CMS\Redirects\Command\CheckIntegrityCommand::class,
-        'schedulable' => true,
-    ],
-];
diff --git a/typo3/sysext/redirects/Configuration/Services.yaml b/typo3/sysext/redirects/Configuration/Services.yaml
index 17372701f400..eb29c25540e0 100644
--- a/typo3/sysext/redirects/Configuration/Services.yaml
+++ b/typo3/sysext/redirects/Configuration/Services.yaml
@@ -13,6 +13,11 @@ services:
   TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook:
     public: true
 
+  TYPO3\CMS\Redirects\Command\CheckIntegrityCommand:
+    tags:
+      - name: 'console.command'
+        command: 'redirects:checkintegrity'
+
   TYPO3\CMS\Redirects\EventListener\RecordHistoryRollbackEventsListener:
     tags:
       - name: event.listener
diff --git a/typo3/sysext/scheduler/Configuration/Commands.php b/typo3/sysext/scheduler/Configuration/Commands.php
deleted file mode 100644
index d6d3aa2fe051..000000000000
--- a/typo3/sysext/scheduler/Configuration/Commands.php
+++ /dev/null
@@ -1,14 +0,0 @@
-<?php
-/**
- * Commands to be executed by typo3, where the key of the array
- * is the name of the command (to be called as the first argument after typo3).
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- */
-return [
-    'scheduler:run' => [
-        'class' => \TYPO3\CMS\Scheduler\Command\SchedulerCommand::class,
-        // command must not be schedulable, otherwise we'll get an endless recursion
-        'schedulable' => false,
-    ]
-];
diff --git a/typo3/sysext/scheduler/Configuration/Services.yaml b/typo3/sysext/scheduler/Configuration/Services.yaml
index c719171541e8..b7456c24a3d6 100644
--- a/typo3/sysext/scheduler/Configuration/Services.yaml
+++ b/typo3/sysext/scheduler/Configuration/Services.yaml
@@ -7,6 +7,12 @@ services:
   TYPO3\CMS\Scheduler\:
     resource: '../Classes/*'
 
+  TYPO3\CMS\Scheduler\Command\SchedulerCommand:
+    tags:
+      - name: 'console.command'
+        command: 'scheduler:run'
+        schedulable: false
+
   TYPO3\CMS\Scheduler\SystemInformation\ToolbarItemProvider:
     tags:
       - name: event.listener
diff --git a/typo3/sysext/workspaces/Configuration/Commands.php b/typo3/sysext/workspaces/Configuration/Commands.php
deleted file mode 100644
index e156d33fa66c..000000000000
--- a/typo3/sysext/workspaces/Configuration/Commands.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-/**
- * Commands to be executed by the typo3 CLI binary, where the key of the array
- * is the name of the command (to be called as the first argument after "bin/typo3").
- * Required parameter is the "class" of the command which needs to be a subclass
- * of Symfony/Console/Command.
- */
-return [
-    'cleanup:versions' => [
-        'class' => \TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand::class,
-    ],
-    'cleanup:previewlinks' => [
-        'class' => \TYPO3\CMS\Workspaces\Command\CleanupPreviewLinksCommand::class,
-    ],
-    'workspace:autopublish' => [
-        'class' => \TYPO3\CMS\Workspaces\Command\AutoPublishCommand::class,
-    ],
-];
diff --git a/typo3/sysext/workspaces/Configuration/Services.yaml b/typo3/sysext/workspaces/Configuration/Services.yaml
index 9c62f633de7c..2649612811de 100644
--- a/typo3/sysext/workspaces/Configuration/Services.yaml
+++ b/typo3/sysext/workspaces/Configuration/Services.yaml
@@ -15,6 +15,21 @@ services:
   TYPO3\CMS\Workspaces\Service\GridDataService:
     public: true
 
+  TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:versions'
+
+  TYPO3\CMS\Workspaces\Command\CleanupPreviewLinksCommand:
+    tags:
+      - name: 'console.command'
+        command: 'cleanup:previewlinks'
+
+  TYPO3\CMS\Workspaces\Command\AutoPublishCommand:
+    tags:
+      - name: 'console.command'
+        command: 'workspace:autopublish'
+
   TYPO3\CMS\Workspaces\Compatibility\SlotReplacement:
     tags:
       - name: event.listener
-- 
GitLab