← Voltar ao blog

Extension attributes no Magento 2: dados em pedidos e APIs

Rodrigo Biazzi

Extension attributes são o jeito de o Magento 2 permitir que um módulo adicione dados às data interfaces de outro módulo sem alterá-las. Se você precisa que um pedido carregue uma observação de entrega, que um produto exponha um valor vindo de um sistema externo ou que um cliente tenha um nível de fidelidade nas respostas REST, extension attributes são a forma suportada de fazer isso. Também são um dos recursos mais mal entendidos, principalmente porque as pessoas esperam que eles carreguem e salvem dados sozinhos. Não fazem isso.

Este artigo cobre como eles funcionam no Magento Open Source e no Adobe Commerce 2.4.8, com um exemplo completo em pedidos.

O que extension attributes são e o que não são

Toda data interface que estende Magento\Framework\Api\ExtensibleDataInterface tem um método getExtensionAttributes(). Ele devolve um objeto gerado, por exemplo Magento\Sales\Api\Data\OrderExtensionInterface, com getters e setters para todo extension attribute declarado por qualquer módulo.

O que você ganha de graça:

  • Um getter e um setter tipados gerados a partir do XML.
  • Serialização nas respostas REST sob a chave extension_attributes, e desserialização a partir do corpo das requisições.

O que você precisa construir sozinho:

  • O storage, normalmente uma tabela própria.
  • Carregar o valor quando a entidade é lida.
  • Salvar o valor quando a entidade é salva.

Extension attributes também não são a mesma coisa que custom attributes EAV. Um atributo de produto criado com um data patch já aparece em custom_attributes e é armazenado pelo sistema EAV. Use extension attributes para dados que não são EAV, ou para entidades como pedidos, que não usam EAV.

Declarando o atributo

Crie o etc/extension_attributes.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
    <extension_attributes for="Magento\Sales\Api\Data\OrderInterface">
        <attribute code="delivery_note" type="string"/>
    </extension_attributes>
</config>

O atributo for precisa ser a data interface, não a classe do model. O type pode ser um escalar, um array de escalares como string[], ou outra data interface, o que permite anexar um objeto estruturado.

Depois de adicionar ou alterar esse arquivo, as classes de extension geradas precisam ser reconstruídas. Em developer mode, remova generated/code/Magento/Sales/Api/Data/OrderExtension* e limpe o cache. Em production mode, rode setup:di:compile. Até lá o setDeliveryNote() não existe e você recebe um erro de call to undefined method. O artigo sobre generated code explica o motivo.

Storage

Guarde os dados em uma tabela própria, com o ID do pedido como chave. Não adicione colunas em sales_order para isso, porque as tabelas do core são compartilhadas com outras extensões e com upgrades. Uma tabela em etc/db_schema.xml:

<?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_order_delivery_note" resource="default" engine="innodb" comment="Order delivery notes">
        <column xsi:type="int" name="order_id" unsigned="true" nullable="false" comment="Order ID"/>
        <column xsi:type="text" name="note" nullable="true" comment="Delivery note"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="order_id"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="ACME_ORDER_DELIVERY_NOTE_ORDER_ID_SALES_ORDER_ENTITY_ID"
                    table="acme_order_delivery_note" column="order_id"
                    referenceTable="sales_order" referenceColumn="entity_id" onDelete="CASCADE"/>
    </table>
</schema>

A foreign key com onDelete="CASCADE" remove a observação quando um pedido é apagado, então você nunca fica com linhas órfãs. Lembre de gerar também o db_schema_whitelist.json. As regras completas para arquivos de schema e whitelists estão no artigo sobre declarative schema.

Carregando e salvando com plugins de repository

A abordagem padrão é um plugin na interface do repository. Ele preenche o extension attribute depois do get() e do getList(), e grava depois do save().

<?php
declare(strict_types=1);

namespace Acme\DeliveryNote\Plugin;

use Acme\DeliveryNote\Model\NoteStorage;
use Magento\Sales\Api\Data\OrderExtensionFactory;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\Data\OrderSearchResultInterface;
use Magento\Sales\Api\OrderRepositoryInterface;

class OrderRepositoryPlugin
{
    public function __construct(
        private readonly NoteStorage $storage,
        private readonly OrderExtensionFactory $extensionFactory
    ) {
    }

    public function afterGet(OrderRepositoryInterface $subject, OrderInterface $order): OrderInterface
    {
        $notes = $this->storage->getByOrderIds([(int) $order->getEntityId()]);
        $this->apply($order, $notes[(int) $order->getEntityId()] ?? null);
        return $order;
    }

    public function afterGetList(
        OrderRepositoryInterface $subject,
        OrderSearchResultInterface $result
    ): OrderSearchResultInterface {
        $ids = [];
        foreach ($result->getItems() as $order) {
            $ids[] = (int) $order->getEntityId();
        }
        $notes = $this->storage->getByOrderIds($ids);
        foreach ($result->getItems() as $order) {
            $this->apply($order, $notes[(int) $order->getEntityId()] ?? null);
        }
        return $result;
    }

    public function afterSave(
        OrderRepositoryInterface $subject,
        OrderInterface $result,
        OrderInterface $order
    ): OrderInterface {
        $extension = $order->getExtensionAttributes();
        if ($extension !== null && $extension->getDeliveryNote() !== null) {
            $this->storage->save((int) $result->getEntityId(), $extension->getDeliveryNote());
        }
        return $result;
    }

    private function apply(OrderInterface $order, ?string $note): void
    {
        $extension = $order->getExtensionAttributes() ?? $this->extensionFactory->create();
        $extension->setDeliveryNote($note);
        $order->setExtensionAttributes($extension);
    }
}

O NoteStorage é uma classe pequena que lê e grava acme_order_delivery_note pela resource connection. A parte importante é o getByOrderIds(): o afterGetList() carrega todas as observações da página em uma única query. Carregar uma linha por pedido dentro do loop é o erro de performance mais comum com extension attributes, e com endpoints de lista devolvendo centenas de pedidos isso pesa rápido.

Registre o plugin em etc/di.xml:

<type name="Magento\Sales\Api\OrderRepositoryInterface">
    <plugin name="acme_delivery_note" type="Acme\DeliveryNote\Plugin\OrderRepositoryPlugin"/>
</type>

O resultado no REST

Um GET /rest/V1/orders/42 agora inclui a observação:

{
  "entity_id": 42,
  "increment_id": "000000042",
  "extension_attributes": {
    "delivery_note": "Leave at the back door"
  }
}

Outros extension attributes do core e de outros módulos aparecem ao lado dele no mesmo objeto. Os clientes enviam o valor de volta do mesmo jeito em uma requisição de save.

Anexando um objeto estruturado

Uma string simples é o caso mais fácil. Quando os dados têm vários campos, declare sua própria data interface e use como type:

<extension_attributes for="Magento\Sales\Api\Data\OrderInterface">
    <attribute code="delivery_info" type="Acme\DeliveryNote\Api\Data\DeliveryInfoInterface"/>
</extension_attributes>

A interface precisa de getters e setters com docblocks completos, e de uma preference no di.xml para a implementação, exatamente como qualquer outra data interface em um service contract. O REST então renderiza como um objeto aninhado. Isso mantém os valores relacionados juntos e evita uma lista crescente de atributos escalares soltos, cada um precisando do próprio tratamento no plugin.

Armadilhas

Nem todo load passa pelo repository

O plugin só roda quando o código usa o OrderRepositoryInterface. Os grids do admin leem de collections e tabelas de grid, muitos caminhos do core e de terceiros carregam pedidos por factories ou resource models, e os observers recebem os models de pedido diretamente. Em todos esses casos seu extension attribute fica vazio. Se você precisa do valor em um desses lugares, leia direto da sua classe de storage em vez de assumir que ele está preenchido.

Vale o mesmo para salvar. Muito código salva pedidos pelo resource model, não pelo repository, então o afterSave do repository não dispara. Para dados que precisam ser gravados durante o checkout, entre no próprio fluxo do checkout. Por exemplo, observe sales_model_service_quote_submit_before para copiar um valor da quote para o pedido e salve quando o pedido já tiver um ID.

A diretiva join

O extension_attributes.xml suporta um nó <join> que diz ao Magento para fazer join de uma tabela ao carregar collections. Ele só tem efeito onde o repository roda JoinProcessorInterface::process() na sua collection, e não salva nada. Acho plugins explícitos mais fáceis de raciocinar, mas o join pode economizar uma query em endpoints de lista que o suportam.

Permissões

Um atributo pode ter um filho <resources> com referências de ACL. Chamadores de API sem essa permissão não veem o atributo. Use para valores internos como custo ou margem que não devem chegar a toda integração.

GraphQL

Extension attributes não aparecem no GraphQL automaticamente. O GraphQL tem o próprio schema, então você adiciona um campo no schema.graphqls e um resolver que lê o seu storage. Veja o artigo sobre resolver GraphQL customizado para o padrão.

Quando usar outra coisa

  • Para dados de produto, categoria ou cliente que os admins devem editar nos formulários padrão, um atributo EAV é mais simples e já vem com UI do admin, import e export.
  • Para dados que só o seu módulo lê internamente, você nem precisa de um extension attribute. Leia sua tabela onde precisar.
  • Use extension attributes quando os dados precisam viajar com a entidade pelos service contracts e APIs.

FAQ

Por que o getExtensionAttributes devolve meu atributo como null?

Extension attributes não são carregados automaticamente. Algo precisa preenchê-los, normalmente um plugin no repository. Se a entidade foi carregada por uma collection, factory ou resource model, seu plugin não rodou.

Por que recebo call to undefined method setMyAttribute?

As classes de extension geradas foram criadas antes de você declarar o atributo. Remova os arquivos relacionados em generated/code em developer mode, ou rode setup:di:compile em production mode.

Posso adicionar uma coluna em sales_order no lugar?

Pode, mas uma tabela separada com o ID do pedido como chave mantém seus dados independentes de mudanças no schema do core e de outras extensões, e deixa a remoção do módulo mais limpa.

Extension attributes estão disponíveis no GraphQL?

Não automaticamente. Você precisa adicionar o campo ao schema do GraphQL e escrever um resolver para ele.

Atendo como freelancer em Magento e Adobe Commerce. Se você precisa de dados customizados em pedidos, produtos ou APIs de um jeito que sobreviva a upgrades, fale comigo.

Artigos relacionados