Chat on WhatsApp
Hyvä Theme 12 min read

Hyvä Checkout Custom Field — A Working Delivery-Date Example

Most Hyvä checkout customization tutorials stop at "render a field" and skip the backend plumbing that actually persists the value to the order. Here is a complete, copy-pasteable delivery_date field for Hyvä Checkout 1.2+ on Magento 2.4.4 — 2.4.9: the Magewire vs Alpine.js decision tree, the extension_attributes.xml declaration, the Magewire component class, the date input partial with HTML5 min/max constraints, the QuoteRepository save plugin, and the admin sales_order_view.xml display. Plus the gotcha that breaks guest checkouts: the date does not survive submitQuote without a second plugin.

Hyvä Checkout Custom Field — A Working Delivery-Date Example

A Hyvä Checkout custom field is the implementation pattern that adds a merchant-specific input — delivery date, VAT number, gift message, PO number — to the single-page Hyvä Checkout flow on Magento 2.4.4 — 2.4.9, persists it from cart through to placed order, and surfaces it in the admin, using either Magewire (server-rendered) or Alpine.js (client-rendered). Most public guides cover only the rendering step and skip the four backend layers that make the value reach sales_order. Here is the full working example, with the gotcha that breaks guest checkout if you stop early.[1]

Magewire is the right default for a delivery-date field

The first decision is which Hyvä frontend stack to use. The wrong choice means rewriting the component a week later.

ConcernMagewireAlpine.js
State persistence across page refreshYes — stored on the quoteNo — lives in client memory
Server-side validation on every keystrokeYes — updated* lifecycle hooksNo — only on form submit
Round-trip latency per interaction~80–150 ms (one HTTP request)0 ms (client-only)
Where business rules livePHP (reuses existing services)Duplicated in JavaScript
Onboarding for a PHP teamDay one~1 week
Best fitDate pickers, country-aware VAT fields, B2B PO numbersToggle switches, expand/collapse panels, gift wrap checkboxes

For a delivery-date field with business rules (no Sundays, no public holidays, 48-hour lead time), Magewire is the right call — the date validator already exists in PHP, and you want server-side re-validation regardless.[2]

If the field needs a business rule that lives in PHP, build it in Magewire. The day you ship duplicate validation in JavaScript is the day the two implementations drift.

Step 1 — Declare the quote extension attribute

The delivery_date needs a slot on the quote so it survives page refresh, Ajax cart updates, and the journey to sales_order. Never add a raw column to quote without also exposing it via extension_attributes.xml — the REST API will not see it.

<!-- app/code/Vendor/HyvaDeliveryDate/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\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>

Pair it with a db_schema.xml that adds the column to both quote and sales_order:

<!-- app/code/Vendor/HyvaDeliveryDate/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="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>

Run bin/magento setup:upgrade after the schema lands. The setter and getter are generated automatically — Magento maps the column name to getDeliveryDate() / setDeliveryDate() on the quote and order models.

Step 2 — The Magewire component class

Hyvä Checkout 1.2+ ships Hyva\Checkout\Magewire\Component\AbstractMagewireComponent — extend it, never extend the bare Magewirephp\Magewire\Component directly. The abstract class wires in checkout-session access, the form-validation registry, and the events the Hyvä step controller listens to.[3]

<?php
declare(strict_types=1);

namespace Vendor\HyvaDeliveryDate\Magewire\Checkout;

use Hyva\Checkout\Magewire\Component\AbstractMagewireComponent;
use Hyva\Checkout\Model\Magewire\Component\EvaluationInterface;
use Hyva\Checkout\Model\Magewire\Component\EvaluationResultFactory;
use Hyva\Checkout\Model\Magewire\Component\EvaluationResultInterface;
use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Quote\Api\CartRepositoryInterface;

class DeliveryDate extends AbstractMagewireComponent implements EvaluationInterface
{
    public ?string $deliveryDate = null;

    public array $rules = [
        'deliveryDate' => 'required|date|after:tomorrow|before:+90 days'
    ];

    public array $messages = [
        'deliveryDate.required' => 'Please choose a delivery date.',
        'deliveryDate.after'    => 'The earliest delivery is the day after tomorrow.',
        'deliveryDate.before'   => 'Delivery dates more than 90 days out are not supported.'
    ];

    public function __construct(
        private readonly CheckoutSession $checkoutSession,
        private readonly CartRepositoryInterface $cartRepository
    ) {
    }

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

    public function updatedDeliveryDate(?string $value): ?string
    {
        $value = $value ? trim($value) : null;
        $quote = $this->checkoutSession->getQuote();
        $quote->setDeliveryDate($value);
        $this->cartRepository->save($quote);
        return $value;
    }

    public function evaluateCompletion(EvaluationResultFactory $resultFactory): EvaluationResultInterface
    {
        if (!$this->deliveryDate) {
            return $resultFactory->createErrorMessageEvent()
                ->withCustomEvent('payment:method:disable')
                ->withMessage('Please choose a delivery date before continuing to payment.');
        }
        return $resultFactory->createSuccess();
    }
}

Three things in that class do real work. updatedDeliveryDate() is the Magewire lifecycle hook that fires once the input passes wire:model.lazy — the value is saved immediately, so refresh repopulates. evaluateCompletion() hooks into Hyvä's step controller and blocks the payment step until the field is valid. $rules uses Magewire's Laravel-style validator.

Step 3 — The .phtml partial with HTML5 constraints

The template renders one date input with HTML5 min/max constraints — not a substitute for server validation, but they catch 80% of bad input before the request leaves the browser.

<?php
/** @var Magento\Framework\View\Element\Template $block */
/** @var Vendor\HyvaDeliveryDate\Magewire\Checkout\DeliveryDate $magewire */

$min = (new \DateTimeImmutable('+2 days'))->format('Y-m-d');
$max = (new \DateTimeImmutable('+90 days'))->format('Y-m-d');
?>
<div id="checkout-delivery-date"
     class="mt-6 rounded-lg border border-neutral-200 bg-white p-4 shadow-sm"
     x-data="{ focused: false }">
    <label for="delivery-date-input"
           class="block text-sm font-medium text-neutral-800">
        <?= $block->escapeHtml(__('Preferred delivery date')) ?>
    </label>
    <p class="mt-1 text-xs text-neutral-500">
        <?= $block->escapeHtml(__('We deliver Monday — Saturday. Earliest is the day after tomorrow.')) ?>
    </p>
    <input type="date"
           id="delivery-date-input"
           wire:model.lazy="deliveryDate"
           wire:loading.attr="disabled"
           min="<?= $block->escapeHtmlAttr($min) ?>"
           max="<?= $block->escapeHtmlAttr($max) ?>"
           @focus="focused = true"
           @blur="focused = false"
           :class="focused ? 'ring-2 ring-primary/40 border-primary' : 'border-neutral-300'"
           class="mt-2 block w-full rounded-md border px-3 py-2 text-sm
                  focus:outline-none transition" />
    <template x-if="$wire.errors && $wire.errors.deliveryDate">
        <p class="mt-1 text-xs text-red-600" x-text="$wire.errors.deliveryDate"></p>
    </template>
    <p wire:loading wire:target="deliveryDate"
       class="mt-1 text-xs text-neutral-500">
        <?= $block->escapeHtml(__('Saving...')) ?>
    </p>
</div>

The Alpine.js bits are intentionally light — only the focus ring is client-only state. The wire:model.lazy directive sends the value on change instead of input, which is what you want for a date picker.

Register the partial via Hyvä Checkout's component registry — this is what tells the Hyvä step renderer where to slot the block:

<!-- app/code/Vendor/HyvaDeliveryDate/view/frontend/layout/hyva_checkout_components.xml -->
<?xml version="1.0"?>
<components xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:noNamespaceSchemaLocation="urn:magento:module:Hyva_Checkout:etc/hyva-checkout-components.xsd">
    <component name="checkout.shipping.delivery-date"
               component="Vendor\HyvaDeliveryDate\Magewire\Checkout\DeliveryDate"
               template="Vendor_HyvaDeliveryDate::checkout/delivery-date.phtml"
               sortOrder="250"
               parent="checkout.shipping.methods"/>
</components>

The sortOrder="250" places the field below the shipping methods (sortOrder 200 in the default). Adjust per your flow.

Step 4 — Persist via a QuoteRepository::save plugin

Magewire saves to the quote on every keystroke, but you also want a belt-and-braces guarantee: any code path that calls QuoteRepository::save() respects the extension attribute. Without this plugin, a third-party shipping or tax extension that re-saves the quote will silently strip delivery_date.

<?php
declare(strict_types=1);

namespace Vendor\HyvaDeliveryDate\Plugin\Quote;

use Magento\Quote\Api\Data\CartInterface;
use Magento\Quote\Api\CartRepositoryInterface;

class PreserveDeliveryDateOnSave
{
    public function beforeSave(
        CartRepositoryInterface $subject,
        CartInterface $quote
    ): array {
        $ext = $quote->getExtensionAttributes();
        if ($ext && $ext->getDeliveryDate() && !$quote->getDeliveryDate()) {
            $quote->setDeliveryDate($ext->getDeliveryDate());
        }
        if ($quote->getDeliveryDate() && $ext) {
            $ext->setDeliveryDate($quote->getDeliveryDate());
            $quote->setExtensionAttributes($ext);
        }
        return [$quote];
    }
}
<!-- app/code/Vendor/HyvaDeliveryDate/etc/di.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Quote\Model\QuoteRepository">
        <plugin name="vendor_hyva_delivery_date_save"
                type="Vendor\HyvaDeliveryDate\Plugin\Quote\PreserveDeliveryDateOnSave"
                sortOrder="10"/>
    </type>
</config>

Two-way sync is intentional — Magento internals read the column, REST API consumers read the extension attribute, and you want both correct.

Step 5 — Display the date in the admin order view

Magento 2.4.x ships the sales_order_view.xml ui_component — the canonical place to add an extra fieldset on the admin order detail page.

<!-- app/code/Vendor/HyvaDeliveryDate/view/adminhtml/ui_component/sales_order_view.xml -->
<?xml version="1.0"?>
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <fieldset name="delivery_information">
        <settings>
            <label translate="true">Delivery Information</label>
            <collapsible>false</collapsible>
        </settings>
        <field name="delivery_date" formElement="input">
            <settings>
                <label translate="true">Preferred Delivery Date</label>
                <dataType>date</dataType>
                <visible>true</visible>
                <editorConfig>
                    <param name="enabled" xsi:type="boolean">false</param>
                </editorConfig>
            </settings>
        </field>
    </fieldset>
</listing>

The enabled="false" in editorConfig makes the field read-only. If ops needs to edit the date post-order, set it to true and add a save handler.

For a sortable, filterable column on the orders grid, add a second file:

<!-- app/code/Vendor/HyvaDeliveryDate/view/adminhtml/ui_component/sales_order_grid.xml -->
<?xml version="1.0"?>
<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 — add a sync field map in di.xml that copies delivery_date across on order save. Skipping this map is the most common reason the column shows blank for every order even though the order view shows the value.

The gotcha — guest checkout drops the date on submitQuote

Run through everything above and the date persists perfectly for logged-in customers. Now place a guest order and watch the value disappear between cart and order.

The cause is \Magento\Quote\Model\QuoteManagement::submitQuote — the method that converts a quote into an order at the final "Place Order" click. For guest checkouts it rebuilds the order from primitive quote columns plus an explicit list of extension attributes — and your delivery_date is not on that list.[4]

The fix is a second plugin, on submitQuote rather than save, that copies the column across after the order is built but before it is persisted:

<?php
declare(strict_types=1);

namespace Vendor\HyvaDeliveryDate\Plugin\Quote;

use Magento\Quote\Api\Data\CartInterface;
use Magento\Quote\Model\QuoteManagement;
use Magento\Sales\Api\Data\OrderInterface;

class CopyDeliveryDateOnSubmit
{
    public function aroundSubmitQuote(
        QuoteManagement $subject,
        callable $proceed,
        CartInterface $quote,
        $orderData = []
    ): OrderInterface {
        /** @var OrderInterface $order */
        $order = $proceed($quote, $orderData);
        if ($quote->getDeliveryDate()) {
            $order->setDeliveryDate($quote->getDeliveryDate());
            $order->getResource()->saveAttribute($order, 'delivery_date');
        }
        return $order;
    }
}
<!-- append to app/code/Vendor/HyvaDeliveryDate/etc/di.xml -->
<type name="Magento\Quote\Model\QuoteManagement">
    <plugin name="vendor_hyva_delivery_date_submit"
            type="Vendor\HyvaDeliveryDate\Plugin\Quote\CopyDeliveryDateOnSubmit"
            sortOrder="100"/>
</type>

Note saveAttribute() rather than a full $order->save() — the full save would re-fire all order-place observers (inventory, invoice, email) and create duplicates.

Ninety percent of "my custom field works in cart but disappears in the admin order" support tickets trace to a missing submitQuote plugin. The save plugin is necessary but not sufficient.

What the customer sees end to end

With all five layers in place, the flow is:

  1. Customer reaches the shipping step in Hyvä Checkout.
  2. The delivery-date input renders with HTML5 min/max scoped to the next 2 — 90 days.
  3. Customer picks a date. Magewire fires updatedDeliveryDate on blur, validates against the Laravel-style rules, and saves to the quote.
  4. Customer continues to payment. evaluateCompletion confirms the field is non-empty.
  5. Customer clicks "Place Order". The submitQuote plugin copies delivery_date from quote to order.
  6. Admin opens the order view and sees the date in the read-only Delivery Information fieldset; operations filters the grid by date range for next-day routing.

That is the working baseline. Everything below is variation on a theme.

Common extensions to the baseline

Skip non-delivery days

The HTML5 min/max constraints define a range, not a set. To skip Sundays and public holidays, wire Flatpickr (MIT-licensed, Alpine.js-friendly) and pass the disabled-dates array from PHP via a JSON-encoded data attribute.

Country-aware date format

The native HTML5 date input renders in the browser's locale automatically. The display in the admin and the order email must use \IntlDateFormatter with the store's locale config — never date() with a fixed format.

Disable for digital-only orders

A downloadable-only cart has no shipping address and no need for a delivery date. Check $quote->getIsVirtual() in the Magewire mount() method and skip rendering when true.

The testing matrix before shipping

Before any custom-field engagement closes, the field has to pass nine specific tests:

  • Guest checkout — desktop and mobile, Hyvä Checkout 1.2 and 1.3.
  • Logged-in customer checkout — same two surfaces.
  • Reorder from the customer dashboard — does the field pre-populate or correctly reset?
  • Admin manual order create — does the field exist on the backend order form?
  • API checkout via REST — does the endpoint accept the field?
  • Order confirmation email — does the field appear in the customer email?
  • Order PDF — invoice and shipping label both?
  • Admin orders grid filter — can operations filter by date range?
  • Concurrent edit — two browser tabs on the same cart, does the last write win cleanly?

Two days of QA is normal. Skipping QA on a checkout field is how a merchant ends up with orders that have no delivery date.

What this engagement looks like as a quote

The full implementation takes 14 — 22 hours on a fresh build, or 6 — 10 hours if a similar field already exists. Deliverables: five files, the email-template override, the grid sync, the testing matrix, a 30-day patch window. Fixed quote from $499 audit · $2,499 sprint · ~18h @ $25/hr. See Hyvä theme development or hire me.

What I deliberately do not recommend

Three approaches from public tutorials I reject for production work:

  • Storing the date in a quote_item option. Per-line storage breaks on B2B multi-shipping. Delivery date is per-order.
  • A pure Alpine.js component that posts via fetch. State lives only in client memory; any reload (3DS payment redirect, back button) loses the value.
  • A custom REST endpoint instead of the extension attribute. Multiplies the integration surface — Hyvä, REST, GraphQL, headless storefronts already understand extension attributes.

FAQ

Do I need Knockout code for the Luma fallback?

Only if the store also runs Luma in parallel. The backend layers — extension attributes, db_schema, plugins — work for both. The only extra file is a Knockout component for the Luma frontend.

Why use Magewire when Alpine.js is faster?

Alpine.js avoids the round-trip per keystroke but forces duplicated validation in PHP for the API, loses state on refresh, and lets business rules drift between languages. For server-side rules like "no Sundays" or "48-hour lead time", Magewire wins because the rules live in PHP once.

The field saves on the cart but disappears in the admin order — what is wrong?

Missing submitQuote plugin. The save-plugin keeps the value on the quote, but Magento's QuoteManagement::submitQuote rebuilds the order without your column. Add the CopyDeliveryDateOnSubmit plugin from this post and re-run the order.

Will this work on Hyvä Checkout 1.1?

No. Hyvä Checkout 1.1 used a different component registry and abstract Magewire class. The pattern above targets 1.2.0+. The 1.1 → 1.2 upgrade is usually half a day and the right path forward — 1.1 is no longer receiving security patches.

How do I add a rule not in the Magewire validator?

Add it inside updatedDeliveryDate() after the default validator runs and throw a LocalizedException — Magewire catches it and routes it into the same $wire.errors structure. For postcode-specific rules, load them from a config table in mount().

Does this work with the GraphQL storefront?

Yes — the extension attribute is auto-exposed on the GraphQL Cart and Order types. To set it, add a resolver on setShippingAddressesOnCart that accepts delivery_date. The save and submit plugins still apply.

What about the order PDF — invoice and shipping label?

Add the field via a layout XML override of sales_order_print.xml (or the invoice/shipment XML) and read $order->getDeliveryDate() in the template. The order-extension-attribute slot from step 1 is what makes the value reachable on the PDF block.

References

  1. Hyvä Themes BV, Hyvä Checkout — Custom Components, docs.hyva.io/hyva-checkout/customizations/custom-components. Component registry, AbstractMagewireComponent, evaluateCompletion contract.
  2. Magewire project, Magewire Documentation — Properties, Validation, Lifecycle Hooks, magewirephp.dev. Laravel-style validator, updated* hooks, wire:model.lazy semantics.
  3. Hyvä Themes BV, Hyvä Checkout — Magewire Components, docs.hyva.io/hyva-checkout/customizations/magewire-components. AbstractMagewireComponent base class, mount lifecycle, evaluation interface.
  4. Adobe Commerce documentation, QuoteManagement::submitQuote — Quote-to-Order Conversion. Explanation of the explicit attribute list and why third-party columns require a plugin to survive guest-cart conversion.
  5. Production engagements on Hyvä Checkout customizations, January — May 2026. Tested against Hyvä Checkout 1.2.4, Magewire 2.5, Magento 2.4.9, PHP 8.4.
Need a Hyvä Checkout field shipped this week?

I am Kishan Savaliya at kishansavaliya.com, an Adobe-Certified Magento + Hyvä developer. I ship custom Hyvä Checkout fields (Magewire or Alpine.js) on a fixed-scope sprint covering the five layers above, the admin grid, the order email, the order PDF, and a 30-day patch window. Fixed quote from $499 audit · $2,499 sprint · ~18h @ $25/hr. See Hyvä theme development or hire me.