Desenvolvimento

Desenvolvimento de Módulos no Magento 2: Arquitetura, DI, Plugins e Boas Práticas de Produção

Como construir módulos Magento 2 / Adobe Commerce que sobrevivem a um upgrade e a um code review: anatomia do módulo, injeção de dependência, plugins versus observers, declarative schema, data patches e os erros que eu corrijo toda semana — com XML, PHP e comandos reais, não teoria.

Por Roger Takemiya · Atualizado em 20 de junho de 2026 · 24 min de leitura

Em 14 anos revisando código Magento, eu diria que 90% dos bugs de módulo de terceiro que chegam na minha mesa cabem em meia dúzia de erros: ObjectManager chamado direto, around plugin onde bastava um after, lógica de negócio dentro do .phtml, preference sobrescrevendo uma classe inteira para mudar duas linhas, e db_schema_whitelist.json que ninguém gerou. Nenhum desses erros aparece num teste rápido em developer mode — todos explodem no setup:di:compile da pipeline ou, pior, em produção depois de um upgrade.

Módulo de Magento 2 não é "uma pasta com PHP". É um componente que conversa com um container de injeção de dependência, com um sistema de interceptação (plugins), com contratos de serviço versionados e com um esquema de banco declarativo. Quem entende essa arquitetura escreve módulos que sobrevivem a 2.4.4 → 2.4.7; quem não entende reescreve tudo a cada minor. Este guia é exatamente a diferença entre os dois — e vale igual para Magento Open Source e Adobe Commerce, porque o framework por baixo é o mesmo.

Vou trocar prosa por di.xml, events.xml, db_schema.xml e linha de comando. Tudo que está aqui você cola, audita nas fontes oficiais da Adobe e versiona no seu pipeline. É o conjunto de decisões que eu defendo em todo code review e que faz um módulo passar de "funciona na minha máquina" para "funciona em produção, no modo production, depois do compile".

Anatomia de um módulo: registration.php, module.xml e composer

Todo módulo vive em app/code/Vendor/Modulo (em desenvolvimento) ou, em produção, sob vendor/vendor/module-modulo quando instalado via Composer. A diferença não é cosmética: módulo em app/code você edita direto e versiona no repositório da loja; módulo via Composer é imutável, atualiza por composer update e é o jeito correto de distribuir extensão reutilizável. Na minha operação, código do cliente fica em app/code e biblioteca/extensão que eu reuso entre projetos vira pacote Composer.

Dois arquivos tornam o módulo existente para o framework. Sem eles, o autoloader simplesmente não enxerga seu código.

registration.php — o módulo se apresenta ao framework

O registration.php fica na raiz do módulo e registra o componente no ComponentRegistrar. Sem esse arquivo, nada acontece — o módulo é invisível.

<?php
use Magento\Framework\Component\ComponentRegistrar;

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

A Adobe é explícita: cada componente precisa de um registration.php que invoca ComponentRegistrar::register(); sem ele o autoloader não reconhece o módulo (fonte oficial de component registration).

etc/module.xml — nome canônico e ordem de carregamento

O etc/module.xml declara o nome canônico no atributo name do nó <module> e, opcionalmente, lista em <sequence> os módulos que devem ser carregados antes do seu.

<?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="Vendor_Modulo">
        <sequence>
            <module name="Magento_Catalog"/>
        </sequence>
    </module>
</config>

Atenção a uma confusão comum, que a própria documentação esclarece: módulos listados em <sequence> têm seus arquivos carregados antes do seu módulo — isso ordena, por exemplo, sobreposição de layout e eventos — mas não cria dependência hard automática. Dependência rígida, aquela que impede a instalação sem o pacote, mora no composer.json.

composer.json — dependências hard vs soft

É no composer.json > require que você declara o que o módulo realmente precisa para funcionar. O exemplo que a Adobe usa na documentação de dependências é literalmente "magento/module-catalog": "103.0.*". Dependências soft (recomendações opcionais) vão em suggest e, quando há ordem de carregamento envolvida, também no <sequence> do module.xml.

TipoOnde declararEfeito
Hard (obrigatória)composer.json > requireImpede instalar/atualizar sem o pacote
Ordem de carregamentomodule.xml > sequenceCarrega o outro módulo antes; não é hard
Soft (opcional)composer.json > suggestApenas sugere; não bloqueia nada

Ligando o módulo

Depois de criar os arquivos, o ciclo é sempre o mesmo:

bin/magento module:enable Vendor_Modulo
bin/magento setup:upgrade
bin/magento setup:di:compile   # obrigatório fora do developer mode
bin/magento cache:flush

Note que, no Magento 2.3+, não existe mais setup_version obrigatório no module.xml para versionar schema/data — isso foi substituído por declarative schema e data patches, que eu detalho mais adiante. Versionar o módulo errado (ou inventar um setup_version que não bate com o que está no banco) é uma das causas clássicas de setup:upgrade que não roda os patches.

Dependency Injection: o coração do Magento 2 e por que o ObjectManager é proibido

Se você entende só uma coisa deste guia, que seja esta seção. Dependency Injection (DI) é o mecanismo central do Magento 2: você nunca instancia colaboradores com new; você os declara no construtor e o framework os entrega prontos. O ObjectManager lê o di.xml, resolve a árvore de dependências e injeta automaticamente.

Injeção por construtor (o jeito certo)

<?php
namespace Vendor\Modulo\Model;

use Psr\Log\LoggerInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;

class MeuServico
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly LoggerInterface $logger
    ) {
    }
}

Repare em dois detalhes que eu cobro em review: dependa de interfaces (ProductRepositoryInterface, LoggerInterface), não de classes concretas; e nunca chame o ObjectManager dentro da classe. A regra oficial é taxativa: como o object manager fornece seu serviço de forma indireta, sua classe não deve depender do próprio objeto ObjectManager. As únicas exceções são factories customizadas com lógica complexa e testes de integração que precisam montar o ambiente.
Fonte: Adobe Developer — Dependency Injection

Ou seja: ObjectManager direto é anti-padrão, com exatamente duas exceções legítimas — dentro de factories com lógica complexa e em testes de integração que precisam montar o ambiente. Qualquer $this->_objectManager->get(...) em um Model, Block ou Controller é regressão na hora.

Preferences, types e virtualTypes no di.xml

Como o framework sabe qual classe concreta entregar quando você pede uma interface? Pelo <preference> no di.xml. E você ajusta argumentos de construtor por classe com <type>:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <preference for="Vendor\Modulo\Api\GatewayInterface"
                type="Vendor\Modulo\Model\PagarmeGateway"/>

    <type name="Vendor\Modulo\Model\PagarmeGateway">
        <arguments>
            <argument name="timeout" xsi:type="number">30</argument>
        </arguments>
    </type>
</config>

O recurso mais subestimado é o <virtualType>: ele cria uma subclasse virtual de uma classe existente mudando apenas os argumentos de construtor para uma dependência específica, sem você escrever uma nova classe PHP. Isso é o que o core usa para ter dezenas de "variantes" do mesmo collection factory ou do mesmo logger:

<virtualType name="VendorModuloLogger"
             type="Magento\Framework\Logger\Monolog">
    <arguments>
        <argument name="handlers" xsi:type="array">
            <item name="system" xsi:type="object">Vendor\Modulo\Logger\Handler</item>
        </argument>
    </arguments>
</virtualType>

di.xml global vs por área

O di.xml pode ser global (etc/di.xml) ou específico de área (etc/frontend/di.xml, etc/adminhtml/di.xml, etc/webapi_rest/di.xml...). A regra da Adobe: configuração da camada de apresentação deve ficar no arquivo de área, não no global. Eu vejo muito plugin de admin declarado no etc/di.xml global, encarecendo o frontend à toa — coloque-o em etc/adminhtml/di.xml.

Factories e Proxies para dependências pesadas

Quando você precisa criar instâncias novas em runtime (não um singleton injetado), peça a Factory gerada: declare ProductInterfaceFactory no construtor e o framework gera a classe em generated/. E quando uma dependência é cara de instanciar mas nem sempre é usada, injete um Proxy via di.xml (type="Vendor\Classe\Proxy") — ele adia a construção real até o primeiro método ser chamado. Factory e Proxy são, junto dos testes, os únicos lugares onde o ObjectManager aparece legitimamente — e ainda assim escondido no código gerado, não no seu.

Plugins (interceptors): before, after, around e os limites que ninguém lê

Plugins, ou interceptors, são o mecanismo para modificar o comportamento de um método público sem reescrever a classe. São a primeira escolha para customizar o core: você não toca no código original, sobrevive a upgrade e convive com outros módulos. Existem três tipos, declarados no di.xml.

TipoRecebePara que serveCusto
beforeMetodoOs argumentos do métodoModificar/validar argumentos de entrada; retorna array ou nullBaixo
afterMetodoO resultado do métodoModificar/enriquecer o retorno; retorna o valor finalBaixo
aroundMetodoUm callable $proceed + argumentosEnvolver totalmente a execução; pode até evitar a chamada originalAlto

Declaração e exemplo

<!-- etc/di.xml -->
<type name="Magento\Catalog\Model\Product">
    <plugin name="vendor_modulo_ajusta_nome"
            type="Vendor\Modulo\Plugin\ProductPlugin"
            sortOrder="10"/>
</type>
<?php
namespace Vendor\Modulo\Plugin;

use Magento\Catalog\Model\Product;

class ProductPlugin
{
    // roda DEPOIS de getName(), recebe e pode alterar o resultado
    public function afterGetName(Product $subject, string $result): string
    {
        return trim($result);
    }
}

O perigo do around: chamar $proceed() ou quebrar tudo

O around recebe um callable $proceed que precisa ser invocado para a cadeia continuar. Se você esquecer de chamar $proceed(...$args), você cancela todos os plugins seguintes e o método original — e é assim que um módulo "some" com o comportamento de outro sem ninguém entender por quê. Já passei horas debugando checkout porque um around de terceiro não chamava proceed sob certa condição.

public function aroundSave($subject, callable $proceed, ...$args)
{
    // ... lógica antes ...
    $result = $proceed(...$args);   // OBRIGATÓRIO: sem isto, a cadeia morre
    // ... lógica depois ...
    return $result;
}

A própria Adobe recomenda evitar around sempre que possível: segundo a documentação oficial, o único caso de uso legítimo para around plugins é quando a execução de todos os plugins e métodos seguintes precisa ser encerrada; para substituir ou modificar o resultado de uma função, o recomendado é o after plugin.
Fonte: Adobe Developer — Plugins (Interceptors)

E o Technical Guidelines reforça na regra 4.1:

Around-plugins SHOULD only be used when behavior of an original method is supposed to be substituted in certain scenarios

Fonte: Adobe Developer — Technical Guidelines (4.1)

Minha regra de campo: precisa só ler o retorno? after. Precisa só mexer no input? before. Precisa impedir condicionalmente a execução original? Só então around — e sempre chamando $proceed no caminho normal. O Technical Guidelines (regra 4.4) ainda exige que plugins sejam stateless: não guarde estado entre chamadas no plugin, ele é compartilhado.

sortOrder: quem roda primeiro

Quando vários plugins interceptam o mesmo método, o sortOrder decide a ordem. A documentação oficial descreve dois fluxos de execução: tanto os métodos before e around quanto os métodos after são executados do menor para o maior sortOrder. Em integrações com vários módulos eu deixo o sortOrder explícito justamente para não depender de ordem alfabética acidental.

O que plugins NÃO conseguem interceptar

Esta lista evita horas perdidas tentando "plugar" algo impossível. Plugins não interceptam:

  • Métodos final e classes final
  • Métodos privados ou não-públicos (só public)
  • Métodos static
  • Construtores (__construct/__destruct)
  • Virtual types
  • Objetos instanciados antes do bootstrap do Magento\Framework\Interception (incluindo objetos criados com new fora do container de DI)
  • Classes que implementam Magento\Framework\ObjectManager\NoninterceptableInterface

O ponto sobre objetos fora do container de DI é o que mais pega gente: se a classe alvo é instanciada diretamente com new em algum lugar (em vez de injetada via DI/Factory), seu plugin nunca dispara para aquela instância. Por isso o core injeta tudo — para ser interceptável.

Observers e eventos: quando usar evento em vez de plugin

O segundo mecanismo de extensão é o par evento + observer. Onde plugin intercepta um método específico de uma classe, evento é uma notificação nomeada que o código dispara e qualquer módulo pode escutar — desacoplado, sem saber quem dispara.

Disparando e escutando

Eventos são disparados pelo EventManager:

$this->eventManager->dispatch(
    'vendor_modulo_pedido_processado',
    ['order' => $order, 'extra' => $dados]
);

O observer implementa Magento\Framework\Event\ObserverInterface com o método execute(Observer $observer) e é registrado no events.xml — global em etc/events.xml ou por área em etc/frontend/events.xml, etc/adminhtml/events.xml, etc.

<!-- etc/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_order_place_after">
        <observer name="vendor_modulo_notifica_erp"
                  instance="Vendor\Modulo\Observer\NotificaErp"/>
    </event>
</config>
<?php
namespace Vendor\Modulo\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;

class NotificaErp implements ObserverInterface
{
    public function execute(Observer $observer): void
    {
        $order = $observer->getEvent()->getData('order');
        // dispara a sincronização com o ERP...
    }
}

Observer ou plugin? O critério que eu uso

Use OBSERVER quando...Use PLUGIN quando...
Reagir a um fato de negócio já consolidado (pedido salvo, cliente criado)Modificar argumentos ou retorno de um método específico
O core já dispara o evento que você precisaNão existe evento adequado, mas há um método público
Ação "fire-and-forget" e desacoplada (notificar, logar, enfileirar)Você precisa do valor de retorno na própria chamada
Não precisa alterar o resultado da operação em siPrecisa interceptar o fluxo (validar, bloquear, enriquecer)

Na prática: para integração com ERP/NF-e, eu quase sempre uso observer em sales_order_place_after ou — melhor ainda — enfileiro numa message queue e processo assíncrono, para não segurar o checkout. Esse padrão eu detalho no guia de integração Magento 2 com ERP e NF-e. Já quando preciso alterar como um total é calculado ou validar um dado antes de salvar, é plugin. Evite uma armadilha clássica: observers que disparam save() em loop dentro de eventos _save_after, gerando recursão. E lembre que o velho collection_load_after some pode degradar performance se você fizer trabalho pesado nele.

Preference (rewrite) vs plugin: por que eu quase nunca uso class override

<preference> no di.xml substitui uma classe inteira por outra — é o equivalente moderno do antigo "rewrite" do Magento 1. Funciona, mas é a opção mais perigosa de customização, e eu a evito por princípio.

O problema do override total

Quando você dá <preference for="..." type="Sua\Classe"/>, sua classe vira a implementação única daquela interface/classe em toda a loja. Dois efeitos ruins:

  • Conflito mortal entre módulos: se outro módulo declara uma preference para a mesma classe, só uma vence (a de maior prioridade de carregamento). A outra é silenciosamente ignorada — e você fica com bug intermitente que depende da ordem de sequence. Plugins de módulos diferentes coexistem; preferences se atropelam.
  • Dívida de upgrade: se você estendeu a classe original e a Adobe muda a assinatura de um método ou o construtor num upgrade, sua subclasse quebra ou perde correções. Plugin sobre um método específico é cirúrgico e sobrevive melhor.

A regra de decisão

CenárioFerramenta certa
Mudar o resultado de um método públicoPlugin (after)
Mudar argumentos de entrada de um métodoPlugin (before)
Reagir a um evento já disparadoObserver
Trocar a implementação de uma interface sua ou de contratoPreference (legítimo)
Reescrever a classe inteira porque mudou metade delaRepensar o design — provavelmente é módulo novo

Onde preference é legítima: trocar a implementação concreta de uma interface (que é o uso desenhado para isso) — por exemplo, fornecer seu próprio GatewayInterface de pagamento. O que eu rejeito em review é preference sobre uma classe concreta do core para mudar duas ou três linhas: quase sempre dá para fazer com plugin. Quando dois módulos brigam por uma preference, a saída costuma ser converter pelo menos um deles para plugin. Esse cuidado conversa diretamente com a migração do Magento 1, onde os antigos rewrites precisam ser reescritos como plugins/observers em vez de virarem preferences automáticas.

Service contracts e API: contratos estáveis com @api, Api/ e Api/Data/

Service contracts são a fronteira pública e estável do seu módulo. A ideia: em vez de outros módulos chamarem suas classes concretas (frágeis), eles chamam interfaces que você promete manter compatíveis. É o que torna possível escrever uma integração que não quebra no próximo minor.

Api/ e Api/Data/

A convenção divide as interfaces em dois diretórios:

  • Api/service interfaces: escondem a lógica de negócio. Ex.: ProductRepositoryInterface com save(), getById(), delete().
  • Api/Data/data interfaces: garantem a integridade dos dados que entram e saem. Ex.: ProductInterface com getters/setters tipados.
<?php
namespace Vendor\Modulo\Api;

use Vendor\Modulo\Api\Data\AssinaturaInterface;

/**
 * @api
 */
interface AssinaturaRepositoryInterface
{
    public function save(AssinaturaInterface $assinatura): AssinaturaInterface;
    public function getById(int $id): AssinaturaInterface;
    public function delete(AssinaturaInterface $assinatura): bool;
}

A anotação @api é um contrato de versão

A anotação @api não é decoração: ela sinaliza que aquela interface/classe tem compatibilidade retroativa garantida entre versões de minor release. Tudo que está marcado @api a Adobe se compromete a não quebrar sem subir major. Por extensão: só consuma @api de outros módulos e só exponha @api aquilo que você se compromete a manter. Construir extensão em cima de classe interna não-@api é assinar para refazer no próximo upgrade.

Conectando ao Web API com webapi.xml

A grande vantagem: uma interface de service contract vira automaticamente endpoint REST/SOAP via etc/webapi.xml, sem você escrever controller de API.

<!-- etc/webapi.xml -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
    <route url="/V1/assinaturas/:id" method="GET">
        <service class="Vendor\Modulo\Api\AssinaturaRepositoryInterface"
                 method="getById"/>
        <resources>
            <resource ref="Vendor_Modulo::assinatura_view"/>
        </resources>
    </route>
</routes>

O Magento serializa o retorno conforme a Api/Data interface, aplica ACL pelo <resource> e expõe em /rest/V1/.... Esse é o caminho correto para integrar ERP, marketplace e PDV — exatamente o assunto do guia de integração com ERP e NF-e. Service contract bem feito é a diferença entre uma integração estável e um scraping de banco que quebra todo upgrade.

Declarative schema e data patches: db_schema.xml, whitelist e patch_list

A forma como o módulo cria e altera tabelas mudou no Magento 2.3, quando o declarative schema foi introduzido. Os scripts PHP InstallSchema, UpgradeSchema, InstallData e UpgradeData foram colocados em rota de substituição: a partir do 2.3, a abordagem recomendada é o declarative schema (estrutura) e data patches (dados). Se você ainda escreve UpgradeSchema em projetos novos, está escrevendo código de 2018.

db_schema.xml: você declara o estado final, o framework calcula o diff

Em vez de escrever "adicione a coluna X", você declara como a tabela deve ser em etc/db_schema.xml e o framework descobre o que mudar comparando com o banco atual:

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="vendor_assinatura" resource="default" engine="innodb">
        <column xsi:type="int" name="entity_id" unsigned="true"
                nullable="false" identity="true"/>
        <column xsi:type="varchar" name="sku" length="64" nullable="false"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="entity_id"/>
        </constraint>
    </table>
</schema>

Rodar bin/magento setup:upgrade aplica o diff. Isso permite, inclusive, reverter mudanças (basta remover do XML) — algo impossível com os scripts imperativos antigos.

db_schema_whitelist.json: o passo que todo mundo esquece

Para qualquer operação destrutiva (remover coluna ou tabela), o framework exige que aquela estrutura esteja no db_schema_whitelist.json — uma trava de segurança contra perda de dados acidental. Você não escreve esse arquivo à mão; gera com:

bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_Modulo

Rode esse comando toda vez que alterar o db_schema.xml e versione o JSON. O erro clássico — que vejo em quase todo módulo de terceiro malfeito — é alterar o schema e esquecer de regenerar a whitelist: aí o setup:upgrade reclama ou ignora a alteração destrutiva silenciosamente.

Data patches: dados, uma vez só, em Setup/Patch/Data

Para popular dados (criar atributo EAV, inserir config, semear registros), use data patches. Cada patch é uma classe em Setup/Patch/Data/ que implementa \Magento\Framework\Setup\Patch\DataPatchInterface com apply(), getDependencies() e getAliases().

<?php
namespace Vendor\Modulo\Setup\Patch\Data;

use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;

class SeedAssinaturas implements DataPatchInterface
{
    public function __construct(
        private readonly ModuleDataSetupInterface $setup
    ) {
    }

    public function apply(): void
    {
        // inserir/atualizar dados aqui
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

O ponto forte: cada patch roda uma única vez e fica registrado na tabela patch_list do banco. Reexecutou setup:upgrade? O patch já aplicado é pulado. Isso elimina os antigos if (version_compare(...)) espalhados pelos scripts de UpgradeData. Use getDependencies() para garantir ordem entre patches e getAliases() ao renomear um patch sem reexecutá-lo. Patch nunca deve gravar fora do apply(), e nunca dependa de patch de módulo que não está no composer.json.

Frontend e admin: ViewModel em vez de Helper, layout XML e UI Components

Na camada de apresentação, a regra de ouro é: o template não pensa. Ele só renomeia, formata e itera dados que recebe pronto. O Technical Guidelines é explícito sobre isso:

Templates MUST NOT instantiate objects. All objects MUST be passed from the Block objects

Fonte: Adobe Developer — Technical Guidelines (6.2.6)

ViewModel: a substituição moderna do Helper no template

Por anos, gente injetou Helper no .phtml para buscar dado ou config. A própria documentação agora desaconselha Helper em template e recomenda ViewModel (disponível desde o 2.2). ViewModel implementa \Magento\Framework\View\Element\Block\ArgumentInterface e é injetado por layout XML, sem você ter que estender Block:

<!-- view/frontend/layout/catalog_product_view.xml -->
<referenceBlock name="product.info.main">
    <arguments>
        <argument name="view_model" xsi:type="object">
            Vendor\Modulo\ViewModel\Assinatura
        </argument>
    </arguments>
</referenceBlock>
<?php
namespace Vendor\Modulo\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;

class Assinatura implements ArgumentInterface
{
    public function __construct(
        private readonly \Vendor\Modulo\Api\AssinaturaRepositoryInterface $repo
    ) {
    }

    public function getPlano(int $id): string
    {
        return $this->repo->getById($id)->getPlano();
    }
}

No template, você só chama $block->getViewModel()->getPlano($id). Vantagens: o ViewModel é testável isoladamente, é reutilizável entre temas e não obriga você a sobrescrever um Block do core só para expor um método. Eu não aceito mais Helper novo em .phtml em review.

Block vs ViewModel — quando ainda usar Block

Block continua válido quando você precisa de comportamento de renderização de fato (cache key, lógica de exibição própria, sobrescrever _toHtml). Para fornecer dados ao template, ViewModel é o caminho. Pense: Block = "como renderiza"; ViewModel = "que dado eu entrego".

UI Components: grids e forms do admin

Para grids e formulários do admin, o Magento usa UI Components — definidos por XML (view/adminhtml/ui_component/*.xml) e renderizados por componentes Knockout/JS, com dados vindos de uma DataProvider ligada a um Collection ou repositório. É verboso, mas dá grid com filtro, paginação, ações em massa e export "de graça". A visão geral que eu passo para o time: um listing XML aponta para um data_source (DataProvider) e define columns; um form XML aponta para uma DataProvider de modify e define fields. Não tente construir grid de admin com Block + tabela HTML na mão em 2.4 — você reinventa, pior, o que o UI Component já entrega. Boa parte das decisões de performance dessa camada (LESS, JS, full page cache) eu detalho no guia de performance e Core Web Vitals.

CLI, padrões de código e setup:di:compile como gate de qualidade

Um módulo só está "pronto" quando passa pelos comandos certos e pelos linters. Aqui está o ferramental que eu exijo no pipeline — e que separa código que "roda" de código que "roda em produção".

Os comandos que você vai digitar todo dia

ComandoO que fazQuando
setup:upgradeAplica schema declarativo e data patches; atualiza o estado dos módulosApós mexer em schema/patch ou habilitar módulo
setup:di:compileCompila o container de DI; gera proxies, factories e interceptors em generated/Antes de produção / no CI
setup:static-content:deployGera os assets estáticos (CSS/JS/imagens) por tema e localeDeploy em production
cache:cleanLimpa apenas as entradas inválidas dos tipos de cache habilitadosDia a dia, durante o dev
cache:flushEsvazia o storage de cache inteiro (inclusive de terceiros no mesmo Redis)Quando clean não resolve
indexer:reindexReconstrói os índices (preço, estoque, catálogo)Após import/alteração de dados em massa

A diferença cache:clean × cache:flush importa: clean invalida só o que é do Magento; flush apaga o backend inteiro — se você compartilha Redis com outra app, flush mata o cache dela também. No dia a dia eu uso cache:clean; flush só quando há suspeita de cache corrompido.

Os três modos de deploy

O Magento 2 / Adobe Commerce tem três modos:

  • developer: assets estáticos gerados sob demanda, erros detalhados, sem cache de DI agressivo. É onde você desenvolve — e onde plugins/preferences "funcionam" mesmo sem compile, mascarando erros.
  • default: comportamento padrão, sem as otimizações nem a verbosidade do developer.
  • production: exige setup:di:compile e setup:static-content:deploy pré-executados; máxima performance. Mudar para production dispara automaticamente o compile e o deploy de estáticos.
bin/magento deploy:mode:show
bin/magento deploy:mode:set production   # roda di:compile + static-content:deploy

O ponto que eu martelo: teste sempre no modo production antes de subir. Um módulo que funciona em developer e quebra em production quase sempre tem erro de DI que só o compile pega.

setup:di:compile é o seu gate de CI

O setup:di:compile não é só performance: ele valida o grafo de DI em compile time. Tipo de argumento errado, interface sem preference mapeada, dependência circular — tudo isso falha o compile, antes de chegar em produção. Eu coloco setup:di:compile como etapa obrigatória do CI: se não compila, o merge não passa.

magento-coding-standard: phcs com o ruleset Magento2

O padrão de código oficial vem no pacote magento/magento-coding-standard, que fornece o ruleset Magento2 para o PHP_CodeSniffer:

composer require --dev magento/magento-coding-standard

# verificar
vendor/bin/phpcs --standard=Magento2 app/code/Vendor/Modulo

# corrigir o que for auto-corrigível
vendor/bin/phpcbf --standard=Magento2 app/code/Vendor/Modulo

Complemento com PHPStan (análise estática de tipos) e phpmd quando o projeto pede. A pipeline mínima que eu defendo: phpcs --standard=Magento2phpstansetup:di:compile. Os três passam? Aí sim é candidato a merge. Isso conversa com o hardening que descrevo no guia de segurança e hardening do Magento 2 — escaping, validação e ACL também são pegos por esses linters.

Os 8 erros que eu corrijo em todo code review

Depois de centenas de reviews, os mesmos defeitos voltam. Esta é a checklist que eu rodo mentalmente em qualquer módulo — seu ou de terceiro — antes de aprovar para produção. Cada item destes já causou incidente real em loja que eu atendi.

ErroPor que é graveCorreção
ObjectManager diretoEsconde dependências, impede teste, quebra interceptaçãoInjetar interface/Factory no construtor
Helper como depósito de lógicaVira saco de gato sem coesão, difícil de testarService contract / Model; ViewModel no template
Abuso de around pluginAumenta stack trace, degrada performance, risco de não chamar $proceedbefore/after sempre que possível
Lógica de negócio no .phtmlViola o Technical Guidelines 6.2.6; intestável, inseguraMover para ViewModel/Block
Faltar escaping no templateVetor de XSS direto$escaper->escapeHtml() / escapeUrl()
Não gerar db_schema_whitelist.jsonAlteração destrutiva ignorada ou erro no upgradesetup:db-declaration:generate-whitelist
Versionamento de módulo erradoPatches não rodam / rodam fora de ordemData patches + patch_list, sem setup_version legado
preference onde cabia pluginConflito entre módulos, dívida de upgradePlugin sobre o método específico

Escaping no template não é opcional

Como o template não instancia objetos (regra 6.2.6), todo dado dinâmico que vai para o HTML precisa ser escapado. O $escaper já vem disponível no .phtml:

<span class="plano">
    <?= $escaper->escapeHtml($block->getViewModel()->getPlano($id)) ?>
</span>
<a href="<?= $escaper->escapeUrl($url) ?>">Detalhes</a>

Esquecer escapeHtml/escapeUrl é o erro de segurança que mais aparece em módulo de terceiro — e o phpcs com ruleset Magento2 pega boa parte deles. Não confie em "o dado é meu, é seguro": qualquer campo que um dia possa vir de input de usuário (até nome de produto) é vetor de XSS.

O fluxo que eu uso com IA — sem abrir mão do review

Hoje eu acelero scaffolding de módulo, di.xml e boilerplate de UI Component com assistentes de IA, mas nada entra sem passar pela checklist acima e pelo compile. IA erra exatamente nos pontos finos desta lista: gera ObjectManager direto porque viu em código antigo, abusa de around, esquece a whitelist. Como uso isso na prática — e onde a IA ajuda de verdade no Magento — está no guia de IA no desenvolvimento Magento. A regra não muda: a IA escreve o rascunho, o engenheiro responde pelo código. Vale igual para Adobe Commerce: a arquitetura de módulos é idêntica à do Open Source, só muda o metapacote e os módulos enterprise por cima.

Perguntas frequentes

Qual a diferença entre instalar um módulo via Composer e colocá-lo em app/code?

Módulo em app/code você edita direto e versiona no repositório da loja, ideal para código específico do cliente. Módulo via Composer é imutável, atualiza por composer update e é o jeito correto de distribuir uma extensão reutilizável entre projetos. Na minha operação, código do cliente fica em app/code e biblioteca que eu reuso vira pacote Composer. Em ambos os casos o módulo precisa de registration.php e etc/module.xml, e você liga com bin/magento module:enable, setup:upgrade e setup:di:compile.

Por que o ObjectManager direto é considerado anti-padrão no Magento 2?

Porque ele esconde as dependências reais da classe, impede testes unitários, e quebra a interceptação por plugins. A regra oficial da Adobe é que sua classe não deve depender do ObjectManager; as únicas exceções permitidas são factories customizadas com lógica complexa e testes de integração que precisam montar o ambiente. O jeito correto é declarar as dependências (de preferência interfaces) no construtor e deixar o framework injetá-las. Qualquer chamada ao ObjectManager dentro de um Model, Block ou Controller é regressão em code review.

Quando devo usar um plugin around em vez de before ou after?

Quase nunca. A própria Adobe indica que o único caso de uso para around plugins é quando a execução de todos os plugins e métodos seguintes precisa ser encerrada. O Technical Guidelines diz que around só deve ser usado quando o comportamento do método original precisa ser substituído em certos cenários. Use before para modificar argumentos de entrada, after para modificar o retorno, e around apenas quando precisa impedir condicionalmente a execução de toda a cadeia. E se usar around, chame sempre o callable proceed no caminho normal, senão você cancela todos os plugins seguintes e o método original.

Qual a diferença entre plugin, observer e preference para customizar o Magento?

Plugin intercepta um método público específico de uma classe (before, after ou around). Observer reage a um evento nomeado já disparado pelo core, de forma desacoplada, sem alterar o resultado da operação. Preference substitui uma classe inteira por outra. Eu prefiro plugin para modificar comportamento de método, observer para reagir a fatos de negócio como pedido salvo, e evito preference sobre classes do core porque dois módulos com a mesma preference se atropelam silenciosamente. Preference é legítima apenas para trocar a implementação de uma interface sua.

O que substituiu o InstallSchema e o UpgradeSchema no Magento 2.3 em diante?

O declarative schema, introduzido no Magento 2.3 como abordagem recomendada. Em vez de scripts PHP imperativos, você declara o estado final das tabelas em etc/db_schema.xml e o framework calcula o diff contra o banco atual ao rodar setup:upgrade. Para operações destrutivas, como remover coluna ou tabela, é obrigatório o arquivo db_schema_whitelist.json, gerado com bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_Modulo a cada alteração. Para popular dados, o InstallData e o UpgradeData foram substituídos por data patches em Setup/Patch/Data, classes que implementam DataPatchInterface, rodam uma única vez e ficam registradas na tabela patch_list.

O que é a anotação @api e por que ela importa nos service contracts?

A anotação @api marca uma interface ou classe como parte da API pública estável do módulo, com compatibilidade retroativa garantida entre versões de minor release. Service contracts são interfaces divididas em Api, que escondem a lógica de negócio, e Api/Data, que garantem integridade de dados. Na prática isso significa que você deve consumir apenas código marcado @api de outros módulos, e expor @api somente aquilo que você se compromete a manter. Construir uma integração em cima de uma classe interna não-@api é assinar para refazer tudo no próximo upgrade.

Por que eu deveria usar ViewModel em vez de Helper nos templates?

Porque a documentação oficial desaconselha Helper em template e recomenda ViewModel, disponível desde o Magento 2.2. O ViewModel implementa ArgumentInterface, é injetado por layout XML sem você precisar estender um Block do core, é testável isoladamente e reutilizável entre temas. Além disso, o Technical Guidelines (regra 6.2.6) determina que templates não devem instanciar objetos: todo objeto deve vir do Block. Lógica de negócio e instanciação no .phtml são anti-padrão oficial. No template você só formata e escapa o dado pronto que o ViewModel entrega.

Referências oficiais

  1. Dependency Injection | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  2. Plugins (Interceptors) | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  3. Dependency Injection Configuration (di.xml) | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  4. Declarative Schema | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  5. Configure Declarative Schema (db_schema.xml) | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  6. Develop Data and Schema Patches | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  7. Events and Observers | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  8. Service Contracts | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  9. View Models | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  10. Technical Guidelines | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  11. Register a Component (registration.php) | Commerce PHP Extensions — Adobe Developer (developer.adobe.com)
  12. magento/magento-coding-standard — Ruleset Magento2 para PHPCS — GitHub / Magento
Precisa de um orçamento? Ficarei feliz em ajudar. Clique Aqui