
Magento 2 extension attributes let a module attach extra data to a service contract entity such as ProductInterface or OrderInterface without changing the core interface. You declare them in etc/extension_attributes.xml, Magento generates the getters and setters, and you write plugins on the repository to load and save the values yourself.
That last part is where most implementations go wrong. Extension attributes look like a storage feature, but they are only a contract. Magento gives you a typed slot on the entity and puts it in the extension_attributes key of every REST response. Where the data lives, when it loads, and how it gets saved are all your job. This guide walks through the full lifecycle, with the patterns Magento’s own modules use and the gaps most tutorials skip: order data, list endpoints, ACL, generated code, and GraphQL.
Extension attributes vs custom (EAV) attributes
Magento has two ways to add data to an entity, and they solve different problems. Adobe’s EAV and extension attributes reference draws the line clearly: custom attributes are added on behalf of a merchant and can be managed in the Admin, while extension attributes extend functionality, often use more complex data types, and do not appear in the Admin at all.
| Custom (EAV) attributes | Extension attributes | |
|---|---|---|
| Who creates them | Merchants in the Admin, or developers in a data patch | Developers, in extension_attributes.xml |
| Visible in Admin | Yes, as product or customer attributes | No |
| Storage | EAV value tables, handled by Magento | Wherever you decide; Magento stores nothing |
| Data types | Scalar values (text, int, decimal, select) | Scalars, arrays, and full data interfaces |
| Entities | EAV entities (products, categories, customers, addresses) | Any interface that implements ExtensibleDataInterface |
| API key | custom_attributes |
extension_attributes |
| Typical use | Merchandising data like color, material, or size | Integration data, computed values, related objects |
A simple rule of thumb: if a merchant should be able to edit the value on the product form, it is a custom attribute. If the value belongs to an integration, comes from another table or system, or is an object rather than a single value, it is an extension attribute. Orders are the classic case. sales_order is a flat table, not an EAV entity, so extension attributes are the supported way to expose extra order data through the API.
Which entities support extension attributes
An entity supports extension attributes if its data interface extends Magento\Framework\Api\ExtensibleDataInterface and defines getExtensionAttributes() and setExtensionAttributes(). Adobe’s guide to adding extension attributes to entities puts it plainly: third-party developers cannot change the API data interfaces defined in core, so you check the interface for those two methods and extend it instead.
In practice almost everything in a module’s Api/Data directory qualifies: ProductInterface, CategoryInterface, CustomerInterface, AddressInterface, CartInterface, OrderInterface, OrderItemInterface, CreditmemoInterface, and many more. Classes that are not service contract interfaces, such as resource models or blocks, are not valid targets.
Declaring extension attributes in extension_attributes.xml
The declaration lives in app/code/Vendor/Module/etc/extension_attributes.xml. There are three shapes of attribute.
A scalar attribute is a single typed value:
<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="erp_order_number" type="string"/>
<attribute code="erp_export_attempts" type="int"/>
</extension_attributes>
</config>
An array attribute appends [] to the type, for example type="string[]". The [] suffix also works on object types.
An object attribute points at a data interface you define:
<extension_attributes for="Magento\Catalog\Api\Data\ProductInterface">
<attribute code="compliance_info" type="Vendor\Compliance\Api\Data\ComplianceInfoInterface"/>
</extension_attributes>
Use snake case for the code. Magento converts it to camel case for the generated methods, so erp_order_number becomes getErpOrderNumber() and setErpOrderNumber().
Core uses all three shapes. The Gift Message module’s extension_attributes.xml adds a gift_message object to both OrderInterface and OrderItemInterface, and an array of MessageInterface[] to quote totals. It is a good reference because it is small and covers orders, which is where most custom work lands.
Generated code: why your new getter does not exist yet
You never write the extension classes yourself. For ProductInterface, Magento generates Magento\Catalog\Api\Data\ProductExtensionInterface and a concrete ProductExtension class, and a ProductExtensionFactory you can inject. Those generated files merge every module’s declarations for that interface, which is how ten modules can each add their own attribute to products without conflicting.
Because the classes are generated, changes to extension_attributes.xml do not show up until the generated code is rebuilt. In developer mode, clear the relevant files under generated/code (or the whole directory) and flush the cache. In production mode, run bin/magento setup:di:compile as part of the deploy. Adobe’s code compiler documentation covers what that command generates. “Call to undefined method ProductExtension::setMyAttribute()” almost always means stale generated code, not a typo in your plugin.
Loading and saving: plugins on the repository
Magento will not populate or persist your attribute. Adobe’s guidance is to write after plugins on the entity’s repository for get, getList, and save. Here is a complete order example, where the ERP number lives in a custom table vendor_erp_order keyed by order_id.
Register the plugin in etc/di.xml:
<type name="Magento\Sales\Api\OrderRepositoryInterface">
<plugin name="vendor_erp_order_extension" type="Vendor\Erp\Plugin\OrderRepositoryPlugin"/>
</type>
Then populate on read and persist on write:
public function afterGet(OrderRepositoryInterface $subject, OrderInterface $order): OrderInterface
{
$this->attach($order);
return $order;
}
public function afterGetList(
OrderRepositoryInterface $subject,
OrderSearchResultInterface $result
): OrderSearchResultInterface {
$ids = array_map(fn ($o) => (int) $o->getEntityId(), $result->getItems());
$numbers = $this->erpResource->getNumbersByOrderIds($ids); // one query, not one per order
foreach ($result->getItems() as $order) {
$this->attach($order, $numbers[(int) $order->getEntityId()] ?? null);
}
return $result;
}
public function afterSave(
OrderRepositoryInterface $subject,
OrderInterface $result,
OrderInterface $order
): OrderInterface {
$ext = $order->getExtensionAttributes();
if ($ext && $ext->getErpOrderNumber() !== null) {
$this->erpResource->saveNumber((int) $result->getEntityId(), $ext->getErpOrderNumber());
}
$this->attach($result, $ext ? $ext->getErpOrderNumber() : null);
return $result;
}
private function attach(OrderInterface $order, ?string $number = null): void
{
$ext = $order->getExtensionAttributes() ?? $this->extensionFactory->create();
$ext->setErpOrderNumber($number ?? $this->erpResource->getNumberByOrderId((int) $order->getEntityId()));
$order->setExtensionAttributes($ext);
}
Three details in that code matter more than they look.
Always read the existing extension object first. Other modules attach their own attributes to the same object. If you create a fresh extension object and set it, you wipe out theirs. Adobe’s docs call this out: get the current extension attributes, add yours, set them back.
Batch the getList lookup. The doc example loads custom data inside the loop, one query per item. That is fine for a sample, but GET /V1/orders?searchCriteria[pageSize]=100 will then issue 100 extra queries. Collect the IDs and fetch once.
Handle a null extension object. Some entities return null from getExtensionAttributes() until something creates it. The fallback to the factory covers that. Adobe also shows an afterGetExtensionAttributes plugin on the data interface as a way to guarantee a non-null object everywhere.
This is the same pattern core uses. Gift Message registers OrderGet and OrderSave plugins on OrderRepositoryInterface in its di.xml. If you are deciding between a plugin and an observer for this kind of work, our guide to plugins vs observers vs preferences explains why repository plugins are the right hook here.
Where repository plugins do not reach
Repository plugins only fire when code goes through the repository. Plenty of Magento code does not.
- Admin order grids read from
sales_order_grid, not fromOrderRepositoryInterface. Your attribute will not appear in the grid unless you add a column to the grid table or join it in the grid collection. - Legacy model loads like
$orderFactory->create()->load($id)skip the repository entirely. - Order placement builds the order from the quote. If the data starts on the cart, you also need to copy it from the quote to the order, usually with a
fieldset.xmlmapping when it is a real column on both tables, or a plugin on the quote-to-order conversion when it lives elsewhere. - Emails and PDFs use the order model directly, so you load the value there as well.
None of this is a reason to avoid extension attributes. It is a reason to test every channel that reads the entity, not only the REST endpoint.
Storage: where the data should actually live
Extension attributes have no storage of their own, so you pick it. Three patterns cover nearly every case.
| Storage pattern | When to use it | Trade-off |
|---|---|---|
New column on the core table (via db_schema.xml) |
One or two scalar values that map 1:1 to the entity | Simplest reads; you are altering a core table, so keep columns nullable |
| Separate table keyed by entity ID | Integration data, many fields, or data you want to drop cleanly with the module | Needs its own resource model and a join or batch lookup |
| External system, fetched at read time | Live values like ERP credit limits | Slow if called per item; cache aggressively or move to async |
For the first two, declarative schema is the right tool. Our walkthrough of Magento declarative schema and db_schema.xml covers adding columns and tables without legacy install scripts. For the third, be careful. An API call inside afterGetList turns every product or order listing into a chain of remote calls.
Making extension attributes searchable with join
By default, a getList filter on your attribute will fail, because the collection does not know where the column is. The join directive in extension_attributes.xml fixes that by telling Magento which table and field to join:
<extension_attributes for="Magento\Sales\Api\Data\OrderInterface">
<attribute code="erp_order_number" type="string">
<join reference_table="vendor_erp_order" reference_field="order_id" join_on_field="entity_id">
<field>erp_order_number</field>
</join>
</attribute>
</extension_attributes>
The join is applied by Magento\Framework\Api\ExtensionAttribute\JoinProcessor, whose process() method adds the join to a collection. Repositories that support it call the processor inside getList. If you write your own repository, inject JoinProcessorInterface and call process($collection) before applying search criteria.
Adobe’s reference flags one trap: a join back to the same table the entity uses, such as joining sales_order onto sales_order, makes column names ambiguous in the SQL. In that case REST filters must name the table explicitly, for example searchCriteria[filter_groups][0][filters][0][field]=main_table.created_at.
Restricting access with ACL resources
Extension attributes can carry sensitive data like cost, margin, or credit limits. You can limit who sees them with a resources element:
<attribute code="erp_credit_limit" type="float">
<resources>
<resource ref="Vendor_Erp::credit"/>
</resources>
</attribute>
Core uses this for inventory. CatalogInventory declares stock_item on ProductInterface restricted to Magento_CatalogInventory::cataloginventory, so an anonymous GET /V1/products/<sku> does not include it. Two notes from the current core file: the stock_item extension attribute is marked deprecated in favor of the stock item and stock status interfaces, and the restriction only filters API output. It does not stop your own PHP code from reading the value.
Exposing extension attributes in GraphQL
This is the gap that catches Hyva and headless teams. GraphQL does not read extension_attributes.xml. A value can show up perfectly in REST and be completely missing from the GraphQL schema.
To expose it, extend the schema in your module’s etc/schema.graphqls and give the field a resolver, as described in Adobe’s guide to extending an existing GraphQL schema:
type CustomerOrder {
erp_order_number: String
@doc(description: "Order number in the ERP")
@resolver(class: "Vendor\\Erp\\Model\\Resolver\\ErpOrderNumber")
}
Inside the resolver, read the value from the model in $value['model'] through its extension attributes, or look it up directly. Watch for the same N+1 problem as getList: a resolver that queries once per order in a list of 20 orders runs 20 queries. Batch resolvers or a preloading plugin solve that. We go deeper on this in custom GraphQL resolvers on Magento and Hyva.
On a standard Hyva storefront, most templates render from PHP view models rather than GraphQL, so you can read the extension attribute straight from the entity in a view model. GraphQL matters for Hyva Checkout integrations, PWA or headless frontends, and any external consumer.
Common mistakes and how to spot them
| Symptom | Likely cause | Fix |
|---|---|---|
Call to undefined method ...Extension::setX() |
Generated code is stale | Clear generated/code in developer mode, or run setup:di:compile |
Attribute appears on GET /V1/orders/:id but not on GET /V1/orders |
Only afterGet is implemented |
Add afterGetList, batched |
| Another module’s attribute disappears | Your plugin creates a new extension object | Read the existing object, add yours, set it back |
| Filtering by the attribute returns an SQL error | No join directive, or ambiguous columns |
Add join; prefix fields with main_table. |
| Value missing in the Admin grid or emails | Those paths skip the repository | Add a grid column, load the value in the email or PDF layer |
| Value saved through REST is lost | No afterSave persistence |
Persist in afterSave and reattach to the result |
| Field missing in GraphQL | GraphQL ignores extension_attributes.xml |
Add schema.graphqls and a resolver |
How this fits a larger build
Extension attributes are one of the cleanest extension points Magento has. They keep your changes off core interfaces and survive upgrades well, and they give integrations a stable API shape. The cost is that every read and write path is yours to wire. That is a fair trade on most Adobe Commerce builds, especially ones with ERP or OMS integrations where orders and products carry external IDs.
Similar declarative patterns show up across Magento. If you have not used them, virtual types in di.xml are worth learning alongside extension attributes.
If your team is adding integration data to orders and products and wants the storage, plugins, and GraphQL wired correctly from the start, our Magento development services team works on this kind of module every week, and our Hyva development services cover the storefront side, including surfacing that data in Hyva templates and Hyva Checkout. Bemeir is a Brooklyn ecommerce agency focused on Magento, Adobe Commerce, and Hyva. You can learn more about Bemeir and the technology partners we work with. We also build on Shopify, Shopware, and BigCommerce when a different platform is the better fit.
FAQ
Are Magento 2 extension attributes saved to the database automatically?
No. Extension attributes are only a typed field on the entity’s data interface. Magento generates the getter and setter but stores nothing. You choose the storage, usually a new column or a separate table, and write repository plugins that save in afterSave and load in afterGet and afterGetList.
What is the difference between custom attributes and extension attributes in Magento 2?
Custom attributes are EAV attributes that merchants can manage in the Admin and that Magento stores for you. Extension attributes are declared by developers in extension_attributes.xml, never appear in the Admin, can hold complex objects or arrays, and work on any extensible interface, including flat entities like orders.
Why does my extension attribute show in REST but not in GraphQL?
GraphQL does not read extension_attributes.xml. You need to add the field to the relevant type in your module’s schema.graphqls and give it a resolver class that returns the value. Batch the lookup inside the resolver so lists of orders or products do not run one query per item.
Why do I get “Call to undefined method” after adding an extension attribute?
The extension classes, such as ProductExtension, are generated code. After you change extension_attributes.xml, regenerate them. In developer mode, delete the affected files under generated/code and flush the cache. In production mode, run bin/magento setup:di:compile during deployment.
Can I filter REST API results by an extension attribute?
Yes, if you add a join directive to the attribute in extension_attributes.xml and the repository’s getList runs the extension attribute join processor. Without the join, the collection does not know which table holds the value, and the filter fails.





