Steps

  1. Add address attribute via setup script
  2. Add the attributes to the extension attributes of the Address api
  3. Change the input field name and data scope to {address}.custom_attributes.{attribute_code}
  4. Add javascript mixins to transport custom_attributes values to extension_attributes values
  5. Write a plugin to save the extension_attributes data to your database destination

Tools

  1. Mage2gen.com
  2. Knockout Chrome Extension

Examples

  1. https://github.com/experius/Magento-2-Module-Experius-ExtraCheckoutAddressFields
  2. https://github.com/experius/Magento-2-Module-Experius-ExtraCheckoutAddressFieldsTest

Params

  1. Vendor name: Alldu
  2. Module name: AddressAttributeToCheckout
  3. Attribute name: example

Step 1: Add address attribute via setup script

Create File: Setup/InstallData.php

<?php
 
namespace Alldu\AddressAttributeToCheckout\Setup;
 
use Magento\Framework\Setup\InstallDataInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Customer\Model\Customer;
use Magento\Customer\Setup\CustomerSetupFactory;
 
class InstallData implements InstallDataInterface
{
 
    private $customerSetupFactory;
 
    /**
     * Constructor
     *
     * @param \Magento\Customer\Setup\CustomerSetupFactory $customerSetupFactory
     */
    public function __construct(
        CustomerSetupFactory $customerSetupFactory
    ) {
        $this->customerSetupFactory = $customerSetupFactory;
    }
 
    /**
     * {@inheritdoc}
     */
    public function install(
        ModuleDataSetupInterface $setup,
        ModuleContextInterface $context
    ) {
        $customerSetup = $this->customerSetupFactory->create(['setup' => $setup]);
 
        $customerSetup->addAttribute('customer_address', 'example', [
            'label' => 'example',
            'input' => 'text',
            'type' => 'varchar',
            'source' => '',
            'required' => false,
            'position' => 333,
            'visible' => true,
            'system' => false,
            'is_used_in_grid' => false,
            'is_visible_in_grid' => false,
            'is_filterable_in_grid' => false,
            'is_searchable_in_grid' => false,
            'backend' => ''
        ]);
 
 
        $attribute = $customerSetup->getEavConfig()->getAttribute('customer_address', 'example')
            ->addData(['used_in_forms' => [
                'customer_address_edit',
                'customer_register_address'
            ]]);
        $attribute->save();
 
        $installer->getConnection()->addColumn(
            $installer->getTable('quote_address'),
            'example',
            [
                'type' => 'varchar',
                'length' => 255
            ]
        );
 
        $installer->getConnection()->addColumn(
            $installer->getTable('sales_order_address'),
            'example',
            [
                'type' => 'varchar',
                'length' => 255
            ]
        );
    }
}

Checkpoint 1: Address field should show in the checkout.

Step 2: Add the attributes to the extension attributes of the Address api

Create File: 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\Customer\Api\Data\AddressInterface">
        <attribute code="example" type="string"/>
    </extension_attributes>
</config>

Checkpoint 2: The following files are generated

var/generation/Magento/Quote/Api/Data/AddressExtension.php
var/generation/Magento/Quote/Api/Data/AddressExtensionInterface.php

It should contain getters and setters for your address attribute. In this case. setExample(), getExample()

Step 3: Change the input field name and data scope to {address}.custom_attributes.{attribute_code}

Warning: this is not pretty.

Create File: etc/frontend/di.xml

This adds an extra layout processor to the Magento checkout onepage block. Its gives us the possibility to change the form fields before the checkout is loaded.

<?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\Checkout\Block\Onepage">
        <arguments>
            <argument name="layoutProcessors" xsi:type="array">
                <item name="experius_extra_checkout_address_fields_layoutprocessor" xsi:type="object">Alldu\AddressAttributeToCheckout\Block\Checkout\LayoutProcessor</item>
            </argument>
        </arguments>
    </type>
</config>

Create File: Block/Checkout/LayoutProcessor.php

This our own layout processor. The $result param in the process method contains a array with all the fields that will be rendered on the checkout page.

<?php
 
namespace Alldu\AddressAttributeToCheckout\Block\Checkout;
 
class LayoutProcessor implements \Magento\Checkout\Block\Checkout\LayoutProcessorInterface
{
    
    public function process($result) {
        $result = $this->getShippingFormFields($result);
        $result = $this->getBillingFormFields($result);
        return $result;
    }
 
    public function getAdditionalFields($addressType='shipping'){
        if($addressType=='shipping') {
            return ['example'];
        }
        return  ['example'];
    }
 
    public function getShippingFormFields($result){
        if(isset($result['components']['checkout']['children']['steps']['children']
                ['shipping-step']['children']['shippingAddress']['children']
                ['shipping-address-fieldset'])
        ){
 
            $shippingPostcodeFields = $this->getFields('shippingAddress.custom_attributes','shipping');
 
            $shippingFields = $result['components']['checkout']['children']['steps']['children']
            ['shipping-step']['children']['shippingAddress']['children']
            ['shipping-address-fieldset']['children'];
 
            if(isset($shippingFields['street'])){
                unset($shippingFields['street']['children'][1]['validation']);
                unset($shippingFields['street']['children'][2]['validation']);
            }
 
            $shippingFields = array_replace_recursive($shippingFields,$shippingPostcodeFields);
 
            $result['components']['checkout']['children']['steps']['children']
            ['shipping-step']['children']['shippingAddress']['children']
            ['shipping-address-fieldset']['children'] = $shippingFields;
 
        }
 
        return $result;
    }
 
    public function getBillingFormFields($result){
        if(isset($result['components']['checkout']['children']['steps']['children']
            ['billing-step']['children']['payment']['children']
            ['payments-list'])) {
 
            $paymentForms = $result['components']['checkout']['children']['steps']['children']
            ['billing-step']['children']['payment']['children']
            ['payments-list']['children'];
 
            foreach ($paymentForms as $paymentMethodForm => $paymentMethodValue) {
 
                $paymentMethodCode = str_replace('-form', '', $paymentMethodForm);
 
                if (!isset($result['components']['checkout']['children']['steps']['children']['billing-step']['children']['payment']['children']['payments-list']['children'][$paymentMethodCode . '-form'])) {
                    continue;
                }
 
                $billingFields = $result['components']['checkout']['children']['steps']['children']
                ['billing-step']['children']['payment']['children']
                ['payments-list']['children'][$paymentMethodCode . '-form']['children']['form-fields']['children'];
 
                $billingPostcodeFields = $this->getFields('billingAddress' . $paymentMethodCode . '.custom_attributes','billing');
 
                $billingFields = array_replace_recursive($billingFields, $billingPostcodeFields);
 
                $result['components']['checkout']['children']['steps']['children']
                ['billing-step']['children']['payment']['children']
                ['payments-list']['children'][$paymentMethodCode . '-form']['children']['form-fields']['children'] = $billingFields;
            }
        }
 
        return $result;
    }
 
    public function getFields($scope,$addressType){
        $fields = [];
        foreach($this->getAdditionalFields($addressType) as $field){
            $fields[$field] = $this->getField($field,$scope);
        }
        return $fields;
    }
 
    public function getField($attributeCode,$scope) {
        $field = [
            'config' => [
                'customScope' => $scope,
            ],
            'dataScope' => $scope . '.'.$attributeCode,
        ];
 
        return $field;
    }

Checkpoint 3: the name attribute from the input field should now contain custom_attributes

Modify file: etc/module.xml

Add a sequence so our module is loaded after the Magento Checkout module and our layout processor is triggered after the one from the magento checkout module

<?xml version="1.0" ?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Alldu_AddressAttributeToCheckout" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Checkout"/>
        </sequence>
    </module>
</config>

Step 4: Add javascript mixins to transport custom_attributes values to extension_attributes values

The javascript of the checkout looks for extra fields with name customer_attributes and includes them in the postdata to the checkout api
The api looks for extra fields to save in the extension_attributes. So we have to transport them from custom_attributes to extension_attributes.

What is a javascript mixin? Its the javascript equivalent of plugin (interceptor). Thats how i see it being a backend developer

Create file: view/frontend/requirejs-config.js

var config = {
    config: {
        mixins: {
            'Magento_Checkout/js/action/set-billing-address': {
                'Experius_CodeBlogAddressAttributeToCheckout/js/action/set-billing-address-mixin': true
            },
            'Magento_Checkout/js/action/set-shipping-information': {
                'Experius_CodeBlogAddressAttributeToCheckout/js/action/set-shipping-information-mixin': true
            },
            'Magento_Checkout/js/action/create-shipping-address': {
                'Experius_CodeBlogAddressAttributeToCheckout/js/action/create-shipping-address-mixin': true
            },
            'Magento_Checkout/js/action/place-order': {
                'Experius_CodeBlogAddressAttributeToCheckout/js/action/set-billing-address-mixin': true
            },
            'Magento_Checkout/js/action/create-billing-address': {
                'Experius_CodeBlogAddressAttributeToCheckout/js/action/set-billing-address-mixin': true
            }
        }
    }
};

Register the mixins

Create file: view/frontend/web/js/action/create-shipping-address-mixin.js

define([
    'jquery',
    'mage/utils/wrapper',
    'Magento_Checkout/js/model/quote'
], function ($, wrapper,quote) {
    'use strict';
 
    return function (setShippingInformationAction) {
        return wrapper.wrap(setShippingInformationAction, function (originalAction, messageContainer) {
 
            if (messageContainer.custom_attributes != undefined) {
                $.each(messageContainer.custom_attributes , function( key, value ) {
                    messageContainer['custom_attributes'][key] = {'attribute_code':key,'value':value};
                });
            }
 
            return originalAction(messageContainer);
        });
    };
});

For the logged in customers the create shipping addres mixin is needed and it requires a key value content. Only in this case

Create file: view/frontend/web/js/action/set-billing-address-mixin.js

define([
    'jquery',
    'mage/utils/wrapper',
    'Magento_Checkout/js/model/quote'
], function ($, wrapper,quote) {
    'use strict';
 
    return function (setBillingAddressAction) {
        return wrapper.wrap(setBillingAddressAction, function (originalAction, messageContainer) {
 
            var billingAddress = quote.billingAddress();
 
            if(billingAddress != undefined) {
 
                if (billingAddress['extension_attributes'] === undefined) {
                    billingAddress['extension_attributes'] = {};
                }
 
                if (billingAddress.customAttributes != undefined) {
                    $.each(billingAddress.customAttributes, function (key, value) {
 
                        if($.isPlainObject(value)){
                            value = value['value'];
                        }
 
                        billingAddress['extension_attributes'][key] = value;
                    });
                }
 
            }
 
            return originalAction(messageContainer);
        });
    };
});

Create file: view/frontend/web/js/action/set-shipping-information-mixin.js

define([
    'jquery',
    'mage/utils/wrapper',
    'Magento_Checkout/js/model/quote'
], function ($, wrapper,quote) {
    'use strict';
 
    return function (setShippingInformationAction) {
        return wrapper.wrap(setShippingInformationAction, function (originalAction, messageContainer) {
 
            var shippingAddress = quote.shippingAddress();
 
            if (shippingAddress['extension_attributes'] === undefined) {
                shippingAddress['extension_attributes'] = {};
            }
 
            if (shippingAddress.customAttributes != undefined) {
                $.each(shippingAddress.customAttributes , function( key, value ) {
 
                    if($.isPlainObject(value)){
                        value = value['value'];
                    }
 
                    shippingAddress['customAttributes'][key] = value;
                    shippingAddress['extension_attributes'][key] = value;
 
                });
            }
 
            return originalAction(messageContainer);
        });
    };
});

Checkpoint 4: Enable your browsers inspector, monitor ajax calls. When you save the shipping or billing address the example value should now be in both the custom_attributes and extension_attributes when you look at the post data

Step 5 Write a plugin to save the extension_attributes data to your database destination

Create file: etc/di.xml

This registers your plugin classes

<?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\BillingAddressManagement">
        <plugin disabled="false" name="Alldu_AddressAttributeToCheckout_Plugin_Magento_Quote_Model_BillingAddressManagement" sortOrder="10" type="Alldu_AddressAttributeToCheckout\Plugin\Magento\Quote\Model\BillingAddressManagement"/>
    </type>
    <type name="Magento\Quote\Model\ShippingAddressManagement">
        <plugin disabled="false" name="Alldu_AddressAttributeToCheckout_Plugin_Magento_Quote_Model_ShippingAddressManagement" sortOrder="10" type="Alldu_AddressAttributeToCheckout\Plugin\Magento\Quote\Model\ShippingAddressManagement"/>
    </type>
</config>

Create file: Plugin/Magento/Quote/Model/BillingAddressManagement.php

<?php
 
 
namespace Alldu\AddressAttributeToCheckout\Plugin\Magento\Quote\Model;
 
class BillingAddressManagement
{
    
    protected $logger;
 
    public function __construct(
        \Psr\Log\LoggerInterface $logger
    ) {
        $this->logger = $logger;
    }
 
    public function beforeAssign(
        \Magento\Quote\Model\BillingAddressManagement $subject,
        $cartId,
        \Magento\Quote\Api\Data\AddressInterface $address,
        $useForShipping = false
    ) {
 
        $extAttributes = $address->getExtensionAttributes();
        if (!empty($extAttributes)) {
 
            try {
                $address->setExample($extAttributes->getExample());
            } catch (\Exception $e) {
                $this->logger->critical($e->getMessage());
            }
            
        }
 
    }
}

Create file: Plugin/Magento/Quote/Model/ShippingAddressManagement.php

<?php
 
 
namespace Alldu\AddressAttributeToCheckout\Plugin\Magento\Quote\Model;
 
class ShippingAddressManagement
{
 
    protected $logger;
 
    public function __construct(
        \Psr\Log\LoggerInterface $logger
    ) {
        $this->logger = $logger;
    }
 
    public function beforeAssign(
        \Magento\Quote\Model\ShippingAddressManagement $subject,
        $cartId,
        \Magento\Quote\Api\Data\AddressInterface $address
    ) {
 
        $extAttributes = $address->getExtensionAttributes();
        if (!empty($extAttributes)) {
 
            try {
                $address->setExample($extAttributes->getExample());
            } catch (\Exception $e) {
                $this->logger->critical($e->getMessage());
            }
 
        }
 
    }
}

Checkpoint 5: Place your order. Check the quote_address table in the database. You should see the entered value in the example field in the example column