Como criar um comando CLI customizado no Magento 2
Comandos bin/magento customizados estão entre as coisas mais úteis que um módulo pode entregar. Imports, correções de dados, jobs de limpeza, cache warmers e migrações pontuais ficam mais fáceis de rodar, testar e agendar como comandos do que como botões no admin ou URLs escondidas em controllers. A CLI do Magento é construída sobre o Symfony Console, então quase tudo que você sabe de Symfony se aplica. Há alguns detalhes específicos do Magento que causam problemas reais se passarem batido, e eles são o foco aqui.
Os exemplos miram Magento Open Source e Adobe Commerce 2.4.8 em PHP 8.3 ou 8.4.
Um comando básico
Um comando é uma classe que estende 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 um prefixo de namespace no nome do comando, como acme:, para não colidir com o core ou com outros vendors. Os nomes dos comandos do core aparecem em bin/magento list.
Sempre retorne um exit code inteiro. Command::SUCCESS é 0 e Command::FAILURE é 1. Scripts de deploy, jobs de CI e wrappers de cron dependem do exit code para saber se algo falhou. Um comando que imprime um erro e retorna 0 vai parecer sucesso para todos eles.
Argumentos, opções e validação
Argumentos são posicionais e normalmente obrigatórios, como o caminho do arquivo acima. Opções são nomeadas, opcionais e podem ser combinadas livremente, como --dry-run ou --store=de. Uma boa regra é que aquilo sobre o qual o comando age é um argumento e tudo que muda como ele age é uma opção.
O Symfony converte tudo para strings ou booleanos. Faça cast e valide a entrada no topo do execute() e falhe cedo, com uma mensagem clara e um exit code diferente de zero. Não deixe um código de store inválido ou um tamanho de batch negativo viajar até o fundo de um service, onde o erro resultante será muito mais difícil de entender. Para opções com um conjunto fixo de valores, liste-os na descrição da opção para que o bin/magento help acme:notice:import os documente.
Registrando o comando
O Magento não descobre comandos automaticamente. Adicione a classe à lista de comandos em 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>
O array commands é mesclado entre os módulos pelo nome do item, como explicado em o artigo sobre dependency injection. Dê ao item um nome único. Depois de adicionar, rode bin/magento setup:upgrade se o módulo for novo, ou limpe o cache de config. Em modo production, recompile com setup:di:compile.
Por que o proxy importa
Toda vez que alguém roda bin/magento, para qualquer comando, o Magento instancia todos os comandos registrados para montar a lista. Isso inclui o seu constructor e os constructors de tudo que ele injeta.
Se o Importer injeta um repository que injeta um resource model que conecta no banco de dados, então toda chamada de bin/magento abre essa conexão. Pior, comandos como setup:install rodam antes de o banco existir, e um comando cuja cadeia de constructors precisa do banco os quebra. O proxy no di.xml acima adia a criação do Importer até o execute() realmente chamá-lo. Use proxies para toda dependência não trivial de um comando. O artigo sobre factories e proxies explica como eles são gerados.
Pelo mesmo motivo, nunca faça trabalho no constructor nem no configure(). Os dois rodam em toda chamada de bin/magento.
Area code
Comandos CLI começam sem area code. Alguns caminhos de código exigem um e lançam Area code is not set, por exemplo ao salvar produtos, renderizar templates, enviar e-mails ou qualquer coisa que toque a configuração de design.
Definir a área no início do execute() resolve. O bloco try e catch é necessário porque a área só pode ser definida uma vez por processo, e outro comando ou plugin pode já ter definido. Escolha a área que combina com o que o código espera: adminhtml para alterações de catálogo feitas como um admin faria, frontend para qualquer coisa que renderize conteúdo da loja, crontab se você está reproduzindo um job de cron.
Uma alternativa é o State::emulateAreaCode(), que roda um callback em uma determinada área e restaura o estado anterior depois. É mais limpo quando só parte do comando precisa de uma área.
Saída melhor
Para operações longas, uma barra de progresso é melhor do que uma linha por 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('');
Respeite a verbosidade. Imprima resumos por padrão e detalhes só com -v, usando $output->isVerbose(). Quando um comando roda pelo cron ou CI, o log não deve conter milhares de linhas que ninguém lê.
Peça confirmação antes de ações destrutivas com o question helper e adicione uma opção --force que pula a pergunta, para o comando ainda poder rodar sem supervisão.
Evitando execuções em paralelo
Se um comando é agendado pelo cron e uma execução demora mais que o intervalo, duas instâncias podem rodar ao mesmo tempo. O Magento tem um lock manager que funciona entre processos:
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');
}
Injete o LockManagerInterface pelo constructor como qualquer outra dependência. O backend do lock é configurado no env.php e por padrão usa o banco de dados. Um timeout de 0 significa não esperar. O bloco finally garante que o lock seja liberado quando o import lançar uma exceção.
Memória e execuções longas
Comandos que processam grandes volumes de dados esbarram em limites de memória. Alguns hábitos os mantêm estáveis:
- Processe em batches e leia os dados por faixa de ID em vez de carregar tudo de uma vez.
- Evite repositories com cache interno para trabalho em massa, ou limpe-os entre os batches. O product repository é o ofensor de sempre.
- Chame
gc_collect_cycles()entre os batches se a memória ainda crescer, e registrememory_get_usage(true)durante os testes. - Rode com
php -d memory_limit=2G bin/magento ...quando você sabe que o job é pesado, em vez de aumentar o limite globalmente.
Testes e agendamento
Mantenha a lógica em uma classe de service e o comando como um wrapper fino. Assim o mesmo service pode ser testado em unit tests, chamado de um job de cron e usado pelo comando. O CommandTester do Symfony funciona com comandos do Magento para testar o próprio wrapper, com os services mockados.
Se um comando precisa rodar em um agendamento, prefira um job de cron do Magento chamando o mesmo service em vez de uma linha separada no crontab do sistema. Isso mantém o agendamento visível no cron_schedule e no code review. O guia de cron jobs cobre esse lado.
FAQ
Por que meu comando não aparece em bin/magento list?
Confira se ele está registrado no argumento commands de Magento\Framework\Console\CommandList no di.xml, se o módulo está habilitado e se o cache de config está limpo. Em modo production, rode setup:di:compile de novo.
Como corrigir Area code is not set em um comando CLI?
Chame setAreaCode em Magento\Framework\App\State no início do execute, dentro de um try e catch, porque ele só pode ser definido uma vez. Use emulateAreaCode se só parte do comando precisar disso.
Por que o bin/magento ficou lento depois que adicionei meu comando?
Todo comando é instanciado a cada chamada. Se a cadeia do seu constructor é pesada ou conecta no banco de dados, configure proxies para essas dependências no di.xml.
O que o execute deve retornar?
Um exit code inteiro. Retorne Command::SUCCESS em caso de sucesso e Command::FAILURE em caso de falha, para que scripts e CI detectem erros.
Atendo como freelancer em Magento e Adobe Commerce. Se você precisa de ferramentas de import, correções de dados ou comandos de manutenção para a sua loja, fale comigo.
Artigos relacionados
Magento 2: tipos de cache, clean, flush e cache type próprio
O que cada cache type do Magento 2 guarda, a diferença real entre cache:clean e cache:flush, caches invalidados e como criar seu próprio cache type.
Shipping method customizado no Magento 2: crie um módulo de carrier
Como criar um shipping method customizado no Magento 2.4: config.xml, system.xml, um carrier model com collectRates, regras de frete grátis e APIs externas de tarifa.
Magento 2: plugins, observers e preferences, quando usar cada um
Como plugins, observers e preferences funcionam no Magento 2.4.8, o que cada um não faz e um checklist para escolher o que sobrevive a upgrades e extensões.
Método de pagamento customizado no Magento 2 com a Payment Gateway API
Crie um método de pagamento customizado no Magento 2.4 com a Payment Gateway API: virtual type de facade, config, commands e o renderer JS do checkout, passo a passo.