Often, in Magento 2, the number of standard product attributes that can be used to set up conditions is limited. Extra customization would be required to meet business needs.
From this article, you’ll learn how to achieve that and add custom product attributes as a filter for the shipping rates.
Notes:
- See the complete code example on GitHub.
- The first part of the example, which adds the ‘Volume Weight’ attribute as a filter to shipping rates, is available here.
- The original Magento 2 Shipping Suite Ultimate module is required.
Let’s go straight to discussing what exactly shall be done to achieve the objective.
Table of Contents
Step-by-Step Guide on Adding Custom Product Attribute
Step 1. Create a New Module by Adding the Base Files
Start with naming the module:
> app/code/MageWorx/ShippingRateByProductAttribute/registration.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'MageWorx_ShippingRateByProductAttribute',
__DIR__
);
Then, detect and declare its requirements. You’ll also need to give a name to our module for the composer, set version, and add a short description:
> app/code/MageWorx/ShippingRateByProductAttribute/composer.json
{
"name": "mageworx/module-shipping-rate-by-product-attribute",
"description": "Shipping Rules Extension: Adds product attribute to the Rates",
"require": {
"magento/module-shipping": ">=100.1.0 < 101",
"magento/module-ui": ">=100.1.0 < 102",
"mageworx/module-shippingrules": ">=2.7.1"
},
"type": "magento2-module",
"version": "1.0.0",
"license": [
"OSL-3.0",
"AFL-3.0"
],
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"MageWorx\\ShippingRateByProductAttribute\\": ""
}
}
}
Further, we can set the initial name and version in the Magento 2 configuration, declare the sequence:
> app/code/MageWorx/ShippingRateByProductAttribute/etc/module.xml
<?xml version="1.0"?>
<!--
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MageWorx_ShippingRateByProductAttribute" setup_version="1.0.0">
<sequence>
<module name="MageWorx_ShippingRules" />
</sequence>
</module>
</config>
Step 2. Create a Module Structure
Let’s assume that we have a product attribute named ‘shippingnew’, which was created from the admin side. It is a dropdown input type and has few options named ‘A, B, C, D’, etc. These options describe how we ship our items by zones. Each value has its own price, and products with the highest price will modify the shipping method cost during checkout.
First of all, we need to create a separate table for our shipping rates extended conditions. Later, we will add them using the regular extension attributes of the model (the ‘Shipping Rate’ model extends `\Magento\Framework\Model\AbstractExtensibleModel` ).
> app/code/MageWorx/ShippingRateByProductAttribute/Setup/InstallSchema.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
namespace MageWorx\ShippingRateByProductAttribute\Setup;
use Magento\Framework\DB\Ddl\Table;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
/**
* Class InstallSchema
*/
class InstallSchema implements InstallSchemaInterface
{
/**
* Installs DB schema for a module
*
* @param SchemaSetupInterface $setup
* @param ModuleContextInterface $context
* @return void
* @throws \Zend_Db_Exception
*/
public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
{
$installer = $setup;
$installer->startSetup();
$ratesTable = $installer->getTable(\MageWorx\ShippingRules\Model\Carrier::RATE_TABLE_NAME);
/**
* Create table 'mageworx_shippingrules_rates_shippingnew'
*/
$table = $installer->getConnection()->newTable(
$installer->getTable('mageworx_shippingrules_rates_shippingnew')
)->addColumn(
'rate_id',
Table::TYPE_INTEGER,
null,
['unsigned' => true, 'nullable' => false],
'Rate Id'
)->addColumn(
'shippingnew',
Table::TYPE_TEXT,
'120',
['nullable' => false],
'shippingnew attribute value'
)->addForeignKey(
$installer->getFkName('mageworx_shippingrules_rates_shippingnew', 'rate_id', $ratesTable, 'rate_id'),
'rate_id',
$ratesTable,
'rate_id',
Table::ACTION_CASCADE
)->addIndex(
$installer->getIdxName(
'mageworx_shippingrules_rates_product_attributes',
['rate_id', 'shippingnew'],
\Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_UNIQUE
),
['rate_id', 'shippingnew'],
['type' => \Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_UNIQUE]
)->setComment(
'Product Attributes For Shipping Suite Rates'
);
$installer->getConnection()->createTable($table);
}
}
We named our table as follows: `’mageworx_shippingrules_rates_shippingnew’`. It has just 2 columns. One of them is used as a foreign key. It is the `rate_id` column, which will be linked with the regular table `mageworx_shippingrules_rates` from the MageWorx Shipping Suite Ultimate module for Magento 2. Another column will contain values from the `shippingnew` attribute.
Before we make an observer load/save/delete our custom data to the table, we must create at least two models―regular model and resource model.
> app/code/MageWorx/ShippingRateByProductAttribute/Model/ShippingNew.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
namespace MageWorx\ShippingRateByProductAttribute\Model;
use Magento\Framework\Model\AbstractModel;
/**
* Class ShippingNew
*/
class ShippingNew extends AbstractModel
{
/**
* Prefix of model events names
*
* @var string
*/
protected $_eventPrefix = 'mageworx_shippingnew';
/**
* Parameter name in event
*
* In observe method you can use $observer->getEvent()->getObject() in this case
*
* @var string
*/
protected $_eventObject = 'shippingnew';
/**
* Set resource model and Id field name
*
* @return void
*/
protected function _construct()
{
parent::_construct();
$this->_init('MageWorx\ShippingRateByProductAttribute\Model\ResourceModel\ShippingNew’);
$this->setIdFieldName('rate_id');
}
}
Notes:
- `_eventPrefix` will be used to detect our model events.
- `_eventObject` will be used to store data in the event object. Using this name we can get our model from the event object.
- `$this->_init(‘MageWorx\ShippingRateByProductAttribute\Model\ResourceModel\ShippingNew’);` links our model with the corresponding resource model.
- `$this->setIdFieldName(‘rate_id’);` describes, which field from the table must be used as a key (usually we call it id)
> app/code/MageWorx/ShippingRateByProductAttribute/Model/ResourceModel/ShippingNew.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
namespace MageWorx\ShippingRateByProductAttribute\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
/**
* Class ShippingNew
*/
class ShippingNew extends AbstractDb
{
/**
* Resource initialization
*
* @return void
*/
protected function _construct()
{
$this->_init('mageworx_shippingrules_rates_shippingnew', 'rate_id');
}
/**
* @param $rateId
* @param int $shippingNew
* @return int
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function insertUpdateRecord($rateId, int $shippingNew)
{
$rowsAffected = $this->getConnection()->insertOnDuplicate(
$this->getMainTable(),
[
'rate_id' => $rateId,
'shippingnew' => $shippingNew
]
);
return $rowsAffected;
}
/**
* @param $rateId
* @return int
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function deleteRecord($rateId)
{
$rowsAffected = $this->getConnection()->delete(
$this->getMainTable(),
[
'rate_id = ?' => $rateId
]
);
return $rowsAffected;
}
}
Notes:
- $this->_init(‘mageworx_shippingrules_rates_shippingnew’, ‘rate_id’); set the main table name and id field name.
- public function insertUpdateRecord($rateId, int $shippingNew) is the method, which could help us update the attribute value in our custom table.
- public function deleteRecord($rateId) is designed to remove the column.
Later, we will use those methods in our observers.
Now, let’s add our new data as an extension attribute to the Shipping Rate model:
> app/code/MageWorx/ShippingRateByProductAttribute/etc/extension_attributes.xml
<?xml version="1.0"?>
<!--
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
<!-- Rate Extension -->
<extension_attributes for="MageWorx\ShippingRules\Api\Data\RateInterface">
<attribute code="shippingnew" type="int">
<join reference_table="mageworx_shippingrules_rates_shippingnew" reference_field="rate_id" join_on_field="rate_id">
<field>shippingnew</field>
</join>
</attribute>
</extension_attributes>
</config>
We should also take care of the regular operations of our custom condition:
> app/code/MageWorx/ShippingRateByProductAttribute/etc/events.xml
<?xml version="1.0"?>
<!--
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<!-- Add Extension Attributes to the Rates Collection -->
<!-- Save custom attribute value during rate saving -->
<event name="mageworx_shippingrules_rate_save_after">
<observer
name="mageworx_save_shippingnew_attribute"
instance="MageWorx\ShippingRateByProductAttribute\Observer\SaveShippingNewRateAttribute"
/>
</event>
<!-- Add custom attribute value to the rates collection -->
<event name="rates_collection_render_filters_before">
<observer
name="mageworx_add_shippingnew_attribute"
instance="MageWorx\ShippingRateByProductAttribute\Observer\AddShippingNewToRatesCollection"
/>
</event>
<!-- Take care of filtering the rates grid -->
<event name="mageworx_suitable_rates_collection_load_before">
<observer
name="mageworx_filter_rates_by_shippingnew_attribute"
instance="MageWorx\ShippingRateByProductAttribute\Observer\FilterRatesCollectionByShippingNewAttribute"
/>
</event>
<!-- 3 event observers for the Export/Import rates with custom attribute in conditions -->
<event name="mageworx_rates_export_collection_join_linked_tables_after">
<observer
name="mageworx_join_shipping_new_table_to_export_rates_collection"
instance="MageWorx\ShippingRateByProductAttribute\Observer\JoinShippingNewTableToExportRatesCollection"
/>
</event>
<event name="mageworx_filter_rates_data_before_insert">
<observer
name="mageworx_remove_shipping_new_before_insert"
instance="MageWorx\ShippingRateByProductAttribute\Observer\RemoveShippingNewBeforeInsert"
/>
</event>
<event name="mageworx_shippingrules_import_insert_rates">
<observer
name="mageworx_shippingrules_import_insert_update_shipping_new"
instance="MageWorx\ShippingRateByProductAttribute\Observer\InsertUpdateShippingNewDuringImport"
/>
</event>
</config>
The first event is for save/update/delete custom attribute value in the rates condition.
The second two events are for adding this attribute value to the collection.
The last three events are for the Import/Export functionality.
Let’s analyze them one by one in more detail:
> app/code/MageWorx/ShippingRateByProductAttribute/Observer/SaveShippingNewRateAttribute.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
namespace MageWorx\ShippingRateByProductAttribute\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Exception\LocalizedException;
use MageWorx\ShippingRules\Api\Data\RateInterface;
/**
* Class SaveShippingNewRateAttribute
*
* Saves custom attribute (`shippingnew`) values after model was saved
*/
class SaveShippingNewRateAttribute implements ObserverInterface
{
/**
* @var \MageWorx\ShippingRateByProductAttribute\Model\ResourceModel\ShippingNew
*/
private $resource;
/**
* @var \Magento\Framework\Message\ManagerInterface
*/
private $messagesManager;
/**
* SaveVolumeWeightRateAttribute constructor.
*
* @param \MageWorx\ShippingRateByProductAttribute\Model\ResourceModel\ShippingNew $resource
* @param \Magento\Framework\Message\ManagerInterface $messagesManager
*/
public function __construct(
\MageWorx\ShippingRateByProductAttribute\Model\ResourceModel\ShippingNew $resource,
\Magento\Framework\Message\ManagerInterface $messagesManager
) {
$this->resource = $resource;
$this->messagesManager = $messagesManager;
}
/**
* @param Observer $observer
* @return void
*/
public function execute(Observer $observer)
{
/** @var RateInterface $model */
$model = $observer->getEvent()->getData('rate');
if (!$model instanceof RateInterface) {
return;
}
$shippingNewValue = $model->getData('shippingnew') !== '' ? $model->getData('shippingnew') : null;
if ($shippingNewValue === null) {
try {
$this->resource->deleteRecord($model->getRateId());
} catch (LocalizedException $deleteException) {
$this->messagesManager->addErrorMessage(
__('Unable to delete the Shipping Category for the Rate %1', $model->getRateId())
);
}
} else {
try {
$this->resource->insertUpdateRecord($model->getRateId(), $shippingNewValue);
} catch (LocalizedException $saveException) {
$this->messagesManager->addErrorMessage(
__('Unable to save the Shipping Category for the Rate %1', $model->getRateId())
);
}
}
return;
}
}
It’s as simple as that. When we save a rate, we must take care of saving the custom attribute value too. In case its value equals `null`, just delete a record.
> app/code/MageWorx/ShippingRateByProductAttribute/Observer/AddShippingNewToRatesCollection.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
namespace MageWorx\ShippingRateByProductAttribute\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
/**
* Class AddShippingNewToRatesCollection
*
* Adds custom attribute to the rates collection.
* It will be used later during quote validation.
*/
class AddShippingNewToRatesCollection implements ObserverInterface
{
/**
* Join custom table to the rates collection to obtain the `shippingnew` attribute anywhere in the code.
*
* @param Observer $observer
* @return void
*/
public function execute(Observer $observer)
{
/** @var \MageWorx\ShippingRules\Model\ResourceModel\Rate\Collection $collection */
$collection = $observer->getEvent()->getData('collection');
if (!$collection instanceof \MageWorx\ShippingRules\Model\ResourceModel\Rate\Collection) {
return;
}
if ($collection->isLoaded()) {
return;
}
$joinTable = $collection->getTable('mageworx_shippingrules_rates_shippingnew');
$collection->getSelect()
->joinLeft(
$joinTable,
'`main_table`.`rate_id` = `' . $joinTable . '`.`rate_id`',
['shippingnew']
);
}
}
To make validation available, when a customer goes to the checkout or shipping rates estimation, let’s join our table with the custom attribute to the regular rates table.
> app/code/MageWorx/ShippingRateByProductAttribute/Observer/FilterRatesCollectionByShippingNewAttribute.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
namespace MageWorx\ShippingRateByProductAttribute\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
/**
* Class FilterRatesCollectionByShippingNewAttribute
*
* Filter rates collection before we load it by custom attribute: shippingnew.
*
* For more details
*
* @see \MageWorx\ShippingRules\Model\Carrier\Artificial::getSuitableRatesAccordingRequest()
*
*/
class FilterRatesCollectionByShippingNewAttribute implements ObserverInterface
{
/**
* @param Observer $observer
* @return void
*/
public function execute(Observer $observer)
{
/** @var \MageWorx\ShippingRules\Model\ResourceModel\Rate\Collection $collection */
$collection = $observer->getEvent()->getData('rates_collection');
if (!$collection instanceof \MageWorx\ShippingRules\Model\ResourceModel\Rate\Collection) {
return;
}
/** @var \Magento\Quote\Model\Quote\Address\RateRequest $request */
$request = $observer->getEvent()->getData('request');
if (!$request instanceof \Magento\Quote\Model\Quote\Address\RateRequest) {
return;
}
/** @var \Magento\Quote\Model\Quote\Item[] $items */
$items = $request->getAllItems() ?? [];
$shippingCategories = [];
foreach ($items as $item) {
$value = $item->getProduct()->getData('shippingnew');
if ($value !== null) {
$shippingCategories[] = $value;
}
}
$shippingCategories = array_unique($shippingCategories);
$joinTable = $collection->getTable('mageworx_shippingrules_rates_shippingnew');
$collection->getSelect()
->joinLeft(
['sn' => $joinTable],
'`main_table`.`rate_id` = `sn`.`rate_id`',
['shippingnew']
);
$collection->getSelect()->where(
"`sn`.`shippingnew` IN (?)",
$shippingCategories
);
}
}
This is the most complicated observer in our stack. It is designed to collect all attribute values (`$shippingCategories`) from a customer’s cart and adds the attribute value as a filter to the regular rates collection (our table is already joined). To keep it simple, I’ve named it ‘Filter’. When work is done, a customer will see actual shipping rates for the current cart items.
Another 3 event observers are designed to add and receive custom data during shipping rates export and import. We skip its code in the blog post, but it will be available in the repository with the source code.
Step 3. User Interface
It’s time to add our attribute to the grid and to the form of the shipping rates.
Form
> app/code/MageWorx/ShippingRateByProductAttribute/view/adminhtml/ui_component/mageworx_shippingrules_rate_form.xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
-->
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<fieldset name="conditions">
<field name="shippingnew">
<argument name="data" xsi:type="array">
<item name="options" xsi:type="object">MageWorx\ShippingRateByProductAttribute\Model\Config\Source\ShippingCategory</item>
<item name="config" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Shipping Category</item>
<item name="dataType" xsi:type="string">int</item>
<item name="formElement" xsi:type="string">select</item>
<item name="dataScope" xsi:type="string">shippingnew</item>
<item name="source" xsi:type="string">mageworx_shippingrules_rate_form.custom_attributes</item>
</item>
</argument>
</field>
</fieldset>
</form>
Grid
> app/code/MageWorx/ShippingRateByProductAttribute/view/adminhtml/ui_component/mageworx_shippingrules_rates_regular_listing.xml
<?xml version="1.0"?>
<!--
Copyright © MageWorx. All rights reserved.
See LICENSE.txt for license details.
-->
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Ui/etc/ui_configuration.xsd">
<columns name="mageworx_shippingrules_rates_columns">
<column name="shippingnew">
<argument name="data" xsi:type="array">
<item name="options" xsi:type="object">MageWorx\ShippingRateByProductAttribute\Model\Config\Source\ShippingCategory</item>
<item name="config" xsi:type="array">
<item name="filter" xsi:type="string">select</item>
<item name="component" xsi:type="string">Magento_Ui/js/grid/columns/select</item>
<item name="dataType" xsi:type="string">select</item>
<item name="label" xsi:type="string" translate="true">Shipping Category</item>
<item name="visible" xsi:type="boolean">true</item>
<item name="sortOrder" xsi:type="number">40</item>
<item name="editor" xsi:type="string">select</item>
</item>
</argument>
</column>
</columns>
</listing>
As you can see, we use the custom source model in those files. Let’s create it. It will load a corresponding attribute (`shippingnew`) and give us all the available values.
> app/code/MageWorx/ShippingRateByProductAttribute/Model/Config/Source/ShippingCategory.php
<?php
/**
* Copyright © MageWorx. All rights reserved.
* See LICENSE.txt for license details.
*/
namespace MageWorx\ShippingRateByProductAttribute\Model\Config\Source;
use Magento\Framework\Exception\LocalizedException;
/**
* Class ShippingCategory
*
* Obtain options for specified product attribute
*/
class ShippingCategory extends \Magento\Eav\Model\Entity\Attribute\Source\AbstractSource
{
/**
* @var \Magento\Catalog\Api\ProductAttributeRepositoryInterface
*/
protected $productAttributeRepository;
/**
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* ShippingCategory constructor.
*
* @param \Magento\Catalog\Api\ProductAttributeRepositoryInterface $productAttributeRepository
* @param \Psr\Log\LoggerInterface $logger
*/
public function __construct(
\Magento\Catalog\Api\ProductAttributeRepositoryInterface $productAttributeRepository,
\Psr\Log\LoggerInterface $logger
) {
$this->productAttributeRepository = $productAttributeRepository;
$this->logger = $logger;
}
/**
* @inheritDoc
*/
public function getAllOptions()
{
if (empty($this->_options)) {
try {
/** @var \Magento\Catalog\Api\Data\ProductAttributeInterface $attribute */
$attribute = $this->productAttributeRepository->get('shippingnew');
$this->_options = $attribute->usesSource() ? $attribute->getSource()->getAllOptions() : [];
} catch (LocalizedException $localizedException) {
$this->logger->critical($localizedException->getLogMessage());
}
}
return $this->_options;
}
}
The piece of code is pretty simple. We use the attributes repository to load our attribute and then obtain all options (values) from it. Keep in mind that the attribute with the `shippingnew` code must be created in the admin panel and must have a dropdown input type with predefined options (values). You can do that from the menu ‘Stores > Attributes > Product’. Don’t forget to add this attribute to the attribute set you are using for the products.
When everything is done, we only need to enable the module and run the `setup:upgrade`. Cache will be cleared automatically.
Go to the rates grid (‘Stores > Shipping Rates’) and you’ll see the new column:
That condition will be available inside the rates form:
If we set the ‘Multiple rates price calculation’ setting to the ‘Use Rate with Max Price’ in the corresponding Shipping Method, the rate with the highest price will be used during shipping price calculation.
Here is a small example of how it works in the format of screenshots:
- Set up your products
- Set up the rates
- Set up the price calculation algorithm (in the shipping method form)
- Check shipping price for the corresponding method with selected products in the cart (on the frontend).
This is not everything the Shipping Suite module is capable of. Feel free to play with the settings to get the desired result.
I’ll be glad to answer any questions! Thus, feel free to leave your comments in the dedicated field below.