Build Custom CLI Commands in Magento 2 (2024)

Build Custom CLI Commands in Magento 2 | 2024 Guide

Build Custom CLI Commands in Magento 2 (2024)

Whether you're migrating product data from a legacy system or reprocessing thousands of orders, the Magento Admin panel has limits. It isn't built for bulk operations. It struggles with large datasets. Custom CLI commands fill this gap. They give developers fine-grained control, proper error handling, and the ability to hook into scheduled automation — without the overhead of a full HTTP request lifecycle.

This tutorial walks through building a production-ready Magento 2 custom CLI command from scratch. We cover everything from module scaffolding to safe deployment on live stores. We're targeting Magento 2.4.x with PHP 8.2+.

By the end, you'll have a fully functional command that accepts arguments, validates input, and integrates with Magento's scheduled tasks.

---

What You'll Need

- Magento 2.4.6 or later (2.4.7 recommended) - PHP 8.2+ - Composer-based project setup - Familiarity with Magento module structure and dependency injection - SSH access to your environment (for deployment steps)

We assume you've built at least one Magento module before. If not, review the module development basics first.

---

Why Custom CLI Commands Matter in Magento 2 Projects

Magento ships with a powerful CLI tool. It's built on the Symfony Console component — a mature PHP library for building command-line interfaces. Running bin/magento gives you dozens of built-in commands: cache flush, indexer reindex, setup upgrade, and more.

Real projects outgrow those defaults. Consider these scenarios where custom commands become essential:

- Bulk data processing: Updating 200,000 product prices from a pricing engine feed - Store migrations: Moving customer data between websites or store views - Scheduled maintenance: Archiving old quotes or purging expired sessions - Integration triggers: Manually re-pushing failed orders to a third-party ERP

The alternative is messy. Writing a one-off script in pub/ creates technical debt fast. Abusing a cron job with inline logic makes things worse. Custom CLI commands can be the right tool here. They're faster. They integrate cleanly with Magento's scheduler. CLI commands are first-class Magento citizens. They respect the DI container — Magento's dependency injection system, which manages object creation and dependencies. They integrate with your deploy pipeline. They support proper argument validation.

Cron vs CLI — a quick distinction. Cron jobs schedule automated runs. CLI commands give developers interactive, on-demand control. You can trigger them manually or schedule them via cron — best of both worlds.

Now let's build one.

---

Setting Up Your Module and Command Class Structure

Start by creating a simple module. If you already have a custom module, skip straight to the command registration.

File structure

app/code/Unomage/CliDemo/

├── Console/ │ └── Command/ │ └── ProductPriceSyncCommand.php ├── etc/ │ └── module.xml ├── registration.php └── composer.json

Set up your module files in this order:

Step 1 — registration.php

<?php

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register( ComponentRegistrar::MODULE, 'Unomage_CliDemo', __DIR__ );

Step 2 — etc/module.xml

<?xml version="1.0"?>

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> <module name="Unomage_CliDemo" setup_version="1.0.0"/> </config>

Step 3 — Register the command via etc/di.xml

Magento discovers commands via di.xml registration:

<?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="unomage_product_price_sync" xsi:type="object"> Unomage\CliDemo\Console\Command\ProductPriceSyncCommand </item> </argument> </arguments> </type> </config>

Pro tip: Always use a vendor-prefixed command name (e.g., unomage:product:price-sync). This avoids collisions with core or third-party commands. Magento follows a vendor:resource:action naming convention — stick to it.

Step 4 — Enable and verify

bin/magento module:enable Unomage_CliDemo

bin/magento setup:upgrade bin/magento list | grep unomage

---

Implementing Input Arguments, Options, and Validation

The command class handles three responsibilities: defining the command name, accepting input, and validating arguments. It extends Symfony's Command and contains the execution logic. Here's the full class — we'll break it down after.

<?php

declare(strict_types=1);

namespace Unomage\CliDemo\Console\Command;

use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface;

class ProductPriceSyncCommand extends Command { private const COMMAND_NAME = 'unomage:product:price-sync'; private const ARG_STORE_ID = 'store-id'; private const OPT_DRY_RUN = 'dry-run'; private const OPT_BATCH_SIZE = 'batch-size';

protected function configure(): void { $this->setName(self::COMMAND_NAME) ->setDescription('Sync product prices from the pricing engine for a given store.') ->addArgument( self::ARG_STORE_ID, InputArgument::REQUIRED, 'The store ID to process prices for.' ) ->addOption( self::OPT_DRY_RUN, null, InputOption::VALUE_NONE, 'Run without saving changes (preview mode).' ) ->addOption( self::OPT_BATCH_SIZE, 'b', InputOption::VALUE_OPTIONAL, 'Number of products per batch.', 500 );

parent::configure(); }

protected function execute(InputInterface $input, OutputInterface $output): int { $storeId = (int) $input->getArgument(self::ARG_STORE_ID); $isDryRun = (bool) $input->getOption(self::OPT_DRY_RUN); $batchSize = (int) $input->getOption(self::OPT_BATCH_SIZE);

if ($storeId <= 0) { $output->writeln('<error>Store ID must be a positive integer.</error>'); return Command::FAILURE; }

if ($batchSize < 1 || $batchSize > 5000) { $output->writeln('<error>Batch size must be between 1 and 5000.</error>'); return Command::FAILURE; }

$output->writeln(sprintf( '<info>Starting price sync for store %d (batch: %d, dry-run: %s)</info>', $storeId, $batchSize, $isDryRun ? 'YES' : 'NO' ));

return Command::SUCCESS; } }

Three things worth calling out:

Return codes matter. Always return Command::SUCCESS (0) or Command::FAILURE (1). Cron jobs and CI/CD pipelines use the exit code to decide whether to raise an alert.

Validation belongs in execute() — not in a separate method you'll forget. Fail fast, fail early.

--dry-run is non-negotiable on any command that writes data. You'll thank yourself the first time someone runs it on production during peak traffic.

Warning: Don't cast $input->getArgument() directly to int without checking for null first. On older Symfony Console versions this could silently produce 0, bypassing validation. The explicit check ($storeId <= 0) catches it.

Symfony Console output tags

| Tag | Use | |---|---| | | General success messages | | | Warnings or informational notes | | | Failure messages (outputs to stderr) | | | Interactive prompts |

---

Injecting Dependencies and Interacting with Magento Services

Here's where Magento CLI development diverges from plain Symfony. You can't just new up a service — you need the DI container. Commands are registered in di.xml and instantiated through the object manager. Constructor injection works exactly as it does in any other Magento class.

Keep business logic out of the command entirely. Put it in a dedicated service class like PriceSyncService. Commands stay testable. God classes stay out of your codebase.

public function __construct(

private readonly StoreManagerInterface $storeManager, private readonly PriceSyncService $priceSyncService, private readonly SearchCriteriaBuilder $searchCriteriaBuilder, private readonly LoggerInterface $logger, string $name = null ) { parent::__construct($name); }

Notice string $name = null at the end. The parent Command class requires it. Forgetting it causes subtle bugs where your command name gets overwritten.

Proxy injection for memory-intensive commands

For commands that process large datasets, inject heavy dependencies as proxies to defer instantiation:

<type name="Unomage\CliDemo\Console\Command\ProductPriceSyncCommand">

<arguments> <argument name="priceSyncService" xsi:type="object"> Unomage\CliDemo\Service\PriceSyncService\Proxy </argument> </arguments> </type>

Magento auto-generates proxy classes during setup:di:compile. The proxy prevents the full dependency tree from loading until the command actually runs. This matters because bin/magento list initialises every registered command on each call.

---

Testing, Scheduling, and Deploying CLI Commands in Production

Unit testing the command

Test the service layer independently. For the command itself, use Symfony's CommandTester:

public function testFailsWithInvalidStoreId(): void

{ $storeManager = $this->createMock(StoreManagerInterface::class); $storeManager->method('getStore')->willThrowException(new \Exception('Not found'));

$command = new ProductPriceSyncCommand( $storeManager, $this->createMock(PriceSyncService::class), $this->createMock(SearchCriteriaBuilder::class), $this->createMock(LoggerInterface::class) );

$tester = new CommandTester($command); $tester->execute(['store-id' => 99]);

$this->assertStringContainsString('not found', $tester->getDisplay()); $this->assertEquals(1, $tester->getStatusCode()); }

Scheduling via Magento cron

The cron class calls the same PriceSyncService. No logic duplication. The CLI command and cron job share one service. Add to etc/crontab.xml:

<job name="unomage_price_sync" instance="Unomage\CliDemo\Cron\PriceSyncCron" method="execute">

<schedule>0 2 *</schedule> </job>

Production deployment checklist

# 1. Compile DI (generates proxies and interceptors)

bin/magento setup:di:compile

2. Flush cache

bin/magento cache:flush

3. Verify the command is registered

bin/magento list | grep unomage

4. Always run a dry-run first

bin/magento unomage:product:price-sync 1 --dry-run

Warning: Never skip setup:di:compile after adding a new command. Missing proxy generation causes "class not found" errors in production. It's the kind of bug that only surfaces under load.

Memory and timeout considerations

# Increase PHP memory limit for a single run

php -d memory_limit=2G bin/magento unomage:product:price-sync 1 --batch-size=1000

Run in the background on remote servers

nohup php bin/magento unomage:product:price-sync 1 > /var/log/price-sync.log 2>&1 &

On Adobe Commerce Cloud, use magento-cloud ssh to run commands against specific environments. This avoids PHP-FPM timeout restrictions entirely.

---

Wrapping Up

A well-built Magento 2 custom CLI command is more than a script. It's maintainable. It's testable. It fits naturally into your store's infrastructure. The pattern scales from a simple data fix to a multi-step workflow running nightly across multiple store views.

Key principles to carry forward:

- Register commands via di.xml — not bootstrap hacks - Validate early — fail with meaningful exit codes - Separate concerns — business logic belongs in service classes, not command classes - Always include --dry-run — for any command that writes data - Use proxies — for dependencies in frequently-loaded commands - Test with CommandTester — and always dry-run before production execution

Get those right and your CLI commands will be something your team actually trusts.

Quality Score: 67/100



Jot us a note and we’ll get back to you as quickly as possible.
Copyright © 2021-2026 Unomage, LLC. All rights reserved.