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.







