Extending an Order

Add a field to an order with your own table and an extension attribute, then keep the admin grid and the REST payload in step.

Syntax<attribute code="my_field" type="string"><join reference_table="my_table" reference_field="order_id" join_on_field="entity_id"><field>my_field</field></join></attribute>

Sooner or later an order needs a field Magento does not have: a pickup location, a delivery window, a purchase order reference. There are two routes, and the first question is whether the field belongs to you.

Your own table, not a column on sales_order

Adding a column to sales_order works, but the table is already wide and shared by every module. A separate table keyed on order_id keeps your data yours and makes it removable. Declare it in db_schema.xml, regenerate db_schema_whitelist.json, and apply it with bin/magento setup:upgrade.

Extension attributes carry it through the API

An extension attribute is a typed field added to an existing data interface from outside the module that owns it. You declare it in etc/extension_attributes.xml against Magento\Sales\Api\Data\OrderInterface. Magento generates OrderExtensionInterface getters and setters at compile time, and the field then appears in the REST payload with no further work. Run bin/magento setup:di:compile so the generated classes exist.

A join directive inside the attribute tells Magento to fetch the value with a join rather than a query per order. It is read-only and it runs in getList() only.

The admin grid

sales_order_grid is a flat copy of the order, rebuilt on save. Add the column in db_schema.xml, add a join and a column entry to the Magento\Sales\Model\ResourceModel\Order\Grid virtual type, and add the column to view/adminhtml/ui_component/sales_order_grid.xml. Existing orders stay blank until they are saved again. Finish with bin/magento cache:flush.

Example

Example · xml
<!-- app/code/Vendor/OrderOps/etc/extension_attributes.xml -->
<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="pickup_location_code" type="string">
            <join reference_table="vendor_order_pickup"
                  reference_field="order_id"
                  join_on_field="entity_id">
                <field>pickup_location_code</field>
            </join>
        </attribute>
    </extension_attributes>
</config>

<!--
reference_table  the table holding your value
reference_field  the column in that table pointing at the order
join_on_field    the column on sales_order being matched (entity_id)

Apply with:
  bin/magento setup:di:compile
  bin/magento cache:flush

After that, GET /V1/orders?searchCriteria[pageSize]=1 returns
"extension_attributes": { "pickup_location_code": "SHOP-04" }
and $order->getExtensionAttributes()->getPickupLocationCode() works in PHP.
Add <resources><resource ref="Magento_Sales::sales_order"/></resources>
inside the attribute to restrict who may read it.
-->

When to use it

  • Store the click-and-collect shop a customer chose and show it as a column in Sales > Orders.
  • Expose a purchase order reference on GET /V1/orders so an ERP can read it.
  • Add a delivery window to an order without touching the sales_order table Magento owns.
  • Backfill a new grid column for orders placed before the module was installed.

More examples

The table, and the grid column beside it

This is the whole schema side in one file. The second <table> block adds a column to a table your module does not own, which declarative schema allows and is how the grid gets a place to store the value. The step people skip is generate-whitelist: without an entry in etc/db_schema_whitelist.json, setup:upgrade reads your declaration and silently ignores it, and the usual symptom is an admin grid that throws an unknown column error. The ON DELETE CASCADE means deleting an order cleans up your row too.

Example · xml
<!-- app/code/Vendor/OrderOps/etc/db_schema.xml -->
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="vendor_order_pickup" resource="sales" engine="innodb"
           comment="Pickup location chosen for an order">
        <column xsi:type="int" name="entity_id" unsigned="true" nullable="false"
                identity="true" comment="Row ID"/>
        <column xsi:type="int" name="order_id" unsigned="true" nullable="false"
                comment="sales_order.entity_id"/>
        <column xsi:type="varchar" name="pickup_location_code" length="64"
                nullable="true" comment="Location code"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="entity_id"/>
        </constraint>
        <constraint xsi:type="unique" referenceId="VENDOR_ORDER_PICKUP_ORDER_ID">
            <column name="order_id"/>
        </constraint>
        <constraint xsi:type="foreign"
                    referenceId="VENDOR_ORDER_PICKUP_ORDER_ID_SALES_ORDER_ENTITY_ID"
                    table="vendor_order_pickup" column="order_id"
                    referenceTable="sales_order" referenceColumn="entity_id"
                    onDelete="CASCADE"/>
    </table>

    <table name="sales_order_grid">
        <column xsi:type="varchar" name="pickup_location_code" length="64"
                nullable="true" comment="Location code"/>
    </table>
</schema>

<!--
bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_OrderOps
bin/magento setup:upgrade

The second command does nothing to sales_order_grid unless the whitelist
file has been regenerated first.
-->

Teaching the grid synchroniser about the column

Reach for this whenever a value has to be filterable or sortable in the Sales > Orders grid, because the grid reads only sales_order_grid and never joins to your table at render time. A virtual type is a named, pre-configured variant of a class; redeclaring one and supplying only the array items you want merges them into the existing configuration rather than replacing it. The absence of any CLI command to rebuild the whole order grid is why the "existing orders stay blank" caveat matters: plan a one-off script if the merchant needs history backfilled.

Example · xml
<!-- app/code/Vendor/OrderOps/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <virtualType name="Magento\Sales\Model\ResourceModel\Order\Grid"
                 type="Magento\Sales\Model\ResourceModel\Grid">
        <arguments>
            <argument name="joins" xsi:type="array">
                <item name="vendor_order_pickup" xsi:type="array">
                    <item name="table" xsi:type="string">vendor_order_pickup</item>
                    <item name="origin_column" xsi:type="string">entity_id</item>
                    <item name="target_column" xsi:type="string">order_id</item>
                </item>
            </argument>
            <argument name="columns" xsi:type="array">
                <item name="pickup_location_code" xsi:type="string">vendor_order_pickup.pickup_location_code</item>
            </argument>
        </arguments>
    </virtualType>
</config>

<!--
Magento_Sales declares this same virtual type with mainTableName sales_order
and gridTableName sales_order_grid. Array arguments merge by item name, so
the three joins and the column list it already has are kept.

bin/magento setup:di:compile
bin/magento cache:flush

Every order saved from now on copies the value into sales_order_grid.
Orders placed before this change stay blank until they are saved again.
The column still has to be listed in
view/adminhtml/ui_component/sales_order_grid.xml to be visible.
-->

Populating the attribute for a single-order read

This is the fix for the gap in the join directive: OrderRepository::getList runs the extension attribute join processor, but get() does not, so a single-order REST read or any PHP that calls get() comes back without the value. An after plugin is the right shape here because you only need to decorate the return value; before or around would be wrong, and an around plugin that forgot $proceed would break every order read in the application. The null check keeps the plugin cheap when getList has already filled the value in.

Example · php
<?php
// app/code/Vendor/OrderOps/Plugin/AddPickupLocationToOrder.php
declare(strict_types=1);

namespace Vendor\OrderOps\Plugin;

use Magento\Sales\Api\Data\OrderExtensionFactory;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Vendor\OrderOps\Model\PickupLocationReader;

class AddPickupLocationToOrder
{
    public function __construct(
        private readonly OrderExtensionFactory $extensionFactory,
        private readonly PickupLocationReader $reader
    ) {
    }

    public function afterGet(
        OrderRepositoryInterface $subject,
        OrderInterface $result
    ): OrderInterface {
        // Shorthand: null coalescing.
        $extension = $result->getExtensionAttributes() ?? $this->extensionFactory->create();

        // Longhand for the line above, same behaviour:
        // $extension = $result->getExtensionAttributes();
        // if ($extension === null) {
        //     $extension = $this->extensionFactory->create();
        // }

        if ($extension->getPickupLocationCode() === null) {
            $extension->setPickupLocationCode(
                $this->reader->getCodeForOrder((int)$result->getEntityId())
            );
        }

        $result->setExtensionAttributes($extension);

        return $result;
    }
}

// Registered in etc/di.xml:
//   <type name="Magento\Sales\Api\OrderRepositoryInterface">
//     <plugin name="vendor_orderops_pickup"
//             type="Vendor\OrderOps\Plugin\AddPickupLocationToOrder"/>
//   </type>
//
// bin/magento setup:di:compile
// GET /V1/orders/15 now carries pickup_location_code.

Discussion

  • Be the first to comment on this lesson.