← Back to blog

Magento 2 Custom Shipping Method: Build a Carrier Module

Rodrigo Biazzi

Most stores outgrow Flat Rate and Table Rates at some point. Maybe the price depends on a postcode list that changes every month, maybe it comes from a carrier API, or maybe some products must never ship with a certain method. When the built in carriers cannot express the rule, the clean answer is a custom carrier in its own module.

This guide builds one from scratch for Magento Open Source and Adobe Commerce 2.4.8. It covers the configuration files, the carrier model, how rates are collected and cached, and the details that usually cause bugs in production: free shipping from cart rules, country restrictions and slow external calls.

How Magento collects shipping rates

When the customer enters an address at checkout, or the estimate block in the cart runs, Magento builds a Magento\Quote\Model\Quote\Address\RateRequest with the destination, the package weight, the package value and the cart items. Magento\Shipping\Model\Shipping then loops over every carrier under the carriers config section that is active and asks each one for rates.

Each carrier answers with a Magento\Shipping\Model\Rate\Result holding one or more methods, or with an error, or with false when it does not apply. The rates are stored in the quote_shipping_rate table for the address, so they are not recalculated on every page load. They are collected again when the address or the cart changes.

That gives the carrier one clear job: look at the request, decide if it applies, and return prices.

Module skeleton

I will use Vendor_Shipping and the carrier code vendorship. You need the usual registration.php and etc/module.xml. Add a sequence on Magento_Shipping and Magento_Quote so your config loads after theirs. If you have not built a module before, start with building a Magento 2 module from scratch.

Default configuration in config.xml

Every carrier lives under carriers/<code>. The model node is how Magento finds your class. Without it the carrier is ignored even when it is active.

<?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>
            <vendorship>
                <active>0</active>
                <model>Vendor\Shipping\Model\Carrier\VendorShip</model>
                <title>Local Delivery</title>
                <name>Same day</name>
                <price>10.00</price>
                <sallowspecific>0</sallowspecific>
                <showmethod>0</showmethod>
                <specificerrmsg>This shipping method is not available for your address.</specificerrmsg>
                <sort_order>100</sort_order>
            </vendorship>
        </carriers>
    </default>
</config>

title is the carrier name and name is the method name. Checkout shows them together. showmethod controls whether the method is still listed, with the error message, when it does not apply.

Admin settings in system.xml

Put the group inside the existing carriers section so it appears in Stores, Configuration, Sales, Delivery Methods next to the core carriers.

<?xml version="1.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">
            <group id="vendorship" translate="label" type="text" sortOrder="900" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>Local Delivery</label>
                <field id="active" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="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="1">
                    <label>Title</label>
                </field>
                <field id="name" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Method Name</label>
                </field>
                <field id="price" translate="label" type="text" sortOrder="40" showInDefault="1" showInWebsite="1">
                    <label>Price</label>
                    <validate>validate-number validate-zero-or-greater</validate>
                </field>
                <field id="postcodes" translate="label comment" type="textarea" sortOrder="50" showInDefault="1" showInWebsite="1">
                    <label>Allowed Postcode Prefixes</label>
                    <comment>One prefix per line.</comment>
                </field>
                <field id="sallowspecific" translate="label" type="select" sortOrder="60" showInDefault="1" showInWebsite="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">
                    <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">
                    <label>Show Method if Not Applicable</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="sort_order" translate="label" type="text" sortOrder="90" showInDefault="1" showInWebsite="1">
                    <label>Sort Order</label>
                </field>
            </group>
        </section>
    </system>
</config>

Shipping config is usually scoped to the website because quotes and prices are per website. Leave store view scope only for labels.

The carrier model

The class extends Magento\Shipping\Model\Carrier\AbstractCarrier and implements Magento\Shipping\Model\Carrier\CarrierInterface. The parent gives you getConfigData() and getConfigFlag(), which read from carriers/vendorship/* in the right scope, based on $_code.

<?php
declare(strict_types=1);

namespace Vendor\Shipping\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\Result;
use Magento\Shipping\Model\Rate\ResultFactory;
use Psr\Log\LoggerInterface;

class VendorShip extends AbstractCarrier implements CarrierInterface
{
    protected $_code = 'vendorship';

    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 collectRates(RateRequest $request)
    {
        if (!$this->getConfigFlag('active')) {
            return false;
        }

        if (!$this->isPostcodeAllowed((string) $request->getDestPostcode())) {
            return false;
        }

        $price = (float) $this->getConfigData('price');
        if ($request->getFreeShipping() === true) {
            $price = 0.0;
        }

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

        /** @var Result $result */
        $result = $this->rateResultFactory->create();
        $result->append($method);

        return $result;
    }

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

    private function isPostcodeAllowed(string $postcode): bool
    {
        $postcode = strtoupper(str_replace([' ', '-'], '', $postcode));
        $prefixes = preg_split('/\R/', (string) $this->getConfigData('postcodes'), -1, PREG_SPLIT_NO_EMPTY);

        if (!$prefixes) {
            return true;
        }

        foreach ($prefixes as $prefix) {
            if (str_starts_with($postcode, strtoupper(trim($prefix)))) {
                return true;
            }
        }

        return false;
    }
}

The method code that ends up on the order is carrier_method, so here it is vendorship_vendorship. If your carrier offers several services, append one method per service with different setMethod() values and list them all in getAllowedMethods(). Other parts of Magento, like cart price rule conditions on shipping method, read that list.

After deploying, run bin/magento setup:upgrade and bin/magento cache:flush, enable the carrier in the admin and test with an address that matches your rules.

Country restrictions come for free

You do not need to check sallowspecific and specificcountry yourself. Before calling collectRates(), the shipping model calls checkAvailableShipCountries() on the carrier, which reads those two settings. If the country is not allowed it returns an error result when showmethod is on, or skips the carrier. Keep your own checks for the rules Magento does not know about, like the postcode list above.

Free shipping from cart price rules

This is the most common bug I see in custom carriers. A cart price rule with the Free Shipping action sets flags on the address and the items, and the request exposes them through getFreeShipping(). Core carriers read that flag. A custom carrier only honors it if you write the code for it.

Two cases matter:

  • "For shipment with matching items" sets free shipping on the whole address. $request->getFreeShipping() is true.
  • "For matching items only" marks individual items. If you price by weight or by item, skip those items when you calculate. Each item from $request->getAllItems() has getFreeShipping().

Also decide if your method should be affected at all. A rule that gives free standard shipping should not always make express delivery free. Merchants usually expect a setting for that, so add one to system.xml instead of hardcoding the answer.

Returning errors to the customer

Returning false hides the method silently. When customers need to know why a method is missing, return an error object instead.

$error = $this->_rateErrorFactory->create();
$error->setCarrier($this->_code);
$error->setCarrierTitle($this->getConfigData('title'));
$error->setErrorMessage($this->getConfigData('specificerrmsg'));

return $error;

Use it with care. A list of red error messages under the shipping methods looks broken to most shoppers. I prefer showing errors only when the method is something the customer explicitly expects, like store pickup for their city.

Rates from an external API

When the price comes from a carrier API, collectRates() becomes the slowest part of checkout. Some rules keep it under control:

  • Set a short timeout. A few seconds at most. If the API does not answer, return false or a fallback price from config instead of blocking the customer.
  • Cache the response. Build a key from destination, weight and value and store the response in the Magento cache for a few minutes. The same request can run several times while the customer edits the address.
  • Log failures. Use a dedicated log file so a dead API is visible. See custom loggers with Monolog.
  • Consider AbstractCarrierOnline. If you need tracking, packages and shipping labels, Magento\Shipping\Model\Carrier\AbstractCarrierOnline is the base class core online carriers use. It brings more structure and more constructor dependencies, so only use it when you need those features.

Restricting the method by product

A common request is "this product can only ship with method X". The request gives you the items, so you can check a product attribute inside collectRates() and return false if any item is not allowed. Remember that items from configurable products appear as parent and child. Check getParentItem() and decide which one holds the attribute, and load the attribute through the quote item product, which only includes attributes marked as used in the sales quote. Add the attribute to etc/catalog_attributes.xml in the quote_item group so it is loaded.

Testing

A carrier is easy to unit test because collectRates() takes a plain request object. Build a RateRequest, set destination and weight, mock the factories and assert the price. For the full flow, an integration test that creates a quote, sets an address and calls collectShippingRates() on the shipping address catches config mistakes. More on that in Magento 2 integration tests.

FAQ

Why does my custom shipping method not show at checkout?

Check that the carrier is active in the right website scope, that the model node in config.xml points to an existing class, that the country is allowed, and that collectRates does not return false for your test address. Then reload the address so rates are collected again.

Can one carrier return several shipping methods?

Yes. Append one method object per service to the result, each with its own method code, and list all of them in getAllowedMethods.

How do I make my carrier respect free shipping cart rules?

Read getFreeShipping on the request for address level free shipping and on each item for item level free shipping, and set the price to zero or skip those items in your calculation.

Should I call a carrier API inside collectRates?

You can, but use a short timeout, cache responses by destination, weight and value, and have a fallback so a slow API does not block checkout.

I am available for freelance Magento and Adobe Commerce work, including custom shipping and checkout logic. If you need help with this, get in touch.

Related articles