Skip to content
intermediate Phase 117 · Intermediate Projects

Project - Custom Shipping Method

Build a custom shipping method with rate calculation, carrier configuration, tracking, and integration testing

1h 30m
0 problems
Topic Progress 0%

Module Setup and Carrier Configuration

Module Structure

app/code/Vendor/FastShip/
├── registration.php
├── etc/
│   ├── module.xml
│   ├── di.xml
│   ├── system.xml
│   └── carrier.xml
├── Model/
│   ├── Carrier.php
│   ├── Rate\Result.php
│   ├── Rate\Result\Method.php
│   └── Source\Method.php
├── Carrier/
│   └── FastShipCarrier.php
├── Observer/
│   └── AddTrackingInfo.php
└── view/
    └── frontend/
        └── web/
            └── js/
                └── view.js

registration.php and module.xml

<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Vendor_FastShip',
    __DIR__
);
<?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="Vendor_FastShip" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Shipping"/>
            <module name="Magento_Quote"/>
        </sequence>
    </module>
</config>

System Configuration (system.xml)

<?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" translate="label" type="text" sortOrder="320" showInDefault="1" showInWebsite="1" showInStore="1">
            <group id="fastship" translate="label" type="text" sortOrder="330" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>FastShip Shipping</label>
                <field id="active" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Enabled</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="name" translate="label" type="text" sortOrder="2" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Method Name</label>
                </field>
                <field id="title" translate="label" type="text" sortOrder="3" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Title</label>
                </field>
                <field id="sallowspecific" translate="label" type="select" sortOrder="4" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Ship to Applicable Countries</label>
                    <source_model>Magento\Shipping\Model\Config\Source\Allspecificcountries</source_model>
                </field>
                <field id="specificcountry" translate="label" type="multiselect" sortOrder="5" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Ship to Specific Countries</label>
                    <source_model>Magento\Directory\Model\Config\Source\Country</source_model>
                </field>
                <field id="handling_fee" translate="label" type="text" sortOrder="6" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Handling Fee</label>
                    <validate>validate-number validate-zero-or-greater</validate>
                </field>
                <field id="free_shipping_enable" translate="label" type="select" sortOrder="7" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Free Shipping Enable</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="free_shipping_amount" translate="label" type="text" sortOrder="8" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Free Shipping Amount</label>
                    <validate>validate-number validate-zero-or-greater</validate>
                </field>
                <field id="specificerrmsg" translate="label" type="textarea" sortOrder="9" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Displayed Error Message</label>
                </field>
            </group>
        </section>
    </system>
</config>

di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Shipping\Model\CarrierFactory">
        <arguments>
            <argument name="configProviders" xsi:type="array">
                <item name="fastship" xsi:type="object">Vendor\FastShip\Model\Carrier\FastShipCarrier</item>
            </argument>
        </arguments>
    </type>
</config>

Carrier Implementation and Rate Calculation

Carrier Class

<?php
namespace Vendor\FastShip\Model\Carrier;

use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Shipping\Model\Rate\Result;
use Magento\Store\Model\Store;

class FastShipCarrier extends AbstractCarrier implements CarrierInterface
{
    protected $_code = 'fastship';
    protected $_isFixed = false;
    protected $_renderedRate = false;

    private array $methods = [];

    public function __construct(
        \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
        \Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory $rateErrorFactory,
        \Psr\Log\LoggerInterface $logger,
        array $data = [],
        private \Vendor\FastShip\Model\Source\Method $methodSource,
    ) {
        parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);
    }

    public function collectRates(RateRequest $request): Result
    {
        if (!$this->getConfigFlag('active')) {
            return false;
        }

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

        // Check free shipping
        $freeShippingAmount = (float) $this->getConfigData('free_shipping_amount');
        $freeShippingEnable = $this->getConfigFlag('free_shipping_enable');

        if ($freeShippingEnable && $request->getPackageValue() >= $freeShippingAmount) {
            $method = $this->_rateResultMethodFactory->create();
            $method->setCarrier('fastship');
            $method->setCarrierTitle($this->getConfigData('title'));
            $method->setMethod('free');
            $method->setMethodTitle('Free Shipping');
            $method->setPrice(0);
            $method->setCost(0);
            $result->append($method);
            return $result;
        }

        // Calculate rates for each method
        foreach ($this->getAvailableMethods() as $methodCode => $methodConfig) {
            $rate = $this->calculateRate($request, $methodCode, $methodConfig);
            if ($rate !== false) {
                $result->append($rate);
            }
        }

        return $result;
    }

    private function calculateRate(RateRequest $request, string $methodCode, array $config): ?\Magento\Shipping\Model\Rate\Method
    {
        $weight = $request->getPackageWeight();
        $destCountry = $request->getDestCountryId();
        $destZip = $request->getDestPostcode();

        // Rate calculation rules
        $baseRate = (float) $config['base_rate'];
        $perKgRate = (float) $config['per_kg_rate'];
        $handlingFee = (float) $this->getConfigData('handling_fee');

        // Calculate cost
        $cost = $baseRate + ($weight * $perKgRate) + $handlingFee;

        // Apply country-specific rules
        if ($destCountry === 'US') {
            $cost *= 1.0; // Domestic rate
        } else {
            $cost *= 2.5; // International rate
        }

        // Apply distance-based surcharge (simplified)
        $surcharge = $this->calculateDistanceSurcharge($destZip);
        $cost += $surcharge;

        $method = $this->_rateResultMethodFactory->create();
        $method->setCarrier('fastship');
        $method->setCarrierTitle($this->getConfigData('title'));
        $method->setMethod($methodCode);
        $method->setMethodTitle($config['title']);
        $method->setPrice($cost);
        $method->setCost($cost);

        return $method;
    }

    private function calculateDistanceSurcharge(string $destZip): float
    {
        // Simplified distance calculation
        // In production, use a geocoding service
        $originZip = '10001'; // NYC origin
        $distance = abs((int)$destZip - (int)$originZip) / 100;

        return $distance * 0.5; // $0.50 per distance unit
    }

    private function getAvailableMethods(): array
    {
        return [
            'standard' => ['title' => 'Standard (5-7 days)', 'base_rate' => 5.99, 'per_kg_rate' => 1.50],
            'express' => ['title' => 'Express (2-3 days)', 'base_rate' => 12.99, 'per_kg_rate' => 2.50],
            'overnight' => ['title' => 'Overnight', 'base_rate' => 24.99, 'per_kg_rate' => 4.00],
        ];
    }

    public function getAllowedMethods(): array
    {
        return ['standard' => 'Standard', 'express' => 'Express', 'overnight' => 'Overnight'];
    }

    public function isTrackingAvailable(): bool
    {
        return true;
    }
}

Rate Result Classes

<?php
namespace Vendor\FastShip\Model\Rate;

class Result extends \Magento\Shipping\Model\Rate\Result
{
    public function __construct(
        \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency,
        \Magento\Store\Model\StoreManagerInterface $storeManager,
    ) {
        parent::__construct($priceCurrency, $storeManager);
    }
}
<?php
namespace Vendor\FastShip\Model\Rate;

class Method extends \Magento\Shipping\Model\Rate\Method
{
    private string $trackingUrl = '';

    public function getTrackingUrl(): string
    {
        return $this->trackingUrl;
    }

    public function setTrackingUrl(string $url): self
    {
        $this->trackingUrl = $url;
        return $this;
    }
}

Tracking Integration and Observers

Tracking Data Provider

<?php
namespace Vendor\FastShip\Model\Tracking;

use Magento\Shipping\Model\Tracking\Result\Item;

class TrackingProvider implements \Magento\Shipping\Model\Tracking\ResultInterface
{
    private array $trackings = [];

    public function __construct(
        private \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
    ) {
    }

    public function fetchTrackinfo(string $trackingNumber): array
    {
        // API call to FastShip tracking service
        $trackingData = $this->callTrackingApi($trackingNumber);

        $track = [];
        if (!empty($trackingData)) {
            $trackItem = new Item();
            $trackItem->setData([
                'carrier' => 'fastship',
                'carrier_title' => 'FastShip Shipping',
                'tracking_number' => $trackingNumber,
                'status' => $trackingData['status'],
                'status_detail' => $trackingData['detail'],
                'estimated_delivery' => $trackingData['estimated_delivery'],
                'trackings' => $trackingData['history'] ?? [],
            ]);
            $this->trackings[] = $trackItem;
        }

        return $this->trackings;
    }

    private function callTrackingApi(string $trackingNumber): array
    {
        $apiUrl = $this->scopeConfig->getValue('carriers/fastship/tracking_api_url');
        $apiKey = $this->scopeConfig->getValue('carriers/fastship/tracking_api_key');

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $apiUrl . '/' . $trackingNumber,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $apiKey,
                'Content-Type: application/json',
            ],
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode === 200) {
            return json_decode($response, true);
        }

        return [];
    }
}

Observer for Shipment Creation

<?php
namespace Vendor\FastShip\Observer;

use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Magento\Sales\Model\Order\Shipment;

class AddTrackingInfo implements ObserverInterface
{
    public function __construct(
        private \Psr\Log\LoggerInterface $logger,
    ) {
    }

    public function execute(Observer $observer): void
    {
        /** @var Shipment $shipment */
        $shipment = $observer->getEvent()->getShipment();

        if ($shipment->getOrder()->getShippingMethod() !== 'fastship') {
            return;
        }

        $tracks = $shipment->getTracks();

        foreach ($tracks as $track) {
            if ($track->getCarrierCode() === 'fastship') {
                $trackingNumber = $track->getTrackNumber();

                // Send tracking info to FastShip API
                $this->registerTrackingNumber($trackingNumber, $shipment);

                // Set estimated delivery
                $estimatedDelivery = $this->calculateEstimatedDelivery();
                $track->setTitle('FastShip - Estimated: ' . $estimatedDelivery);
            }
        }
    }

    private function registerTrackingNumber(string $trackingNumber, Shipment $shipment): void
    {
        $this->logger->info('FastShip: Registered tracking ' . $trackingNumber);
        // API call to register tracking with FastShip
    }

    private function calculateEstimatedDelivery(): string
    {
        $days = 5; // Default standard
        $shippingMethod = $this->getShippingMethod();

        return match($shippingMethod) {
            'overnight' => date('Y-m-d', strtotime('+1 day')),
            'express' => date('Y-m-d', strtotime('+3 days')),
            default => date('Y-m-d', strtotime('+7 days')),
        };
    }
}

Observer Registration

<!-- etc/events.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_order_shipment_save_after">
        <observer name="fastship_add_tracking" instance="Vendor\FastShip\Observer\AddTrackingInfo"/>
    </event>
</config>

Testing and Configuration

Carrier Test

<?php
namespace Vendor\FastShip\Test\Unit\Model\Carrier;

use PHPUnit\Framework\TestCase;
use Vendor\FastShip\Model\Carrier\FastShipCarrier;

class FastShipCarrierTest extends TestCase
{
    public function testCollectRatesReturnsResult(): void
    {
        $scopeConfig = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class);
        $scopeConfig->method('isSetFlag')->with('carriers/fastship/active')->willReturn(true);
        $scopeConfig->method('getValue')->willReturnMap([
            ['carriers/fastship/handling_fee', null, null, '0'],
            ['carriers/fastship/free_shipping_amount', null, null, '100'],
        ]);

        $rateErrorFactory = $this->createMock(
            \Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory::class
        );

        $logger = $this->createMock(\Psr\Log\LoggerInterface::class);
        $resultFactory = $this->createMock(\Magento\Shipping\Model\Rate\ResultFactory::class);
        $methodFactory = $this->createMock(\Magento\Quote\Model\Quote\Address\RateResult\MethodFactory::class);

        $carrier = new FastShipCarrier(
            $scopeConfig,
            $rateErrorFactory,
            $logger,
            [],
            $this->createMock(\Vendor\FastShip\Model\Source\Method::class)
        );

        $request = $this->createMock(\Magento\Quote\Model\Quote\Address\RateRequest::class);
        $request->method('getPackageValue')->willReturn(50);

        // Test carrier returns rates
        $result = $carrier->collectRates($request);
        $this->assertNotNull($result);
    }

    public function testGetAllowedMethods(): void
    {
        $carrier = $this->createCarrier();
        $methods = $carrier->getAllowedMethods();

        $this->assertArrayHasKey('standard', $methods);
        $this->assertArrayHasKey('express', $methods);
        $this->assertArrayHasKey('overnight', $methods);
    }
}

Complete Configuration Example

<!-- etc/config.xml - Default values -->
<?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>
            <fastship>
                <active>1</active>
                <name>FastShip</name>
                <title>FastShip Shipping</title>
                <sallowspecific>0</sallowspecific>
                <handling_fee>2.50</handling_fee>
                <free_shipping_enable>1</free_shipping_enable>
                <free_shipping_amount>75</free_shipping_amount>
                <specificerrmsg>This shipping method is currently unavailable.</specificerrmsg>
                <model>Vendor\FastShip\Model\Carrier\FastShipCarrier</model>
            </fastship>
        </carriers>
    </default>
</config>

Quiz

1. What must a custom carrier class extend?

Question 1 options

2. What method calculates shipping rates?

Question 2 options

3. Where is carrier configuration defined?

Question 3 options

Flashcards

Question

What does a carrier extend?

Answer

Magento\Shipping\Model\Carrier\AbstractCarrier

Question

What is the rate calculation method?

Answer

collectRates(RateRequest $request): Result

Question

How do you add tracking?

Answer

Implement TrackingProviderInterface and register in di.xml

Question

Where is carrier config?

Answer

etc/system.xml under <section id="carriers">

Question

How do you enable free shipping?

Answer

Set free_shipping_enable and free_shipping_amount in config

Revision Notes

Key Takeaways

  • 1. Custom carriers extend AbstractCarrier and implement CarrierInterface
  • 2. collectRates() calculates rates based on package weight, destination, and rules
  • 3. system.xml defines configuration fields for admin
  • 4. Tracking integration uses observer or dedicated provider class
  • 5. Rate calculation can include base rate, per-kg rate, handling fee, and surcharges

Interview Tips

  • Explain the rate calculation flow in Magento shipping
  • Describe how to add country-specific shipping rules
  • Discuss tracking integration patterns
  • Talk about free shipping configuration and conditions

Cheat Sheet

Custom Shipping Carrier:
  Extends AbstractCarrier implements CarrierInterface
  collectRates(RateRequest) → Result
  getAllowedMethods() → array

Rate Calculation:
  base_rate + (weight × per_kg_rate) + handling_fee
  Country multiplier for international
  Distance surcharge for zones

Configuration:
  etc/system.xml → Admin config fields
  etc/di.xml → Register carrier
  etc/config.xml → Default values

Tracking:
  TrackingProvider → fetchTrackinfo()
  Observer on shipment save
  Register tracking with external API