← Voltar ao blog

Como criar um comando CLI customizado no Magento 2

Rodrigo Biazzi

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 registre memory_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