← Back to blog

Add a Custom Field to Magento 2 Checkout and Save It to the Order

Rodrigo Biazzi

"Can we add one field to checkout?" sounds like a ten minute task. In the Luma checkout it touches the UI component layout, a JavaScript mixin, a web API payload, extension attributes, two database tables and an observer. Skip one of them and the value is lost somewhere between the browser and the order.

This article follows a single field, a delivery note, from the shipping step to the order in Magento Open Source and Adobe Commerce 2.4.8. The same path works for a PO number, a gift message flag or a preferred delivery date.

Decide where the data belongs first

Before writing code, decide what the field describes:

  • The customer's address, like a building access code that should be reused next time. That is a customer address attribute and belongs to the address forms and the address book.
  • This order, like a delivery note or a purchase order reference. That is order data and should be stored on the quote and the order.

Mixing them causes most of the strange bugs I see in this area, like values that show up on the next order or disappear when the customer picks a saved address. The rest of this article covers the second case.

The data path

  1. A UI component field renders in the shipping step and writes its value to the checkout data provider.
  2. When the customer clicks Next, Magento posts shipping information to /V1/carts/mine/shipping-information or the guest equivalent. A mixin adds our value to that payload as an extension attribute.
  3. A plugin on Magento\Checkout\Model\ShippingInformationManagement reads the extension attribute and stores it on the quote.
  4. An observer copies the value from the quote to the order when the order is placed.

Step 1: columns on quote and sales_order

In etc/db_schema.xml, add the column to both tables. Remember to generate the whitelist with bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_DeliveryNote. More on this in declarative schema and data patches.

<?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="quote">
        <column xsi:type="varchar" name="delivery_note" nullable="true" length="255" comment="Delivery Note"/>
    </table>
    <table name="sales_order">
        <column xsi:type="varchar" name="delivery_note" nullable="true" length="255" comment="Delivery Note"/>
    </table>
</schema>

Step 2: the field in the shipping step

You can add the field in checkout_index_index.xml, but the nesting is deep and easy to break. A plugin on Magento\Checkout\Block\Checkout\LayoutProcessor is shorter and lets you use config values, for example to show the field only in some stores.

<?php
declare(strict_types=1);

namespace Vendor\DeliveryNote\Plugin\Checkout;

use Magento\Checkout\Block\Checkout\LayoutProcessor;

class AddDeliveryNoteField
{
    public function afterProcess(LayoutProcessor $subject, array $jsLayout): array
    {
        $jsLayout['components']['checkout']['children']['steps']['children']['shipping-step']
            ['children']['shippingAddress']['children']['shippingAdditional']['children']['delivery_note'] = [
            'component' => 'Magento_Ui/js/form/element/textarea',
            'config' => [
                'template' => 'ui/form/field',
                'elementTmpl' => 'ui/form/element/textarea',
                'rows' => 3,
            ],
            'provider' => 'checkoutProvider',
            'dataScope' => 'deliveryNote.delivery_note',
            'label' => __('Delivery note'),
            'validation' => ['max_text_length' => 255],
            'sortOrder' => 10,
        ];

        return $jsLayout;
    }
}

Register it in etc/frontend/di.xml with a <type name="Magento\Checkout\Block\Checkout\LayoutProcessor"> plugin declaration. The shippingAdditional region renders below the shipping methods. Placing the field there keeps it out of the address fieldset, so Magento never tries to save it as an address attribute.

The dataScope puts the value in the checkout provider under deliveryNote.delivery_note, where our mixin can read it.

Step 3: declare the extension attribute

The shipping information payload is typed as Magento\Checkout\Api\Data\ShippingInformationInterface. Unknown keys are rejected by the web API, so declare the new one in 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\Checkout\Api\Data\ShippingInformationInterface">
        <attribute code="delivery_note" type="string"/>
    </extension_attributes>
</config>

After setup:di:compile, the generated extension interface has getDeliveryNote() and setDeliveryNote(). If extension attributes are new to you, read Magento 2 extension attributes.

Step 4: add the value to the payload

Magento builds the shipping information payload in Magento_Checkout/js/model/shipping-save-processor/default and passes it through Magento_Checkout/js/model/shipping-save-processor/payload-extender. That extender exists to be wrapped by modules like ours, which is cleaner than overriding the whole save processor. Declare the mixin in view/frontend/requirejs-config.js:

var config = {
    config: {
        mixins: {
            'Magento_Checkout/js/model/shipping-save-processor/payload-extender': {
                'Vendor_DeliveryNote/js/payload-extender-mixin': true
            }
        }
    }
};

And the mixin itself:

define([
    'mage/utils/wrapper',
    'uiRegistry'
], function (wrapper, registry) {
    'use strict';

    return function (payloadExtender) {
        return wrapper.wrap(payloadExtender, function (originalFn, payload) {
            var provider = registry.get('checkoutProvider'),
                note = provider ? provider.get('deliveryNote.delivery_note') : '';

            payload = originalFn(payload);
            payload.addressInformation.extension_attributes =
                payload.addressInformation.extension_attributes || {};
            payload.addressInformation.extension_attributes.delivery_note = note || '';

            return payload;
        });
    };
});

Open the browser network tab, click Next on the shipping step, and check the request body. If delivery_note is not under addressInformation.extension_attributes, nothing on the server will ever see it. The article on RequireJS mixins explains how to confirm a mixin is loaded.

Step 5: store it on the quote

A before plugin on saveAddressInformation() covers both logged in and guest customers, because the guest management class resolves the masked cart id and then calls the same method.

<?php
declare(strict_types=1);

namespace Vendor\DeliveryNote\Plugin\Checkout;

use Magento\Checkout\Api\Data\ShippingInformationInterface;
use Magento\Checkout\Model\ShippingInformationManagement;
use Magento\Quote\Api\CartRepositoryInterface;

class SaveDeliveryNoteToQuote
{
    public function __construct(
        private readonly CartRepositoryInterface $cartRepository
    ) {
    }

    public function beforeSaveAddressInformation(
        ShippingInformationManagement $subject,
        $cartId,
        ShippingInformationInterface $addressInformation
    ): void {
        $note = $addressInformation->getExtensionAttributes()?->getDeliveryNote();
        $note = trim(strip_tags((string) $note));

        $quote = $this->cartRepository->getActive((int) $cartId);
        $quote->setData('delivery_note', mb_substr($note, 0, 255) ?: null);
    }
}

Declare this plugin in the global etc/di.xml, not the frontend one, because the call arrives through the webapi_rest area. The quote repository keeps loaded quotes in memory, so the original method saves the same instance a moment later and the value is written to the quote table. Clean the input here. Anything typed in a checkout field ends up in admin screens and emails.

Step 6: copy it to the order

An observer on sales_model_service_quote_submit_before receives both the quote and the order before the order is saved.

<?php
declare(strict_types=1);

namespace Vendor\DeliveryNote\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;

class CopyDeliveryNoteToOrder implements ObserverInterface
{
    public function execute(Observer $observer): void
    {
        $quote = $observer->getEvent()->getQuote();
        $order = $observer->getEvent()->getOrder();

        if ($quote->getData('delivery_note')) {
            $order->setData('delivery_note', $quote->getData('delivery_note'));
        }
    }
}

Register it in the global etc/events.xml so it also runs for orders placed through the API.

Showing it in the admin and in emails

Once the value is on sales_order, display it in the order view with a small block added through sales_order_view.xml, and use it in email templates through the order data you pass in. For external systems, expose it as an extension attribute on Magento\Sales\Api\Data\OrderInterface with a plugin on the order repository, so it appears in /V1/orders responses.

Making the field required

Adding 'required-entry' => true to the validation array shows the usual error under the field, but it does not stop the shipping step on its own, because the shipping component only validates the address form and the method selection. To block the Next button, add a mixin on Magento_Checkout/js/view/shipping that wraps validateShippingInformation, triggers validation on your field through the provider and returns false when it fails.

Then enforce it on the server too. In the quote plugin, throw a Magento\Framework\Exception\InputException when the value is empty. The checkout shows the message, and orders placed through the API follow the same rule.

Testing without the browser

You can test the backend half before the JavaScript exists. Create a guest cart through REST, add an item, then post to /V1/guest-carts/{cartId}/shipping-information with delivery_note under addressInformation.extension_attributes. Query the quote table and you should see the value. This splits the problem in two and saves time when something does not work.

Common mistakes

  • Putting the field inside the address fieldset with a shippingAddress.custom_attributes data scope when it is not an address attribute.
  • Registering the quote plugin only in etc/frontend/di.xml. The REST call never sees it.
  • Forgetting the guest flow. Test both a guest and a logged in customer.
  • Trusting the value. Validate length and strip tags on the server, not only in the UI component.
  • Assuming Hyvä Checkout works the same way. It does not use these components, so see the Hyvä Checkout overview if that is your stack.

FAQ

Why is my custom checkout field empty on the order?

Follow the value step by step. Check the network request for the extension attribute, then the quote table, then the observer. Most often the plugin is registered in the frontend area instead of global, or the value never reached the payload.

Can I add the field to the payment step instead?

Yes. Then send the value with the payment information request instead, and declare the extension attribute on Magento\Quote\Api\Data\PaymentInterface, which is the type of the paymentMethod object in that payload.

Do I need a quote column if I only want the value on the order?

In practice yes. The order is created later in a different request, so the quote is the place to keep the value until then.

Is a customer address attribute a better option?

Only when the value belongs to the address and should be saved in the address book. For order specific data, store it on the quote and the order as shown here.

I am available for freelance Magento and Adobe Commerce work, including checkout changes like this one. If you need help with this, get in touch.

Related articles