Magento 2: plugins, observers e preferences, quando usar cada um
O Magento 2 oferece três formas principais de mudar comportamento sem editar arquivos do core: plugins (interceptors), event observers e preferences. As três funcionam, e na maioria dos codebases que reviso as três são usadas, muitas vezes para o trabalho errado. A escolha importa porque decide o quanto sua mudança sobrevive a upgrades e o quanto ela convive bem com outras extensões que mexem no mesmo código.
Este artigo explica como cada mecanismo funciona no Magento 2.4.8, o que ele pode e não pode fazer, e a ordem em que recorro a eles.
A resposta curta
- Use um observer quando o Magento já dispara um evento no ponto que interessa e você só precisa reagir a ele.
- Use um plugin quando precisar mudar a entrada ou a saída de um método público, ou envolvê-lo com lógica extra.
- Use uma preference quando for implementar uma interface, ou como último recurso quando precisar substituir uma classe e nada mais funcionar.
O resto do artigo é o raciocínio por trás dessa lista.
Observers: reagindo a eventos
O Magento dispara eventos nomeados pelo Magento\Framework\Event\ManagerInterface. Alguns são explícitos, como checkout_cart_product_add_after ou sales_order_place_after. Outros são gerados para models, como catalog_product_save_after, montados a partir do $_eventPrefix do model. Você se inscreve no etc/events.xml, ou em uma pasta de área como etc/frontend/events.xml ou etc/adminhtml/events.xml para limitar onde o observer roda.
<?xml version="1.0"?>
<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="acme_order_export_queue"
instance="Acme\OrderExport\Observer\QueueOrderExport"/>
</event>
</config>
<?php
declare(strict_types=1);
namespace Acme\OrderExport\Observer;
use Acme\OrderExport\Model\ExportQueue;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\Data\OrderInterface;
class QueueOrderExport implements ObserverInterface
{
public function __construct(
private readonly ExportQueue $exportQueue
) {
}
public function execute(Observer $observer): void
{
/** @var OrderInterface $order */
$order = $observer->getEvent()->getData('order');
$this->exportQueue->add((int) $order->getEntityId());
}
}
No que os observers são bons
- Efeitos colaterais: logging, enfileirar uma mensagem, enviar dados para um ERP, limpar um cache customizado.
- Vários listeners no mesmo momento sem que um saiba do outro.
- Código que deve ser fácil de desativar. Adicionar
disabled="true"a um nó observer noevents.xmlde outro módulo o desliga.
Onde os observers ficam devendo
- Você só consegue agir onde existe um evento. Se o core não dispara um, o observer não é opção.
- Observers não devolvem valores. Você pode mudar objetos passados por referência nos dados do evento, mas quem disparou o evento decide o que faz com eles depois. Depender de mutação é frágil.
- A ordem de execução entre observers do mesmo evento não é algo em que você deva se apoiar.
- Trabalho pesado dentro de um observer roda de forma síncrona no request. Chamar uma API externa no
sales_order_place_afterdeixa o checkout tão lento quanto essa API. Enfileire o trabalho e processe com um consumer ou cron.
Plugins: interceptando métodos públicos
Um plugin envolve um método público de uma classe ou interface. O Magento gera uma subclasse Interceptor em generated/code que chama os métodos do seu plugin em volta do original. Você declara plugins no 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\Checkout\Model\Cart">
<plugin name="acme_cart_max_qty"
type="Acme\CartRules\Plugin\LimitQtyPlugin"
sortOrder="20"/>
</type>
</config>
Before, after e around
O prefixo do método decide o tipo do plugin. O primeiro argumento é sempre o objeto interceptado.
<?php
declare(strict_types=1);
namespace Acme\CartRules\Plugin;
use Magento\Checkout\Model\Cart;
use Magento\Framework\Exception\LocalizedException;
class LimitQtyPlugin
{
private const MAX_QTY = 10;
public function beforeAddProduct(Cart $subject, $productInfo, $requestInfo = null): array
{
if (is_array($requestInfo) && isset($requestInfo['qty']) && (float) $requestInfo['qty'] > self::MAX_QTY) {
throw new LocalizedException(__('You can add at most %1 units at once.', self::MAX_QTY));
}
return [$productInfo, $requestInfo];
}
}
- Métodos before recebem os argumentos originais e podem devolver um array de argumentos modificados, ou
nullpara deixá-los como estão. - Métodos after recebem o resultado, depois os argumentos originais, e precisam devolver o resultado. Em
afterGetPrice(Product $subject, $result)o segundo parâmetro é o valor de retorno dogetPrice(). - Métodos around recebem um
callable $proceede decidem se e como chamar o original.
public function aroundSave(
\Magento\Customer\Model\ResourceModel\Customer $subject,
callable $proceed,
\Magento\Framework\Model\AbstractModel $customer
) {
$start = microtime(true);
$result = $proceed($customer);
$this->logger->debug('Customer save took ' . round(microtime(true) - $start, 3) . 's');
return $result;
}
Evite plugins around quando puder
Plugins around aumentam a profundidade da stack e dificultam o debug, e um plugin around que esquece de chamar o $proceed interrompe em silêncio o método original e todos os plugins de sort order maior. A orientação da própria Adobe é preferir before e after. A maioria dos plugins around poderia ser escrita como um before mais um after. Os casos de uso reais são pular a chamada original de forma condicional ou envolvê-la em um try e catch ou em uma transação.
Sort order
O sortOrder controla a sequência quando vários plugins atingem o mesmo método. Plugins before rodam do menor para o maior sort order, assim como a parte de um plugin around antes do $proceed. Plugins after e a parte de um plugin around depois do $proceed se desenrolam na direção oposta. Quando duas extensões conflitam no mesmo método, o sort order costuma ser a primeira coisa a olhar.
O que plugins não conseguem interceptar
- Métodos final e classes final.
- Métodos não públicos (protected e private).
- Métodos estáticos.
__constructe__destruct.- Virtual types diretamente. Aplique o plugin na classe real.
- Objetos criados com
newem vez de pelo object manager, e objetos instanciados antes de o framework de interceptação ser inicializado.
Se o método que você quer é protected, procure um nível acima o método público que o chama. Normalmente é um ponto de integração melhor de qualquer forma.
Aplique em interfaces, não em implementações
Onde o Magento usa service contracts, declare o plugin na interface, por exemplo Magento\Catalog\Api\ProductRepositoryInterface. O plugin então vale para qualquer implementação configurada, inclusive uma de outro módulo, e o seu código depende da API estável e não de uma classe interna.
Preferences: substituindo uma classe
Uma preference diz ao object manager para criar uma classe diferente sempre que uma determinada classe ou interface for solicitada.
<preference for="Acme\Shipping\Api\RateProviderInterface"
type="Acme\Shipping\Model\RateProvider"/>
Esse uso, mapear uma interface para a sua implementação, é o normal e correto. O próprio Magento é construído sobre milhares dessas em app/etc/di.xml e nos di.xml dos módulos.
O uso arriscado é sobrescrever uma classe concreta do core:
<preference for="Magento\Catalog\Model\Product\Url"
type="Acme\Catalog\Model\Product\Url"/>
Por que preferences de classe são último recurso
- Só uma preference vence. Se duas extensões substituem a mesma classe, uma delas é ignorada em silêncio, decidido pela ordem de carregamento dos módulos.
- Sua classe normalmente estende a classe do core e copia um método. No upgrade o método do core muda e a sua cópia não, então você mantém o comportamento antigo ou quebra por uma assinatura alterada.
- Plugins na classe original continuam valendo para a sua subclasse, o que surpreende quem esperava uma substituição limpa.
Eu uso uma preference de classe quando o comportamento de que preciso está em um método protected que nenhum método público me deixa alcançar de forma limpa, e a alternativa é alterar código de vendor. Mesmo assim, mantenho o método sobrescrito o menor possível e adiciono um teste que falha se a assinatura do pai mudar.
Argumentos de constructor: a opção que esquecem
Muitas mudanças não precisam de nenhuma das três. O di.xml permite mudar os argumentos que uma classe recebe. Adicionar um item a um pool, trocar uma dependência por uma classe específica ou criar um virtual type com configurações diferentes é mais limpo que qualquer plugin.
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="acme_import_run" xsi:type="object">Acme\Import\Console\Command\RunImport</item>
</argument>
</arguments>
</type>
<virtualType name="Acme\Import\Logger\Handler" type="Magento\Framework\Logger\Handler\Base">
<arguments>
<argument name="fileName" xsi:type="string">/var/log/acme_import.log</argument>
</arguments>
</virtualType>
É exatamente assim que os módulos registram comandos de CLI. Pools do core como command lists, layout processors e data providers foram feitos para isso. Antes de escrever um plugin, confira se a classe que você quer mudar aceita um argumento de array ou de objeto que você possa estender.
Um checklist de decisão
- O comportamento é configurável por um argumento ou pool do
di.xml? Use isso. - Existe um evento no momento certo e você só precisa de um efeito colateral? Use um observer, e faça-o rápido ou enfileire o trabalho.
- Você precisa mudar os argumentos ou o resultado de um método público? Use um plugin before ou after, na interface quando houver uma.
- Você precisa pular ou envolver a chamada original? Use um plugin around e mantenha curto.
- Você está implementando uma interface? Use uma preference.
- Nada acima funciona e a alternativa é um patch no core? Uma preference de classe, pequena e bem testada, ou um composer patch com uma nota clara do porquê.
Depurando o que já existe
Em uma loja herdada, a primeira tarefa é descobrir o que já se pendura no código que você vai mudar. Alguns comandos ajudam:
bin/magento dev:di:info "Magento\Checkout\Model\Cart"
grep -rn "sales_order_place_after" app/code vendor --include=events.xml
O dev:di:info lista a preference, os argumentos de constructor e os plugins configurados para uma classe, o que muitas vezes basta para explicar por que um método não se comporta como o código do core sugere. Lembre também que o di.xml em etc/ é global, enquanto etc/frontend/di.xml, etc/adminhtml/di.xml e etc/webapi_rest/di.xml só valem para a própria área, então a mesma classe pode se comportar diferente no storefront e na API REST.
FAQ
Plugins são mais rápidos que observers no Magento 2?
O overhead de qualquer um dos mecanismos é pequeno. O que faz diferença é o trabalho feito dentro deles. Escolha o que se encaixa no trabalho e tire trabalho lento, como chamadas de API, do request enfileirando-o.
Um plugin pode interceptar um método protected ou private?
Não. Plugins só funcionam em métodos públicos. Também não interceptam métodos final, classes final, métodos estáticos, constructors nem virtual types. Procure o método público que chama o protected.
O que acontece quando dois módulos definem uma preference para a mesma classe?
Só um vence, pela ordem de carregamento dos módulos, e o outro é ignorado sem nenhum erro. Esse é o principal motivo para evitar preferences de classe em classes do core quando um plugin resolve.
Como vejo quais plugins estão ligados a uma classe?
Rode bin/magento dev:di:info com o nome da classe. Ele lista a preference, os argumentos de constructor e os plugins configurados para ela, incluindo o sort order.
Atendo como freelancer em Magento e Adobe Commerce, de desenvolvimento de módulos e auditorias de performance a migrações e builds em Hyvä. Se isso faz sentido 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.
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.
Método de pagamento customizado no Magento 2 com a Payment Gateway API
Crie um método de pagamento customizado no Magento 2.4 com a Payment Gateway API: virtual type de facade, config, commands e o renderer JS do checkout, passo a passo.