← Back to blog

How to Create a Custom CLI Command in Magento 2

Rodrigo Biazzi

Custom bin/magento commands are one of the most useful things a module can ship. Imports, data fixes, cleanup jobs, cache warmers and one off migrations are all easier to run, test and schedule as commands than as admin buttons or hidden controller URLs. Magento's CLI is built on Symfony Console, so most of what you know from Symfony applies. There are a few Magento specific details that cause real problems if you miss them, and they are the focus here.

The examples target Magento Open Source and Adobe Commerce 2.4.8 on PHP 8.3 or 8.4.

A basic command

A command is a class that extends Symfony\Component\Console\Command\Command:

<?php
declare(strict_types=1);

namespace Acme\Notice\Console\Command;

use Acme\Notice\Model\Importer;
use Magento\Framework\App\Area;
use Magento\Framework\App\State;
use Magento\Framework\Exception\LocalizedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class ImportNotices extends Command
{
    private const ARG_FILE = 'file';
    private const OPT_DRY_RUN = 'dry-run';

    public function __construct(
        private readonly Importer $importer,
        private readonly State $appState
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->setName('acme:notice:import')
            ->setDescription('Import store notices from a CSV file')
            ->addArgument(self::ARG_FILE, InputArgument::REQUIRED, 'Path to the CSV file')
            ->addOption(self::OPT_DRY_RUN, null, InputOption::VALUE_NONE, 'Validate without saving');

        parent::configure();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        try {
            $this->appState->setAreaCode(Area::AREA_ADMINHTML);
        } catch (LocalizedException) {
        }

        $file = (string) $input->getArgument(self::ARG_FILE);
        if (!is_readable($file)) {
            $output->writeln(sprintf('<error>File not readable: %s</error>', $file));
            return Command::FAILURE;
        }

        $dryRun = (bool) $input->getOption(self::OPT_DRY_RUN);
        $count = $this->importer->import($file, $dryRun);

        $output->writeln(sprintf('<info>%d notices %s.</info>', $count, $dryRun ? 'validated' : 'imported'));
        return Command::SUCCESS;
    }
}

Use a namespace prefix in the command name, such as acme:, so it does not collide with core or other vendors. Core command names are listed by bin/magento list.

Always return an integer exit code. Command::SUCCESS is 0 and Command::FAILURE is 1. Deploy scripts, CI jobs and cron wrappers rely on the exit code to know whether something failed. A command that prints an error and returns 0 will look like a success to all of them.

Arguments, options and validation

Arguments are positional and usually required, like the file path above. Options are named, optional and can be combined freely, like --dry-run or --store=de. A good rule is that the thing the command acts on is an argument and everything that changes how it acts is an option.

Symfony converts everything to strings or booleans. Cast and validate input at the top of execute() and fail early with a clear message and a non zero exit code. Do not let an invalid store code or a negative batch size travel deep into a service, where the resulting error will be much harder to understand. For options with a fixed set of values, list them in the option description so bin/magento help acme:notice:import documents them.

Registering the command

Magento does not discover commands automatically. Add the class to the command list in 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\CommandList">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="acme_notice_import" xsi:type="object">Acme\Notice\Console\Command\ImportNotices</item>
            </argument>
        </arguments>
    </type>

    <type name="Acme\Notice\Console\Command\ImportNotices">
        <arguments>
            <argument name="importer" xsi:type="object">Acme\Notice\Model\Importer\Proxy</argument>
        </arguments>
    </type>
</config>

The commands array is merged across modules by item name, as explained in the dependency injection article. Give the item a unique name. After adding it, run bin/magento setup:upgrade if the module is new, or clean the config cache. In production mode, recompile with setup:di:compile.

Why the proxy matters

Every time anyone runs bin/magento, for any command, Magento instantiates all registered commands to build the list. That includes your constructor and the constructors of everything it injects.

If Importer injects a repository that injects a resource model that connects to the database, then every bin/magento call opens that connection. Worse, commands like setup:install run before the database exists, and a command whose constructor chain needs the database breaks them. The proxy in the di.xml above delays creating Importer until execute() actually calls it. Use proxies for every non trivial dependency of a command. The article on factories and proxies explains how they are generated.

For the same reason, never do work in the constructor or in configure(). Both run on every bin/magento call.

Area code

CLI commands start without an area code. Some code paths require one and throw Area code is not set, for example saving products, rendering templates, sending emails or anything that touches design configuration.

Setting the area at the start of execute() fixes it. The try and catch block is needed because the area can only be set once per process, and another command or plugin may have set it already. Pick the area that matches what the code expects: adminhtml for catalog changes made as an admin would, frontend for anything that renders storefront content, crontab if you are reproducing a cron job.

An alternative is State::emulateAreaCode(), which runs a callback in a given area and restores the previous state afterwards. It is cleaner when only part of the command needs an area.

Better output

For long operations, a progress bar is better than one line per item:

$progress = new \Symfony\Component\Console\Helper\ProgressBar($output, count($rows));
$progress->start();
foreach ($rows as $row) {
    $this->importer->importRow($row);
    $progress->advance();
}
$progress->finish();
$output->writeln('');

Respect verbosity. Print summaries by default and details only with -v, using $output->isVerbose(). When a command runs from cron or CI, the log should not contain thousands of lines nobody reads.

Ask for confirmation before destructive actions with the question helper, and add a --force option that skips the question so the command can still run unattended.

Preventing parallel runs

If a command is scheduled from cron and one run takes longer than the interval, two instances may run at once. Magento has a lock manager that works across processes:

use Magento\Framework\Lock\LockManagerInterface;

if (!$this->lockManager->lock('acme_notice_import', 0)) {
    $output->writeln('<comment>Another import is running.</comment>');
    return Command::SUCCESS;
}
try {
    $count = $this->importer->import($file, $dryRun);
} finally {
    $this->lockManager->unlock('acme_notice_import');
}

Inject LockManagerInterface through the constructor like any other dependency. The lock backend is configured in env.php and defaults to the database. A timeout of 0 means do not wait. The finally block makes sure the lock is released when the import throws.

Memory and long runs

Commands that process large data sets run into memory limits. A few habits keep them stable:

  • Process in batches and read data by ID range rather than loading everything at once.
  • Avoid repositories with internal caches for bulk work, or clear them between batches. The product repository is the usual offender.
  • Call gc_collect_cycles() between batches if memory still grows, and log memory_get_usage(true) while testing.
  • Run with php -d memory_limit=2G bin/magento ... when you know the job is heavy, instead of raising the limit globally.

Testing and scheduling

Keep the logic in a service class and the command as a thin wrapper. Then the same service can be unit tested, called from a cron job, and used by the command. Symfony's CommandTester works with Magento commands for testing the wrapper itself, with the services mocked.

If a command needs to run on a schedule, prefer a Magento cron job calling the same service over a separate system crontab line. That keeps scheduling visible in cron_schedule and in code review. The cron jobs guide covers that side.

FAQ

Why does my command not appear in bin/magento list?

Check that it is registered in the commands argument of Magento\Framework\Console\CommandList in di.xml, that the module is enabled, and that the config cache is clean. In production mode, run setup:di:compile again.

How do I fix Area code is not set in a CLI command?

Call setAreaCode on Magento\Framework\App\State at the start of execute, wrapped in a try and catch because it can only be set once. Use emulateAreaCode if only part of the command needs it.

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

Every command is instantiated on each call. If your constructor chain is heavy or connects to the database, configure proxies for those dependencies in di.xml.

What should execute return?

An integer exit code. Return Command::SUCCESS on success and Command::FAILURE on failure, so scripts and CI can detect errors.

I am available for freelance Magento and Adobe Commerce work. If you need import tools, data fixes or maintenance commands built for your store, get in touch.

Related articles