Shipping Methods

A shipping carrier is a class that returns rates for a cart and a destination, found through carriers/<code>/model in config.xml.

Syntaxpublic function collectRates(RateRequest $request)

A carrier is a class that answers one question: given this cart and this destination, what can I ship it for, and at what price. Magento asks every active carrier while collecting totals and gathers the answers into the list of shipping methods the shopper sees. Flat Rate, Table Rates and Free Shipping are carriers; so is UPS.

The one file that makes it load

The class extends Magento\Shipping\Model\Carrier\AbstractCarrier and implements Magento\Shipping\Model\Carrier\CarrierInterface. It sets $_code, returns a map of method code to label from getAllowedMethods(), and builds rates in collectRates(RateRequest $request). Magento finds it through etc/config.xml: the node carriers/<code>/model holds the class name. etc/adminhtml/system.xml only adds the admin fields.

Returning rates

collectRates() returns a Magento\Shipping\Model\Rate\Result from ResultFactory, with one Magento\Quote\Model\Quote\Address\RateResult\Method appended per option. Return false and the carrier vanishes from checkout with no message. Append an Error built from $this->_rateErrorFactory and the shopper is told why.

Restricting a method

Country restriction is already written: set sallowspecific to 1 and list ISO codes in specificcountry, and AbstractCarrier::checkAvailableShipCountries() applies them. Anything else is yours to check from the RateRequest, which carries getPackageWeight(), getPackageValue(), getDestCountryId() and getFreeShipping(). The built-in free shipping threshold is carriers/freeshipping/cutoff_cost.

Override processAdditionalValidation(); the older proccessAdditionalValidation() spelling is deprecated. Run bin/magento cache:flush config after config.xml or system.xml, and bin/magento setup:di:compile in production mode.

Example

Example · php
<?php
// app/code/Vendor/Bike/Model/Carrier/BikeCourier.php
declare(strict_types=1);

namespace Vendor\Bike\Model\Carrier;

use Magento\Framework\App\Config\ScopeConfigInterface;
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 Psr\Log\LoggerInterface;

class BikeCourier extends AbstractCarrier implements CarrierInterface
{
    protected $_code = 'bikecourier';

    protected $_isFixed = true;

    public function __construct(
        ScopeConfigInterface $scopeConfig,
        ErrorFactory $rateErrorFactory,
        LoggerInterface $logger,
        private readonly ResultFactory $rateResultFactory,
        private readonly MethodFactory $rateMethodFactory,
        array $data = []
    ) {
        parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);
    }

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

    public function collectRates(RateRequest $request)
    {
        // getConfigFlag('active') reads carriers/bikecourier/active as a boolean.
        if (!$this->getConfigFlag('active')) {
            return false;
        }

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

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

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

        return $result;
    }
}

When to use it

  • Offer a local same-day courier only for postcodes you deliver to yourself.
  • Hide a method when the cart weighs more than the courier accepts.
  • Quote a rate from a third-party API instead of a fixed price.
  • Explain to a client why their shipping method disappears for one country.

More examples

The config.xml that registers the carrier

These are the default values written into config, and they are also the full list of fields getConfigData() can read for this carrier. The model node is what makes the class load; everything else is data. Defaults in this file do not reach the database, so a field the admin never saves still returns its value here. Apply changes with bin/magento cache:flush config.

Example · xml
<!-- app/code/Vendor/Bike/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>
            <bikecourier>
                <active>0</active>
                <model>Vendor\Bike\Model\Carrier\BikeCourier</model>
                <title>Bike Courier</title>
                <name>Same day by bike</name>
                <price>6.00</price>
                <max_weight>12</max_weight>
                <sallowspecific>0</sallowspecific>
                <specificerrmsg>Sorry, we cannot deliver to that address by bike.</specificerrmsg>
                <sort_order>10</sort_order>
            </bikecourier>
        </carriers>
    </default>
</config>

The admin fields

Putting the group under section carriers places it in Stores > Configuration > Sales > Delivery Methods, next to Flat Rate. The field ids match the nodes in config.xml, which is what lets getConfigData('max_weight') read the saved value. sallowspecific and specificcountry are the names AbstractCarrier::checkAvailableShipCountries() looks for, so using those exact ids gives you country restriction without writing any code. Run bin/magento cache:flush config to see the new fields.

Example · xml
<!-- app/code/Vendor/Bike/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="bikecourier" translate="label" sortOrder="10" showInDefault="1" showInWebsite="1">
                <label>Bike Courier</label>
                <field id="active" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" canRestore="1">
                    <label>Enabled</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="price" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" canRestore="1">
                    <label>Price</label>
                    <validate>validate-number validate-zero-or-greater</validate>
                </field>
                <field id="max_weight" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" canRestore="1">
                    <label>Maximum Weight</label>
                    <validate>validate-number</validate>
                </field>
                <field id="sallowspecific" translate="label" type="select" sortOrder="40" showInDefault="1" showInWebsite="1" 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="50" showInDefault="1" showInWebsite="1" canRestore="1">
                    <label>Ship to Specific Countries</label>
                    <source_model>Magento\Directory\Model\Config\Source\Country</source_model>
                    <can_be_empty>1</can_be_empty>
                </field>
            </group>
        </section>
    </system>
</config>

Refusing the cart with a reason

There are two ways to decline a cart and they look very different to the shopper. Returning false removes the carrier with no explanation, which is right for "this method is switched off". Appending an Error keeps the carrier in the list with your message under it, which is right for "this method exists but not for this cart". Reading getFreeShipping() is how a carrier honours the free-shipping action of a cart price rule instead of ignoring it.

Example · php
<?php
// app/code/Vendor/Bike/Model/Carrier/BikeCourier.php (inside collectRates)
declare(strict_types=1);

$result = $this->rateResultFactory->create();
$maxWeight = (float) $this->getConfigData('max_weight');

if ($maxWeight > 0.0 && (float) $request->getPackageWeight() > $maxWeight) {
    $error = $this->_rateErrorFactory->create();
    $error->setCarrier($this->_code);
    $error->setCarrierTitle((string) $this->getConfigData('title'));
    $error->setErrorMessage(
        __('This cart is too heavy for bike delivery (over %1 kg).', $maxWeight)
    );
    $result->append($error);

    return $result;
}

// A cart price rule granting free shipping sets this flag on the request.
$price = $request->getFreeShipping() ? 0.0 : (float) $this->getConfigData('price');

Discussion

  • Be the first to comment on this lesson.