Magento 2 Add extra billing or shipping field to the checkout

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

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

Params

  1. Vendor name: Experius
  2. Module name: CodeBlogAddressAttributeToCheckout
  3. Attribute name: example

Start with a basic module: download here

Step 1: Add address attribute via setup script

Create File: Setup/InstallData.php

<?php


namespace Experius\CodeBlogAddressAttributeToCheckout\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">Experius\CodeBlogAddressAttributeToCheckout\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 Experius\CodeBlogAddressAttributeToCheckout\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="Experius_CodeBlogAddressAttributeToCheckout" 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="Experius_CodeBlogAddressAttributeToCheckout_Plugin_Magento_Quote_Model_BillingAddressManagement" sortOrder="10" type="Experius_CodeBlogAddressAttributeToCheckout\Plugin\Magento\Quote\Model\BillingAddressManagement"/>
    </type>
    <type name="Magento\Quote\Model\ShippingAddressManagement">
        <plugin disabled="false" name="Experius_CodeBlogAddressAttributeToCheckout_Plugin_Magento_Quote_Model_ShippingAddressManagement" sortOrder="10" type="Experius_CodeBlogAddressAttributeToCheckout\Plugin\Magento\Quote\Model\ShippingAddressManagement"/>
    </type>
</config>

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

<?php


namespace Experius\CodeBlogAddressAttributeToCheckout\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 Experius\CodeBlogAddressAttributeToCheckout\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