Magento 2 Extension Attributes: Add Data to Orders and APIs
Extension attributes are how Magento 2 lets a module add data to another module's data interfaces without changing them. If you need an order to carry a delivery note, a product to expose a value from an external system, or a customer to have a loyalty tier in REST responses, extension attributes are the supported way to do it. They are also one of the most misunderstood features, mostly because people expect them to load and save data by themselves. They do not.
This article covers how they work in Magento Open Source and Adobe Commerce 2.4.8, with a full example on orders.
What extension attributes are and are not
Every data interface that extends Magento\Framework\Api\ExtensibleDataInterface has a getExtensionAttributes() method. It returns a generated object, for example Magento\Sales\Api\Data\OrderExtensionInterface, with getters and setters for every extension attribute declared by any module.
What you get for free:
- A typed getter and setter generated from XML.
- Serialization in REST responses under the
extension_attributeskey, and deserialization from request bodies.
What you have to build yourself:
- Storage, usually your own table.
- Loading the value when the entity is read.
- Saving the value when the entity is saved.
Extension attributes are also not the same as EAV custom attributes. A product attribute created with a data patch already shows up in custom_attributes and is stored by the EAV system. Use extension attributes for data that is not EAV, or for entities like orders that do not use EAV at all.
Declaring the attribute
Create 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>
The for attribute must be the data interface, not the model class. The type can be a scalar, an array of scalars like string[], or another data interface, which lets you attach a structured object.
After adding or changing this file, the generated extension classes have to be rebuilt. In developer mode, remove generated/code/Magento/Sales/Api/Data/OrderExtension* and clean the cache. In production mode run setup:di:compile. Until then setDeliveryNote() does not exist and you get a call to undefined method error. The article on generated code explains why.
Storage
Keep the data in your own table, keyed by the order ID. Do not add columns to sales_order for this, because core tables are shared with other extensions and with upgrades. A table in 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>
The foreign key with onDelete="CASCADE" removes the note when an order is deleted, so you never keep orphan rows. Remember to generate db_schema_whitelist.json as well. The full rules for schema files and whitelists are in the declarative schema article.
Loading and saving with repository plugins
The standard approach is a plugin on the repository interface. It fills the extension attribute after get() and getList(), and writes it after 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);
}
}
NoteStorage is a small class that reads and writes acme_order_delivery_note through the resource connection. The important part is getByOrderIds(): afterGetList() loads all notes for the page in one query. Loading one row per order inside the loop is the most common performance mistake with extension attributes, and with list endpoints returning hundreds of orders it adds up quickly.
Register the plugin in etc/di.xml:
<type name="Magento\Sales\Api\OrderRepositoryInterface">
<plugin name="acme_delivery_note" type="Acme\DeliveryNote\Plugin\OrderRepositoryPlugin"/>
</type>
The result in REST
A GET /rest/V1/orders/42 now includes the note:
{
"entity_id": 42,
"increment_id": "000000042",
"extension_attributes": {
"delivery_note": "Leave at the back door"
}
}
Other extension attributes from core and other modules appear next to it in the same object. Clients send the value back the same way in a save request.
Attaching a structured object
A single string is the simplest case. When the data has several fields, declare your own data interface and use it as the type:
<extension_attributes for="Magento\Sales\Api\Data\OrderInterface">
<attribute code="delivery_info" type="Acme\DeliveryNote\Api\Data\DeliveryInfoInterface"/>
</extension_attributes>
The interface needs getters and setters with full docblocks, and a preference in di.xml to its implementation, exactly like any other data interface in a service contract. REST then renders it as a nested object. This keeps related values together and avoids a growing list of loose scalar attributes that each need their own handling in the plugin.
Pitfalls
Not every load goes through the repository
The plugin only runs when code uses OrderRepositoryInterface. Admin grids read from collections and grid tables, many core and third party paths load orders through factories or resource models, and observers receive order models directly. In all those cases your extension attribute is empty. If you need the value in one of those places, read it from your storage class directly instead of assuming it is set.
The same is true for saving. A lot of code saves orders through the resource model, not the repository, so afterSave on the repository does not fire. For data that must be written during checkout, hook into the checkout flow itself. For example, observe sales_model_service_quote_submit_before to copy a value from the quote to the order, then save it once the order has an ID.
The join directive
extension_attributes.xml supports a <join> node that tells Magento to join a table when loading collections. It only takes effect where the repository runs JoinProcessorInterface::process() on its collection, and it does not save anything. I find explicit plugins easier to reason about, but the join can save a query in list endpoints that support it.
Permissions
An attribute can have a <resources> child with ACL references. API callers without that permission do not see the attribute. Use it for internal values like cost or margin that should not reach every integration.
GraphQL
Extension attributes do not appear in GraphQL automatically. GraphQL has its own schema, so you add a field in schema.graphqls and a resolver that reads your storage. See the custom GraphQL resolver article for the pattern.
When to use something else
- For product, category or customer data that admins should edit in the standard forms, an EAV attribute is simpler and comes with admin UI, import and export.
- For data that only your module reads internally, you do not need an extension attribute at all. Read your table where you need it.
- Use extension attributes when the data has to travel with the entity through service contracts and APIs.
FAQ
Why is getExtensionAttributes returning my attribute as null?
Extension attributes are not loaded automatically. Something has to set them, usually a plugin on the repository. If the entity was loaded through a collection, factory or resource model, your plugin did not run.
Why do I get call to undefined method setMyAttribute?
The generated extension classes were created before you declared the attribute. Remove the related files in generated/code in developer mode, or run setup:di:compile in production mode.
Can I add a column to sales_order instead?
You can, but a separate table keyed by order ID keeps your data independent from core schema changes and other extensions, and makes removing the module cleaner.
Are extension attributes available in GraphQL?
Not automatically. You need to add the field to the GraphQL schema and write a resolver for it.
I am available for freelance Magento and Adobe Commerce work. If you need custom data added to orders, products or APIs in a way that survives upgrades, get in touch.
Related articles
Add a Custom Field to Magento 2 Checkout and Save It to the Order
Add a custom field to the Magento 2 checkout shipping step, send it with extension attributes, store it on the quote and copy it to the order the right way.
How to Create a Custom REST API Endpoint in Magento 2
Create a custom REST API endpoint in Magento 2.4.8 with webapi.xml, a service interface and ACL. Covers admin, customer and anonymous access with examples.
Magento 2 ERP Integration Patterns That Hold Up
Magento 2 ERP integration patterns: data ownership, batch files, REST and async bulk APIs, queue based order export, middleware and the failures to design for.
Magento 2 Service Contracts and Repositories Explained
How Magento 2 service contracts and repositories work: data interfaces, getList with SearchCriteria, building your own repository and when to skip it.