Custom bin/magento commands are perfect for maintenance jobs, imports, reports and one-off fixes that should not be exposed through the admin. They use Symfony Console, so you get argument parsing, help text and formatted output for free.

We will build mageservices:orders:pending, which lists orders stuck in the pending state for longer than a given number of hours — useful for spotting payment integration problems.

Step 1: The Command Class

Console/Command/PendingOrders.php
<?php
declare(strict_types=1);

namespace MageServices\Tools\Console\Command;

use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrderBuilder;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Magento\Sales\Api\OrderRepositoryInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class PendingOrders extends Command
{
    private const OPTION_HOURS = 'hours';
    private const OPTION_LIMIT = 'limit';

    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
        private readonly SortOrderBuilder $sortOrderBuilder,
        private readonly DateTime $dateTime
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->setName('mageservices:orders:pending')
            ->setDescription('List orders that have been pending longer than a number of hours')
            ->addOption(self::OPTION_HOURS, null, InputOption::VALUE_REQUIRED, 'Minimum age in hours', '2')
            ->addOption(self::OPTION_LIMIT, 'l', InputOption::VALUE_REQUIRED, 'Maximum orders to show', '50');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $hours = max(1, (int) $input->getOption(self::OPTION_HOURS));
        $limit = max(1, (int) $input->getOption(self::OPTION_LIMIT));
        $before = $this->dateTime->gmtDate('Y-m-d H:i:s', strtotime(sprintf('-%d hours', $hours)));

        $sortOrder = $this->sortOrderBuilder->setField('created_at')->setAscendingDirection()->create();
        $criteria = $this->searchCriteriaBuilder
            ->addFilter('state', 'new')
            ->addFilter('created_at', $before, 'lteq')
            ->addSortOrder($sortOrder)
            ->setPageSize($limit)
            ->create();

        $orders = $this->orderRepository->getList($criteria)->getItems();

        if ($orders === []) {
            $output->writeln('<info>No pending orders older than ' . $hours . ' hours.</info>');
            return Command::SUCCESS;
        }

        $table = new Table($output);
        $table->setHeaders(['Increment ID', 'Created (UTC)', 'Status', 'Payment', 'Grand total']);

        foreach ($orders as $order) {
            $table->addRow([
                $order->getIncrementId(),
                $order->getCreatedAt(),
                $order->getStatus(),
                $order->getPayment()?->getMethod() ?? '-',
                sprintf('%s %.2f', $order->getOrderCurrencyCode(), (float) $order->getGrandTotal()),
            ]);
        }

        $table->render();
        $output->writeln(sprintf('<comment>%d order(s) found.</comment>', count($orders)));

        return Command::SUCCESS;
    }
}

Note the return types: configure(): void and execute(): int. Magento 2.4.9 adds support for Symfony 7.4, where these signatures are strictly typed, and custom classes that extend Symfony classes must match them.

Step 2: Register the Command in di.xml

Commands are added to Magento's command list through di.xml. Because every command is instantiated when bin/magento starts, inject heavy dependencies as proxies so they are only created when your command actually runs.

etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\Console\CommandListInterface">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="mageservices_orders_pending" xsi:type="object">MageServices\Tools\Console\Command\PendingOrders</item>
            </argument>
        </arguments>
    </type>
    <type name="MageServices\Tools\Console\Command\PendingOrders">
        <arguments>
            <argument name="orderRepository" xsi:type="object">Magento\Sales\Api\OrderRepositoryInterface\Proxy</argument>
        </arguments>
    </type>
</config>

Step 3: Run It

bin/magento setup:upgrade
bin/magento list mageservices
bin/magento mageservices:orders:pending --hours=6 -l 20

Arguments vs Options

ArgumentsOptions
Syntaxcommand valuecommand --name=value or -n value
Order mattersYesNo
Best forRequired inputs such as a SKU or file pathOptional flags such as --dry-run or limits

Running Code in an Area

CLI commands run without an application area. If your command renders templates, sends emails or calls code that depends on area configuration, emulate an area:

use Magento\Framework\App\Area;
use Magento\Framework\App\State;

// Inject State $appState in the constructor, then:
$this->appState->emulateAreaCode(Area::AREA_FRONTEND, function () use ($output): void {
    // Code that needs frontend configuration, e.g. sending a transactional email.
});

Tips for Production Commands

  • Add a --dry-run option to commands that change data, and default to safe behaviour.
  • Process large data sets in batches with collections or search criteria pages, and free memory between batches.
  • Return Command::FAILURE on errors so cron and CI pipelines can detect them.
  • Log what the command changed, not just that it ran.
  • For recurring tasks use a cron job that calls the same service class.

Adding an Argument and a Dry-Run Option

Commands that change data should be safe by default. Here is the pattern we use: a required argument and a --dry-run flag that reports what would change without saving anything.

protected function configure(): void
{
    $this->setName('mageservices:product:disable')
        ->setDescription('Disable a product by SKU')
        ->addArgument('sku', InputArgument::REQUIRED, 'Product SKU')
        ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would change without saving');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $sku = (string) $input->getArgument('sku');
    $dryRun = (bool) $input->getOption('dry-run');

    try {
        $product = $this->productRepository->get($sku, true, 0);
    } catch (NoSuchEntityException) {
        $output->writeln(sprintf('<error>Product "%s" not found.</error>', $sku));
        return Command::FAILURE;
    }

    if ($dryRun) {
        $output->writeln(sprintf('<comment>[dry-run] Would disable %s.</comment>', $sku));
        return Command::SUCCESS;
    }

    $product->setStatus(Status::STATUS_DISABLED);
    $this->productRepository->save($product);
    $output->writeln(sprintf('<info>Disabled %s.</info>', $sku));

    return Command::SUCCESS;
}

This snippet uses Symfony\Component\Console\Input\InputArgument, Magento\Catalog\Api\ProductRepositoryInterface, Magento\Catalog\Model\Product\Attribute\Source\Status and Magento\Framework\Exception\NoSuchEntityException. Loading the product with store ID 0 and saving it changes the default (global) value rather than a store view override.

Frequently Asked Questions

Why does bin/magento become slow after adding my command?

Your command's dependencies are created whenever bin/magento starts. Use proxies for heavy dependencies in di.xml.

Can a CLI command run in production mode?

Yes. After adding a command, run setup:di:compile as part of your normal deployment.

How do I make a command for a specific store?

Add a --store option and use StoreManagerInterface::setCurrentStore() or store emulation before running store-specific logic.

🚀

Need help with this on your store?

Our Magento engineers can implement it for you, review your code or take on the whole project.

Explore Magento Extension Development
MS
About the author

Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.