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 avendor:resource:actionnaming 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 tointwithout checking fornullfirst. On older Symfony Console versions this could silently produce0, 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:compileafter 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.

