Carrier for Craft CMS

Templating and events

craft.carrier

Everything a storefront template needs: the pickup-point picker for checkout, and shipments and tracking for order-confirmation and account pages.

Returns
shipments(order, includeReturns = false)The order's labelled shipments, oldest first. Refused, uncertain and voided ones are left out; so are returns, unless you ask
events(shipment)The shipment's tracking history, newest first
selection(order)The pickup point chosen for the order, or null
method(order)The Carrier checkout method the order is on, or null
pointMethods(order = null)Enabled methods on the order's store that deliver to a pickup point
needsPoint(order)true when the order is on a pickup-point method with no point chosen
pointPicker(order, options = {})The drop-in picker. Renders nothing unless the order is on a pickup-point method
statusLabel(status)A tracking status's label: inTransit → "In transit"
statusColor(status)A Craft status colour for it: green, blue, orange, red, light, grey

Shipments and tracking

{% set shipments = craft.carrier.shipments(order) %}

{% for shipment in shipments %}
    <section>
        <h3>{{ shipment.carrierName }} {{ shipment.serviceName }}</h3>
        <p>
            {% if shipment.trackingUrl %}
                <a href="{{ shipment.trackingUrl }}" rel="noopener">{{ shipment.trackingNumber }}</a>
            {% else %}
                {{ shipment.trackingNumber }}
            {% endif %}
            — {{ shipment.trackingStatusLabel }}
        </p>

        {% if shipment.deliveredAt %}
            <p>Delivered {{ shipment.deliveredAt|date('j M Y') }}</p>
        {% elseif shipment.estimatedDelivery %}
            <p>Expected {{ shipment.estimatedDelivery|date('j M Y') }}</p>
        {% endif %}

        <ol>
            {% for event in craft.carrier.events(shipment) %}
                <li>
                    {{ event.occurredAt ? event.occurredAt|datetime('short') }}
                    {{ event.statusLabel }}: {{ event.description }}
                    {{ event.location }}
                </li>
            {% endfor %}
        </ol>
    </section>
{% else %}
    <p>Not shipped yet.</p>
{% endfor %}

Useful shipment properties:

trackingNumberThe first parcel's number. trackingNumbers() returns one per parcel
trackingUrlThe carrier's public tracking page, or null
trackingStatusOne of the tracking statuses
trackingStatusLabelIts label
trackingStatusTextThe carrier's own latest wording
carrierName, serviceName"GLS", "GLS ParcelShop"
labelledAt, shippedAt, deliveredAt, estimatedDeliveryDates, or null
pointThe pickup point it was sent to, as a hash (name, street, city, postcode, hours…), or empty
codAmount, codCurrencyCash on delivery, if any
isReturnWhether it is a return label

Each event from events() has occurredAt (a UTC DateTime, or null), status, statusLabel, description and location.

The pickup point

{# Checkout shipping step #}
{{ craft.carrier.pointPicker(cart) }}

{# Order summary #}
{% set selection = craft.carrier.selection(cart) %}
{% if selection %}
    <p>Collect from: {{ selection.getLabel() }}</p>
    {% if selection.point.hours ?? false %}<p>{{ selection.point.hours }}</p>{% endif %}
{% endif %}

{# Payment step #}
{% if craft.carrier.needsPoint(cart) %}
    <p>Choose a pickup point before paying.</p>
{% endif %}
<button type="submit" {{ craft.carrier.needsPoint(cart) ? 'disabled' }}>Pay</button>

pointMethods() is handy for a shipping-method list that shows which options need a point:

{% set pointHandles = craft.carrier.pointMethods(cart)|map(m => m.shippingMethodHandle) %}

{% for handle, option in cart.availableShippingMethodOptions %}
    <label>
        <input type="radio" name="shippingMethodHandle" value="{{ handle }}">
        {{ option.name }} — {{ option.priceAsCurrency }}
        {% if handle in pointHandles %}<small>Collect from a locker or shop</small>{% endif %}
    </label>
{% endfor %}

A Carrier method's handle in Commerce is carrier- followed by the method's own handle.

See Pickup points for the picker's options, overriding its template and the endpoints behind it.

PHP events

All events are standard Yii events. Register them in a module's or plugin's init().

Changing the shipment request — Orders::EVENT_AFTER_BUILD_REQUEST

There is one place a Commerce order becomes a shipment request, and rates, labels, the CP preview and the console all go through it. This event fires after the request is built and before anyone uses it: the place to add a gate code, change the reference, adjust a parcel or set a carrier-specific option.

use justinholtweb\carrier\events\ShipmentRequestEvent;
use justinholtweb\carrier\services\Orders;
use yii\base\Event;

Event::on(Orders::class, Orders::EVENT_AFTER_BUILD_REQUEST, function(ShipmentRequestEvent $event) {
    $request = $event->request;

    // Print the delivery note on the label.
    $note = $event->order->getFieldValue('deliveryNote');
    if ($note) {
        $request->instructions = mb_substr($note, 0, 60);
    }

    // Our own reference format.
    $request->reference = 'WEB-' . $event->order->reference;
});

The event has order, connection, method (may be null), request and purpose — rate, label or preview — so you can change only what is bought, not what is quoted. Weights are in grams and dimensions in millimetres on the request, whatever the store's units.

Adjusting a price — Rates::EVENT_AFTER_QUOTE

Fires after a method has been priced for a cart. Change the price, or withdraw the method.

use justinholtweb\carrier\events\RateEvent;
use justinholtweb\carrier\services\Rates;
use yii\base\Event;

Event::on(Rates::class, Rates::EVENT_AFTER_QUOTE, function(RateEvent $event) {
    $quote = $event->quote;

    // No Saturday courier for wholesale customers.
    if ($event->method->handle === 'ups-saturday' && $event->order->getCustomer()?->isInGroup('wholesale')) {
        $quote->available = false;
        $quote->reason = 'Not offered to wholesale accounts.';
    }

    // Round live rates to .95.
    if ($quote->source === 'live') {
        $quote->price = floor($quote->price) + 0.95;
    }
});

The quote has available, price, carrierAmount (the carrier's own figure before markup, for live rates), transitDays, source (live, flat, table, free or fallback), reason, parcelCount and weightG. Keep listeners quick and do not call out to other services: this runs several times per cart page.

Before and after a label — Labels events

Labels::EVENT_BEFORE_PURCHASE fires after the purchase is claimed and checked, just before the carrier is called. Set isValid to false to stop it; the shipment is marked refused, and can be retried later.

use justinholtweb\carrier\events\LabelEvent;
use justinholtweb\carrier\services\Labels;
use yii\base\Event;

Event::on(Labels::class, Labels::EVENT_BEFORE_PURCHASE, function(LabelEvent $event) {
    // Never ship an order that is still under fraud review.
    if ($event->shipment->getOrder()?->getFieldValue('fraudHold')) {
        $event->isValid = false;
    }
});

Event::on(Labels::class, Labels::EVENT_AFTER_PURCHASE, function(LabelEvent $event) {
    if ($event->result?->success) {
        // Tell the warehouse system.
    }
});

Event::on(Labels::class, Labels::EVENT_AFTER_VOID, function(LabelEvent $event) {
    // $event->shipment was voided, through the API or by hand.
});

EVENT_AFTER_PURCHASE fires whatever the carrier said: check $event->result->success, or the shipment's status (labelled, failed or uncertain). The event carries shipment, request and result.

Tracking changes — Tracking::EVENT_STATUS_CHANGED

Fires when a shipment's tracking status changes — not for every poll. The event has shipment, tracking (what the carrier reported), previousStatus and newEvents.

use justinholtweb\carrier\base\TrackingStatus;
use justinholtweb\carrier\events\TrackingUpdateEvent;
use justinholtweb\carrier\services\Tracking;
use yii\base\Event;

Event::on(Tracking::class, Tracking::EVENT_STATUS_CHANGED, function(TrackingUpdateEvent $event) {
    if ($event->shipment->trackingStatus === TrackingStatus::EXCEPTION) {
        Craft::warning("Parcel {$event->shipment->trackingNumber} has a problem: {$event->shipment->trackingStatusText}", 'shipping');
    }
});

See Tracking for a customer email example.

Checkout methods — Methods events

Methods::EVENT_BEFORE_SAVE_METHOD and Methods::EVENT_AFTER_SAVE_METHOD fire around saving a checkout method. The event has method and isNew; set isValid to false before the save to stop it.

use justinholtweb\carrier\events\MethodEvent;
use justinholtweb\carrier\services\Methods;
use yii\base\Event;

Event::on(Methods::class, Methods::EVENT_BEFORE_SAVE_METHOD, function(MethodEvent $event) {
    // House rule: every live-rate method needs a fallback price.
    $method = $event->method;
    if ($method->pricingMode() === 'live' && $method->pricingValue('fallbackRate') === null) {
        $method->addError('pricing', 'Set a fallback price.');
        $event->isValid = false;
    }
});

Registering a carrier — Carriers::EVENT_REGISTER_CARRIERS

How an add-on adds its carriers. See Writing a carrier.

use craft\events\RegisterComponentTypesEvent;
use justinholtweb\carrier\services\Carriers;
use yii\base\Event;

Event::on(Carriers::class, Carriers::EVENT_REGISTER_CARRIERS, function(RegisterComponentTypesEvent $event) {
    $event->types[] = MyCarrier::class;
});