← Voltar ao blog

Magento 2: plugins, observers e preferences, quando usar cada um

Rodrigo Biazzi

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 no events.xml de 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_after deixa 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 null para 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 do getPrice().
  • Métodos around recebem um callable $proceed e 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.
  • __construct e __destruct.
  • Virtual types diretamente. Aplique o plugin na classe real.
  • Objetos criados com new em 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

  1. O comportamento é configurável por um argumento ou pool do di.xml? Use isso.
  2. 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.
  3. 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.
  4. Você precisa pular ou envolver a chamada original? Use um plugin around e mantenha curto.
  5. Você está implementando uma interface? Use uma preference.
  6. 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