Chat on WhatsApp
Checkout & Conversion 12 min read

Conditional Checkout Fields in Magento 2 — Show Field A Only When B Is True

Conditional checkout fields are the rule set that says "only show field A when condition B is true" — and they look identical on Luma and Hyvä until you try to ship one. This is the production recipe for three real rules on both stacks: a Knockout subscribe on Luma, a Magewire $reactive plus Alpine x-show on Hyvä, plus the quote_extension_attributes wiring that keeps the value alive across page reload and 3DS redirect. The three rules ship together in roughly 38 hours on Magento 2.4.4 — 2.4.9 with Hyvä Checkout 1.1+.

Conditional Checkout Fields in Magento 2 — Show Field A Only When B Is True

A conditional checkout field in Magento 2 is a rule that hides or reveals a form input based on another value already on the page — account type, shipping country, cart contents, customer group. The Adobe DevDocs checkout LayoutProcessor[1] covers Luma; the Hyvä Checkout docs[2] cover Magewire. Below are three real rules from kishansavaliya.com client work, with the exact Knockout, Magewire, and Alpine code each one ships with.

Conditional fields fail in the same three places regardless of stack.

The pattern is identical across every "show A when B" rule we have shipped on Magento 2.4.4 — 2.4.9. Three layers must agree or the field misbehaves.

  • Visibility — the DOM hides or shows the input. Pure UI concern.
  • Validation — the input is required (or not) only when visible. Frontend and backend must match.
  • Persistence — the value survives page reload, payment 3DS redirect, and reorder. Lives on quote_extension_attributes.

Luma drives all three from Knockout observables. Hyvä drives visibility from Alpine and validation/persistence from Magewire. Mix them up and the field passes QA but breaks for customers who reload the tab.

A conditional field is a state machine with a visual representation. Build the state machine first, the UI second.

1. Rule one — Company VAT field only when Account type is Business

The merchant is a B2B-friendly storefront. The checkout has a radio: Personal · Business. The VAT input must vanish for personal accounts and become required for business accounts. The default account type comes from the customer record on logged-in checkout, or defaults to Personal on guest checkout.

Database column on quote

<!-- app/code/Panth/ConditionalCheckout/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="varchar" name="account_type" length="16" nullable="true" default="personal"/>
        <column xsi:type="varchar" name="company_vat" length="32" nullable="true"/>
    </table>
    <table name="sales_order">
        <column xsi:type="varchar" name="account_type" length="16" nullable="true"/>
        <column xsi:type="varchar" name="company_vat" length="32" nullable="true"/>
    </table>
</schema>

Luma — Knockout subscribe

The Luma checkout exposes an observable for every form value through the checkoutData singleton. To gate visibility on account type, subscribe to the source and toggle a second observable.

// view/frontend/web/js/view/company-vat.js
define([
    'uiComponent',
    'ko',
    'Magento_Checkout/js/checkout-data',
    'mage/translate'
], function (Component, ko, checkoutData) {
    'use strict';
    return Component.extend({
        defaults: {
            template: 'Panth_ConditionalCheckout/company-vat'
        },
        accountType: ko.observable(checkoutData.getAccountType() || 'personal'),
        companyVat: ko.observable(''),
        isVisible: ko.observable(false),

        initialize: function () {
            this._super();
            // Subscribe to the source observable.
            this.accountType.subscribe(function (next) {
                this.isVisible(next === 'business');
                if (next !== 'business') {
                    this.companyVat('');
                }
            }.bind(this));
            // Seed initial visibility from cached checkoutData.
            this.isVisible(this.accountType() === 'business');
            return this;
        },

        validate: function () {
            if (this.isVisible() && !this.companyVat()) {
                return false;
            }
            return true;
        }
    });
});

The trap that costs three hours on every fresh build — checkoutData.getAccountType() only exists once you register it via a checkout-data mixin. Without the mixin the observable starts undefined and the subscribe never fires on initial load.

Hyvä — Alpine x-show plus Magewire $reactive

On Hyvä Checkout, the visibility flip is pure Alpine. The validation and persistence are Magewire. Two files, clean separation.

<?php
// app/code/Panth/ConditionalCheckout/Magewire/Checkout/CompanyVat.php
namespace Panth\ConditionalCheckout\Magewire\Checkout;

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

class CompanyVat extends Component
{
    public string $accountType = 'personal';
    public ?string $companyVat = null;

    public array $rules = [
        'companyVat' => 'required_if:accountType,business|max:32'
    ];

    public function __construct(private CheckoutSession $checkoutSession) {}

    public function mount(): void
    {
        $quote = $this->checkoutSession->getQuote();
        $this->accountType = $quote->getAccountType() ?: 'personal';
        $this->companyVat = $quote->getCompanyVat();
    }

    public function updatedAccountType(string $value): void
    {
        // $reactive — when accountType changes, persist and clear VAT if Personal.
        $quote = $this->checkoutSession->getQuote();
        $quote->setAccountType($value);
        if ($value !== 'business') {
            $quote->setCompanyVat(null);
            $this->companyVat = null;
        }
        $quote->save();
    }

    public function updatedCompanyVat(?string $value): void
    {
        $this->checkoutSession->getQuote()->setCompanyVat($value)->save();
    }
}
<!-- view/frontend/templates/magewire/checkout/company-vat.phtml -->
<div wire:id="company-vat" x-data="{ accountType: @entangle('accountType').defer }" class="mt-4">
    <fieldset class="flex gap-4">
        <label class="inline-flex items-center">
            <input type="radio" value="personal" wire:model.live="accountType" />
            <span class="ml-2">Personal</span>
        </label>
        <label class="inline-flex items-center">
            <input type="radio" value="business" wire:model.live="accountType" />
            <span class="ml-2">Business</span>
        </label>
    </fieldset>

    <div x-show="accountType === 'business'" x-transition class="mt-3">
        <label for="company-vat-input" class="block text-sm font-medium">Company VAT number</label>
        <input type="text"
               id="company-vat-input"
               wire:model.live.debounce.500ms="companyVat"
               class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" />
        <p wire:loading wire:target="companyVat" class="text-xs text-gray-500 mt-1">Saving…</p>
    </div>
</div>

wire:model.live on the radio round-trips to PHP on every click — the right default for keeping quote state in sync across abandoned tabs. The text input adds .debounce.500ms so keystrokes do not POST.

2. Rule two — Customs declaration only when shipping country is not the merchant's home country

The merchant ships from Belgium. EU customers do not need a customs declaration. Anywhere outside the EU does. The trigger is shipping_address.country_id resolved against a server-known merchant country — never hardcode the country in JavaScript or you cannot resell the module.

The REST endpoint that exposes merchant country

<!-- etc/webapi.xml -->
<route url="/V1/panth-checkout/merchant-country" method="GET">
    <service class="Panth\ConditionalCheckout\Api\MerchantCountryInterface" method="get"/>
    <resources><resource ref="anonymous"/></resources>
</route>

The resolver reads general/country/default from ScopeConfigInterface at SCOPE_STORE — one line of PHP behind the interface.

Luma — subscribe on shipping country

// view/frontend/web/js/view/customs-declaration.js
define([
    'uiComponent',
    'ko',
    'mage/storage',
    'Magento_Checkout/js/model/quote'
], function (Component, ko, storage, quote) {
    'use strict';
    return Component.extend({
        defaults: { template: 'Panth_ConditionalCheckout/customs-declaration' },
        customsText: ko.observable(''),
        merchantCountry: ko.observable(null),
        isVisible: ko.observable(false),

        initialize: function () {
            this._super();
            // Pull the merchant country once per session.
            storage.get('/rest/V1/panth-checkout/merchant-country').done(function (resp) {
                this.merchantCountry(resp);
                this.recompute();
            }.bind(this));

            // Quote.shippingAddress() is an observable. Subscribe to it.
            quote.shippingAddress.subscribe(this.recompute.bind(this));
            return this;
        },

        recompute: function () {
            var addr = quote.shippingAddress();
            var customerCountry = addr && addr.countryId;
            if (!customerCountry || !this.merchantCountry()) {
                this.isVisible(false);
                return;
            }
            this.isVisible(customerCountry !== this.merchantCountry());
        }
    });
});

Hyvä — Magewire $reactive with merchant-country resolved in PHP

<?php
// Magewire/Checkout/CustomsDeclaration.php
namespace Panth\ConditionalCheckout\Magewire\Checkout;

use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use Magewirephp\Magewire\Component;

class CustomsDeclaration extends Component
{
    public ?string $customsText = null;
    public ?string $shippingCountry = null;
    public string $merchantCountry = '';

    public function __construct(
        private CheckoutSession $checkoutSession,
        private ScopeConfigInterface $config
    ) {}

    public function mount(): void
    {
        $quote = $this->checkoutSession->getQuote();
        $address = $quote->getShippingAddress();
        $this->shippingCountry = $address ? $address->getCountryId() : null;
        $this->merchantCountry = (string)$this->config->getValue(
            'general/country/default',
            ScopeInterface::SCOPE_STORE
        );
        $this->customsText = $quote->getCustomsDeclaration();
    }

    public function getIsVisibleProperty(): bool
    {
        return $this->shippingCountry !== null
            && $this->shippingCountry !== $this->merchantCountry;
    }

    public function updatedCustomsText(?string $value): void
    {
        $this->checkoutSession->getQuote()
            ->setCustomsDeclaration($value)
            ->save();
    }
}
<!-- view/frontend/templates/magewire/checkout/customs-declaration.phtml -->
<div wire:id="customs-declaration">
    <?php if ($this->getIsVisibleProperty()): ?>
        <label class="block text-sm font-medium">Customs declaration</label>
        <textarea wire:model.live.debounce.500ms="customsText"
                  rows="3"
                  class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
                  placeholder="Describe the contents — e.g. apparel, books, gifts."></textarea>
        <p class="text-xs text-gray-500 mt-1">Required for shipments outside <?= $block->escapeHtml($this->merchantCountry) ?>.</p>
    <?php endif; ?>
</div>

The Magewire component re-renders on every shipping-country update because country is part of shared Hyvä Checkout state — no manual subscribe needed.

3. Rule three — Delivery instructions only when a specific SKU is in the cart

The merchant sells one fragile product line (SKU prefix FRG-). When any FRG- item is in the cart, the customer must provide delivery instructions. This rule is server-side authoritative — the cart contents live on the quote, not in the customer's browser, so the trigger comes from PHP.

Server-side guard — read quote items in PHP

<?php
// Magewire/Checkout/DeliveryInstructions.php
namespace Panth\ConditionalCheckout\Magewire\Checkout;

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

class DeliveryInstructions extends Component
{
    public ?string $deliveryInstructions = null;
    public bool $hasFragileItem = false;

    public array $rules = [
        'deliveryInstructions' => 'required_if:hasFragileItem,1|max:500'
    ];

    public function __construct(private CheckoutSession $checkoutSession) {}

    public function mount(): void
    {
        $quote = $this->checkoutSession->getQuote();
        $this->deliveryInstructions = $quote->getDeliveryInstructions();
        $this->hasFragileItem = $this->hasFragileItemInCart($quote);
    }

    public function refresh(): void
    {
        // Called via emit('cart-updated') from other Magewire components.
        $quote = $this->checkoutSession->getQuote();
        $this->hasFragileItem = $this->hasFragileItemInCart($quote);
        if (!$this->hasFragileItem) {
            $this->deliveryInstructions = null;
            $quote->setDeliveryInstructions(null)->save();
        }
    }

    private function hasFragileItemInCart($quote): bool
    {
        foreach ($quote->getAllItems() as $item) {
            if (str_starts_with((string)$item->getSku(), 'FRG-')) {
                return true;
            }
        }
        return false;
    }

    public function updatedDeliveryInstructions(?string $value): void
    {
        $this->checkoutSession->getQuote()
            ->setDeliveryInstructions($value)
            ->save();
    }
}

Alpine prop passed from PHP

<!-- view/frontend/templates/magewire/checkout/delivery-instructions.phtml -->
<div wire:id="delivery-instructions"
     x-data="{ hasFragile: @js($this->hasFragileItem) }"
     x-show="hasFragile"
     x-transition
     class="mt-4">
    <label class="block text-sm font-medium">Delivery instructions</label>
    <textarea wire:model.live.debounce.700ms="deliveryInstructions"
              rows="3"
              class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
              placeholder="Where should the courier leave the package if you are not home?"></textarea>
    <p class="text-xs text-gray-500 mt-1">Required because your cart contains fragile items.</p>
</div>

Luma — Knockout against the cart-items observable

On Luma the visibility comes from a ko.computed bound to quote.totals().items, scanning for the FRG- SKU prefix. Same backend guard runs on the submit plugin below.

Luma KO subscribe vs Hyvä Magewire $reactive — when to reach for which

ConcernLuma — KnockoutHyvä — Magewire + Alpine
Visibility flipko.observable + subscribe()x-show bound to @entangle'd Alpine var
ValidationCustom validate() method on the componentpublic array $rules with Laravel-style rule names
Persistence on field changePOST to a /V1/carts/mine/... REST endpointupdatedX() hook saves to quote directly
Persistence on page reloadRe-seed observable from checkoutConfig JSONmount() reads from quote on every request
Cross-component reactivitySubscribe to quote.* observablesMagewire emit() + $on()
Mobile network failure modeField looks fine, value silently lost on next requestwire:loading shows spinner, retry on reconnect
Lines of code (one rule)~120 JS + 30 XML~70 PHP + 25 HTML

Persistence across page reload — the part that gets cut from every tutorial

The reason conditional fields go to QA and come back with bugs is the field value disappearing on payment 3DS redirect or browser back button. The state lives in the browser only — when the browser navigates away, it is gone. Fix it once, in PHP, on the quote_extension_attributes table.

Extension attribute config

<!-- 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="account_type" type="string"/>
        <attribute code="company_vat" type="string"/>
        <attribute code="customs_declaration" type="string"/>
        <attribute code="delivery_instructions" type="string"/>
    </extension_attributes>
</config>

Quote-to-order copy observer

<?php
// Observer/CopyConditionalFieldsToOrder.php
namespace Panth\ConditionalCheckout\Observer;

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

class CopyConditionalFieldsToOrder implements ObserverInterface
{
    private const FIELDS = [
        'account_type', 'company_vat', 'customs_declaration', 'delivery_instructions'
    ];

    public function execute(Observer $observer): void
    {
        $quote = $observer->getEvent()->getQuote();
        $order = $observer->getEvent()->getOrder();
        if (!$quote || !$order) return;
        foreach (self::FIELDS as $field) {
            $value = $quote->getData($field);
            if ($value !== null && $value !== '') {
                $order->setData($field, $value);
            }
        }
    }
}

The observer hangs off sales_model_service_quote_submit_before in etc/events.xml. After 3DS redirect, the Magewire mount() reads $quote->getCompanyVat() from the DB and re-hydrates the field automatically.

Hyvä registry hook for all three rules

<!-- view/frontend/layout/hyva_checkout_components.xml -->
<component name="checkout.shipping.company-vat"
           template="Panth_ConditionalCheckout::magewire/checkout/company-vat.phtml"
           sortOrder="180"
           parent="checkout.shipping.address"/>
<component name="checkout.shipping.customs-declaration"
           template="Panth_ConditionalCheckout::magewire/checkout/customs-declaration.phtml"
           sortOrder="190"
           parent="checkout.shipping.address"/>
<component name="checkout.shipping.delivery-instructions"
           template="Panth_ConditionalCheckout::magewire/checkout/delivery-instructions.phtml"
           sortOrder="200"
           parent="checkout.shipping.methods"/>

Server-side validation is non-negotiable

Frontend rules can be bypassed by anyone with curl. POST to /V1/carts/mine/order skips every JavaScript check. Guard the same rules in PHP.

<?php
// Plugin/QuoteValidator.php — runs on quote submit
public function aroundSubmit(
    \Magento\Quote\Model\QuoteManagement $subject,
    callable $proceed,
    $cartId,
    $paymentMethod = null
) {
    $quote = $this->cartRepository->get($cartId);
    if ($this->hasFragileItem($quote) && !$quote->getDeliveryInstructions()) {
        throw new \Magento\Framework\Exception\LocalizedException(
            __('Delivery instructions are required for fragile items.')
        );
    }
    if ($quote->getAccountType() === 'business' && !$quote->getCompanyVat()) {
        throw new \Magento\Framework\Exception\LocalizedException(
            __('Company VAT is required for business accounts.')
        );
    }
    return $proceed($cartId, $paymentMethod);
}

B2B-specific gotchas

The rules above were all built first for B2B-flavoured checkouts. Three things that always come up:

  • Customer groups override account type — if the customer is in the Wholesale group, default to Business and mark VAT required without showing the toggle.
  • Multi-shipping — Magento B2B multi-shipping copies the quote items but not extension attributes. Add a second observer on checkout_multishipping_submit_before.
  • Negotiable quotes — the proposal-to-order flow only reads extension attributes via Magento\NegotiableQuote\Model\Quote::convertToOrder. Test that path explicitly.

FAQ

Why use Alpine for visibility and Magewire for validation on Hyvä?

Alpine is purely client-side — the visibility flip happens with zero network round-trip, so the input appears the instant the customer ticks the radio. Magewire owns validation and persistence because both need PHP — validation rules are written once for the form and the REST endpoint, and persistence writes to quote_extension_attributes. Splitting concerns this way mirrors the official Hyvä Checkout pattern.

Does wire:model.live trigger a request on every keystroke?

Yes by default, which is wrong for text fields. Always pair it with .debounce.500ms or .debounce.700ms for free-text inputs. For radio and select inputs, leave the debounce off — those fire once per click.

How do I keep the field value alive when the customer hits the 3DS payment redirect?

Persist on every field change to quote via updatedFieldName(). When the 3DS callback returns, Magento reloads the quote from the DB and the Magewire mount() method re-hydrates the property. The field value is back without any frontend cache.

Can I do the conditional logic in JavaScript only and skip Magewire?

For visibility, yes. For validation and persistence, no — server-side validation is mandatory because any customer can POST to the REST endpoint with curl and bypass JavaScript entirely. The 14 hours saved on the frontend evaporate the first time a malformed VAT lands on a $5,000 B2B order.

What about GraphQL headless storefronts?

Extend setShippingAddressesOnCart with the relevant input fields and add a resolver that writes the value to quote. The same observer that copies to sales_order covers GraphQL because the quote-to-order event fires regardless of which front end placed the order.

How does this interact with Magento's built-in "dependent fields" in EAV?

EAV dependent fields (used in customer attributes) operate on the customer entity at registration, not on the quote. They cannot read cart contents or shipping country. Conditional checkout fields belong on the quote because the trigger values only exist during checkout.

Does this work on Magento 2.4.4 through 2.4.9?

Yes. The Knockout checkoutData module, Magento_Checkout/js/model/quote, the extension attribute mechanism, and the sales_model_service_quote_submit_before event are all stable across that range. Hyvä Checkout requires 2.4.6+ for the registry-based component layout shown above.

References

  1. Adobe Commerce documentation, Checkout LayoutProcessor and jsLayout customization — devdocs reference for Knockout-driven checkout field rendering on Luma.
  2. Hyvä Themes, Hyvä Checkout — Magewire component reference and hyva_checkout_components.xml registry. Hyvä Checkout 1.1+ on Magento 2.4.6+.
  3. Production engagement traces from kishansavaliya.com Magento 2 checkout customization work, January — May 2026. Three rules implemented across five client stores.
  4. Magewire PHP project, Component lifecycle hooks and validation rulesupdatedX, mount(), $rules array.
Need conditional checkout rules shipped on your store?

I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer. I scope and ship conditional-field rules on both Luma and Hyvä — visibility, validation, persistence, admin display, and the quote_extension_attributes wiring. Fixed quote from $499 audit · $2,499 sprint · ~22h @ $25/hr. See Hyvä checkout customization or hire me.