Architecture reference
How the pieces fit together. Audience: developers extending or integrating with the plugin.
Data model
Shipment, a Craft element. Owns a grouped allocation of order line-item quantities plus fulfillment fields (tracking, carrier, service, scheduled ship date, notes, free-text fields via a plugin-wide field layout), and a singlestatuscolumn. Disabling is admin-driven only (the Enabled lightswitch on the shipment edit page); the plugin never disables shipments on its own, and the audit for a manual disable lives in Craft’s element revision log.shipments_shipment_line_items, join rows between a shipment and Commerce line items, carrying the per-line-item quantity the shipment covers.shipments_shipment_status_history, audit log. EveryapplyTransitionwrites one row withfromCode,toCode,userId,sourceIntegrationId,sourceExternalCode.shipments_integrations, named external systems (an ERP, a warehouse platform, etc.). Each integration wraps aProvidersubclass with settings.shipments_integration_references, per-shipment rows mapping(shipmentId, integrationId) -> externalId + optional url override.shipments_integration_status_maps, per-integration translation table between the integration’s vocabulary and ourStatusenum. Rows havedirection(inbound/outbound/bidirectional),externalCode,externalLabel,internalCode.shipments_transition_emails, bindings fromtoCodeto notification emails.shipments_emails, notification-email definitions (project-config backed).shipments_tracked_orders, which completed orders the plugin is actively watching for fulfillment, plus each order’s cachedshippableverdict,state(active or ignored), andunderAllocatedflag. The Attention page joins this table; orders without a row are invisible to it.
Status model
One fixed-vocabulary status stored as a string code:
| Enum | Driven by | Values |
|---|---|---|
Status |
Merchant / 3PL, or an integration | new, in_progress, on_hold, fulfilled, shipped, cancelled |
The vocabulary is fixed; integration-specific external codes map in via IntegrationStatusMaps. A status carries no built-in behavior except shipped: reaching it advances the shipment’s Commerce order (see below). See status vocabulary for the meaning of each case.
Write paths, all route through one service
Every shipment status change, CP click, webhook ingestor, REST call, ends up here:
Shipments::applyTransition(
Shipment $shipment,
Status $to,
?User $user = null,
?string $message = null,
?Integration $source = null,
?string $externalCode = null,
): ?Shipment
Inside that method, in order:
- Acquire a per-shipment mutex:
shipments:shipment:{id}:transition. Serializes concurrent transitions on the same shipment. - Re-read the canonical state under the lock.
- Open a DB transaction.
- Write
statusand save viaCraft::$app->getElements()->saveElement($shipment). No field is required to reach any status. ThedateShippedtimestamp is derived on read from the history rows (Shipment::getDateShipped()), not stored. - Insert a
ShipmentStatusHistoryrecord with the source integration and external code. - Fire
EVENT_SHIPMENT_STATUS_CHANGEDinside the transaction. Listeners that push queue jobs (TransitionEmails, the order-advance hook) ride the same DB connection; Craft’s default queue is DB-backed, so the job push is atomic with the status write. - Commit. Release the mutex.
Same pattern for Shipments::createFromStagingPost (per-order mutex instead; pool validation under the lock).
Order advance on shipped
Plugin::advanceOrderStatusOnShipped listens on EVENT_SHIPMENT_STATUS_CHANGED. On the edge into shipped (toCode->advancesOrder() true and fromCode not already advancing), it pushes an AdvanceOrderStatusJob for the order. The job moves the Commerce order to the handle configured in Settings::$autoAdvanceOrderStatusHandle. One-way; empty setting disables it.
Services
| Service | Responsibility |
|---|---|
Shipments |
Lifecycle orchestrator. applyTransition, createFromStagingPost, saveManual, applyUpdate, history hydration, export. |
ShipmentLineItems |
Allocation math. remainingPoolFor, overflowIfCounted, isOrderUnderAllocated, findUnderAllocatedOrderIds. |
ShipmentReferences |
{orderRef}-sNNN allocation with collision retry. |
Rules |
Rules-engine registry + planFor orchestration. |
IntegrationStatusMaps |
CRUD for mappings. resolveInbound / resolveOutbound. |
Integrations |
Integration CRUD (project-config backed) + provider type registry. |
IntegrationReferences |
Per-shipment external-id tracking. |
Emails |
Email CRUD + render + send. |
TransitionEmails |
toCode -> email[] bindings. Event listener for EVENT_SHIPMENT_STATUS_CHANGED. |
ShipmentFieldLayouts |
Project-config-backed field layout for Shipment. |
TrackedOrders |
Owns shipments_tracked_orders. evaluateAndUpsert, markActive, markIgnored, recomputeUnderAllocation, sweepForNewlyIgnoredStatuses. Drives the Attention page filter. When an order’s status moves into orderStatusesToIgnore it flips the row to ignored (off the Attention page); shipments are left intact. |
Events
| Event | Class | Fired |
|---|---|---|
Shipments::EVENT_BEFORE_CREATE_SHIPMENTS |
CreateShipmentsEvent |
Before rules-engine shipments persist. |
Shipments::EVENT_AFTER_CREATE_SHIPMENTS |
CreateShipmentsEvent |
After rules-engine shipments commit. |
Shipments::EVENT_SHIPMENT_STATUS_CHANGED |
ShipmentStatusChangedEvent |
Creation (fromCode = null) + every transition, in transaction. |
Integrations::EVENT_REGISTER_INTEGRATIONS |
RegisterIntegrationsEvent |
Module classes register provider types. |
Rules::EVENT_REGISTER_RULES |
RegisterShipmentRulesEvent |
Module classes register shipment rules. |
Order completion hook
Auto-creation is wired in Plugin::init() via Event::on(Order::class, Order::EVENT_AFTER_COMPLETE_ORDER, ...). There’s no plugin-level event for “order completed”; listen directly to Commerce’s order event if you need a peer hook.
Queue jobs
| Job | Queued by | Does |
|---|---|---|
CreateShipmentsJob |
Nothing in src/; available for callers that want to defer creation |
Wraps Shipments::createFor for queue execution. The plugin itself runs createFor synchronously on Order::EVENT_AFTER_COMPLETE_ORDER, so admins see new shipments immediately after checkout. Use this job from a custom listener to push creation off the request path. |
AdvanceOrderStatusJob |
The order-advance hook on EVENT_SHIPMENT_STATUS_CHANGED |
Advances a shipment’s Commerce order to the configured target status after the shipment reaches shipped. |
PushShipmentJob |
Per-shipment push button; custom listeners | Calls $provider->sendShipmentWithEvents($shipment, $order). Permanent-vs-retryable. |
RecomputeAllocationJob |
The Attention page, after an order’s shipments change | Recomputes the cached underAllocated verdict for the given orders. |
SendShipmentEmailJob |
TransitionEmails::onShipmentStatusChanged (via event) |
Renders + sends one email. |
Exception hierarchy
yii\base\UserException
├── AllocationMismatchException (staging totals vs pool diverge)
├── AllocationOverflowException (re-enable/restore would over-allocate)
├── DuplicateShipmentReferenceException (reference collision; retriable once)
├── IncompleteCoverageException (saveManual with enforceCoverage=true)
├── IntegrationStatusMapException (map row save failed)
└── OrderNotCompletedException (shipment creation on non-completed order)
yii\base\Exception
└── IntegrationException (retryable integration error)
└── PermanentIntegrationException (non-retryable; PushShipmentJob stops)
Concurrency model
- Staging submit lock:
Craft::$app->getMutex()->acquire("shipments:order:{$orderId}:staging", 10). Two concurrent Save clicks on the same order serialize. - Transition lock:
shipments:shipment:{$shipmentId}:transition. Two concurrent status changes on the same shipment serialize. - Reference retry: on
DuplicateShipmentReferenceException,persistSinglePlanWithReferenceRetryretries up to 3 times.
Elements, queries, GraphQL
Shipment implements the full Craft element contract: index, sources (grouped by status), sort options, table attributes, searchable attributes, field layouts, eager-loading maps (order, lineItems, integrationReferences), GraphQL interface + type + arguments. hasTitles() = false; the title column renders getUiLabel() which returns the reference. The element index has no bulk actions; multi-shipment editing happens via Craft’s slideout-from-row pattern.
Shipment::toArray() overrides the status key to return its translated label (e.g. "Fulfilled") instead of the raw enum value. This is what CSV export and any other toArray() consumer sees. GraphQL bypasses toArray() via field resolvers, so its responses still emit the raw enum value ("fulfilled").
Permissions
On top of Craft’s built-in accessPlugin-shipments:
shipments-viewShipments, see index, attention, edit pages.shipments-editShipments, create/edit shipments.shipments-transitionShipments, change status.shipments-deleteShipments, soft-delete.shipments-pushShipments, queue integration push.shipments-manageIntegrations, CRUD integrations + mappings.shipments-manageEmails, CRUD emails + transition bindings.shipments-manageSettings, edit plugin settings + field layout.
Registered via UserPermissions::EVENT_REGISTER_PERMISSIONS. Controllers + element actions each requirePermission the appropriate handle.