How to create a custom shipping method in Magento 2


While developing Magento web stores, I very often encounter requests for building a custom shipping method for customers, tailored specially for their needs and situations.

One request referred to a simple shipping method that would update price according to cart content, weight of products or user’s country or state.

Yet another request required a very complex extension that would communicate with 3rd party API’s, on both the front and back end of the application, while offering different options to users and calculating shipping price live.

I have decided to write about this topic as custom shipping method is part of most projects. For now, I will start with a single post describing the creation of a very simple custom shipping method which may evolve into a series of posts covering more complex situations and requests.

Having said that, let’s get our hands dirty and start writing some code!

The best way to begin is to start building our own extension. I am going to name my extension Example_Shippingmethod, so we need to create the following directory structure: app/code/Example/Shippingmethod. Create directory “Example” and then directory “Shippingmethod”.

At this point, you directory structure should look something like this:
Dir structure

After this, we will place all further directories and files in the “Shippingmethod” directory. Creating registration.php, module.xml and configuration.xml is absolutely necessary in order for our extension to work properly.

After creating all files and directories needed for this extension, your project should look something like this:
File structure full

We need to register our extension in the Magento system, so Registration.php file should have the following code in it:

<?php
\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Example_Shippingmethod',
    __DIR__
);

It’s time to declare our module in the Module.xml file:

<?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="Example_Shippingmethod" setup_version="1.0.0">
    </module>
</config>

Now that our module is registered and declared, we can proceed with the actual content of the module.

Since this is a very simple showcase module, we can create a simple config file with some basic nodes in it:

<?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>
        <carriers>
            <exampleshippingmethod>
                <active>1</active>
                <allowed_methods>delivery</allowed_methods>
                <methods>delivery</methods>
                <sallowspecific>0</sallowspecific>
                <price>10</price>
                <model>Example\Shippingmethod\Model\Carrier</model>
                <name>24h delivery</name>
                <title>Express Shipping Ltd</title>
                <specificerrmsg>This shipping method is not available.</specificerrmsg>
            </exampleshippingmethod>
        </carriers>
    </default>
</config>

Once the config.xml file has been created, we can start thinking about the logic of our module.

We have several possibilities for calculating price so let’s cover some of the most important ones.

In the first example, we are simply using the price we have hardcoded in the config file (look at lines 42 and 44):

<?php 
namespace Example\Shippingmethod\Model; 
use Magento\Framework\App\Config\ScopeConfigInterface; 
use Magento\Framework\DataObject; 
use Magento\Shipping\Model\Carrier\AbstractCarrier; 
use Magento\Shipping\Model\Carrier\CarrierInterface; 
use Magento\Shipping\Model\Rate\ResultFactory; 
use Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory; 
use Magento\Quote\Model\Quote\Address\RateResult\MethodFactory; 
use Magento\Quote\Model\Quote\Address\RateRequest; 
use Psr\Log\LoggerInterface; 

class Carrier extends AbstractCarrier implements CarrierInterface 
{ 
    protected $_code = 'exampleshippingmethod'; 
    protected $_rateResultFactory; 
    protected $_rateMethodFactory; 
    public function __construct( 
    ResultFactory $rateResultFactory, 
    LoggerInterface $logger, 
    MethodFactory $rateMethodFactory, 
    ScopeConfigInterface $scopeConfig, 
    ErrorFactory $rateErrorFactory, 
    array $data = [] ) 
    {       
        $this->_rateMethodFactory = $rateMethodFactory;
        $this->_rateResultFactory = $rateResultFactory;
        parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);
    }
 
    public function collectRates(RateRequest $request)
    {
        $result = $this->_rateResultFactory->create();
        $shippingMethod = $this->_rateMethodFactory->create();
 
        $shippingMethod->setCarrier($this->getCarrierCode());
        $shippingMethod->setMethod($this->getCarrierCode());
 
        $shippingMethod->setCarrierTitle($this->getConfigData('title'));
        $shippingMethod->setMethodTitle($this->getConfigData('name'));
 
        $shippingPrice = $this->getConfigData('price');
 
        $shippingMethod->setPrice($shippingPrice);
        $result->append($shippingMethod);
 
        return $result;
    }
 
    public function getAllowedMethods()
    {
    }
}

One of the options is to use hardcoded price that will always be the same, for all scenarios. More often than not, this is simply not enough.
Although prices may be fixed, they will be dynamically updated depending on various data entered by the user.

For example, we may have specific fixed prices for different countries, states or even ZIP codes.

Let’s imagine that our shipping method has a specific price for New York deliveries and the price doubled if the package weighs over 40 lbs. Or perhaps it doesn’t show up at all if the selected country is not the US.
This is how our collectRates method looks now:

public function collectRates(RateRequest $request)
{
    $result = $this->_rateResultFactory->create();
    $shippingMethod = $this->_rateMethodFactory->create();
 
    $shippingMethod->setCarrier($this->getCarrierCode());
    $shippingMethod->setMethod($this->getCarrierCode());
 
    $shippingMethod->setCarrierTitle($this->getConfigData('title'));
    $shippingMethod->setMethodTitle($this->getConfigData('name'));
 
    $shippingPrice = $this->calculateShippingPrice($request);
 
    if (!$shippingPrice || !isset($shippingPrice)) {
        return false;
    }
 
    $shippingMethod->setPrice($shippingPrice);
    $result->append($shippingMethod);
 
    return $result;
}
 
public function calculateShippingPrice($request)
{
    $basePrice = 10;
    $shippingCountry = $request->getDestCountryId();
    $shippingState = $request->getDestRegionCode();
    $deliveryWeight = $request->getPackageWeight();
 
    if ($shippingCountry != "US") {
        return false;
    }
 
    if ($shippingState == 'NY') {
        $basePrice += 7;
    }
 
    if ($deliveryWeight > 40) {
        $basePrice *= 2;
    }
 
    return $basePrice;
}

If you examine this piece of code, you will notice that I added a new method to our class. I decided it would be a good idea to separate the price calculation into a new method and avoid polluting the collectRates method with too many lines of different calculations.
I named the new method calculateShippingPrice and passed it an instance of RateRequest class. This class gives us access to a really wonderful set of methods which we can use to calculate our price. I highly recommend you have a look at the RateRequest class (path to it /vendor/magento/module-quote/Model/Quote/Address/RateRequest.php) and see all that is available to us.
After this, it’s easy to achieve our goal and change the price on the checkout page, according to our rules, as users fill out the form.

RateRequest class gives us access to a really wonderful set of methods which we can use to calculate our price.

This is really convenient if we choose conditions for the price of delivery, but what if we want to use a 3rd party delivery service and we want to integrate our Magento 2 web store into it?

Let’s say this delivery service has its own REST API set up, and we need to provide it with some required data to get an already fully calculated price in return.
I’ll use https://jsonplaceholder.typicode.com/ for this example, and pretend that its giving me the price instead of some random data:

    public function collectRates(RateRequest $request)
    {
        $result = $this->_rateResultFactory->create();
        $shippingMethod = $this->_rateMethodFactory->create();
 
        $shippingMethod->setCarrier($this->getCarrierCode());
        $shippingMethod->setMethod($this->getCarrierCode());
 
        $shippingMethod->setCarrierTitle($this->getConfigData('title'));
        $shippingMethod->setMethodTitle($this->getConfigData('name'));
 
//        $shippingPrice = $this->calculateShippingPrice($request);
 
        $shippingPrice = $this->fetchPrice($request);
 
        if (!$shippingPrice || !isset($shippingPrice)) {
            return false;
        }
 
        $shippingMethod->setPrice($shippingPrice);
        $result->append($shippingMethod);
 
        return $result;
    }
 
    public function fetchPrice($request)
    {
        $shippingCountry = $request->getDestCountryId();
        $shippingState = $request->getDestRegionCode();
        $deliveryWeight = $request->getPackageWeight();
        $deliveryCity = $request->getDestCity();
        $deliveryStreet = $request->getDestStreet();
 
        $post = [
            'country' => $shippingCountry,
            'state' => $shippingState,
            'weight' => $deliveryWeight,
            'city' => $deliveryCity,
            'street' => $deliveryStreet
        ];
 
        $ch = curl_init('https://jsonplaceholder.typicode.com/posts');
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
 
        $response = curl_exec($ch);
        curl_close($ch);
 
        $price = json_decode($response);
        return $price->id;
    }

I have created a new method again, and used curl to communicate with the 3rd party REST API. Using the methods from the RateRequest class, I was able to get the required data and pass it via a POST http request.
As soon as I got a response, I parsed it and  presented the price I got to the user.

With this, we have covered some basics to building a custom shipping method in Magento 2. This is a good starting point for creating our own extension and adding what we need to achieve our goal.

In my next posts, I might continue to extend this module and add the front-end part, or I might explore ways of transforming it to a full Collect in Store module.

+ There are no comments

Add yours