Question

I'm trying to figure out how to achieve a simple task that in magento 1 was very simple, adding an attribute to an entity. In my case an attribute to customer_address, so in magento tutorial only appears how to add it to the form in checkout using extension attributes (which is not very clear). So far I have added the attribute by installData, using the config:

$MyAttribute->setData(
    'used_in_forms',
    ['adminhtml_customer_address', 'customer_address_edit', 'customer_register_address']
);

But it only appears in admin, and it works fine saving/editing address, it creates the linked attribute in customer_address_entity_varchar, but cannot figure out how to save the addresses created from checkout and from edit address in customer dashboard, also, if the user does not save the address to address book, it only gets saved in sales_order_address, and even there is not EAV use for attributes, so, how do you have to use the EAV attributes and extension attribute for this? An explanation would be very appreciated.

Was it helpful?

Solution

I had to do something similar in a Twilio module I wrote, https://github.com/pmclain/module-twilio. I've included the relevant code for adding the custom address attribute sms_alert to the frontend customer address edit forms and the checkout address edit forms. I omitted the setup scripts, since your original question included them.

If you haven't already you will want to add an extension_attributes.xml

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\AddressInterface">
    <attribute code="sms_alert" type="boolean" />
  </extension_attributes>
</config>

The customer address template is a configuration value located in:

Stores->Configuration->Customer->Customer Configuration->Address Templates->HTML

If you haven't set a custom value for this setting you can define the default value with your module's config.xml.

etc/config.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
  <default>
    <customer>
      <address_templates>
        <html><![CDATA[{{depend prefix}}{{var prefix}} {{/depend}}{{var firstname}} {{depend middlename}}{{var middlename}} {{/depend}}{{var lastname}}{{depend suffix}} {{var suffix}}{{/depend}}{{depend firstname}}<br/>{{/depend}}
{{depend company}}{{var company}}<br />{{/depend}}
{{if street1}}{{var street1}}<br />{{/if}}
{{depend street2}}{{var street2}}<br />{{/depend}}
{{depend street3}}{{var street3}}<br />{{/depend}}
{{depend street4}}{{var street4}}<br />{{/depend}}
{{if city}}{{var city}},  {{/if}}{{if region}}{{var region}}, {{/if}}{{if postcode}}{{var postcode}}{{/if}}<br/>
{{var country}}<br/>
{{depend telephone}}T: {{var telephone}}{{/depend}}
<!-- The sms_alert below is a custom address attribute -->
{{depend sms_alert}}<br/>SMS Enabled: {{var sms_alert}}{{/depend}}
{{depend fax}}<br/>F: {{var fax}}{{/depend}}
{{depend vat_id}}<br/>VAT: {{var vat_id}}{{/depend}}]]></html>
      </address_templates>
    </customer>
  </default>
</config>

You'll need to add your attribute to the checkout javascript layout. I did this with a plugin, but I imagine it could be done with xml layouts.

etc/frontend/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\Checkout\Block\Checkout\LayoutProcessor">
    <plugin name="pmclain_twilio_block_checkout_layoutprocessor" type="Pmclain\Twilio\Plugin\Checkout\Block\Checkout\LayoutProcessor" sortOrder="10" />
  </type>
</config>

Plugin/Checkout/Block/Checkout/LayoutProcessor.php

<?php
namespace Pmclain\Twilio\Plugin\Checkout\Block\Checkout;
use Pmclain\Twilio\Helper\Data as Helper;
class LayoutProcessor
{
  /**
   * @var \Pmclain\Twilio\Helper\Data
   */
  protected $_helper;
  /**
   * LayoutProcessor constructor.
   * @param Helper $helper
   */
  public function __construct(Helper $helper)
  {
    $this->_helper = $helper;
  }
  /**
   * @param \Magento\Checkout\Block\Checkout\LayoutProcessor $subject
   * @param array $jsLayout
   * @return array
   */
  public function afterProcess(
    \Magento\Checkout\Block\Checkout\LayoutProcessor $subject,
    array $jsLayout
  ) {
    if(!$this->_helper->isTwilioEnabled()) { return $jsLayout; }
    //Add to shipping form
    $jsLayout['components']['checkout']['children']['steps']['children']['shipping-step']['children']
    ['shippingAddress']['children']['shipping-address-fieldset']['children']['sms_alert'] = [
      'component' => 'Magento_Ui/js/form/element/abstract',
      'config' => [
        'customScope' => 'shippingAddress.custom_attributes',
        'template' => 'ui/form/field',
        'elementTmpl' => 'ui/form/element/checkbox',
        'custom_entry' => null,
      ],
      'dataScope' => 'shippingAddress.custom_attributes.sms_alert',
      'label' => __('SMS Order Notifications'),
      'description' => __('Send SMS order notifications to the phone number above.'),
      'provider' => 'checkoutProvider',
      'visible' => true,
      'checked' => true,
      'validation' => [],
      'sortOrder' => 125,
      'custom_entry' => null,
    ];

    return $jsLayout;
  }
}

Next there are a handful of mixins you will need for capturing and displaying the information in the knockout templates used during checkout.

view/frontend/requirejs-config.js

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

view/frontend/web/js/action/create-billing-address-mixin.js

define([
  'mage/utils/wrapper'
], function(wrapper) {
  'use strict';

  return function (createBillingAddressAction) {
    return wrapper.wrap(createBillingAddressAction, function(originalAction, addressData) {
      if (addressData.custom_attributes === undefined) {
        return originalAction();
      }

      if (addressData.custom_attributes['sms_alert']) {
        addressData.custom_attributes['sms_alert'] = {
          'attribute_code': 'sms_alert',
          'value': 'SMS Enabled',
          'status': 1
        }
      }

      return originalAction();
    });
  };
});

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

define([
  'jquery',
  'mage/utils/wrapper'
], function($, wrapper) {
  'use strict';

  return function (createShippingAddressAction) {
    return wrapper.wrap(createShippingAddressAction, function(originalAction, addressData) {
      if (addressData.custom_attributes === undefined) {
        return originalAction();
      }

      if (addressData.custom_attributes['sms_alert']) {
        addressData.custom_attributes['sms_alert'] = {
          'attribute_code': 'sms_alert',
          'value': 'SMS Enabled',
          'status': 1
        }
      }

      return originalAction();
    });
  };
});

view/frontend/web/js/action/place-order-mixin.js

define([
  'jquery',
  'mage/utils/wrapper',
  'Magento_Checkout/js/model/quote'
], function($, wrapper, quote) {
  'use strict';

  return function (placeOrderAction) {
    return wrapper.wrap(placeOrderAction, function(originalAction) {
      var billingAddress = quote.billingAddress();

      if(billingAddress.customAttributes === undefined) {
        billingAddress.customAttributes = {};
      }

      if(billingAddress['extension_attributes'] === undefined) {
        billingAddress['extension_attributes'] = {};
      }

      try {
        var smsStatus = billingAddress.customAttributes['sms_alert'].status;
        var smsValue = billingAddress.customAttributes['sms_alert'].value;

        if(smsStatus == true) {
          billingAddress['extension_attributes']['sms_alert'] = smsStatus;
        }else if(smsValue == true) {
          billingAddress['extension_attributes']['sms_alert'] = smsValue
        }else {
          billingAddress['extension_attributes']['sms_alert'] = false;
        }
      }catch (e) {
        return originalAction();
      }

      return originalAction();
    });
  };
});

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) {
      var shippingAddress = quote.shippingAddress();

      if(shippingAddress.customAttributes === undefined) {
        shippingAddress.customAttributes = {};
      }

      if(shippingAddress['extension_attributes'] === undefined) {
        shippingAddress['extension_attributes'] = {};
      }

      try {
        var smsStatus = shippingAddress.customAttributes['sms_alert'].status;
        var smsValue = shippingAddress.customAttributes['sms_alert'].value;

        if(smsStatus == true) {
          shippingAddress['extension_attributes']['sms_alert'] = smsStatus;
        }else if(smsValue == true) {
          shippingAddress['extension_attributes']['sms_alert'] = smsValue
        }else {
          shippingAddress['extension_attributes']['sms_alert'] = false;
        }
      }catch (e) {
        return originalAction();
      }

      return originalAction();
    });
  };
});

view/frontend/web/js/model/customer/address-mixin.js

define([
  'jquery',
  'mage/utils/wrapper',
  'mage/translate'
], function($, wrapper) {
  'use strict';

  return function (addressModel) {
    return wrapper.wrap(addressModel, function(originalAction) {
      var address = originalAction();

      if(address.customAttributes !== undefined) {
        if(address.customAttributes['sms_alert']) {
          var enabled = address.customAttributes['sms_alert'].value;
          address.customAttributes['sms_alert'].value = $.mage.__(enabled ? 'SMS Enabled' : 'SMS Disabled');
          address.customAttributes['sms_alert'].status = enabled ? 1 : 0;
        }
      }

      return address;
    });
  };
});

Now we'll add the custom attribute to the customer address form contained in the customer account section. This is an override of the customer address edit form. This doesn't seem like a clean solution, but since the form fields are all predefined in the template I did not see another option.

view/frontend/layout/customer_address_form.xml

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
  <update handle="customer_account"/>
  <body>
    <referenceContainer name="content">
      <block class="Magento\Customer\Block\Address\Edit" name="customer_address_edit" template="Pmclain_Twilio::address/edit.phtml" cacheable="false"/>
    </referenceContainer>
  </body>
</page>

view/frontend/templates/address/edit.phtml

<?php
<form class="form-address-edit" action="<?php /* @escapeNotVerified */ echo $block->getSaveUrl() ?>" method="post" id="form-validate" enctype="multipart/form-data" data-hasrequired="<?php /* @escapeNotVerified */ echo __('* Required Fields') ?>">
  <fieldset class="fieldset">
    <legend class="legend"><span><?php /* @escapeNotVerified */ echo __('Contact Information') ?></span></legend><br>
    <?php echo $block->getBlockHtml('formkey')?>
    <input type="hidden" name="success_url" value="<?php /* @escapeNotVerified */ echo $block->getSuccessUrl() ?>">
    <input type="hidden" name="error_url" value="<?php /* @escapeNotVerified */ echo $block->getErrorUrl() ?>">
    <?php echo $block->getNameBlockHtml() ?>
    <div class="field company">
      <label class="label" for="company"><span><?php /* @escapeNotVerified */ echo __('Company') ?></span></label>
      <div class="control">
        <input type="text" name="company" id="company" title="<?php /* @escapeNotVerified */ echo __('Company') ?>" value="<?php echo $block->escapeHtml($block->getAddress()->getCompany()) ?>" class="input-text <?php /* @escapeNotVerified */ echo $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('company') ?>">
      </div>
    </div>
    <div class="field telephone required">
      <label class="label" for="telephone"><span><?php /* @escapeNotVerified */ echo __('Phone Number') ?></span></label>
      <div class="control">
        <input type="text" name="telephone" value="<?php echo $block->escapeHtml($block->getAddress()->getTelephone()) ?>" title="<?php /* @escapeNotVerified */ echo __('Phone Number') ?>" class="input-text <?php /* @escapeNotVerified */ echo $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('telephone') ?>" id="telephone">
      </div>
    </div>
    <div class="field choice sms-alert">
      <?php $smsAlert = $block->getAddress()->getCustomAttribute('sms_alert') ? $block->getAddress()->getCustomAttribute('sms_alert')->getValue() : false; ?>
      <input type="checkbox" name="sms_alert" value="1" title="<?php echo __('Send SMS order notifications'); ?>" id="sms_alert" <?php echo $smsAlert === '1' ? 'checked' : ''; ?>>
      <label for="sms_alert" class="label">
        <span><?php echo __('Send SMS notifications to the telephone number above.'); ?></span>
      </label>
    </div>
    <div class="field fax">
      <label class="label" for="fax"><span><?php /* @escapeNotVerified */ echo __('Fax') ?></span></label>
      <div class="control">
        <input type="text" name="fax" id="fax" title="<?php /* @escapeNotVerified */ echo __('Fax') ?>" value="<?php echo $block->escapeHtml($block->getAddress()->getFax()) ?>" class="input-text <?php /* @escapeNotVerified */ echo $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('fax') ?>">
      </div>
    </div>
  </fieldset>
  <fieldset class="fieldset">
    <legend class="legend"><span><?php /* @escapeNotVerified */ echo __('Address') ?></span></legend><br>
    <?php $_streetValidationClass = $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('street'); ?>
    <div class="field street required">
      <label for="street_1" class="label"><span><?php /* @escapeNotVerified */ echo __('Street Address') ?></span></label>
      <div class="control">
        <input type="text" name="street[]" value="<?php echo $block->escapeHtml($block->getStreetLine(1)) ?>" title="<?php /* @escapeNotVerified */ echo __('Street Address') ?>" id="street_1" class="input-text <?php /* @escapeNotVerified */ echo $_streetValidationClass ?>"  />
        <div class="nested">
          <?php $_streetValidationClass = trim(str_replace('required-entry', '', $_streetValidationClass)); ?>
          <?php for ($_i = 1, $_n = $this->helper('Magento\Customer\Helper\Address')->getStreetLines(); $_i < $_n; $_i++): ?>
            <div class="field additional">
              <label class="label" for="street_<?php /* @escapeNotVerified */ echo $_i+1 ?>">
                <span><?php /* @escapeNotVerified */ echo __('Street Address %1', $_i+1) ?></span>
              </label>
              <div class="control">
                <input type="text" name="street[]" value="<?php echo $block->escapeHtml($block->getStreetLine($_i+1)) ?>" title="<?php /* @escapeNotVerified */ echo __('Street Address %1', $_i+1) ?>" id="street_<?php /* @escapeNotVerified */ echo $_i+1 ?>" class="input-text <?php /* @escapeNotVerified */ echo $_streetValidationClass ?>">
              </div>
            </div>
          <?php endfor; ?>
        </div>
      </div>
    </div>

    <?php if ($this->helper('Magento\Customer\Helper\Address')->isVatAttributeVisible()) : ?>
      <div class="field taxvat">
        <label class="label" for="vat_id"><span><?php /* @escapeNotVerified */ echo __('VAT Number') ?></span></label>
        <div class="control">
          <input type="text" name="vat_id" value="<?php echo $block->escapeHtml($block->getAddress()->getVatId()) ?>" title="<?php /* @escapeNotVerified */ echo __('VAT Number') ?>" class="input-text <?php /* @escapeNotVerified */ echo $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('vat_id') ?>" id="vat_id">
        </div>
      </div>
    <?php endif; ?>
    <div class="field city required">
      <label class="label" for="city"><span><?php /* @escapeNotVerified */ echo __('City') ?></span></label>
      <div class="control">
        <input type="text" name="city" value="<?php echo $block->escapeHtml($block->getAddress()->getCity()) ?>" title="<?php /* @escapeNotVerified */ echo __('City') ?>" class="input-text <?php /* @escapeNotVerified */ echo $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('city') ?>" id="city">
      </div>
    </div>
    <div class="field region required">
      <label class="label" for="region_id"><span><?php /* @escapeNotVerified */ echo __('State/Province') ?></span></label>
      <div class="control">
        <select id="region_id" name="region_id" title="<?php /* @escapeNotVerified */ echo __('State/Province') ?>" class="validate-select" <?php echo(!$block->getConfig('general/region/display_all')) ? ' disabled="disabled"' : '';?>>
          <option value=""><?php /* @escapeNotVerified */ echo __('Please select a region, state or province.') ?></option>
        </select>
        <input type="text" id="region" name="region" value="<?php echo $block->escapeHtml($block->getRegion()) ?>"  title="<?php /* @escapeNotVerified */ echo __('State/Province') ?>" class="input-text <?php /* @escapeNotVerified */ echo $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('region') ?>"<?php echo(!$block->getConfig('general/region/display_all')) ? ' disabled="disabled"' : '';?>/>
      </div>
    </div>
    <div class="field zip required">
      <label class="label" for="zip"><span><?php /* @escapeNotVerified */ echo __('Zip/Postal Code') ?></span></label>
      <div class="control">
        <input type="text" name="postcode" value="<?php echo $block->escapeHtml($block->getAddress()->getPostcode()) ?>" title="<?php /* @escapeNotVerified */ echo __('Zip/Postal Code') ?>" id="zip" class="input-text validate-zip-international <?php /* @escapeNotVerified */ echo $this->helper('Magento\Customer\Helper\Address')->getAttributeValidationClass('postcode') ?>">
      </div>
    </div>
    <div class="field country required">
      <label class="label" for="country"><span><?php /* @escapeNotVerified */ echo __('Country') ?></span></label>
      <div class="control">
        <?php echo $block->getCountryHtmlSelect() ?>
      </div>
    </div>

    <?php if ($block->isDefaultBilling()): ?>
      <div class="message info"><?php /* @escapeNotVerified */ echo __("It's a default billing address.") ?></div>
    <?php elseif ($block->canSetAsDefaultBilling()): ?>
      <div class="field choice set billing">
        <input type="checkbox" id="primary_billing" name="default_billing" value="1" class="checkbox">
        <label class="label" for="primary_billing"><span><?php /* @escapeNotVerified */ echo __('Use as my default billing address') ?></span></label>
      </div>
    <?php else: ?>
      <input type="hidden" name="default_billing" value="1" />
    <?php endif; ?>

    <?php if ($block->isDefaultShipping()): ?>
      <div class="message info"><?php /* @escapeNotVerified */ echo __("It's a default shipping address.") ?></div>
    <?php elseif ($block->canSetAsDefaultShipping()): ?>
      <div class="field choice set shipping">
        <input type="checkbox" id="primary_shipping" name="default_shipping" value="1" class="checkbox">
        <label class="label" for="primary_shipping"><span><?php /* @escapeNotVerified */ echo __('Use as my default shipping address') ?></span></label>
      </div>
    <?php else: ?>
      <input type="hidden" name="default_shipping" value="1">
    <?php endif; ?>
  </fieldset>
  <div class="actions-toolbar">
    <div class="primary">
      <button type="submit" class="action save primary" data-action="save-address" title="<?php /* @escapeNotVerified */ echo __('Save Address') ?>">
        <span><?php /* @escapeNotVerified */ echo __('Save Address') ?></span>
      </button>
    </div>
    <div class="secondary">
      <a class="action back" href="<?php echo $block->escapeUrl($block->getBackUrl()) ?>"><span><?php /* @escapeNotVerified */ echo __('Go back') ?></span></a>
    </div>
  </div>
</form>
<script type="text/x-magento-init">
    {
        "#form-validate": {
            "validation": {}
        },
        "#country": {
            "regionUpdater": {
                "optionalRegionAllowed": <?php /* @escapeNotVerified */ echo($block->getConfig('general/region/display_all') ? 'true' : 'false'); ?>,
                "regionListId": "#region_id",
                "regionInputId": "#region",
                "postcodeId": "#zip",
                "form": "#form-validate",
                "regionJson": <?php /* @escapeNotVerified */ echo $this->helper('Magento\Directory\Helper\Data')->getRegionJson() ?>,
                "defaultRegion": "<?php /* @escapeNotVerified */ echo $block->getRegionId() ?>",
                "countriesWithOptionalZip": <?php /* @escapeNotVerified */ echo $this->helper('Magento\Directory\Helper\Data')->getCountriesWithOptionalZip(true) ?>
            }
        }
    }
</script>

OTHER TIPS

The answer provided by Pmclain is a reflection of the Magento's official guide that you can follow: http://devdocs.magento.com/guides/v2.1/howdoi/checkout/checkout_new_field.html

Speaking about custom EAV attributes of the address entity (entity type: 'customer_address') on the checkout-related forms:

Magento provides this functionality as an out-of-the-box feature in Enterprise Edition (now renamed to Magento Commerce). If you have this edition, you are able to add new address attributes in admin panel and they will be shown on the checkout-related forms and will be saved properly. If you are using community edition (cloned it from github etc) you will need to implement it yourself. As I mentioned, you can follow Pmclain solution and handle your attributes as extension attributes.

The key difference between custom attributes and extension attributes is that the extension attributes are meant to be handled manually by developer i.e. code must be written to save them at backend. Unlike extension attributes, custom attributes can be handle easier. For instance \Magento\Framework\Model\AbstractExtensibleModel::getData() will fall back to custom attributes if property cannot be found directly.

EAV is just an approach that Magento uses to store some attributes. The name "extension attribute" does not assume the way that will be used to store this attribute as a developer must be able to choose any way he/she wants.

But considering the history of EAV attributes in Magento, platform provides ability to handled such attributes automatically. You can see in \Magento\Framework\Model\AbstractExtensibleModel::setCustomAttributes(), in order for custom attributes to be set, they must pass the validation. In order to achieve this \Magento\Quote\Model\Quote\Address::getCustomAttributesCodes() must return the list of valid custom attribute codes. In Community Edition this list is empty (see \Magento\Quote\Model\Quote\Address\CustomAttributeList::getAttributes()) ;-) During checkout such address attributes are saved to customer address in \Magento\Customer\Model\ResourceModel\AddressRepository::save() (see $addressModel->updateData($address); line for more details).

As you understand we cannot provide you with the exact code from the corresponding Enterprise module. But I really hope that my answer helps you to resolve the problem yourself.

Licensed under: CC-BY-SA with attribution
Not affiliated with magento.stackexchange
scroll top