Chat on WhatsApp
Checkout & Conversion 11 min read

Add a Custom Field in Magento Checkout (Luma + Hyvä)

Adding a custom field to Magento 2 checkout (delivery date, VAT number, gift message) is well-documented for Luma — and a moving target on Hyvä. Here is the complete path for both stacks, with quote extension attribute wiring and admin order display.

Add a Custom Field in Magento Checkout (Luma + Hyvä)
TL;DR
  • Adding a custom checkout field has 4 surfaces: frontend form, quote persistence, order persistence, admin display.
  • Luma path: checkout_index_index.xml + Knockout component + shippingInformation mixin.
  • Hyvä path: Magewire component (recommended) or Alpine.js form + REST endpoint.
  • Both stacks share the same backend: quote_extension_attributes, sales_order_extension_attributes, a checkout_submit_all_after observer.
  • Example field: delivery date. The DB column, the validation, the admin grid filter — all included.

Adding a custom field to Magento checkout is the implementation pattern that captures additional customer input (delivery date, VAT number, gift message, PO number) during the Magento 2 checkout flow and persists it from quote to order to admin in 2026 that requires touching four layers — frontend form, quote extension attribute, order extension attribute, admin display. The fix differs by frontend stack — here is the complete path for both Luma and Hyvä with shared backend code.

The 4 surfaces every custom field touches

  1. Frontend form — render the input, validate, post to a Magento endpoint.
  2. Quote persistence — store the value on quote via an extension attribute (survives cart refresh).
  3. Order persistence — copy from quote to sales_order at order placement.
  4. Admin display — show the field in the admin order view and (optionally) in the order grid.

Layers 2, 3, and 4 are identical for Luma and Hyvä. Only layer 1 differs.

A custom checkout field is 80% backend plumbing and 20% frontend. The backend is the same on Luma and Hyvä.

Example: delivery date field

Add a required "Preferred Delivery Date" date picker between the shipping method and the payment step. Persist it. Show it on the admin order view.

Shared backend (Luma + Hyvä)

Database table

<!-- app/code/Vendor/CheckoutField/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="quote">
        <column xsi:type="date" name="delivery_date" nullable="true" comment="Preferred Delivery Date"/>
    </table>
    <table name="sales_order">
        <column xsi:type="date" name="delivery_date" nullable="true" comment="Preferred Delivery Date"/>
    </table>
</schema>

Quote extension attribute

<!-- 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\Quote\Api\Data\CartInterface">
        <attribute code="delivery_date" type="string"/>
    </extension_attributes>
    <extension_attributes for="Magento\Sales\Api\Data\OrderInterface">
        <attribute code="delivery_date" type="string"/>
    </extension_attributes>
</config>

Quote-to-order copy observer

<?php
// app/code/Vendor/CheckoutField/Observer/CopyDeliveryDateToOrder.php
namespace Vendor\CheckoutField\Observer;

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

class CopyDeliveryDateToOrder implements ObserverInterface
{
    public function execute(Observer $observer): void
    {
        $quote = $observer->getEvent()->getQuote();
        $order = $observer->getEvent()->getOrder();
        if ($quote && $order && $quote->getDeliveryDate()) {
            $order->setDeliveryDate($quote->getDeliveryDate());
        }
    }
}
<!-- etc/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_model_service_quote_submit_before">
        <observer name="copy_delivery_date_to_order"
                  instance="Vendor\CheckoutField\Observer\CopyDeliveryDateToOrder"/>
    </event>
</config>

REST endpoint to save on quote

<!-- etc/webapi.xml -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
    <route url="/V1/carts/mine/delivery-date" method="POST">
        <service class="Vendor\CheckoutField\Api\DeliveryDateManagementInterface"
                 method="setDeliveryDate"/>
        <resources>
            <resource ref="self"/>
        </resources>
        <data>
            <parameter name="cartId" force="true">%cart_id%</parameter>
        </data>
    </route>
</routes>

Frontend — Luma (Knockout)

Layout

<!-- view/frontend/layout/checkout_index_index.xml -->
<referenceBlock name="checkout.root">
    <arguments>
        <argument name="jsLayout" xsi:type="array">
            <item name="components" xsi:type="array">
                <item name="checkout" xsi:type="array">
                    <item name="children" xsi:type="array">
                        <item name="steps" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="shipping-step" xsi:type="array">
                                    <item name="children" xsi:type="array">
                                        <item name="delivery-date" xsi:type="array">
                                            <item name="component" xsi:type="string">Vendor_CheckoutField/js/view/delivery-date</item>
                                            <item name="sortOrder" xsi:type="string">200</item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </item>
            </item>
        </argument>
    </arguments>
</referenceBlock>

Knockout component

// view/frontend/web/js/view/delivery-date.js
define([
    'uiComponent',
    'ko',
    'mage/storage',
    'Magento_Customer/js/model/customer'
], function (Component, ko, storage, customer) {
    'use strict';
    return Component.extend({
        defaults: {
            template: 'Vendor_CheckoutField/delivery-date'
        },
        deliveryDate: ko.observable(''),
        save: function () {
            var url = customer.isLoggedIn()
                ? '/rest/V1/carts/mine/delivery-date'
                : '/rest/V1/guest-carts/' + window.checkoutConfig.quoteData.entity_id + '/delivery-date';
            return storage.post(url, JSON.stringify({date: this.deliveryDate()}));
        }
    });
});

Frontend — Hyvä (Magewire)

On Hyvä, skip the Knockout component entirely. Use Magewire — server-rendered, no client-side state to manage.

Magewire component

<?php
// app/code/Vendor/CheckoutField/Magewire/Checkout/DeliveryDate.php
namespace Vendor\CheckoutField\Magewire\Checkout;

use Magento\Checkout\Model\Session as CheckoutSession;
use Magewirephp\Magewire\Component;

class DeliveryDate extends Component
{
    public ?string $deliveryDate = null;

    public array $rules = [
        'deliveryDate' => 'required|date|after:today'
    ];

    public function __construct(private CheckoutSession $checkoutSession) {}

    public function mount(): void
    {
        $this->deliveryDate = $this->checkoutSession->getQuote()->getDeliveryDate();
    }

    public function updatedDeliveryDate($value): void
    {
        $quote = $this->checkoutSession->getQuote();
        $quote->setDeliveryDate($value)->save();
    }
}

Magewire template

<!-- view/frontend/templates/magewire/checkout/delivery-date.phtml -->
<div wire:id="delivery-date" class="mt-4">
    <label for="delivery-date-input" class="block text-sm font-medium text-gray-700">
        Preferred delivery date
    </label>
    <input type="date"
           id="delivery-date-input"
           wire:model.lazy="deliveryDate"
           class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
                  focus:border-primary focus:ring-2 focus:ring-primary/30" />
    <p wire:loading class="text-xs text-gray-500 mt-1">Saving...</p>
</div>

Drop it into the Hyvä checkout via the hyva_checkout_components.xml registry:

<!-- view/frontend/layout/hyva_checkout_components.xml -->
<component name="checkout.shipping.delivery-date"
           template="Vendor_CheckoutField::magewire/checkout/delivery-date.phtml"
           sortOrder="200"
           parent="checkout.shipping.methods"/>

Admin display

<!-- view/adminhtml/ui_component/sales_order_view.xml -->
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <fieldset name="order_info">
        <field name="delivery_date" formElement="input">
            <settings>
                <label translate="true">Delivery Date</label>
                <dataType>text</dataType>
            </settings>
        </field>
    </fieldset>
</listing>

Edge cases we hit

  • Guest carts need a separate REST route (/V1/guest-carts/{cartId}/...) — Magento does not auto-route both.
  • Reorder flow resets the delivery date to null because quote_id changes. Re-prompt on reorder.
  • Multi-shipping in B2B copies the quote but not the extension attributes. Add a second observer on checkout_multishipping_submit_before.

Adding the field to the order email

The custom field is useless if the customer never sees it confirmed. Add it to the order email template via a layout XML override.

<!-- view/frontend/layout/sales_email_order_items.xml -->
<referenceContainer name="order.email.items">
    <block class="Magento\Framework\View\Element\Template"
           name="order.email.delivery.date"
           template="Vendor_CheckoutField::email/delivery-date.phtml"
           after="-"/>
</referenceContainer>

The template reads $block->getOrder()->getDeliveryDate() and renders it. Mind the email template directive rules — never put a {{var}} reference to a custom property unless you add it to the whitelist.

Adding the field to the order grid

For operational teams that need to filter orders by delivery date, expose the column on the admin sales grid.

<!-- view/adminhtml/ui_component/sales_order_grid.xml -->
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <columns name="sales_order_columns">
        <column name="delivery_date">
            <settings>
                <filter>dateRange</filter>
                <dataType>date</dataType>
                <label translate="true">Delivery Date</label>
                <visible>true</visible>
            </settings>
        </column>
    </columns>
</listing>

The grid reads from sales_order_grid, not sales_order, so add a sync field map in di.xml so the column populates from the order's delivery_date on save.

Validation across stacks

Frontend validation is per-stack (Knockout validator on Luma, Magewire rules on Hyvä, Alpine.js on lightweight Hyvä). Backend validation must be present regardless of frontend — customers can hit the REST endpoint directly with curl.

// In your Vendor\CheckoutField\Model\DeliveryDateManagement::setDeliveryDate
if (strtotime($date) < strtotime('+1 day')) {
    throw new LocalizedException(__('Delivery date must be at least 1 day in the future.'));
}
if (strtotime($date) > strtotime('+90 days')) {
    throw new LocalizedException(__('Delivery date must be within 90 days.'));
}
// Skip weekends if merchant does not deliver Sat/Sun
$dayOfWeek = (int)date('N', strtotime($date));
if ($dayOfWeek >= 6) {
    throw new LocalizedException(__('Delivery is not available on weekends.'));
}

GraphQL exposure for headless storefronts

If the merchant runs a Next.js or PWA Studio storefront alongside Hyvä, expose the field via GraphQL too. Add a resolver on setShippingAddressesOnCart input that accepts delivery_date.

extend input ShippingAddressInput {
  delivery_date: String
}

extend type ShippingCartAddress {
  delivery_date: String @resolver(class: "Vendor\\CheckoutField\\Model\\Resolver\\DeliveryDate")
}

Common variations we ship

Custom checkout fields rarely arrive as "just one date field". The merchant usually layers two or three together. Three patterns we ship most often:

Field by country

A VAT number field that only shows for EU customers. Bind the visibility to billing_address.country_id using Alpine.js x-show, and validate the format server-side per country (VIES for EU, GST for IN, ABN for AU).

Field by product

A monogram or engraving field that only shows when a personalizable product is in the cart. The trigger is the cart line item's attribute. Server-side guard: do not let a customer post the field if no qualifying product is in the cart.

Field by customer group

A purchase-order number field for B2B-group customers only. Combined with required validation — wholesale customers cannot complete checkout without a PO number. Implemented via customer_group_id check in the Magewire mount() method.

What the merchant pays for

The implementation above takes 14–22 hours on a fresh build, 6–10 hours if a similar field already exists in another extension we have shipped. The merchant pays once and the field works in Luma checkout, Hyvä checkout, GraphQL storefronts, REST API integrations, the admin order grid, the order email, and the order PDF. That breadth is the actual value — half-built "add a field" tutorials online stop at the frontend.

Testing matrix

Before shipping, we run the field through this matrix:

  • Anonymous guest checkout — Luma desktop, Luma mobile, Hyvä desktop, Hyvä mobile.
  • Logged-in customer checkout — same four surfaces.
  • Admin order create from the backend — does the field exist on the manual order form?
  • Reorder from customer dashboard — does the field pre-populate or correctly reset?
  • API checkout via REST — does the endpoint accept the field?
  • Order email — does the field appear in the customer confirmation email?
  • Order PDF — invoice and shipping label both?
  • Admin grid filter — can ops filter by the field's value?

Two days of QA is normal. Skipping QA is how you get the 3am support ticket.

Why Magewire is the right default on Hyvä

If you only ship one stack on Hyvä, ship Magewire. Three reasons drive that recommendation across the 20+ Hyvä checkout customizations we have shipped this year.

1. Server-rendered state survives page refresh

Customers refresh during checkout more often than you think — payment 3DS redirects, browser back button, accidental tab close. With pure Alpine.js, the field value lives only in the client. With Magewire, the value persists on the quote and re-hydrates on refresh. Less customer-support overhead.

2. Validation lives in PHP, where the business rules live

Date-format validation, country-specific format checks, business rules like "no Saturday deliveries" — all already in PHP for the REST endpoint. Magewire reuses that code path instead of duplicating it in JavaScript.

3. The team you already have can ship it

Most Magento agencies have 10x more PHP developers than JS developers. Magewire is PHP that happens to render reactive UI. Onboarding a Magento backend developer to Magewire takes a day. Onboarding the same developer to Alpine.js + reactive state takes a week.

Need a custom checkout field shipped this week?

I ship custom checkout fields (Luma or Hyvä) on a fixed-scope sprint including admin grid, order email, and 30-day patches. Fixed quote from $499 audit · $2,499 sprint · ~20h @ $25/hr. See Hyvä checkout customization.