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.
Core Concepts
- ShipmentRouter — Discovers installed provider packages via Composer metadata and routes
create(),cancel(),exchange(),getDeliveryOptions(), andgetPickupOptions()calls to the correct provider based on theshippingModulefield. - 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 inexchange()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 |
|---|---|
| DHL | quellabs/canvas-shipments-dhl |
| DPD | quellabs/canvas-shipments-dpd |
| MyParcel | quellabs/canvas-shipments-myparcel |
| PostNL | quellabs/canvas-shipments-postnl |
| SendCloud | quellabs/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 |
|---|---|---|---|
name | string | Yes | Full name of the recipient |
street | string | Yes | Street name (without house number) |
houseNumber | string | Yes | House or building number |
houseNumberSuffix | ?string | No | Apartment or unit suffix, e.g. 'A' or 'bis' |
postalCode | string | Yes | Postal / ZIP code |
city | string | Yes | City name |
country | string | Yes | ISO 3166-1 alpha-2 country code, e.g. 'NL' |
email | ?string | No | Recipient email; used by some providers for delivery notifications |
phone | ?string | No | Recipient phone number; used by some providers for delivery notifications |
ShipmentRequest fields
| Field | Type | Required | Description |
|---|---|---|---|
shippingModule | string | Yes | Module identifier that selects the provider and method, e.g. 'sendcloud_postnl' |
reference | string | Yes | Your own order reference; echoed back in ShipmentState::$reference on webhook events |
deliveryAddress | ShipmentAddress | Yes | Recipient address |
weightGrams | int | Yes | Total parcel weight in grams |
methodId | ?int | Provider-dependent | Delivery method ID from getDeliveryOptions(). Required for SendCloud; omit or pass null for providers that select a method automatically. |
packageType | ?string | No | Physical 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 | ?string | No | Service 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 |
|---|---|---|
| DHL | No | — |
| DPD | No | — |
| MyParcel | No | — |
| PostNL | No | — |
| SendCloud | Yes | DeliveryOption::$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 |
|---|---|---|
parcelId | string | Provider-assigned parcel ID. Required for cancel() and exchange(). |
provider | string | Stable driver identifier (e.g. 'sendcloud'). Pass to exchange() when reconciling missed webhooks. |
trackingCode | ?string | Carrier tracking code; may be null until label generation. |
trackingUrl | ?string | Public 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 |
|---|---|---|
provider | string | Driver identifier, e.g. 'sendcloud'. Pass to exchange() for polling. |
parcelId | string | Provider-assigned parcel ID |
reference | string | Your own reference echoed back from ShipmentRequest::$reference |
state | ShipmentStatus | Current normalised shipment status |
trackingCode | ?string | Carrier tracking code; may be null until label generation |
trackingUrl | ?string | Public tracking URL for customer-facing emails |
statusMessage | ?string | Human-readable status from the provider, e.g. 'Delivered at front door' |
internalState | string | Raw status code from the provider, preserved for logging |
metadata | array | Provider-specific data not covered by typed fields (e.g. carrierId, labelUrl) |
Shipment Statuses
| Status | Description |
|---|---|
ShipmentStatus::Created | Parcel record created at provider; label not yet printed |
ShipmentStatus::ReadyToSend | Label generated; awaiting carrier pickup or drop-off |
ShipmentStatus::InTransit | Carrier has scanned and accepted the parcel |
ShipmentStatus::OutForDelivery | Parcel is out for final delivery |
ShipmentStatus::Delivered | Parcel successfully delivered to the recipient |
ShipmentStatus::DeliveryFailed | Delivery failed (recipient absent, address issues). Carrier will retry. |
ShipmentStatus::AwaitingPickup | Parcel held at a post office or service point for recipient pickup |
ShipmentStatus::ReturnedToSender | Parcel returned after failed delivery attempts or explicit return request |
ShipmentStatus::Cancelled | Parcel cancelled before handover to the carrier |
ShipmentStatus::Lost | Parcel confirmed lost by the carrier; a claim process may apply |
ShipmentStatus::Destroyed | Parcel destroyed by carrier or customs (damaged, prohibited contents) |
ShipmentStatus::Unknown | Unrecognised 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.