Skip to main content
Version: 2.0.0

Building a Shipping Carrier Plugin

This guide covers everything needed to ship a new carrier integration in roughly one working day. All the routing, CP feature flags, settings persistence, and the order-edit hook are handled by the scaffold — you only implement what is specific to your carrier.

Prerequisites

  • PHP 8.2+, Craft CMS 5, yui/craft-core installed
  • A fresh Composer package (e.g. yui/craft-acme) with a src/ directory
  • The target yStore installation available locally (DDEV recommended)

Architecture overview

AbstractShippingPlugin  (craft-core)
└── Plugin.php ← your only required class
├── Settings.php ← carrier-specific settings (extends BaseShippingSettingsModel)
├── plugin/Routes.php (trait — CP and site URL rules)
└── plugin/Services.php (trait — Yii2 component wiring)

AbstractShippingPlugin itself extends AbstractNavPlugin, so CP navigation, the settings-redirect, template-root registration, and the order-edit-extra-actions hook are inherited at no cost.


Step 1 — Plugin class

Create src/Plugin.php. The minimum viable implementation requires four methods:

<?php

namespace Yui\Acme;

use Craft;
use craft\services\Plugins;
use yii\base\Event;
use yui\craft\base\collector\ShippingMethodRegistry;
use yui\craftcore\base\AbstractShippingPlugin;
use Yui\Acme\models\Settings;

class Plugin extends AbstractShippingPlugin
{
public const string PLUGIN_HANDLE = 'acme';

// --- Required: AbstractShippingPlugin contract ---

public function getLogoUrl(): string
{
return 'https://assets.yui.sk/craft-cms/images/svg/acme-logo.svg';
}

public function getCarrierDescription(): string
{
return Craft::t('acme', 'ACME next-day delivery.');
}

protected function getNavItems(): array
{
return [
['key' => 'settings', 'label' => Craft::t('acme', 'Settings'), 'url' => 'acme/settings', 'icon' => 'settings'],
];
}

protected function createSettingsModel(): Settings
{
return new Settings();
}

// --- Required: wire ShippingMethodRegistry ---

protected function registerCarrierHooks(): void
{
Event::on(Plugins::class, Plugins::EVENT_AFTER_LOAD_PLUGINS, static function (): void {
(new ShippingMethodRegistry())->registerShippingMethod(self::$plugin);
});

parent::registerCarrierHooks(); // preserves CP/site hook dispatch
}
}
Static self-reference

Assign self::$plugin = $this; in init() so the static closure in registerCarrierHooks() can reference the instance, exactly as existing carriers do.


Step 2 — Settings model

Create src/models/Settings.php. Extend BaseShippingSettingsModel (preferred) or the legacy AbstractShippingSettingsModel:

<?php

namespace Yui\Acme\models;

use yui\craftcore\models\BaseShippingSettingsModel;

class Settings extends BaseShippingSettingsModel
{
public string $name = 'ACME'; // shown in checkout and admin
public string $defaultCurrency = 'EUR';
public string $apiKey = ''; // carrier-specific field

public function rules(): array
{
$rules = parent::rules();
if ($this->enabled) {
$rules[] = [['apiKey'], 'required'];
}
return $rules;
}
}

BaseShippingSettingsModel already provides: enabled, price, instructions, maxWeight, selectedCountries, visibleFrom/visibleTo, sandboxMode, dashboardLayout, license fields, and more. Only add fields that are genuinely specific to this carrier.


Step 3 — CP routes and settings template

Add a plugin/Routes.php trait and register it from the plugin class:

<?php

namespace Yui\Acme\plugin;

use craft\events\RegisterUrlRulesEvent;
use craft\web\UrlManager;
use yii\base\Event;

trait Routes
{
protected function _registerCpRoutes(): void
{
Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_CP_URL_RULES,
function (RegisterUrlRulesEvent $event): void {
$event->rules = array_merge($event->rules, [
'acme/settings' => 'acme/settings/index',
'acme/license' => 'acme/license/index',
'acme/license/redeem' => 'acme/license/redeem',
]);
}
);
}

protected function _registerSiteRoutes(): void
{
// Add site-side routes only if the carrier needs them (e.g. pickup-point AJAX).
}
}

Then call _registerCpRoutes() from registerCarrierHooks():

protected function registerCarrierHooks(): void
{
Event::on(Plugins::class, Plugins::EVENT_AFTER_LOAD_PLUGINS, static function (): void {
(new ShippingMethodRegistry())->registerShippingMethod(self::$plugin);
});

Craft::$app->onInit(function (): void {
$request = Craft::$app->getRequest();
if (!$request->getIsConsoleRequest()) {
if ($request->getIsCpRequest()) {
$this->_registerCpRoutes();
} else {
$this->_registerSiteRoutes();
}
}
});

parent::registerCarrierHooks();
}

Create a minimal settings Twig template at src/templates/settings/index.twig. Copy the standard layout from an existing carrier (e.g. craft-gls/src/templates/settings/index.twig) and adapt the field names.


Step 4 — Extension points reference

Required

MethodWhereWhat to return
getLogoUrl()PluginAbsolute URL to the carrier SVG logo
getCarrierDescription()PluginShort translatable string for checkout / admin
getNavItems()PluginArray of [key, label, url, icon] entries
createSettingsModel()Pluginnew Settings()
registerCarrierHooks()PluginWire ShippingMethodRegistry; call parent::

Optional

MethodWhen to override
getPickupPointAction()Carrier has a pickup-point widget in checkout (DPD Pickup, Foxpost, Packeta pattern)
renderOrderEditAction()Carrier manages labels — renders the "Create shipment" button in the CP order-edit view
registerCpHooks()Additional CP-only wiring (asset bundles, field layout elements)
registerSiteHooks()Additional site-only wiring (checkout scripts)

Shipment management (optional)

Set protected bool $hasShipmentManagement = true; in Plugin and call $this->_registerShipmentCpRoutes() from registerCarrierHooks() to get a full CRUD route set (index, view, create, refresh, resync, label, delete-label, export). You must also:

  • Provide ShipmentsController extending AbstractShipmentsController
  • Implement ShipmentServiceContract (shouldSyncOrder, syncOrder, hasReachedLabelLimit) in a ShipmentService
  • Add a 'shipments' entry to getNavItems()

See craft-mpl for a complete shipment-management implementation.

Dashboard (optional)

Extend AbstractShippingDashboardService and implement getCarrierHandle() (return the plugin handle string) and getPlugin() (return the plugin instance). Wire the service as a dashboard component in Plugin::config(). The base class provides eight ready-made dashboard modules (KPIs, trend chart, status donut, destinations bar chart, recent shipments table) — no query code required.


Step 5 — composer.json

{
"name": "yui/craft-acme",
"type": "craft-plugin",
"require": {
"yui/craft-core": "^2.0"
},
"autoload": {
"psr-4": { "Yui\\Acme\\": "src/" }
},
"extra": {
"handle": "acme",
"name": "ACME Shipping",
"class": "Yui\\Acme\\Plugin"
}
}

Day-one checklist

  • Plugin class compiles, plugin installs with craft plugin/install acme
  • Settings model saves and loads from project config
  • Shipping method appears in yStore → Shipping Methods after ShippingMethodRegistry registration
  • checkRestrictions() returns true when enabled = true and no visibility window conflicts
  • CP nav renders; settings page loads without errors
  • shippingDetails() returns a non-empty array (check via Craft::dd(Plugin::getInstance()->shippingDetails()))

Troubleshooting

Shipping method does not appear in checkout Confirm registerShippingMethod() fires on EVENT_AFTER_LOAD_PLUGINS, not earlier. The registry lives in yui/craft which loads after craft-core.

Settings not saving Verify createSettingsModel() returns the correct class and all custom fields are declared as public properties on the Settings model.

CP nav missing getNavItems() must return at least one entry. The key must match a route registered in _registerCpRoutes().

Template not found AbstractShippingPlugin::init() auto-registers src/templates/ under the plugin handle. Twig paths use the handle as root: acme/settings/index.