When developing and modifying an online store, it often comes up the task to add attributes to the entities. Let’s suppose; there is an online store based on Magento® 2 which retails the products not only for customers but also for some businesses (corporate clients). The process requires that the on the client must indicate whether the shipping address is the Residence or the Business on the order page. The data entered by the customer must get to the order address. In case the customer is authorized, these data must also get to the client’s address book for later use.
When having this task, you may face two difficulties:
- correct implementation of the entered data storage (backend);
- displaying the attribute input (select) field on the checkout page (frontend).
We’ll consider these tasks below.
Magento 2: Adding a New Field In Address Form
In Magento 2, the checkout page is formed using the knockout javascript component. This component gets the fields which the user can output from the configuration. The configuration is transferred from the server to the browser in JSON format. The output of system fields is implemented in core, but the output of an additional field must be implemented with the extension. You can look through the example of adding the field here.
Magento 2: Implementation of The Entered Data Storage
Now we’ll pay close attention to the backend, and, more exactly, implementation of the entered data storage. The most required for the way of data storage is the easy access to data and the ability to copy them.
In Magento 2, the customer’s address from the address book is EAV. Therefore, it can have additional attributes which are stored in tables created by Magento developers. You can easily add the attribute using the installation script of InstallData module, for example, using the following method:
$eavSetup = $this->_eavSetupFactory->create(['setup' => $setup]);
$eavSetup->addAttribute('customer_address', 'type', array(
'type' => 'int',
'input' => 'select',
'label' => 'Address Type',
'source'=>'Web4pro\Ajaxcart\Model\Address\Type',
'global' => 1,
'visible' => 1,
'required' => 1,
'user_defined' => 1,
'system'=>0,
'group'=>'General',
'visible_on_front' => 1,
));
$eavSetup->getEavConfig()->getAttribute('customer_address','type')
->setUsedInForms(array('adminhtml_customer_address','customer_address_edit','customer_register_address'))
->save();
An additional attribute will be created, and its values will be stored in customer_address_entity_int table because the attribute is not static. The static attributes are stored in customer_address_entity table, and all system attributes of this entity are static.
When the user adds the product to the shopping cart, goes to the checkout page, and enters their address, the address is saved not to the address book (there could be no address book if the user is not authorized), but to the shopping cart entity. Physically, it’s quote_address table, and we can work with it with \Magento\Quote\Model\Quote\Address. The user can enter two addresses overall: a payment address and a shipping address. When creating the order, addresses from the shopping cart are copied to the order (sales_order_address table and \Magento\Sales\Model\Order\Address). These models are not EAVs. Mainly, Magento 2 implementation is similar to Magento 1 one, but at the same time, there are several important differences.
Magento 2: Working with Address Attributes
In Magento 2 and Magento 1, the task to copy address fields is implemented with a fieldset configuration. In Magento 2, etc/fieldset.xml file in module configuration is responsible for this. In Magento 1, we would add the necessary fields to quote_address and sales_order_address tables, write the attributes to the fieldset configuration, and the core code would have copied the necessary values. But in Magento 2, it doesn’t work. And this is why.
The data copying is implemented in Magento\Framework\Api\DataObjectHelper class in public populateWithArray() method which calls protected() method with practically the same parameters. Look at its implementation below:
protected function _setDataValues($dataObject, array $data, $interfaceName)
{
$dataObjectMethods = get_class_methods(get_class($dataObject));
foreach ($data as $key => $value) {
/* First, verify is there any setter for the key on the Service Data Object */
$camelCaseKey = \Magento\Framework\Api\SimpleDataObjectConverter::snakeCaseToUpperCamelCase($key);
$possibleMethods = [
'set' . $camelCaseKey,
'setIs' . $camelCaseKey,
];
if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES
&& ($dataObject instanceof ExtensibleDataInterface)
&& is_array($data[$key])
&& !empty($data[$key])
) {
foreach ($data[$key] as $customAttribute) {
$dataObject->setCustomAttribute(
$customAttribute[AttributeInterface::ATTRIBUTE_CODE],
$customAttribute[AttributeInterface::VALUE]
);
}
} elseif ($methodNames = array_intersect($possibleMethods, $dataObjectMethods)) {
$methodName = array_values($methodNames)[0];
if (!is_array($value)) {
if ($methodName === 'setExtensionAttributes' && $value === null) {
// Cannot pass a null value to a method with a typed parameter
} else {
$dataObject->$methodName($value);
}
} else {
$getterMethodName = 'get' . $camelCaseKey;
$this->setComplexValue($dataObject, $getterMethodName, $methodName, $value, $interfaceName);
}
} elseif ($dataObject instanceof CustomAttributesDataInterface) {
$dataObject->setCustomAttribute($key, $value);
}
}
return $this;
}
As you can see, this method searches through the incoming array of data and writes them into $dataObject. Only determined methods in the object class are used for writing. If the writing method isn’t found and the entity implements CustomAttributesDataInterface, it will try to write a value with setCustomAttribute.
\Magento\Quote\Model\Quote\Address and \Magento\Sales\Model\Order\Address models implement this interface, but we will not be able to use it for the following reasons. This method checks the presence of enabled attributes which _getCustomAttributesCodes() method returns only when our key matches the attribute code. _getCustomAttributesCodes() method is implemented in Magento\Framework\Model\AbstractExtensibleModel class which is a parent for \Magento\Quote\Model\Quote\Address and \Magento\Sales\Model\Order\Address. But this method returns the empty array and it is protected, so we can’t add our own attribute codes using the plugin. We can only redefine the class with dependency-injection which is an undesirable way of customization.
That’s why we’ll use another way. All classes which are inherited from Magento\Framework\Model\AbstractExtensibleModel also support extension_attributes. The attribute of this kind should be declared in etc/extension_attributes.xml file of the module. The attribute could have a simple scalar type (int, string) as well, as a type of some class. A module developer can implement the saving of this attribute, but Magento also has the standard built-in tools of attributes data uploading. We can’t save this attribute into the table where the main entity which it’s added to is placed. It should be a separate table, and we can put the link to this table in the description. In order to do this task, we’ll show the content of etc/extension attributes.xml file below.
<?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="type" type="int">
<join reference_table="web4pro_quote_address" join_on_field="address_id" reference_field="address_id">
<field column="type">type</field>
</join>
</attribute>
</extension_attributes>
<extension_attributes for="Magento\Sales\Api\Data\OrderAddressInterface">
<attribute code="type" type="int">
<join reference_table="web4pro_order_address" join_on_field="entity_id" reference_field="entity_id">
<field column="type">type</field>
</join>
</attribute>
</extension_attributes>
<extension_attributes for="Magento\Customer\Api\Data\AddressInterface">
<attribute code="type" type="int"/>
</extension_attributes>
</config>
The extension attribute is defined not for a class, but for the interface. It’s necessary to get the code and the type defined for the attribute. You can also describe the table where it’s stored as well, as a rule of join request to this table. In this case, the attribute address type will be saved in an ordinary flat table which is connected with shopping cart address table via address_id key, and entity_id key – with the order address table. Therefore, it’s necessary to create each table in Setup/InstallSchema.php class and implement the model and resource model for each table. We can write data to the table using the save_after event processors for the order and the shopping cart.
<event name="sales_quote_address_save_after">
<observer name="ajaxcart" instance="Web4pro\Ajaxcart\Observer\Address" shared="false" />
</event>
<event name="sales_order_address_save_after">
<observer name="ajaxcart" instance="Web4pro\Ajaxcart\Observer\Order\Address" shared="false" />
</event>
namespace Web4pro\Ajaxcart\Observer;
class Address implements \Magento\Framework\Event\ObserverInterface {
protected $_objectManager;
public function __construct(\Magento\Framework\ObjectManagerInterface $objectManagerInterface){
$this->_objectManager = $objectManagerInterface;
}
public function execute(\Magento\Framework\Event\Observer $observer){
if($address = $observer->getEvent()->getQuoteAddress()){
if($attributes = $address->getExtensionAttributes()){
$customAddress = $this->_objectManager->create('\Web4pro\Ajaxcart\Model\Quote\Address');
$customAddress->setType($attributes->getType())->setAddressId($address->getId())->save();
}
}
}
}
namespace Web4pro\Ajaxcart\Observer\Order;
class Address implements \Magento\Framework\Event\ObserverInterface {
protected $_objectManager;
public function __construct(\Magento\Framework\ObjectManagerInterface $objectManagerInterface){
$this->_objectManager = $objectManagerInterface;
}
public function execute(\Magento\Framework\Event\Observer $observer){
if($address = $observer->getEvent()->getAddress()){
if($attributes = $address->getExtensionAttributes()){
$customAddress = $this->_objectManager->create('\Web4pro\Ajaxcart\Model\Order\Address');
$customAddress->setType($attributes->getType())->setId($address->getId())->save();
}
}
}
}
As there are usually two addresses in the shopping cart and the order: shipping and delivery, both addresses are loaded with the collection. It’s pretty easy to add the attributes to the collection. You can do it using the load_before processor of the collection event via\Magento\Framework\Api\ExtensionAttribute\JoinProcessor object. Let’s look at its implementation:
<event name="sales_quote_address_collection_load_before">
<observer name="ajaxcart" instance="Web4pro\Ajaxcart\Observer\AddressCollectionLoad" shared="false" />
</event>
<event name="sales_order_address_collection_load_before">
<observer name="ajaxcart" instance="Web4pro\Ajaxcart\Observer\Order\AddressCollectionLoad" shared="false" />
</event>
namespace Web4pro\Ajaxcart\Observer;
class AddressCollectionLoad implements \Magento\Framework\Event\ObserverInterface {
protected $_joinProcessor;
public function __construct(\Magento\Framework\Api\ExtensionAttribute\JoinProcessor $joinProcessor){
$this->_joinProcessor = $joinProcessor;
}
public function execute(\Magento\Framework\Event\Observer $observer){
if($collection = $observer->getEvent()->getQuoteAddressCollection()){
$this->_joinProcessor->process($collection);
}
}
}
namespace Web4pro\Ajaxcart\Observer\Order;
class AddressCollectionLoad implements \Magento\Framework\Event\ObserverInterface {
protected $_joinProcessor;
public function __construct(\Magento\Framework\Api\ExtensionAttribute\JoinProcessor $joinProcessor){
$this->_joinProcessor = $joinProcessor;
}
public function execute(\Magento\Framework\Event\Observer $observer){
if($collection = $observer->getEvent()->getOrderAddressCollection()){
$this->_joinProcessor->process($collection);
}
}
}
As a result, we can get the extension attributes after loading the collection using _getExtensionAttributes() method which returns the entity that implements \Magento\Quote\Api\Data\AddressExtensionInterface for the shopping cart address and \Magento\Sales\Api\Data\OrderAddressExtensionInterface for the order address. The interface and the class which implements it are dynamic. And they are formed right on the go in var/generation directory on the base of the description in etc/extension_attributes.xml files of different modules for this type.
But there are some pitfalls in this implementation. Say, you successfully outputted the attribute to the checkout page (we’ll show how to do it below) and saved, for example, the shipping address. But in case we reload the checkout page or go to the shopping cart, we could get the empty checkout page without any content or the error message in the main log. Here is how it looks like:
Recoverable Error: Argument 1 passed to Magento\Quote\Model\Cart\Totals::setExtensionAttributes() must be an instance of Magento\Quote\Api\Data\TotalsExtensionInterface, instance of Magento\Quote\Api\Data\AddressExtension given, called in /var/www/brandrpm/vendor/magento/framework/Api/DataObjectHelper.php on line 127 and defined in /var/www/brandrpm/vendor/magento/module-quote/Model/Cart/Totals.php on line 592 [] []
We can find the reason for this error in the Magento\Quote\Model\Cart\CartTotalRepository class. This class implements Totals values load for display. It’s implemented the following way:
public function get($cartId)
{
/** @var \Magento\Quote\Model\Quote $quote */
$quote = $this->quoteRepository->getActive($cartId);
if ($quote->isVirtual()) {
$addressTotalsData = $quote->getBillingAddress()->getData();
$addressTotals = $quote->getBillingAddress()->getTotals();
} else {
$addressTotalsData = $quote->getShippingAddress()->getData();
$addressTotals = $quote->getShippingAddress()->getTotals();
}
/** @var \Magento\Quote\Api\Data\TotalsInterface $quoteTotals */
$quoteTotals = $this->totalsFactory->create();
$this->dataObjectHelper->populateWithArray(
$quoteTotals,
$addressTotalsData,
'\Magento\Quote\Api\Data\TotalsInterface'
);
$items = [];
foreach ($quote->getAllVisibleItems() as $index => $item) {
$items[$index] = $this->itemConverter->modelToDataObject($item);
}
$calculatedTotals = $this->totalsConverter->process($addressTotals);
$quoteTotals->setTotalSegments($calculatedTotals);
$amount = $quoteTotals->getGrandTotal() - $quoteTotals->getTaxAmount();
$amount = $amount > 0 ? $amount : 0;
$quoteTotals->setCouponCode($this->couponService->get($cartId));
$quoteTotals->setGrandTotal($amount);
$quoteTotals->setItems($items);
$quoteTotals->setItemsQty($quote->getItemsQty());
$quoteTotals->setBaseCurrencyCode($quote->getBaseCurrencyCode());
$quoteTotals->setQuoteCurrencyCode($quote->getQuoteCurrencyCode());
return $quoteTotals;
}
As you can see, this method just extracts the _data array values from the address entity and puts them to the entity which implements \Magento\Quote\Api\Data\TotalsInterface. It’s done this way because Totals values are physically stored in the address table and uploaded to the address entity. And the extension entities attributes of various classes can implement different and even incompatible interfaces. By the way, the same would happen when copying extension attributes using etc/fieldset.xml. In our case the file will look the following way:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:DataObject/etc/fieldset.xsd">
<scope id="global">
<fieldset id="customer_address">
<field name="type">
<aspect name="to_quote_address" />
</field>
</fieldset>
<fieldset id="sales_convert_quote_address">
<field name="extension_attributes">
<aspect name="to_customer_address" />
<aspect name="to_order_address" />
</field>
</fieldset>
<fieldset id="sales_convert_order_address">
<field name="extension_attributes">
<aspect name="to_quote_address" />
</field>
</fieldset>
</scope>
</config>
We can sort out the problem of type incompatibility using the plugin for populateWithArray() method.
<type name="Magento\Framework\Api\DataObjectHelper">
<plugin name="move-extension-attributes" type="Web4pro\Ajaxcart\Model\Plugin" sortOrder="20"/>
</type>
public function beforePopulateWithArray($helper,$dataObject, array $data, $interfaceName){
switch($interfaceName){
case '\Magento\Sales\Api\Data\OrderAddressInterface':
if($data['extension_attributes'] instanceof \Magento\Quote\Api\Data\AddressExtensionInterface){
$data['extension_attributes'] = $data['extension_attributes']->__toArray();
}
break;
case '\Magento\Customer\Api\Data\AddressInterface':
if($data['extension_attributes'] instanceof \Magento\Quote\Api\Data\AddressExtensionInterface){
$data['extension_attributes'] = $data['extension_attributes']->__toArray();
if(isset($data['extension_attributes']['type'])){
$data['type'] = $data['extension_attributes']['type'];
}
}
break;
case '\Magento\Quote\Api\Data\TotalsInterface':
unset($data['extension_attributes']);
break;
}
return array($dataObject,$data,$interfaceName);
}
As the interface name of the source object is transmitted to _populateWithArray() method, we can check it and modify the data. We can get away extension attributes at all, as it’s done for \Magento\Quote\Api\Data\TotalsInterface in our case. But if we need any extension attributes for this interface, it’s better to use __toArray() method which any extension attribute entity has and which returns the array key – the extensions attribute value. If transmit this array to _populateWithArray() method, it will automatically write it to the extensions attribute value and transform it into the type of the relevant interface.
The question is: “Why Magento 2 developers haven’t included the automatic converting of extension attribute values to the array for the comfort transmitting between the entities?” – We can only suppose all extension attribute classes implement __toArray() method, which is implemented in Magento\Framework\Api\AbstractSimpleObject parent class. For \Magento\Quote\Api\Data\AddressExtensionInterface, we also saved the attribute value right to the $data array, because in this case this value will be written with _setCustomAttribute() method, and the entity of this interface supports all created EVAs.
You can look at the frontend implementation here. But we must admit that Mixin Javascript is implemented the next way:
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['extension_attributes'] === undefined) {
shippingAddress['extension_attributes'] = {};
}
var tp = shippingAddress.customAttributes['type'];
if(typeof tp=='object'){
tp = tp.value;
}
shippingAddress['extension_attributes']['type'] = tp;
// pass execution to original action ('Magento_Checkout/js/action/set-shipping-information')
return originalAction();
});
};
});
ShippingAddress.customAttributes[‘type’] value checking also should be implemented. Because if the user who places the order has already given the address, and this address is already saved in the address book, the result is the entity in this variable. We must transmit only the value to the server. This variable will have the scalar type for a new address.
The value output on the order view page on admin panel is implemented with a simple plugin:
<type name="Magento\Sales\Block\Adminhtml\Order\View\Info">
<plugin name="render-type" type="Web4pro\Ajaxcart\Model\Plugin" sortOrder="20"/>
</type>
public function beforeGetFormattedAddress($block,$address){
if($attributes = $address->getExtensionAttributes()){
$address->setType($attributes->getType());
}
return array($address);
}
After all, we must only add the {{var type}} variable to the address format in Magento configuration, and as a result, this field will be outputted.