← Voltar ao blog

Como criar um módulo customizado no Magento 2 (guia 2.4.8)

Rodrigo Biazzi

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.xml sem regenerar o db_schema_whitelist.json, o que deixa colunas que o Magento se recusa a remover.
  • Colocar um plugin de frontend no etc/di.xml global, de modo que ele também roda no admin, no cron e em chamadas REST.
  • Usar o ObjectManager diretamente em vez de constructor injection. Funciona, mas esconde as dependências e quebra os unit tests.
  • Esquecer de fazer commit do app/etc/config.php depois 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