Como criar um módulo customizado no Magento 2 (guia 2.4.8)
A maioria dos tutoriais de módulo customizado do Magento 2 foi escrita para a 2.1 ou 2.2 e ainda mostra scripts de install, atributos setup_version e outras coisas que não pertencem mais a um módulo novo. Este guia monta um módulo pequeno para Magento Open Source ou Adobe Commerce 2.4.8 do jeito que eu escreveria hoje: declarative schema, data patches, uma rota de frontend, um view model e um plugin, tudo rodando em PHP 8.3 ou 8.4.
O módulo de exemplo se chama Acme_StoreNotice. Ele guarda avisos curtos em uma tabela customizada e os mostra em uma página do frontend. Troque os nomes de vendor e de módulo pelos seus.
Onde o módulo fica
Para código específico de um projeto, app/code/Vendor/Module está ótimo. Se o módulo vai ser compartilhado entre projetos ou instalado pelo Composer, dê a ele um composer.json e mantenha em um repositório próprio. O Magento carrega os dois do mesmo jeito, a única diferença é como o código chega ao servidor.
app/code/Acme/StoreNotice/
├── registration.php
├── composer.json
├── etc/
│ ├── module.xml
│ ├── db_schema.xml
│ ├── db_schema_whitelist.json
│ ├── di.xml
│ └── frontend/
│ └── routes.xml
├── Controller/Index/Index.php
├── Model/
├── Setup/Patch/Data/AddDefaultNotice.php
├── ViewModel/NoticeList.php
└── view/frontend/
├── layout/storenotice_index_index.xml
└── templates/list.phtml
Os dois arquivos obrigatórios
Um módulo precisa de exatamente dois arquivos para existir: registration.php e etc/module.xml. Todo o resto é opcional.
registration.php
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Acme_StoreNotice',
__DIR__
);
etc/module.xml
Deixe o setup_version de fora. Desde que o declarative schema chegou na 2.3, o atributo de versão não é necessário, e mantê-lo só convida scripts de upgrade no estilo antigo. Use sequence para módulos que precisam carregar antes do seu, por exemplo quando você estende o layout deles ou sobrescreve a configuração.
<?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="Acme_StoreNotice">
<sequence>
<module name="Magento_Store"/>
</sequence>
</module>
</config>
Agora habilite o módulo e rode o setup upgrade:
bin/magento module:enable Acme_StoreNotice
bin/magento setup:upgrade
bin/magento module:status Acme_StoreNotice
Depois do setup:upgrade o módulo aparece em app/etc/config.php. Faça commit desse arquivo. Os deploys dependem dele para saber quais módulos estão habilitados.
Um composer.json para o módulo
Mesmo em app/code, um composer.json documenta os requisitos de PHP e do framework. Ele passa a ser obrigatório quando você move o módulo para um pacote próprio.
{
"name": "acme/module-store-notice",
"description": "Store notices shown on a frontend page",
"type": "magento2-module",
"license": "proprietary",
"require": {
"php": "~8.3.0||~8.4.0",
"magento/framework": "*",
"magento/module-store": "*"
},
"autoload": {
"files": ["registration.php"],
"psr-4": {
"Acme\\StoreNotice\\": ""
}
}
}
Tabelas no banco com declarative schema
As tabelas são descritas em etc/db_schema.xml. O Magento compara esse arquivo com o banco real a cada setup:upgrade e cria ou altera o que for diferente. Você nunca escreve CREATE TABLE por conta própria.
<?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="acme_store_notice" resource="default" engine="innodb" comment="Store notices">
<column xsi:type="int" name="notice_id" unsigned="true" nullable="false" identity="true" comment="Notice ID"/>
<column xsi:type="smallint" name="store_id" unsigned="true" nullable="false" default="0" comment="Store ID"/>
<column xsi:type="varchar" name="message" length="255" nullable="false" comment="Message"/>
<column xsi:type="boolean" name="is_active" nullable="false" default="1" comment="Is Active"/>
<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" on_update="false" comment="Created At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="notice_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="ACME_STORE_NOTICE_STORE_ID_STORE_STORE_ID"
table="acme_store_notice" column="store_id"
referenceTable="store" referenceColumn="store_id" onDelete="CASCADE"/>
<index referenceId="ACME_STORE_NOTICE_STORE_ID_IS_ACTIVE" indexType="btree">
<column name="store_id"/>
<column name="is_active"/>
</index>
</table>
</schema>
Depois gere a whitelist. O Magento a usa para saber quais colunas e constraints pertencem ao seu módulo, para poder removê-las com segurança mais tarde se você tirá-las do schema.
bin/magento setup:db-declaration:generate-whitelist --module-name=Acme_StoreNotice
bin/magento setup:upgrade --dry-run=1
bin/magento setup:upgrade
O dry run grava o SQL que executaria em var/log/dry-run-installation.log. Eu leio esse arquivo antes de toda mudança de schema que toque uma tabela grande, porque um índice alterado em uma tabela grande pode travá-la por muito tempo.
Dados iniciais com um data patch
Data patches substituem os antigos scripts InstallData e UpgradeData. Cada patch roda uma vez e é registrado na tabela patch_list.
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\Setup\Patch\Data;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
class AddDefaultNotice implements DataPatchInterface
{
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup
) {
}
public function apply(): self
{
$connection = $this->moduleDataSetup->getConnection();
$connection->insert(
$this->moduleDataSetup->getTable('acme_store_notice'),
['store_id' => 0, 'message' => 'Free shipping on orders over 100', 'is_active' => 1]
);
return $this;
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
Mantenha os patches idempotentes sempre que puder. Se alguém apagar a linha em patch_list para rodar um patch de novo, ele não deve criar duplicatas. Para qualquer coisa que dependa de outro patch, liste essa classe em getDependencies().
Models, resource models e collections
O trio clássico ainda funciona na 2.4.8 e é o que a maioria dos módulos do core usa. Um model estende Magento\Framework\Model\AbstractModel, um resource model estende Magento\Framework\Model\ResourceModel\Db\AbstractDb e uma collection estende Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection.
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Notice extends AbstractDb
{
protected function _construct(): void
{
$this->_init('acme_store_notice', 'notice_id');
}
}
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\Model;
use Magento\Framework\Model\AbstractModel;
class Notice extends AbstractModel
{
protected function _construct(): void
{
$this->_init(ResourceModel\Notice::class);
}
}
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\Model\ResourceModel\Notice;
use Acme\StoreNotice\Model\Notice;
use Acme\StoreNotice\Model\ResourceModel\Notice as NoticeResource;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
class Collection extends AbstractCollection
{
protected $_idFieldName = 'notice_id';
protected function _construct(): void
{
$this->_init(Notice::class, NoticeResource::class);
}
}
Se outros módulos ou a API vão usar a sua entidade, adicione um service contract por cima: uma interface em Api/Data, uma interface de repository em Api e uma preference no di.xml que mapeia cada interface para sua implementação. Para um módulo interno pequeno, a collection sozinha muitas vezes basta.
Uma rota de frontend e um controller
As rotas são declaradas por área. Esta vai em etc/frontend/routes.xml, então a URL fica /storenotice/index/index, que também responde em /storenotice.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="storenotice" frontName="storenotice">
<module name="Acme_StoreNotice"/>
</route>
</router>
</config>
Controllers não precisam mais estender Magento\Framework\App\Action\Action. Implemente a interface do método HTTP que você aceita e injete o que precisar.
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;
class Index implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $pageFactory
) {
}
public function execute(): Page
{
$page = $this->pageFactory->create();
$page->getConfig()->getTitle()->set(__('Store notices'));
return $page;
}
}
Layout, template e um view model
O layout handle é montado a partir do id da rota, da pasta do controller e da classe da action: storenotice_index_index. Em vez de uma classe de block customizada, passe um view model como argumento para um block de template genérico. View models mantêm a lógica fora dos templates e são fáceis de testar.
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block name="acme.store.notice.list" template="Acme_StoreNotice::list.phtml">
<arguments>
<argument name="view_model" xsi:type="object">Acme\StoreNotice\ViewModel\NoticeList</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\ViewModel;
use Acme\StoreNotice\Model\ResourceModel\Notice\CollectionFactory;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;
class NoticeList implements ArgumentInterface
{
public function __construct(
private readonly CollectionFactory $collectionFactory,
private readonly StoreManagerInterface $storeManager
) {
}
public function getMessages(): array
{
$collection = $this->collectionFactory->create()
->addFieldToFilter('is_active', 1)
->addFieldToFilter('store_id', ['in' => [0, (int) $this->storeManager->getStore()->getId()]])
->setOrder('created_at', 'DESC');
return $collection->getColumnValues('message');
}
}
A classe CollectionFactory não existe no seu código. O Magento a gera sob demanda em modo developer e durante o setup:di:compile em production.
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Acme\StoreNotice\ViewModel\NoticeList $viewModel */
$viewModel = $block->getData('view_model');
?>
<ul class="store-notices">
<?php foreach ($viewModel->getMessages() as $message): ?>
<li><?= $escaper->escapeHtml($message) ?></li>
<?php endforeach; ?>
</ul>
Sempre escape a saída. A variável $escaper está disponível em todo template desde a 2.4, então você não precisa mais do $block->escapeHtml().
Mudando o comportamento do core com um plugin
A maioria dos módulos reais muda algo que o Magento já faz. A ferramenta de sempre é um plugin declarado em etc/di.xml. Este acrescenta um sufixo a todo nome de produto na loja. Coloque-o em etc/frontend/di.xml para não afetar o admin nem a API.
<?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\Catalog\Model\Product">
<plugin name="acme_store_notice_product_name"
type="Acme\StoreNotice\Plugin\ProductNamePlugin"
sortOrder="10"/>
</type>
</config>
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\Plugin;
use Magento\Catalog\Model\Product;
class ProductNamePlugin
{
public function afterGetName(Product $subject, ?string $result): ?string
{
return $result === null ? null : $result . ' *';
}
}
Plugins, observers e preferences têm cada um o seu lugar. Escrevi um artigo à parte sobre quando usar cada um.
Um valor de configuração no admin
Quase todo módulo acaba precisando de um interruptor em Stores, Configuration. O campo é declarado em etc/adminhtml/system.xml e seu valor padrão vai em etc/config.xml. Lê-lo é uma única chamada ao Magento\Framework\App\Config\ScopeConfigInterface.
<?php
declare(strict_types=1);
namespace Acme\StoreNotice\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
class Config
{
private const XML_PATH_ENABLED = 'acme_store_notice/general/enabled';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {
}
public function isEnabled(?int $storeId = null): bool
{
return $this->scopeConfig->isSetFlag(self::XML_PATH_ENABLED, ScopeInterface::SCOPE_STORE, $storeId);
}
}
Eu mantenho todos os config paths em uma pequena classe como essa. Quando um path muda, há um só lugar para editar, e o view model ou o plugin só faz uma pergunta clara como isEnabled(). Você também pode definir o valor pela linha de comando, o que é útil em scripts de deploy: bin/magento config:set acme_store_notice/general/enabled 1.
Testando antes de publicar
Os testes unitários de um módulo ficam em Test/Unit e rodam com a configuração do PHPUnit que acompanha o Magento:
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Acme/StoreNotice/Test/Unit
View models e classes de config como as acima são fáceis de testar em unit tests porque toda dependência chega pelo constructor. Controllers, layout e código de banco são mais bem cobertos por testes de integração, que precisam de um banco de teste separado. No mínimo, rode o coding standard do Magento no módulo com o PHP_CodeSniffer antes de abrir um pull request. Ele pega escape faltando em templates, uso direto de superglobals e outras coisas que os revisores apontariam de qualquer jeito.
Fazendo o deploy do módulo
Em modo developer, setup:upgrade e uma limpeza de cache geralmente bastam. Em modo production a sequência completa é:
bin/magento maintenance:enable
bin/magento setup:upgrade --keep-generated
bin/magento setup:di:compile
bin/magento setup:static-content:deploy -f en_US
bin/magento cache:flush
bin/magento maintenance:disable
Melhor ainda, gere o generated/ e o pub/static no CI e rode só o setup:upgrade --keep-generated no servidor. Isso mantém a janela de manutenção curta.
Erros comuns
- Maiúsculas e minúsculas erradas no namespace ou na pasta. Sistemas de arquivos Linux diferenciam maiúsculas de minúsculas, então um módulo que funciona no macOS pode falhar no servidor.
- Editar o
db_schema.xmlsem regenerar odb_schema_whitelist.json, o que deixa colunas que o Magento se recusa a remover. - Colocar um plugin de frontend no
etc/di.xmlglobal, de modo que ele também roda no admin, no cron e em chamadas REST. - Usar o
ObjectManagerdiretamente em vez de constructor injection. Funciona, mas esconde as dependências e quebra os unit tests. - Esquecer de fazer commit do
app/etc/config.phpdepois de habilitar o módulo.
FAQ
Ainda preciso do setup_version no module.xml?
Não. Desde que o declarative schema e os patches foram introduzidos no Magento 2.3, o setup_version não é necessário em módulos novos. Mudanças de schema vão no db_schema.xml e mudanças de dados vão em data patches.
Um módulo customizado deve ficar em app/code ou em vendor?
Código específico de um projeto pode ficar em app/code. Código compartilhado entre projetos, ou qualquer coisa que você queira versionar e instalar de forma independente, fica melhor como um pacote Composer que acaba em vendor.
Por que meu módulo novo não aparece depois do setup:upgrade?
Confira se o registration.php usa o nome exato do módulo do module.xml, se a caixa das pastas e do namespace bate e se o módulo está listado e habilitado em app/etc/config.php. O bin/magento module:status mostra o que o Magento realmente enxerga.
Quais comandos rodo depois de mudar o di.xml?
Em modo developer, uma limpeza de cache geralmente basta, mais a remoção das classes afetadas em generated/ se um constructor mudou. Em modo production rode bin/magento setup:di:compile e depois limpe o cache.
Atendo como freelancer em Magento e Adobe Commerce, de desenvolvimento de módulos e auditorias de performance a migrações e projetos em Hyvä. Se isso for útil para você, 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 no PHP 8.3 e 8.4: corrigindo problemas de compatibilidade
Como levar o código do Magento 2 para PHP 8.3 e 8.4: as deprecações que quebram extensões, como achá-las com PHPCompatibility, Rector e PHPStan, e as correções.
Como criar um comando CLI customizado no Magento 2
Crie um comando bin/magento customizado no Magento 2.4.8: registro no di.xml, argumentos e opções, area code, proxies, barras de progresso, locks e exit codes.