Shipments

Canvas includes a modular shipment system built around ShipmentRouter, which discovers installed shipping provider packages automatically and routes shipment operations to the correct provider based on the shippingModule field.

explanation

Core Concepts

  • ShipmentRouter — Discovers installed provider packages via Composer metadata and routes create(), cancel(), exchange(), getDeliveryOptions(), and getPickupOptions() calls to the correct provider based on the shippingModule field.
  • ShipmentInterface — The contract every provider package implements.
  • Driver — A concrete provider implementation (e.g. Quellabs\Shipments\SendCloud\Driver). Registered automatically when its package is installed.
  • shippingModule — A string identifier that selects both the provider and the shipping method, e.g. 'sendcloud_postnl' or 'myparcel_dpd'.
  • driver — A stable provider identifier (e.g. 'sendcloud') used in exchange() to reconcile missed webhooks without knowing the original module name.

Installation

Install the router and at least one provider package:

composer require quellabs/canvas-shipments
composer require quellabs/canvas-shipments-sendcloud

ShipmentRouter scans installed packages for a provider entry in their Composer metadata and registers them automatically.

Supported Providers

The following provider packages are available:

Provider Package
DHLquellabs/canvas-shipments-dhl
DPDquellabs/canvas-shipments-dpd
MyParcelquellabs/canvas-shipments-myparcel
PostNLquellabs/canvas-shipments-postnl
SendCloudquellabs/canvas-shipments-sendcloud

Creating a Shipment

Inject ShipmentInterface via Canvas DI and call create() with a ShipmentRequest:

use Quellabs\Shipments\Contracts\ShipmentInterface;
use Quellabs\Shipments\Contracts\ShipmentRequest;
use Quellabs\Shipments\Contracts\ShipmentAddress;
use Quellabs\Shipments\Contracts\ShipmentCreationException;

class OrderFulfillmentService {

    public function __construct(private ShipmentInterface $router) {}

    public function shipOrder(): void {
        $address = new ShipmentAddress(
            name:              'Jan de Vries',
            street:            'Keizersgracht',
            houseNumber:       '123',
            houseNumberSuffix: 'A',
            postalCode:        '1015 CJ',
            city:              'Amsterdam',
            country:           'NL',
            email:             'jan@example.com',
            phone:             '+31612345678',
        );

        // Fetch available methods and let the customer choose — persist $chosen->methodId on the order.
        // Required for providers like SendCloud; omit for providers that select a method automatically.
        $options = $this->router->getDeliveryOptions('sendcloud_postnl', $address);
        $chosen  = $options[0]; // whichever the customer selected at checkout

        $request = new ShipmentRequest(
            shippingModule:  'sendcloud_postnl',
            reference:       'ORDER-12345',
            deliveryAddress: $address,
            weightGrams:     1200,
            methodId:        $chosen->methodId, // DeliveryOption::$methodId — null for providers that don't require it
        );

        try {
            $result = $this->router->create($request);

            // Persist these — needed for tracking, webhooks, and cancellation
            echo $result->parcelId;     // provider-assigned parcel ID
            echo $result->trackingCode; // carrier tracking code
            echo $result->trackingUrl;  // public tracking URL for customer emails
        } catch (ShipmentCreationException $e) {
            // handle error
        }
    }
}

ShipmentAddress fields

Field Type Required Description
namestringYesFull name of the recipient
streetstringYesStreet name (without house number)
houseNumberstringYesHouse or building number
houseNumberSuffix?stringNoApartment or unit suffix, e.g. 'A' or 'bis'
postalCodestringYesPostal / ZIP code
citystringYesCity name
countrystringYesISO 3166-1 alpha-2 country code, e.g. 'NL'
email?stringNoRecipient email; used by some providers for delivery notifications
phone?stringNoRecipient phone number; used by some providers for delivery notifications

ShipmentRequest fields

Field Type Required Description
shippingModulestringYesModule identifier that selects the provider and method, e.g. 'sendcloud_postnl'
referencestringYesYour own order reference; echoed back in ShipmentState::$reference on webhook events
deliveryAddressShipmentAddressYesRecipient address
weightGramsintYesTotal parcel weight in grams
methodId?intProvider-dependentDelivery method ID from getDeliveryOptions(). Required for SendCloud; omit or pass null for providers that select a method automatically.
packageType?stringNoPhysical parcel classification hint for providers that structure their API around parcel type rather than method IDs. Omit to let the driver decide. See packageType values by provider below.
servicePointId?stringNoService point / pickup location code from getPickupOptions(). Pass when the customer chose click-and-collect.

methodId requirement by provider

methodId is optional at the contract level — pass null or omit it for providers that select a shipping method automatically. Providers that require it will throw a ShipmentCreationException if it is absent.

Provider methodId required Source
DHLNo
DPDNo
MyParcelNo
PostNLNo
SendCloudYesDeliveryOption::$methodId from getDeliveryOptions()

packageType values by provider

packageType is a physical classification hint passed to providers that structure their API around parcel type rather than method IDs. Omit it to let the driver decide.

Provider Accepted values Default when omitted
DHL'SMALL', 'MEDIUM', 'LARGE', 'XL'Weight-based auto-selection
DPD
MyParcel'parcel', 'mailbox', 'letter', 'digital_stamp''parcel'
PostNL
SendCloud

ShipmentResult fields

The ShipmentResult returned by create() contains all identifiers needed for subsequent operations. Persist parcelId, provider, and trackingCode alongside your order record.

Field Type Description
parcelIdstringProvider-assigned parcel ID. Required for cancel() and exchange().
providerstringStable driver identifier (e.g. 'sendcloud'). Pass to exchange() when reconciling missed webhooks.
trackingCode?stringCarrier tracking code; may be null until label generation.
trackingUrl?stringPublic tracking URL suitable for customer-facing emails.

Handling Webhook Events

When a shipment status changes, the provider's webhook controller emits a shipment_exchange signal carrying a ShipmentState object. Listen for it using the @ListenTo annotation on any Canvas-managed class:

use Quellabs\Canvas\Annotations\ListenTo;
use Quellabs\Shipments\Contracts\ShipmentState;
use Quellabs\Shipments\Contracts\ShipmentStatus;

class OrderService {

    /**
     * @ListenTo("shipment_exchange")
     */
    public function onShipmentExchange(ShipmentState $state): void {
        match ($state->state) {
            ShipmentStatus::InTransit       => $this->markShipped($state->reference, $state->trackingCode),
            ShipmentStatus::Delivered       => $this->markDelivered($state->reference),
            ShipmentStatus::DeliveryFailed  => $this->scheduleRetry($state->reference),
            ShipmentStatus::ReturnedToSender => $this->handleReturn($state->reference),
            ShipmentStatus::Lost            => $this->openClaim($state->reference),
            default                         => null,
        };
    }
}

Canvas wires the listener automatically. The shipment_exchange signal carries only state — database handling belongs to your application.

Reconciling Missed Webhooks

If a webhook was missed, call exchange() with the driver name stored in ShipmentResult::$provider and the provider-assigned parcel ID to fetch the current state on demand:

use Quellabs\Shipments\Contracts\ShipmentExchangeException;

try {
    $state = $this->router->exchange(
        driver:   'sendcloud',      // ShipmentResult::$provider
        parcelId: 'SC-123456789',   // ShipmentResult::$parcelId
    );

    echo $state->state->name;       // e.g. 'Delivered'
    echo $state->trackingCode;
} catch (ShipmentExchangeException $e) {
    // handle error
}

Cancelling a Shipment

Call cancel() before the parcel has been handed to the carrier. Not all providers support cancellation after label generation — check CancelResult::$accepted and CancelResult::$message to determine the outcome:

use Quellabs\Shipments\Contracts\CancelRequest;
use Quellabs\Shipments\Contracts\ShipmentCancellationException;

try {
    $result = $this->router->cancel(new CancelRequest(
        shippingModule: 'sendcloud_postnl',
        parcelId:       'SC-123456789',
        reference:      'ORDER-12345',
    ));

    if ($result->accepted) {
        // parcel successfully cancelled
    } else {
        echo $result->message; // e.g. 'Parcel already in transit'
    }
} catch (ShipmentCancellationException $e) {
    // handle error
}

Delivery Options

Fetch available home delivery methods for a given module to present to the customer at checkout. Pass a ShipmentAddress for providers that compute delivery windows per recipient location (e.g. MyParcel). Providers that do not require an address silently ignore it:

use Quellabs\Shipments\Contracts\ShipmentAddress;

$options = $this->router->getDeliveryOptions('myparcel_postnl', $deliveryAddress);

foreach ($options as $option) {
    echo $option->label;         // 'Tomorrow 09:00–12:00'
    echo $option->carrierName;   // 'PostNL'
    echo $option->methodId;      // pass this as ShipmentRequest::$methodId
}

Store the chosen methodId on the order and pass it in ShipmentRequest::$methodId when creating the shipment.

Pickup Points

Fetch nearby service points for click-and-collect. The address is used as the search origin — providers return points sorted by proximity:

$points = $this->router->getPickupOptions('sendcloud_postnl', $deliveryAddress);

foreach ($points as $point) {
    echo $point->name;           // 'Albert Heijn Keizersgracht'
    echo $point->street . ' ' . $point->houseNumber;
    echo $point->distanceMetres; // distance from the queried address
    echo $point->locationCode;   // pass this as ShipmentRequest::$servicePointId
}

Pass the chosen locationCode as ShipmentRequest::$servicePointId when creating the shipment.

Box Packing

ShipmentRouter exposes a pack() method that calculates the optimal box assignment for a set of items before creating a shipment. Box sizes and weight limits are configured in config/shipment_packing.php. All dimensions are in millimetres, all weights in grams:

use Quellabs\Shipments\Packing\PackableItem;

$result = $this->router->pack([
    new PackableItem('Widget A', width: 100, length: 80,  depth: 60,  weight: 250),
    new PackableItem('Widget B', width: 200, length: 150, depth: 100, weight: 800),
    new PackableItem('Widget C', width: 90,  length: 90,  depth: 90,  weight: 400),
]);

if ($result->hasUnpackedItems()) {
    // one or more items exceed the dimensions or weight limit of every box in the catalog
}

foreach ($result->getPackedBoxes() as $packed) {
    echo $packed->getBox()->getReference(); // 'small', 'medium', 'large'
    echo $packed->getGrossWeight();         // box tare weight + all item weights in grams

    $shipmentRequest = new ShipmentRequest(
        shippingModule: 'sendcloud_postnl',
        reference:      'ORDER-12345',
        deliveryAddress: $address,
        weightGrams:    $packed->getGrossWeight(),
        methodId:       8,
    );
}

Weight is balanced across multiple boxes of the same size where possible, within the configurable per-box weight ceiling. Always check hasUnpackedItems() before proceeding — unpacked items require manual intervention.

Shipment State Reference

ShipmentState is emitted via the shipment_exchange signal on every webhook hit and every exchange() call.

Property Type Description
providerstringDriver identifier, e.g. 'sendcloud'. Pass to exchange() for polling.
parcelIdstringProvider-assigned parcel ID
referencestringYour own reference echoed back from ShipmentRequest::$reference
stateShipmentStatusCurrent normalised shipment status
trackingCode?stringCarrier tracking code; may be null until label generation
trackingUrl?stringPublic tracking URL for customer-facing emails
statusMessage?stringHuman-readable status from the provider, e.g. 'Delivered at front door'
internalStatestringRaw status code from the provider, preserved for logging
metadataarrayProvider-specific data not covered by typed fields (e.g. carrierId, labelUrl)

Shipment Statuses

Status Description
ShipmentStatus::CreatedParcel record created at provider; label not yet printed
ShipmentStatus::ReadyToSendLabel generated; awaiting carrier pickup or drop-off
ShipmentStatus::InTransitCarrier has scanned and accepted the parcel
ShipmentStatus::OutForDeliveryParcel is out for final delivery
ShipmentStatus::DeliveredParcel successfully delivered to the recipient
ShipmentStatus::DeliveryFailedDelivery failed (recipient absent, address issues). Carrier will retry.
ShipmentStatus::AwaitingPickupParcel held at a post office or service point for recipient pickup
ShipmentStatus::ReturnedToSenderParcel returned after failed delivery attempts or explicit return request
ShipmentStatus::CancelledParcel cancelled before handover to the carrier
ShipmentStatus::LostParcel confirmed lost by the carrier; a claim process may apply
ShipmentStatus::DestroyedParcel destroyed by carrier or customs (damaged, prohibited contents)
ShipmentStatus::UnknownUnrecognised status from the provider; see ShipmentState::$internalState

Discovering Registered Modules

Retrieve all shipping module identifiers currently registered across all installed providers:

$modules = $this->router->getRegisteredModules();
// ['sendcloud_postnl', 'sendcloud_dhl', 'myparcel_postnl', ...]

Adding a Provider Package

Any package can register itself as a shipping provider by declaring a provider entry in its composer.json:

"extra": {
    "discover": {
        "shipments": {
            "provider": "Quellabs\\Shipments\\SendCloud\\Driver",
            "config": "/config/sendcloud.php"
        }
    }
}

The declared class must implement ShipmentProviderInterface. ShipmentRouter validates this at discovery time and silently skips any class that does not. If two installed packages declare the same module identifier, a RuntimeException is thrown at boot.