HowTo: Create a Shipping Method in Magento 2


A while ago I needed a flat, custom shipping method with a rate that depended on some of our own business rules. The native flat rate was not flexible enough, so I built a small custom carrier. It is one of those tasks that looks scary the first time but is actually very tidy once you know the three pieces involved.

This post assumes that there is already a custom module created in app/code/Vendor/Shipping. If you need to know how to create a custom module, look at How to create a custom module for Magento 2.

The configuration

The default values live in config.xml. The model node is the one that ties everything to our carrier class.

xml
<?xml version="1.0"?>
<!-- app/code/Vendor/Shipping/etc/config.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <carriers>
            <vendorcustom>
                <active>1</active>
                <model>Vendor\Shipping\Model\Carrier\Custom</model>
                <title>My Custom Shipping</title>
                <name>Fixed rate</name>
                <price>10.00</price>
                <sallowspecific>0</sallowspecific>
                <specificerrmsg>This shipping method is currently unavailable.</specificerrmsg>
            </vendorcustom>
        </carriers>
    </default>
</config>

And the admin fields in system.xml under Stores > Configuration > Sales > Shipping Methods.

xml
<?xml version="1.0"?>
<!-- app/code/Vendor/Shipping/etc/adminhtml/system.xml -->
<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">
            <group id="vendorcustom" translate="label" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>My Custom Shipping</label>
                <field id="active" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1">
                    <label>Enabled</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="price" translate="label" type="text" sortOrder="2" showInDefault="1" showInWebsite="1">
                    <label>Price</label>
                </field>
                <field id="title" translate="label" type="text" sortOrder="3" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Title</label>
                </field>
            </group>
        </section>
    </system>
</config>

The Carrier

This is where the interesting bit happens. The carrier extends AbstractCarrier and implements CarrierInterface. The method that matters is collectRates(), that is where you decide the price. Do not forget getAllowedMethods(), Magento needs it.

php
<?php
/** app/code/Vendor/Shipping/Model/Carrier/Custom.php */
namespace Vendor\Shipping\Model\Carrier;

use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Quote\Model\Quote\Address\RateResult\MethodFactory;
use Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
use Magento\Shipping\Model\Rate\ResultFactory;

class Custom extends AbstractCarrier implements CarrierInterface
{
    /**
     * @var string
     */
    protected $_code = 'vendorcustom';

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

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

    public function __construct(
        \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
        ErrorFactory $rateErrorFactory,
        \Psr\Log\LoggerInterface $logger,
        ResultFactory $rateResultFactory,
        MethodFactory $rateMethodFactory,
        array $data = []
    ) {
        $this->rateResultFactory = $rateResultFactory;
        $this->rateMethodFactory = $rateMethodFactory;
        parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);
    }

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

        $result = $this->rateResultFactory->create();

        $method = $this->rateMethodFactory->create();
        $method->setCarrier($this->_code);
        $method->setCarrierTitle($this->getConfigData('title'));
        $method->setMethod($this->_code);
        $method->setMethodTitle($this->getConfigData('name'));

        $price = (float) $this->getConfigData('price');
        $method->setPrice($price);
        $method->setCost($price);

        $result->append($method);

        return $result;
    }

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

Flush the cache, add something to the cart, and the new method should appear in the estimate at the checkout.

Hope this helps.

If you have any question or know a better way to achieve this, feel free to share in the comments below.