Writing a Command
A Magento CLI command is a Symfony command class in your module, registered in di.xml so bin/magento can find it.
protected function execute(InputInterface $input, OutputInterface $output): intbin/magento is a Symfony Console application. Every subcommand you have typed — cache:flush, setup:upgrade — is a PHP class extending Symfony\Component\Console\Command\Command. Adding your own means writing one more such class and telling Magento it exists.
The two halves
The class lives in Console/Command inside your module — the directory under app/code/Vendor/Module that registration.php declares. It overrides two methods. configure() declares the name, the description and any input. execute(InputInterface $input, OutputInterface $output): int does the work and returns an exit code.
Magento does not scan for command classes. It builds its list from the commands array argument on Magento\Framework\Console\CommandList, so the second half is an entry in your module's etc/di.xml. The item name there is only an array key used for merging; the name a user types comes from the class.
Making it appear
After editing di.xml, run bin/magento cache:flush and then bin/magento setup:di:compile. Until the config cache is cleared the command is missing from bin/magento list, which looks identical to the class being broken.
Shorthand and longhand
Magento 2.4.9 ships Symfony Console 7.4, where #[AsCommand(name: ..., description: ...)] above the class is read by Command::__construct(). It is the same thing as calling setName() and setDescription() inside configure(). Write either, recognise both. Neither replaces the di.xml entry.
Example
<?php
// app/code/Soundscode/Ops/Console/Command/VersionShowCommand.php
declare(strict_types=1);
namespace Soundscode\Ops\Console\Command;
use Magento\Framework\App\ProductMetadataInterface;
use Magento\Framework\Console\Cli;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'soundscode:version:show',
description: 'Prints the installed product name, version and edition'
)]
class VersionShowCommand extends Command
{
public function __construct(
private readonly ProductMetadataInterface $productMetadata
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln(sprintf(
'<info>%s %s (%s)</info>',
$this->productMetadata->getName(),
$this->productMetadata->getVersion(),
$this->productMetadata->getEdition()
));
return Cli::RETURN_SUCCESS;
}
}
// $ bin/magento soundscode:version:show
// Magento 2.4.9 (Community)When to use it
- Expose a one-off data fix as bin/magento vendor:fix:prices instead of a throwaway script in pub/.
- Give operations a supported way to warm or report on something without database access.
- Wrap a third-party API sync so cron and a human can trigger the same code path.
More examples
The di.xml entry that registers it
This is the half people forget. The class on its own does nothing: bin/magento asks CommandList for its commands, and CommandList only knows what di.xml gave it. Note the target is the concrete class Magento\Framework\Console\CommandList, not CommandListInterface, because arguments are declared against a type. The item name soundscode_version_show never appears on the command line; it exists so another module could override or remove this entry by reusing the same key. After saving this file run bin/magento cache:flush then bin/magento setup:di:compile, and confirm with bin/magento list | grep soundscode.
<?xml version="1.0"?>
<!-- app/code/Soundscode/Ops/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="soundscode_version_show" xsi:type="object">Soundscode\Ops\Console\Command\VersionShowCommand</item>
</argument>
</arguments>
</type>
</config>
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="soundscode_version_show" xsi:type="object">Soundscode\Ops\Console\Command\VersionShowCommand</item>
</argument>
</arguments>
</type>
</config>The same command written longhand
Two shorthands from the primary example expanded. The #[AsCommand] attribute becomes setName() and setDescription() calls in configure(); the promoted constructor property becomes a declared property plus an assignment. Both pairs compile to the same object, and Magento core still uses these longer forms everywhere, so this is the shape you will meet when reading vendor/magento. Keep the parent::configure() call: Symfony's base implementation is empty today, but core commands all make it and an abstract parent class in your own code may rely on it.
<?php
// app/code/Soundscode/Ops/Console/Command/VersionShowCommand.php
declare(strict_types=1);
namespace Soundscode\Ops\Console\Command;
use Magento\Framework\App\ProductMetadataInterface;
use Symfony\Component\Console\Command\Command;
class VersionShowCommand extends Command
{
private ProductMetadataInterface $productMetadata;
public function __construct(ProductMetadataInterface $productMetadata)
{
$this->productMetadata = $productMetadata;
parent::__construct();
}
protected function configure(): void
{
$this->setName('soundscode:version:show');
$this->setDescription('Prints the installed product name, version and edition');
parent::configure();
}
// execute() unchanged
}Checking that it loaded
Before debugging the class, prove Magento can see it. If grep finds nothing, the problem is registration or caching, not your code: check that the module is enabled in app/etc/config.php, that the di.xml path is etc/di.xml and not etc/frontend/di.xml, and that you flushed the cache. The description shown by list and help comes straight from the attribute or setDescription(), so a blank description there is a reliable sign that configure() is not being reached.
$ bin/magento list | grep soundscode
soundscode:version:show Prints the installed product name, version and edition
$ bin/magento help soundscode:version:show
Description:
Prints the installed product name, version and edition
Usage:
soundscode:version:show
Discussion