Custom Magento 2 Shipping Plugin Development

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Developing a Custom Shipping Plugin for Magento 2

Magento 2 has a well-developed shipping system — Shipping Carriers, Rates, Carriers Facade. But built-in methods (UPS, FedEx, DHL) are configured for the Western market. To work with local carriers, non-standard rate logic, or integration with an internal WMS, you need a custom carrier module.

Shipping Carrier Architecture in Magento 2

The module is built according to standard Magento 2 structure:

app/code/Vendor/MyCourier/
├── etc/
│   ├── module.xml
│   ├── config.xml          # default config values
│   ├── adminhtml/
│   │   └── system.xml      # settings form in Admin > Stores > Config
│   └── frontend/
│       └── routes.xml      # if a frontend controller is needed
├── Model/
│   └── Carrier/
│       └── MyCourier.php   # main class
├── registration.php
└── composer.json

The central class extends \Magento\Shipping\Model\Carrier\AbstractCarrier:

<?php
// Model/Carrier/MyCourier.php

namespace Vendor\MyCourier\Model\Carrier;

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

class MyCourier extends AbstractCarrier implements CarrierInterface {

    protected $_code = 'mycourier';

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

        /** @var Result $result */
        $result = $this->_rateResultFactory->create();

        $rates = $this->fetchRatesFromApi( $request );

        foreach ( $rates as $rateData ) {
            /** @var \Magento\Quote\Model\Quote\Address\RateResult\Method $method */
            $method = $this->_rateMethodFactory->create();
            $method->setCarrier( $this->_code );
            $method->setCarrierTitle( $this->getConfigData( 'title' ) );
            $method->setMethod( $rateData['code'] );
            $method->setMethodTitle( $rateData['name'] );
            $method->setPrice( $rateData['price'] );
            $method->setCost( $rateData['price'] );
            $result->append( $method );
        }

        return $result;
    }

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

Rate Calculation: API Request

To work with external APIs, use \Magento\Framework\HTTP\Client\Curl — Magento 2's standard HTTP client, requires no additional dependencies:

private function fetchRatesFromApi( RateRequest $request ): array {
    $apiKey   = $this->getConfigData( 'api_key' );
    $fromCity = $this->getConfigData( 'from_city' );
    $toCity   = $request->getDestCity();
    $postcode = $request->getDestPostcode();

    $weight = 0;
    foreach ( $request->getAllItems() as $item ) {
        if ( $item->getParentItem() ) {
            continue; // skip configurable parent
        }
        $weight += $item->getWeight() * $item->getQty();
    }

    $payload = json_encode([
        'from'     => $fromCity,
        'to_city'  => $toCity,
        'postcode' => $postcode,
        'weight'   => max( 0.1, $weight ),
        'currency' => $request->getPackageCurrency()->getCurrencyCode(),
    ]);

    $this->_curl->addHeader( 'Authorization', 'Bearer ' . $apiKey );
    $this->_curl->addHeader( 'Content-Type', 'application/json' );
    $this->_curl->setTimeout( 10 );

    try {
        $this->_curl->post( 'https://api.mycourier.ru/v2/rates', $payload );
        $body   = $this->_curl->getBody();
        $status = $this->_curl->getStatus();
    } catch ( \Exception $e ) {
        $this->_logger->error( 'MyCourier API error: ' . $e->getMessage() );
        return [];
    }

    if ( $status !== 200 ) {
        return [];
    }

    $data = json_decode( $body, true );
    return $data['services'] ?? [];
}

DI in constructor:

public function __construct(
    \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
    \Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory $rateErrorFactory,
    \Psr\Log\LoggerInterface $logger,
    \Magento\Shipping\Model\Rate\ResultFactory $rateResultFactory,
    \Magento\Quote\Model\Quote\Address\RateResult\MethodFactory $rateMethodFactory,
    \Magento\Framework\HTTP\Client\Curl $curl,
    array $data = []
) {
    $this->_curl = $curl;
    parent::__construct( $scopeConfig, $rateErrorFactory, $logger, $rateResultFactory, $rateMethodFactory, $data );
}

Module Configuration

etc/config.xml sets defaults — without this file config fields return null:

<?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>
            <mycourier>
                <active>0</active>
                <title>MyCourier</title>
                <from_city>Moscow</from_city>
                <sallowspecific>0</sallowspecific>
                <sort_order>10</sort_order>
            </mycourier>
        </carriers>
    </default>
</config>

etc/adminhtml/system.xml adds a section to Stores > Configuration > Sales > Shipping Methods:

<section id="carriers">
    <group id="mycourier" translate="label" sortOrder="100" showInDefault="1" showInWebsite="1" showInStore="1">
        <label>MyCourier</label>
        <field id="active" translate="label" type="select" sortOrder="1" showInDefault="1">
            <label>Enabled</label>
            <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
        </field>
        <field id="api_key" translate="label" type="text" sortOrder="20" showInDefault="1">
            <label>API Key</label>
            <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
        </field>
        <field id="from_city" translate="label" type="text" sortOrder="30" showInDefault="1">
            <label>From City</label>
        </field>
    </group>
</section>

Use the Encrypted backend for the API key — it encrypts in the database via \Magento\Framework\Encryption\EncryptorInterface.

Observer: Create Shipment After Payment

<?php
// Observer/CreateShipment.php

namespace Vendor\MyCourier\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order\ShipmentFactory;

class CreateShipment implements ObserverInterface {

    public function execute( Observer $observer ): void {
        /** @var \Magento\Sales\Model\Order $order */
        $order = $observer->getEvent()->getOrder();

        if ( ! $order->canShip() ) {
            return;
        }
        if ( strpos( $order->getShippingMethod(), 'mycourier' ) === false ) {
            return;
        }

        $trackingNumber = $this->courierApi->createShipment( $order );
        if ( ! $trackingNumber ) {
            return;
        }

        // Create shipment in Magento
        $shipment = $this->shipmentFactory->create(
            $order,
            $this->prepareItems( $order ),
            [ [
                'carrier_code'  => 'mycourier',
                'title'         => 'MyCourier',
                'number'        => $trackingNumber,
            ] ]
        );
        $shipment->register();
        $shipment->getOrder()->setIsInProcess( true );

        $this->transaction
            ->addObject( $shipment )
            ->addObject( $shipment->getOrder() )
            ->save();
    }
}

Register observer in etc/events.xml:

<config>
    <event name="sales_order_invoice_pay">
        <observer name="mycourier_create_shipment"
                  instance="Vendor\MyCourier\Observer\CreateShipment"/>
    </event>
</config>

Plugin for Checkout: Pickup Points

Magento 2 allows embedding a custom UI component in checkout via checkout_index_index.xml. Add a field for selecting a pickup point after choosing the shipping method:

<!-- view/frontend/layout/checkout_index_index.xml -->
<referenceBlock name="checkout.root">
    <arguments>
        <argument name="jsLayout" xsi:type="array">
            <item name="components" xsi:type="array">
                <item name="checkout" xsi:type="array">
                    <item name="children" xsi:type="array">
                        <item name="steps" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="shipping-step" xsi:type="array">
                                    <item name="children" xsi:type="array">
                                        <item name="shippingAddress" xsi:type="array">
                                            <item name="children" xsi:type="array">
                                                <item name="mycourier-pvz" xsi:type="array">
                                                    <item name="component" xsi:type="string">
                                                        Vendor_MyCourier/js/pvz-selector
                                                    </item>
                                                    <item name="sortOrder" xsi:type="string">200</item>
                                                </item>
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </item>
            </item>
        </argument>
    </arguments>
</referenceBlock>

Caching via Magento Cache

For rate calculation, it's important to use Magento cache, not static variables:

use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;

private function getCachedRates( string $cacheKey ): ?array {
    $cached = $this->cache->load( $cacheKey );
    if ( $cached ) {
        return $this->serializer->unserialize( $cached );
    }
    return null;
}

private function saveCachedRates( string $cacheKey, array $rates ): void {
    $this->cache->save(
        $this->serializer->serialize( $rates ),
        $cacheKey,
        [ 'mycourier_rates' ],
        1800
    );
}

The mycourier_rates tag allows invalidating all rate cache with the command:

bin/magento cache:clean mycourier_rates

Installation and Deployment

# Copy module
cp -r Vendor/MyCourier app/code/Vendor/MyCourier

# Enable
bin/magento module:enable Vendor_MyCourier
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento setup:static-content:deploy ru_RU en_US -f

# For production
bin/magento deploy:mode:set production

Important: after any DI change (constructors, plugins, observers), setup:di:compile is required. Without it, changes won't be picked up due to code generation caching.

Testing

Unit test for collectRates:

public function testCollectsRatesWhenApiReturnsData(): void {
    $this->curlMock->method( 'getStatus' )->willReturn( 200 );
    $this->curlMock->method( 'getBody' )->willReturn( json_encode([
        'services' => [
            [ 'code' => 'standard', 'name' => 'Standard', 'price' => 350.0 ],
        ]
    ]));

    $result = $this->carrier->collectRates( $this->createRateRequest() );

    $this->assertInstanceOf( Result::class, $result );
    $rates = $result->getAllRates();
    $this->assertCount( 1, $rates );
    $this->assertEquals( 350.0, $rates[0]->getPrice() );
}

Implementation Timeline

Basic carrier with rate calculation via API and admin configuration: 3–4 days. Adding observer for shipment creation, tracking, and notifications: plus 2–3 days. UI component for pickup point selection in checkout with custom address fields: plus 2–3 days. Integration with Magento MSI (multi-source inventory) for inventory tracking across warehouses: plus 3–5 days.