Skip to content

Shipping Methods ​

Every Magento carrier works in the Lynx checkout without extra code: the Shipping Method step lists the rates Magento collects for the quote's address, and saving the step writes the chosen carrier and method to the quote.

A carrier that needs more from the shopper (pick a store, a locker, a delivery date) registers a renderer: a Vue component shown right under its rate when the shopper picks it. Lynx's In-Store Pickup support (OOCS_LynxInStorePickup) is built this way, and it is the reference implementation for this page.

Input that every shopper gives, whatever the carrier, belongs in a step of its own: see Checkout Steps.

Claude Code

The Lynx Claude Code skills can add a shipping method renderer for you, following this page. See Working with Claude Code.

Registering a renderer ​

Renderers live in the shippingMethodRenderers pool of OOCS\LynxCheckout\Provider\Checkout\Step\ShippingMethod, one item per carrier code, declared in your module's etc/frontend/di.xml:

xml
<type name="OOCS\LynxCheckout\Provider\Checkout\Step\ShippingMethod">
    <arguments>
        <argument name="shippingMethodRenderers" xsi:type="array">
            <item name="vendorfoo" xsi:type="array">
                <item name="component" xsi:type="string">Vendor_ShippingFoo/Components/DeliveryDate</item>
                <item name="config" xsi:type="object">Vendor\ShippingFoo\Model\Config\DeliveryDate</item>
            </item>
        </argument>
    </arguments>
</type>
KeyMeaning
componentPath of the renderer: <Module>/<path under src> without extension. It resolves to the .vue file
configAn object implementing OOCS\LynxCheckout\Api\ShippingMethodRendererConfigInterface. Its getConfig() array becomes the renderer's config prop

As with payment renderers, keep every contributor to this pool in etc/frontend/di.xml: Magento replaces a global array argument with the area's one of the same name, so mixing scopes drops renderers.

getConfig() runs only for a carrier that actually returned a rate for the current address, so reading the quote there is safe. Return the strings the renderer shows under labels, translated with __(). OOCS\LynxInStorePickup\Model\PickupLocations returns the REST search URL, the website code, the cart's SKUs, the store already on the quote and its labels.

The renderer contract ​

Props

PropMeaning
rateThe rate the renderer sits under: carrier_code, method_code, carrier_title, method_title, amount, available, error_message
configWhat getConfig() returned

What the renderer exposes (with defineExpose). Every member is optional.

MemberTypeMeaning
validate() => booleanCalled on Continue. false keeps the shopper on the step; the renderer shows its own error
data() => Record<string, unknown>Merged into the step's data, which is posted to the server with every step request
summarystringOne line shown under the chosen method once the step is collapsed, such as the store name

The renderer is mounted only while its rate is the one picked, and it unmounts when the step collapses. The step takes a snapshot of data() and summary on Continue, so both stay available after the renderer is gone.

Pick keys for data() that cannot clash with other modules, such as a vendor prefix. In-Store Pickup sends pickupLocationCode.

Saving the renderer's data ​

The step's own save only writes the carrier and method. Saving your data is your module's job: add a beforeSave plugin on OOCS\LynxCheckout\Provider\Checkout\Step\ShippingMethod.

save(array $draft) runs each time the step after it is fetched: on Continue, and again on later moves such as Edit or going back. $draft holds the data of every step, your data() keys included. Your plugin reads its keys and writes them to the quote before the step saves the carrier. A LocalizedException thrown from the plugin is shown as the step's error, and the shopper stays on the step.

OOCS\LynxMSI\Plugin\SavePickupLocation is the example to follow. It replaces the shipping address with the store's address, sets pickup_location_code, and saves the shipping information with the instore_pickup method. When the step's own save() then runs, the quote already holds that method, so it does nothing.

Make the plugin idempotent: compare with what the quote already holds and return early when nothing changed. The shopper can go back and leave the step again, and the Address step rewrites the shipping address when the shopper edits it.

Example: a delivery date ​

Vendor_ShippingFoo has a carrier vendorfoo and asks for a delivery date under its rate. The date is stored in a quote column vendor_foo_delivery_date that the module adds in db_schema.xml and copies to the order with a fieldset or observer (standard Magento, not shown).

etc/frontend/di.xml:

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="OOCS\LynxCheckout\Provider\Checkout\Step\ShippingMethod">
        <arguments>
            <argument name="shippingMethodRenderers" xsi:type="array">
                <item name="vendorfoo" xsi:type="array">
                    <item name="component" xsi:type="string">Vendor_ShippingFoo/Components/DeliveryDate</item>
                    <item name="config" xsi:type="object">Vendor\ShippingFoo\Model\Config\DeliveryDate</item>
                </item>
            </argument>
        </arguments>
    </type>
    <type name="OOCS\LynxCheckout\Provider\Checkout\Step\ShippingMethod">
        <plugin name="vendor_shippingfoo_save_delivery_date"
                type="Vendor\ShippingFoo\Plugin\SaveDeliveryDate" sortOrder="20"/>
    </type>
</config>

Model/Config/DeliveryDate.php:

php
<?php
declare(strict_types=1);

namespace Vendor\ShippingFoo\Model\Config;

use Magento\Checkout\Model\Session as CheckoutSession;
use OOCS\LynxCheckout\Api\ShippingMethodRendererConfigInterface;

class DeliveryDate implements ShippingMethodRendererConfigInterface
{
    public function __construct(
        private readonly CheckoutSession $checkoutSession,
    ) {
    }

    public function getConfig(): array
    {
        return [
            // The date already on the quote, so going back to the step shows it
            'selected' => (string) $this->checkoutSession->getQuote()->getData('vendor_foo_delivery_date'),
            'min'      => date('Y-m-d', strtotime('+1 day')),
            'labels'   => [
                'deliveryDate' => (string) __('Delivery Date'),
                'required'     => (string) __('This is a required field.'),
            ],
        ];
    }
}

Plugin/SaveDeliveryDate.php:

php
<?php
declare(strict_types=1);

namespace Vendor\ShippingFoo\Plugin;

use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Quote\Api\CartRepositoryInterface;
use OOCS\LynxCheckout\Provider\Checkout\Step\ShippingMethod;

class SaveDeliveryDate
{
    public function __construct(
        private readonly CheckoutSession $checkoutSession,
        private readonly CartRepositoryInterface $quoteRepository,
    ) {
    }

    /**
     * Write the delivery date to the quote before the step saves the carrier
     *
     * @param ShippingMethod $subject
     * @param array $draft
     * @return array
     */
    public function beforeSave(ShippingMethod $subject, array $draft): array
    {
        $date = (string) ($draft['vendorFooDeliveryDate'] ?? '');
        if (!str_starts_with((string) ($draft['shippingMethod'] ?? ''), 'vendorfoo_') || $date === '') {
            return [$draft];
        }

        $quote = $this->checkoutSession->getQuote();
        if (!$quote->getId() || $quote->getData('vendor_foo_delivery_date') === $date) {
            return [$draft];
        }

        $quote->setData('vendor_foo_delivery_date', $date);
        $this->quoteRepository->save($quote);

        return [$draft];
    }
}

draft.shippingMethod is <carrier_code>_<method_code>, as in Magento's shipping_method field.

The renderer:

vue
<script setup lang="ts">
import { ref } from 'vue'
import { useCheckoutStore } from 'OOCS_LynxCheckout/stores/checkoutStore'

const props = defineProps<{
    rate: { carrier_code: string; method_code: string }
    config: { selected?: string; min?: string; labels?: Record<string, string> }
}>()

const checkout = useCheckoutStore()
const date = ref(props.config.selected ?? '')
const error = ref('')

defineExpose({
    validate() {
        error.value = date.value ? '' : (props.config.labels?.required ?? '')
        return !error.value
    },
    data: () => ({ vendorFooDeliveryDate: date.value }),
    // A ref is unwrapped on the exposed instance, so the step reads the current value
    summary: date,
})
</script>

<template>
    <div>
        <label for="vendor-foo-date" class="block text-sm text-base-content mb-1.5">
            {{ config.labels?.deliveryDate }} <span class="text-error">*</span>
        </label>
        <input
            id="vendor-foo-date"
            v-model="date"
            type="date"
            :min="config.min"
            :class="checkout.inputClass(!!error)"
            @input="error = ''"
        />
        <p v-if="error" data-checkout-error class="mt-1 text-sm text-error">{{ error }}</p>
    </div>
</template>

Place the files at view/frontend/lynx/vue/src/Components/DeliveryDate.vue. The build picks them up from every enabled module; see Frontend Development.

In-Store Pickup, step by step ​

How OOCS_LynxMSI uses these pieces, as a worked reference:

  1. The shopper enters an address in the Address step, as usual. It is used as the billing address.
  2. In the Shipping Method step, picking the instore_pickup rate opens the store list under it. The renderer searches Magento's anonymous REST endpoint GET /V1/inventory/in-store-pickup/pickup-locations/, passing the website code and the cart's SKUs, so only stores that can fulfil the whole cart are listed.
  3. validate() requires a store, data() returns { pickupLocationCode }, and summary is the store name.
  4. On leaving the step, the beforeSave plugin sets the store's address as the shipping address and saves pickup_location_code on it. From there Magento's own In-Store Pickup code validates the quote and copies the pickup location to the order.

The module depends on Magento's Inventory (MSI) modules, In-Store Pickup included. A store without MSI disables the module and the checkout works as before.

Address form fields ​

The address forms of Lynx (the checkout's Address step, the address book and the address part of registration) have no hardcoded fields. Which fields show, in which order and width, and their rules come from Stores › Attributes › Customer Address Fields (module OOCS_LynxCustomerAddress), stored in Magento's own attribute settings: required flag, validate_rules (length limits, input validation), the forms an attribute is used in, and a form_width column of the module. The checkout reads the customer_register_address form, as Magento's checkout does; the address book reads customer_address_edit. The same definition drives the form, its validation, the payload sent to Magento's REST API, and the saved addresses shown to customers, and Magento checks the same rules on the server.

On the server, OOCS\LynxCustomerAddress\Model\AddressFields::get($formCode) returns the fields (type Lynx.AddressField); on the client, OOCS_LynxCustomerAddress/utils/addressFields holds addressErrors() and isRequired() for your own form.

What this means for a module that adds a custom customer address attribute:

  • Create the attribute as usual and make it visible. It then appears in Customer Address Fields, and the merchant ticks the forms it belongs to. No Lynx code is needed.
  • A custom attribute renders as a text input. Only country_id, region, street, telephone and fax have their own controls.
  • The value is sent in the address payload of shipping-information and payment-information under custom_attributes ([{attribute_code, value}]), as Magento's REST API expects for merchant-created attributes, and read back from there when the form is filled again. Where Magento does not store custom address attributes by itself (on the quote and order addresses), your module persists them as with any REST checkout.
  • region and postcode stay required according to Magento's own country rules (State Options and Zip/Postal Code is Optional for), not the attribute's required flag.

Merchant-facing details are in Customer Address Fields.