Extension Attributes to jeden z najbardziej eleganckich mechanizmów Magento 2 który pozwala dodawać pola do istniejących encji (produkty, zamówienia, klienci) bez modyfikowania ich tabel ani klas. Zamiast tego deklarujesz atrybuty w XML, implementujesz plugin do ładowania i zapisywania danych, i Magento automatycznie dołącza je do odpowiedzi REST API i GraphQL. Pokazuję kompletną implementację krok po kroku.
Extension Attributes vs Custom Attributes vs EAV
| Mechanizm | Gdzie dane | Kiedy używać |
|---|---|---|
| EAV Attribute | Tabele _varchar/_int/_decimal | Atrybuty produktów widoczne w adminie, filtrowanie w layered navigation |
| Custom Attribute (extension_attributes) | Własna tabela (join) | Dane techniczne lub B2B, nie wymagają UI w adminie, powiązane z encją 1:1 |
| getData/setData (magic) | Pamięć obiektu, nie persystuje | Tymczasowe dane w scope jednego requestu |
Deklaracja Extension Attribute
<!-- 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">
<!-- Rozszerz Order o dane dostawy z zewnętrznego systemu -->
<extension_attributes for="Magento\Sales\Api\Data\OrderInterface">
<attribute code="vendor_delivery_note" type="string"/>
<attribute code="vendor_erp_order_id" type="string"/>
<attribute code="vendor_warehouse_id" type="int"/>
</extension_attributes>
<!-- Rozszerz Product o dane z PIM -->
<extension_attributes for="Magento\Catalog\Api\Data\ProductInterface">
<attribute code="vendor_pim_id" type="string"/>
<attribute code="vendor_family_code" type="string"/>
<attribute code="vendor_completeness" type="float"/>
<!-- Zagnieżdżony obiekt - własny interfejs danych -->
<attribute code="vendor_stock_info"
type="Vendor\Module\Api\Data\StockInfoInterface"/>
</extension_attributes>
<!-- Rozszerz Customer -->
<extension_attributes for="Magento\Customer\Api\Data\CustomerInterface">
<attribute code="vendor_company_code" type="string"/>
<attribute code="vendor_credit_limit" type="float"/>
</extension_attributes>
</config>
Plugin do ładowania i zapisywania danych
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\Data\OrderExtensionFactory;
use Magento\Sales\Api\OrderRepositoryInterface;
class OrderExtensionAttributesPlugin
{
public function __construct(
private OrderExtensionFactory $orderExtensionFactory,
private \Vendor\Module\Model\ResourceModel\OrderExtData $resourceModel
) {}
// Ładuj dane po pobraniu zamówienia
public function afterGet(
OrderRepositoryInterface $subject,
OrderInterface $order
): OrderInterface {
$this->loadExtensionAttributes($order);
return $order;
}
// Ładuj dane dla listy zamówień
public function afterGetList(
OrderRepositoryInterface $subject,
\Magento\Sales\Api\Data\OrderSearchResultInterface $searchResult
): \Magento\Sales\Api\Data\OrderSearchResultInterface {
// Pobierz wszystkie ID zamówień naraz - unikamy N+1
$orderIds = array_map(
fn($o) => (int) $o->getEntityId(),
$searchResult->getItems()
);
$allExtData = $this->resourceModel->getByOrderIds($orderIds);
foreach ($searchResult->getItems() as $order) {
$data = $allExtData[(int) $order->getEntityId()] ?? [];
$this->applyExtensionAttributes($order, $data);
}
return $searchResult;
}
// Zapisz dane razem z zamówieniem
public function afterSave(
OrderRepositoryInterface $subject,
OrderInterface $order
): OrderInterface {
$extAttributes = $order->getExtensionAttributes();
if ($extAttributes === null) {
return $order;
}
$this->resourceModel->saveForOrder((int) $order->getEntityId(), [
'delivery_note' => $extAttributes->getVendorDeliveryNote(),
'erp_order_id' => $extAttributes->getVendorErpOrderId(),
'warehouse_id' => $extAttributes->getVendorWarehouseId(),
]);
return $order;
}
private function loadExtensionAttributes(OrderInterface $order): void
{
$data = $this->resourceModel->getByOrderId((int) $order->getEntityId());
$this->applyExtensionAttributes($order, $data);
}
private function applyExtensionAttributes(OrderInterface $order, array $data): void
{
$extAttributes = $order->getExtensionAttributes()
?? $this->orderExtensionFactory->create();
$extAttributes->setVendorDeliveryNote($data['delivery_note'] ?? null);
$extAttributes->setVendorErpOrderId($data['erp_order_id'] ?? null);
$extAttributes->setVendorWarehouseId(
isset($data['warehouse_id']) ? (int) $data['warehouse_id'] : null
);
$order->setExtensionAttributes($extAttributes);
}
}
Resource Model dla własnej tabeli
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\ResourceModel;
use Magento\Framework\App\ResourceConnection;
class OrderExtData
{
private const TABLE = 'vendor_order_ext_data';
public function __construct(
private ResourceConnection $resourceConnection
) {}
public function getByOrderId(int $orderId): array
{
$select = $this->getConnection()->select()
->from($this->getTable())
->where('order_id = ?', $orderId);
return $this->getConnection()->fetchRow($select) ?: [];
}
// Batch load – unikamy N+1 przy getList()
public function getByOrderIds(array $orderIds): array
{
if (empty($orderIds)) {
return [];
}
$select = $this->getConnection()->select()
->from($this->getTable())
->where('order_id IN (?)', $orderIds);
$rows = $this->getConnection()->fetchAll($select);
$result = [];
foreach ($rows as $row) {
$result[(int) $row['order_id']] = $row;
}
return $result;
}
public function saveForOrder(int $orderId, array $data): void
{
$data['order_id'] = $orderId;
$this->getConnection()->insertOnDuplicate(
$this->getTable(),
$data,
array_keys(array_diff_key($data, ['order_id' => null]))
);
}
private function getConnection(): \Magento\Framework\DB\Adapter\AdapterInterface
{
return $this->resourceConnection->getConnection();
}
private function getTable(): string
{
return $this->resourceConnection->getTableName(self::TABLE);
}
}
Rejestracja pluginu w di.xml
<!-- etc/di.xml -->
<?xml version="1.0"?>
<config>
<type name="Magento\Sales\Api\OrderRepositoryInterface">
<plugin name="vendor_module_order_extension_attributes"
type="Vendor\Module\Plugin\OrderExtensionAttributesPlugin"
sortOrder="10"/>
</type>
</config>
Dostęp do Extension Attributes w REST API i GraphQL
# REST API - extension_attributes automatycznie w odpowiedzi
curl -X GET \
https://shop.example.com/rest/V1/orders/1042 \
-H 'Authorization: Bearer ADMIN_TOKEN'
# Odpowiedź zawiera:
# {
# "entity_id": 1042,
# "status": "processing",
# "extension_attributes": {
# "vendor_delivery_note": "Dzwonek nie działa - zostawić u sąsiada",
# "vendor_erp_order_id": "ERP-20251118-001",
# "vendor_warehouse_id": 3
# }
# }
# Zapis przez REST API
curl -X PUT \
https://shop.example.com/rest/V1/orders/1042 \
-H 'Authorization: Bearer ADMIN_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"entity": {
"entity_id": 1042,
"extension_attributes": {
"vendor_erp_order_id": "ERP-20251118-001",
"vendor_warehouse_id": 3
}
}
}'
Extension Attributes w testach integracyjnych
<?php
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
class OrderExtensionAttributesTest extends TestCase
{
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository;
protected function setUp(): void
{
$this->orderRepository = Bootstrap::getObjectManager()
->get(\Magento\Sales\Api\OrderRepositoryInterface::class);
}
public function testExtensionAttributesPersist(): void
{
$order = $this->orderRepository->get(1);
$extAttr = $order->getExtensionAttributes();
$extAttr->setVendorDeliveryNote('Test note');
$extAttr->setVendorErpOrderId('ERP-TEST-001');
$order->setExtensionAttributes($extAttr);
$this->orderRepository->save($order);
// Przeładuj świeże z bazy
$loaded = $this->orderRepository->get(1);
$this->assertEquals('Test note', $loaded->getExtensionAttributes()->getVendorDeliveryNote());
$this->assertEquals('ERP-TEST-001', $loaded->getExtensionAttributes()->getVendorErpOrderId());
}
}
Podsumowanie
Extension Attributes to najczystszy sposób rozszerzania modeli Magento 2 bez ingerencji w rdzeń. Deklaracja w XML, plugin do ładowania/zapisywania, własna tabela z batch loadingiem żeby uniknąć N+1 – to pełny zestaw. Kluczowa zasada: zawsze implementuj batch loading w afterGetList() – bez tego każda lista zamówień czy produktów generuje N dodatkowych zapytań do bazy. REST API i GraphQL automatycznie dostają nowe pola bez żadnej dodatkowej konfiguracji.
