Como criar um widget customizado no Magento 2
Widgets permitem que editores de conteúdo coloquem blocos dinâmicos em páginas CMS, static blocks e posições de layout, com opções preenchidas por um formulário no admin. O Magento traz alguns prontos, como a lista de produtos do catálogo e o link para página CMS. Quando os editores precisam de algo específico da loja, um widget customizado costuma ser a resposta mais limpa, porque a lógica fica no código e os editores só escolhem opções.
Este artigo monta um módulo pequeno de widget para o Magento 2.4.8: um banner promocional com título, texto, uma opção de estilo e uma categoria vinculada. No caminho, passa pelos tipos de parâmetro, choosers, a widget directive e o cache.
Widgets x content types do Page Builder
Se a loja usa Page Builder, os editores podem esperar um elemento de arrastar e soltar em vez de um widget. Widgets continuam funcionando dentro do Page Builder pelo elemento HTML Code ou inserindo um widget em um elemento de texto, mas a experiência de edição não é tão boa. Para elementos que os editores usam o tempo todo, um content type customizado do Page Builder é mais agradável de usar. Widgets continuam sendo a melhor ferramenta quando o bloco também precisa ser posicionado por widget instances em posições do layout, ou usado fora do Page Builder.
Estrutura do módulo
app/code/Vendor/PromoWidget/
registration.php
etc/module.xml
etc/widget.xml
Block/Widget/PromoBanner.php
view/frontend/templates/widget/promo-banner.phtml
O módulo precisa de Magento_Widget e Magento_Cms no sequence do module.xml. Se você nunca montou um módulo, o guia de módulo customizado cobre o registration.php e o module.xml.
widget.xml
Esse arquivo declara o widget, o label dele no admin e os parâmetros. Cada parâmetro vira um campo no formulário do widget.
<?xml version="1.0"?>
<widgets xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Widget:etc/widget.xsd">
<widget id="vendor_promo_banner"
class="Vendor\PromoWidget\Block\Widget\PromoBanner"
is_email_compatible="false">
<label translate="true">Promo Banner</label>
<description translate="true">Banner with title, text and a category link</description>
<parameters>
<parameter name="title" xsi:type="text" required="true" visible="true" sort_order="10">
<label translate="true">Title</label>
</parameter>
<parameter name="text" xsi:type="text" required="false" visible="true" sort_order="20">
<label translate="true">Text</label>
</parameter>
<parameter name="style" xsi:type="select" required="true" visible="true" sort_order="30">
<label translate="true">Style</label>
<options>
<option name="light" value="light" selected="true">
<label translate="true">Light</label>
</option>
<option name="dark" value="dark">
<label translate="true">Dark</label>
</option>
</options>
</parameter>
<parameter name="show_button" xsi:type="select" visible="true" sort_order="40"
source_model="Magento\Config\Model\Config\Source\Yesno">
<label translate="true">Show Button</label>
</parameter>
<parameter name="id_path" xsi:type="block" required="true" visible="true" sort_order="50">
<label translate="true">Category</label>
<block class="Magento\Catalog\Block\Adminhtml\Category\Widget\Chooser">
<data>
<item name="button" xsi:type="array">
<item name="open" xsi:type="string" translate="true">Select Category...</item>
</item>
</data>
</block>
</parameter>
<parameter name="template" xsi:type="select" required="true" visible="true" sort_order="60">
<label translate="true">Template</label>
<options>
<option name="default" value="Vendor_PromoWidget::widget/promo-banner.phtml" selected="true">
<label translate="true">Default</label>
</option>
</options>
</parameter>
</parameters>
</widget>
</widgets>
Notas sobre os tipos de parâmetro:
textrenderiza um input de uma linha.selectemultiselectaceitamoptionsinline ou umsource_modelque implementaMagento\Framework\Data\OptionSourceInterface.blockrenderiza um block customizado no admin. O core traz choosers para categorias, produtos e páginas CMS, que devolvem valores comocategory/12ouproduct/34.conditionsrenderiza a árvore de condições de regra, como faz o widget de lista de produtos do catálogo do core.
Declarar template como parâmetro deixa os editores alternarem entre templates que você fornece, e permite que outros módulos adicionem as próprias opções de template. Você também pode usar depends para mostrar um campo só quando outro campo tem determinado valor.
Opções vindas de um source model
Opções inline servem para uma lista fixa. Quando a lista vem de dados, como um conjunto de estilos de banner definidos na configuração ou uma lista de valores de atributo, escreva um source model e referencie com source_model:
<?php
declare(strict_types=1);
namespace Vendor\PromoWidget\Model\Config\Source;
use Magento\Framework\Data\OptionSourceInterface;
class BannerStyle implements OptionSourceInterface
{
public function toOptionArray(): array
{
return [
['value' => 'light', 'label' => __('Light')],
['value' => 'dark', 'label' => __('Dark')],
['value' => 'brand', 'label' => __('Brand colour')],
];
}
}
A mesma classe pode atender um campo de system configuration ou um formulário de UI component, então a lista de valores permitidos fica em um lugar só. Mantenha o getter do block alinhado com ela, para que o template nunca receba um valor que o source model não oferece.
A classe do block
<?php
declare(strict_types=1);
namespace Vendor\PromoWidget\Block\Widget;
use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\View\Element\Template;
use Magento\Framework\View\Element\Template\Context;
use Magento\Widget\Block\BlockInterface;
class PromoBanner extends Template implements BlockInterface
{
protected $_template = 'Vendor_PromoWidget::widget/promo-banner.phtml';
public function __construct(
Context $context,
private readonly CategoryRepositoryInterface $categoryRepository,
array $data = []
) {
parent::__construct($context, $data);
}
public function getTitle(): string
{
return (string) $this->getData('title');
}
public function getText(): string
{
return (string) $this->getData('text');
}
public function getStyle(): string
{
return $this->getData('style') === 'dark' ? 'dark' : 'light';
}
public function isButtonVisible(): bool
{
return (bool) $this->getData('show_button');
}
public function getCategoryUrl(): ?string
{
$categoryId = $this->getCategoryId();
if ($categoryId === null) {
return null;
}
try {
$storeId = (int) $this->_storeManager->getStore()->getId();
$category = $this->categoryRepository->get($categoryId, $storeId);
} catch (NoSuchEntityException) {
return null;
}
return $category->getUrl();
}
public function getCacheKeyInfo(): array
{
return array_merge(parent::getCacheKeyInfo(), [
'vendor_promo_banner',
$this->getTitle(),
$this->getStyle(),
(string) $this->getData('id_path'),
]);
}
private function getCategoryId(): ?int
{
$parts = explode('/', (string) $this->getData('id_path'));
return isset($parts[1]) && ctype_digit($parts[1]) ? (int) $parts[1] : null;
}
}
A classe do widget precisa implementar Magento\Widget\Block\BlockInterface. Os parâmetros chegam como block data, então getData('title') devolve o que o editor digitou. Envolver cada um em um getter tipado deixa o template simples e dá um lugar único para validar valores. O getter de estilo, por exemplo, nunca devolve nada fora dos dois valores permitidos, mesmo que alguém edite a directive na mão.
O template
<?php
/** @var \Vendor\PromoWidget\Block\Widget\PromoBanner $block */
/** @var \Magento\Framework\Escaper $escaper */
$url = $block->getCategoryUrl();
?>
<section class="promo-banner promo-banner--<?= $escaper->escapeHtmlAttr($block->getStyle()) ?>">
<h2 class="promo-banner__title"><?= $escaper->escapeHtml($block->getTitle()) ?></h2>
<?php if ($block->getText()): ?>
<p class="promo-banner__text"><?= $escaper->escapeHtml($block->getText()) ?></p>
<?php endif; ?>
<?php if ($url && $block->isButtonVisible()): ?>
<a class="action primary" href="<?= $escaper->escapeUrl($url) ?>">
<?= $escaper->escapeHtml(__('Shop now')) ?>
</a>
<?php endif; ?>
</section>
Faça escape de todo parâmetro. Os parâmetros do widget são digitados por usuários do admin, mas continuam sendo input, e um título sem escape é uma porta fácil para HTML quebrado ou XSS armazenado entrar na loja.
Usando o widget
Depois de rodar bin/magento setup:upgrade e limpar o cache, o widget aparece em dois lugares:
- Insert Widget no editor WYSIWYG de páginas e blocks CMS. O Magento escreve uma directive no conteúdo.
- Content, Widgets, onde você cria uma widget instance e atribui a um tema, páginas e containers de layout, sem mexer no conteúdo CMS.
Vale olhar as widget instances com mais atenção, porque os editores costumam entendê-las errado. Uma instance é presa a um tema. Ao criar, você escolhe o tema primeiro e depois adiciona layout updates: em quais páginas ela aparece (todas as páginas, categorias específicas, produtos específicos ou um page handle) e em qual container ela entra. O Magento transforma isso em layout updates guardados no banco, então o bloco entra na página sem que ninguém toque em template. Se a loja trocar depois para um tema novo, as instances continuam presas ao tema antigo e precisam ser recriadas ou editadas.
Os containers oferecidos no formulário da widget instance vêm do atributo label dos containers no layout XML. Se você quer que os editores coloquem widgets em uma posição do seu próprio tema, declare ali um container com um label claro.
A directive no conteúdo fica assim:
{{widget type="Vendor\PromoWidget\Block\Widget\PromoBanner" title="Summer sale" style="dark" show_button="1" id_path="category/12" template="Vendor_PromoWidget::widget/promo-banner.phtml"}}
O type é o nome da classe. Se você renomear a classe depois, toda directive guardada em conteúdo CMS quebra. Escolha o nome com cuidado, ou mantenha a classe antiga como uma subclasse fina quando precisar movê-la.
Cache
Widgets renderizados dentro de uma página CMS fazem parte do HTML da página, então o full page cache guarda tudo junto. O block cache é uma segunda camada. Um block só é guardado no block cache quando tem cache_lifetime, que você pode definir por data ou sobrescrevendo getCacheLifetime(). Se ativar, inclua em getCacheKeyInfo() todo parâmetro que muda a saída, como a classe acima faz. Do contrário, dois banners com títulos diferentes na mesma store compartilham uma entrada de cache.
Quando o widget mostra dados de uma entidade, como o nome de uma categoria, implemente também Magento\Framework\DataObject\IdentityInterface e devolva as cache tags da entidade, para que salvar a categoria limpe o HTML em cache. O artigo sobre cache types explica como essas tags passam pelo block cache e pelo page cache.
Nunca coloque conteúdo específico de cliente em um widget. Ele vai parar no HTML em cache e será mostrado ao próximo visitante.
Manutenção
Um widget é um pequeno contrato público com o time de conteúdo. Quando as directives estão guardadas em centenas de blocks CMS, mudá-lo custa mais do que construí-lo custou. Algumas regras mantêm esse custo baixo:
- Adicione parâmetros novos como opcionais, com um default sensato no getter do block, para que as directives existentes continuem funcionando.
- Nunca renomeie ou remova um parâmetro que conteúdo guardado usa. Se precisar mudar, leia o nome antigo como fallback.
- Antes de remover um módulo de widget, procure o nome da classe no conteúdo de
cms_pageecms_blocke na tabelawidget_instance. Directives que sobram não renderizam nada ou geram erros no log.
FAQ
Por que meu widget não aparece na lista do Insert Widget?
Confira se o widget.xml é válido contra o schema, se o módulo está habilitado e se você limpou o cache. Confirme também que a classe do widget.xml existe e implementa BlockInterface.
Um widget pode ser usado em e-mails transacionais?
Só se for declarado com is_email_compatible igual a true, e o template precisa funcionar sem o CSS e o JavaScript da loja, usando estilos inline.
Como adiciono um chooser de produto a um widget?
Use um parâmetro do tipo block com a classe Magento\Catalog\Block\Adminhtml\Product\Widget\Chooser. Ele devolve um valor como product/34 que você faz o parse na classe do block.
Widgets funcionam com Hyvä?
O lado PHP funciona igual. O template precisa de markup no estilo Hyvä, com classes Tailwind e Alpine.js no lugar de RequireJS, então entregue uma versão Hyvä do template se a loja usar os dois.
Atendo como freelancer em Magento e Adobe Commerce, incluindo ferramentas de CMS customizadas para times de conteúdo. Se precisar de ajuda com isso, fale comigo.
Artigos relacionados
Magento 2 Page Builder: criando um content type customizado
Passo a passo de um content type customizado do Page Builder no Magento 2.4: XML do content type, formulário, templates de preview e master, saída no storefront e armadilhas.
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.
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.