Extension attributes are a powerful mechanism in Magento 2 for extending core functionality without modifying the core codebase. They provide a clean and upgrade-safe way to add custom data to entities like products, customers, and orders.
While the standard approach to populate extension attributes is via plugins, it often involves hardcoding the logic for each attribute within the plugin’s afterGet
or afterGetList
methods. This can lead to code bloat and maintenance challenges as the number of attributes grows.
In this post, we’ll explore a more flexible approach to dynamically add extension attributes to orders using a combination of plugins and di.xml
configuration. This method allows you to easily add or modify extension attributes without altering the plugin’s code, making it a more maintainable and scalable solution.
Standard Approach to Populate Extension Attributes
Before diving into the dynamic approach, let’s briefly review the standard way of populating extension attributes using plugins, as demonstrated in this example from Adobe: Adding attributes.
This approach typically involves:
- Retrieving the entity’s extension attributes using
$entity->getExtensionAttributes()
. - Retrieving or calculating the custom data for the attribute.
- Setting the attribute value on the extension attributes object.
- Setting the modified extension attributes back on the entity using
$entity->setExtensionAttributes()
.
While effective, this method requires code changes within the plugin every time you add or modify an attribute.
Dynamic Extension Attributes with Plugins and di.xml
Our approach leverages a custom plugin that intercepts order retrieval methods and dynamically adds extension attributes based on configuration in di.xml
. This allows you to define the mapping between extension attribute codes and the corresponding data keys or hydrator classes in a centralized location.
Key Components:
extension_attributes.xml
: This file defines the extension attributes themselves, specifying their code and data type.<?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="custom_attribute_1" type="string"/> <attribute code="custom_attribute_2" type="int"/> <attribute code="complex_attribute" type="string"/> </extension_attributes> </config>
di.xml
: This file configures the plugin and defines the mapping between extension attribute codes and data sources.<?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\Sales\Api\OrderRepositoryInterface"> <plugin name="test_order_attributes_plugin" type="TestOrderAttributes\Plugin\Sales\Model\Order\OrderAttributesPlugin" sortOrder="10" /> </type> <type name="TestOrderAttributes\Plugin\Sales\Model\Order\OrderAttributesPlugin"> <arguments> <argument name="attributes" xsi:type="array"> <item name="custom_attribute_1" xsi:type="string">customer_email</item> <item name="custom_attribute_2" xsi:type="string">total_qty_ordered</item> <item name="complex_attribute" xsi:type="object">TestOrderAttributes\Model\Order\Attribute\ComplexAttribute</item> </argument> </arguments> </type> </config>
- Plugin Class: The plugin intercepts order retrieval methods (
afterGet
,afterGetList
) and populates the extension attributes based on thedi.xml
configuration.<?php namespace TestOrderAttributes\Plugin\Sales\Model\Order; use Magento\Sales\Api\Data\OrderExtensionInterface; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\Data\OrderSearchResultInterface; use Magento\Sales\Api\OrderRepositoryInterface; use TestOrderAttributes\Api\OrderAttributeHydratorInterface; class OrderAttributesPlugin { private $attributes; public function __construct( array $attributes = [] ) { $this->attributes = $attributes; } public function afterGet(OrderRepositoryInterface $subject, OrderInterface $order): OrderInterface { $this->setAttributes($order, $order->getExtensionAttributes()); return $order; } public function afterGetList( OrderRepositoryInterface $subject, OrderSearchResultInterface $orders ): OrderSearchResultInterface { foreach ($orders->getItems() as $order) { $this->setAttributes($order, $order->getExtensionAttributes()); } return $orders; } private function setAttributes(OrderInterface $order, ?OrderExtensionInterface $orderExtension = null): void { $entity = $orderExtension ?? $order; foreach ($this->attributes as $attributeCode => $attributeValue) { if ($attributeValue instanceof OrderAttributeHydratorInterface) { $value = $attributeValue->hydrate($order); } else { $value = $order->getData($attributeValue); } $entity->setData($attributeCode, $value); } } }
- Hydrator Classes (Optional): For complex attribute logic, you can create dedicated hydrator classes that implement an interface (e.g.,
OrderAttributeHydratorInterface
).<?php namespace TestOrderAttributes\Model\Order\Attribute; use Magento\Sales\Api\Data\OrderInterface; use TestOrderAttributes\Api\OrderAttributeHydratorInterface; class ComplexAttribute implements OrderAttributeHydratorInterface { public function hydrate(OrderInterface $order) { // Example: Combine customer group ID and order status return $order->getCustomerGroupId() . '_' . $order->getStatus(); } }
Benefits of this Approach
- Flexibility: Easily add or modify extension attributes by simply updating the
di.xml
configuration. - Maintainability: Keeps your code clean and organized, reducing the risk of conflicts and making upgrades smoother.
- Scalability: Easily handle a growing number of extension attributes without code bloat.
- Centralized Configuration: All attribute mappings are defined in one place (
di.xml
).
Conclusion
This dynamic approach to adding extension attributes to orders provides a flexible and maintainable solution for extending core functionality in Magento 2. By leveraging plugins and di.xml
configuration, you can easily manage and customize order data without modifying core files, ensuring a more robust and upgrade-safe implementation.
This approach is particularly beneficial when dealing with a large number of extension attributes or frequent changes, as it minimizes code modifications and promotes a more declarative approach to customization.
Download the complete module: https://github.com/apedikdev/module-test
Test: Order Attributes Population
This test verifies the correct population of custom extension attributes on orders, ensuring the plugin and hydrator classes function as expected.
class OrderAttributesTest extends TestCase
{
private ?OrderRepository $orderRepository = null;
private ?SearchCriteriaBuilder $searchCriteriaBuilder = null;
protected function setUp(): void
{
$objectManager = Bootstrap::getObjectManager();
$this->orderRepository = $objectManager->get(OrderRepositoryInterface::class);
$this->searchCriteriaBuilder = $objectManager->get(SearchCriteriaBuilder::class);
}
/**
* @magentoDataFixture Magento/Sales/_files/order.php
*/
public function testOrderSaveAndAttributePopulation(): void
{
// Get an order
$searchCriteria = $this->searchCriteriaBuilder->setPageSize(1)->create();
$orders = $this->orderRepository->getList($searchCriteria)->getItems();
$order = current($orders);
$orderId = $order->getEntityId();
$extensionAttributes = $this->orderRepository->get($orderId)->getExtensionAttributes();
$this->assertNotNull($extensionAttributes);
$this->assertEquals(
$order->getCustomerEmail(),
$extensionAttributes->getCustomAttribute1(),
'custom_attribute_1 is not populated correctly'
);
$this->assertEquals(
$order->getTotalQtyOrdered(),
$extensionAttributes->getCustomAttribute2(),
'custom_attribute_2 is not populated correctly'
);
$complexAttributeValue = sprintf(
'%s_%s',
$order->getCustomerGroupId(),
$order->getStatus()
);
$this->assertEquals(
$complexAttributeValue,
$extensionAttributes->getComplexAttribute(),
'complex_attribute is not populated correctly'
);
}
}
To achieve this, the test performs the following steps:
- Sets up a Magento environment: It initializes the necessary Magento components for testing.
- Creates a sample order: A Magento data fixture (
Magento/Sales/_files/order.php
) is used to generate a test order with predefined data. This ensures a consistent starting point for the test. - Retrieves the order: The test fetches the created order using Magento’s order repository.
- Accesses extension attributes: It retrieves the extension attributes associated with the order.
- Validates attribute values: The test then asserts that the extension attributes contain the expected data. For example, it might check if
custom_attribute_1
holds the correct customer email from the order.
By verifying these conditions, the test confirms that the dynamic attribute population, driven by the di.xml
configuration and plugin logic, works correctly. It ensures that when an order is loaded, the custom extension attributes are populated with the appropriate values, whether they are simple order properties or the result of more complex calculations within a hydrator class.
Comments