In this article we are going to create a custom shipping method which will shown only once the cart grand total value reaches over a configured value. In addition we will also add functionality that allows hiding other methods if the new one is available. In order to give this a bit of business meaning we will create the method as a custom free shipping method. This can turn out to be useful in case you already use the stock method for something else and do not want to handle cart rules. Lets get started.

First you will need to create a Magento 2 module. Ours is going to be called Techflarestudio_FreeShipping. Once you have created the module the first step is to define system configuration for our new shipping method. We can do this in system.xml just as any other configuration file. Only in this case we will add a new group under carriers section.

<?xml version="1.0"?>
<!--
  ~ * @category	Techflarestudio
  ~ * @author		Wasalu Duckworth
  ~ * @copyright  Copyright (c) 2021 Techflarestudio, Ltd. 			(https://techflarestudio.com)
  ~ * @license	http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
  ~
  -->

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="carriers" translate="label" type="text" sortOrder="320" showInDefault="1" showInWebsite="1" showInStore="1">
            <group id="custom_free_shipping" translate="label" type="text" sortOrder="900" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>Custom Free Shipping</label>
                <field id="active" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1">
                    <label>Enabled</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="title" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Title</label>
                </field>
                <field id="name" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Method Name</label>
                </field>
                <field id="shipping_cost" translate="label" type="text" sortOrder="40" showInDefault="1" showInWebsite="1" showInStore="0" >
                    <label>Shipping Cost</label>
                    <validate>validate-number validate-zero-or-greater</validate>
                </field>
                <field id="subtotal_threshold" translate="label" type="text" sortOrder="40" showInDefault="1" showInWebsite="1" showInStore="0" >
                    <label>Threshold</label>
                    <validate>validate-number validate-zero-or-greater</validate>
                </field>
                <field id="sallowspecific" translate="label" type="select" sortOrder="60" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1">
                    <label>Ship to Applicable Countries</label>
                    <frontend_class>shipping-applicable-country</frontend_class>
                    <source_model>Magento\Shipping\Model\Config\Source\Allspecificcountries</source_model>
                </field>
                <field id="specificcountry" translate="label" type="multiselect" sortOrder="70" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Ship to Specific Countries</label>
                    <source_model>Magento\Directory\Model\Config\Source\Country</source_model>
                    <can_be_empty>1</can_be_empty>
                </field>
                <field id="showmethod" translate="label" type="select" sortOrder="80" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Show Method if Not Applicable</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                    <frontend_class>shipping-skip-hide</frontend_class>
                </field>
                <field id="sort_order" translate="label" type="text" sortOrder="90" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Sort Order</label>
                </field>
            </group>
        </section>
    </system>
</config>
Techflarestudio/FreeShipping/etc/adminhtml/system.xml

Lots of things to break down here:

  • We are defining a yes/no field which will allow the store admin to enable or disable the method
  • We are defining a title, name, cost which are self explanatory
  • We are also setting the in-built Ship to Applicable/Specifi Countries which will allow the store admin to configure the method to work for only certain shipping addresses
  • Sort order allows us to define the methods position in regards to other in shipping step
  • We are also adding a custom field Subtotal Threshold. This will allow the store admin to define the cart subtotal value which must be reached in order to allow using this method

Once we have the configuration set we need to add default values. We can do this in config.php.

<?xml version="1.0"?>
<!--
  ~ * @category	Techflarestudio
  ~ * @author		Wasalu Duckworth
  ~ * @copyright  Copyright (c) 2021 Techflarestudio, Ltd. 			(https://techflarestudio.com)
  ~ * @license	http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
  ~
  -->

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <carriers>
            <custom_free_shipping>
                <active>1</active>
                <title>Free Shipping</title>
                <name>Free Shipping</name>
                <shipping_cost>0</shipping_cost>
                <sallowspecific>0</sallowspecific>
                <sort_order>15</sort_order>
                <model>Techflarestudio\FreeShipping\Model\Carrier\FreeShipping</model>
            </custom_free_shipping>
        </carriers>
    </default>
</config>
Techflarestudio/FreeShipping/etc/config.xml

This is simply setting the default values for the configuration. However one important thing to note here is the model which we have set to use a custom carrier. Lets go on and create the carrier model.

<?php
/*
 *  @category	Techflarestudio
 *  @author		Wasalu Duckworth
 *  @copyright  Copyright (c) 2021 Techflarestudio, Ltd. 			(https://techflarestudio.com)
 *  @license	http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
 *
 */

namespace Techflarestudio\FreeShipping\Model\Carrier;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory;
use Magento\Quote\Model\Quote\Address\RateResult\MethodFactory;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
use Magento\Shipping\Model\Rate\ResultFactory;
use Magento\Checkout\Model\Session as CheckoutSession;

/**
 * Class Freeshipping
 * @package Techflarestudio\FreeShipping\Model\Carrier
 */
class FreeShipping extends AbstractCarrier implements CarrierInterface
{
    /**
     * @var string
     */
    protected $_code = 'custom_free_shipping';

    /**
     * @var bool
     */
    protected $_isFixed = true;

    /**
     * @var TotalsInterface
     */
    protected $_cartTotalRepository;

    /**
     * @var CheckoutSession
     */
    protected $_checkoutSession;

    /**
     * @var ResultFactory
     */
    private $rateResultFactory;

    /**
     * @var MethodFactory
     */
    private $rateMethodFactory;


    /**
     * @param ScopeConfigInterface $scopeConfig
     * @param ErrorFactory $rateErrorFactory
     * @param \Psr\Log\LoggerInterface $logger
     * @param ResultFactory $rateResultFactory
     * @param MethodFactory $rateMethodFactory
     * @param TotalsInterface $_cartTotalRepository
     * @param CheckoutSession $_checkoutSession
     * @param array $data
     */
    public function __construct(
        ScopeConfigInterface $scopeConfig,
        ErrorFactory $rateErrorFactory,
        \Psr\Log\LoggerInterface $logger,
        ResultFactory $rateResultFactory,
        MethodFactory $rateMethodFactory,
        TotalsInterface $_cartTotalRepository,
        CheckoutSession $_checkoutSession,
        array $data = []
    ) {
        parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);

        $this->_cartTotalRepository = $_cartTotalRepository;
        $this->_checkoutSession = $_checkoutSession;
        $this->rateResultFactory = $rateResultFactory;
        $this->rateMethodFactory = $rateMethodFactory;
    }

    /**
     * Custom Shipping Rates Collector
     *
     * @param RateRequest $request
     * @return \Magento\Shipping\Model\Rate\Result|bool
     */
    public function collectRates(RateRequest $request)
    {
        if (!$this->getConfigFlag('active') || !$this->validateSubtotal()) {
            return false;
        }

        /** @var \Magento\Shipping\Model\Rate\Result $result */
        $result = $this->rateResultFactory->create();

        /** @var \Magento\Quote\Model\Quote\Address\RateResult\Method $method */
        $method = $this->rateMethodFactory->create();

        $method->setCarrier($this->_code);
        $method->setCarrierTitle($this->getConfigData('title'));

        $method->setMethod($this->_code);
        $method->setMethodTitle($this->getConfigData('name'));

        $shippingCost = (float)$this->getConfigData('shipping_cost');

        $method->setPrice($shippingCost);
        $method->setCost($shippingCost);

        $result->append($method);

        return $result;
    }

    /**
     * @return array
     */
    public function getAllowedMethods()
    {
        return [$this->_code => $this->getConfigData('name')];
    }

    /**
     * @return bool
     */
    protected function validateSubtotal()
    {
        $subtotalThreshold = (float)$this->getConfigData('subtotal_threshold');
        $subtotal = $this->getSubtotal();

        if(!$subtotalThreshold || !$subtotal) {
            return false;
        }

        if($subtotalThreshold > $subtotal) {
            return false;
        }

        return true;
    }

    /**
     * @return false|float
     */
    protected function getSubtotal()
    {
        try {
            return $this->_checkoutSession->getQuote()->getSubtotal();
        } catch (NoSuchEntityException $e) {
            $this->_logger->critical($e);
        } catch (LocalizedException $e) {
            $this->_logger->critical($e);
        }
        return false;
    }
}

This is a fairly standard shipping carrier model. As you can see there are 2 conditions which can make the method available in checkout. Both of these happen in collectRates function.

  • First we are checking if the carrier is enabled in configuration
  • Secondly we are fetching the current quote subtotal value and comparing that to the configured threshold value \Techflarestudio\FreeShipping\Model\Carrier\FreeShipping::validateSubtotal
  • The tricky part here is to access the quote data. We do this by injecting Magento\Checkout\Model\Session which gives us access to current session and cart contents.

Go to store admin - Stores->Configuration->Sales->Delivery Methods->Custom Free Shipping. You should be able to see something similar:

Custom Free Shipping configuration

Lets configure the threshold to be 50$. Enable the method, set all of the required fields, flush the cache and go to checkout. Make sure to add more than 50$ to cart before getting there - the subtotal validation will filter out the method otherwise. We should be able to see our newly defined custom shipping method.

New custom free shipping

This is great. We have already added a new shipping method which the store admin can configure based on subtotal threshold and set a custom price for the method.

Next we will add functionality that will allow to hide other methods in case our custom free shipping is available. To do so we will need to hook into the shipping method functionality and filter the methods before rendering.

When entering the address Magento is loading the following route rest/default/V1/guest-carts/../estimate-shipping-methods.

If we look up the route definition we can see that it is defined like this:

    <route url="/V1/guest-carts/:cartId/estimate-shipping-methods" method="POST">
        <service class="Magento\Quote\Api\GuestShipmentEstimationInterface" method="estimateByExtendedAddress"/>
        <resources>
            <resource ref="anonymous" />
        </resources>
    </route>
magento/module-quote/etc/webapi.xml

So we can see that this function is using the following service class Magento\Quote\Api\GuestShipmentEstimationInterface. Following the login in estimateByExtendedAddress we can see that the parent logic is actually defined here:

public function estimateByExtendedAddress($cartId, AddressInterface $address)
    {
        /** @var Quote $quote */
        $quote = $this->quoteRepository->getActive($cartId);

        // no methods applicable for empty carts or carts with virtual products
        if ($quote->isVirtual() || 0 == $quote->getItemsCount()) {
            return [];
        }
        return $this->getShippingMethods($quote, $address);
    }
\Magento\Quote\Model\ShippingMethodManagement::estimateByExtendedAddress

The getShippingMethods function is a private method that returns a ShippingMethodInterface[]. This is a perfect place to create a plugin for estimateByExtendedAddress function. We will hook into the function after it returns the list and do our custom modifications to the output array.

To create the plugin we first need to define a plugin in xml.

<?xml version="1.0"?>
<!--
  ~ * @category	Techflarestudio
  ~ * @author		Wasalu Duckworth
  ~ * @copyright  Copyright (c) 2021 Techflarestudio, Ltd. 			(https://techflarestudio.com)
  ~ * @license	http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.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\ShippingMethodManagement">
        <plugin name="tfc_shipping_method_management_plugin" type="Techflarestudio\FreeShipping\Plugin\Model\ShippingMethodManagement" />
    </type>
</config>
Techflarestudio/FreeShipping/etc/di.xml

Next we will define the plugin class and the after method.

<?php
/*
 *  @category	Techflarestudio
 *  @author		Wasalu Duckworth
 *  @copyright  Copyright (c) 2021 Techflarestudio, Ltd. 			(https://techflarestudio.com)
 *  @license	http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
 *
 */

namespace Techflarestudio\FreeShipping\Plugin\Model;

/**
 * Class ShippingMethodManagement
 * @package Techflarestudio\FreeShipping\Plugin\Model
 */
class ShippingMethodManagement
{
    /**
     * This could be fetched from configuration field where you would be able to define multiple methods
     */
    const ALLOWED_METHODS = array(
        'custom_free_shipping'
    );

    /**
     * @param $shippingMethodManagement
     * @param $shippingMethods
     * @return array
     */
    public function afterEstimateByExtendedAddress($shippingMethodManagement, $shippingMethods)
    {
        /**
         * Add configuration to disable the functionality
         */
        if(true) {
            $shippingMethods = $this->getAllowedMethods($shippingMethods);
        }

        return $shippingMethods;
    }

    /**
     * Filter allowed methods if any are set
     *
     * @param $shippingMethods
     * @return array
     */
    protected function getAllowedMethods($shippingMethods)
    {
        $allowedMethods = [];

        foreach ($shippingMethods as $shippingMethod) {
            if(in_array($shippingMethod->getCarrierCode(),self::ALLOWED_METHODS)) {
                $allowedMethods[] = $shippingMethod;
            }
        }

        if($allowedMethods) {
            return $allowedMethods;
        }

        return $shippingMethods;
    }
}
\Techflarestudio\FreeShipping\Plugin\Model\ShippingMethodManagement

A few things to note here:

  • In our example we have hard-coded the allowed method code. We could easily create a system configuration field which would allow to both enable/disable this filter + allow to configure which methods are allowed to be shown when this is enabled.

That is it. Make sure to refresh the cache, generate the interceptors and you should be able to see only the custom free shipping valid on checkout.

Filtered shipping methods