Skip to main content
Order fulfillment in Shopper is designed around the concept of shipments — physical packages sent to customers. A single order can have multiple shipments, each with its own carrier, tracking number, and set of items.

Architecture

The fulfillment system connects four models:
Order (1) ──→ (many) OrderShipping (1) ──→ (many) OrderItem
                      (shipment)    │              (items in this package)
                                    └──→ (many) OrderShippingEvent
                                                 (tracking timeline)
  • Order has many shipments (OrderShipping) and tracks overall shipping_status
  • Each shipment has its own status (ShipmentStatus) and a timeline of events (OrderShippingEvent)
  • Each item tracks its own fulfillment status independently
use Shopper\Core\Models\Order;
use Shopper\Core\Models\OrderShipping;
use Shopper\Core\Models\OrderShippingEvent;
use Shopper\Core\Models\OrderItem;
use Shopper\Core\Enum\ShippingStatus;
use Shopper\Core\Enum\ShipmentStatus;
use Shopper\Core\Enum\FulfillmentStatus;

Database Schema

OrderShipping Table

ColumnTypeNullableDescription
idbigintnoPrimary key
order_idbigintnoFK to order
carrier_idbigintyesFK to carrier
statusstring(32)yesShipment status enum
shipped_atdatetimeyesWhen the shipment was dispatched
received_atdatetimeyesWhen the customer received it
returned_atdatetimeyesIf the shipment was returned
tracking_numberstringyesCarrier tracking number
tracking_urlstringyesURL for customer tracking
voucherjsonyesShipping label/voucher data

OrderShippingEvent Table

ColumnTypeNullableDescription
idbigintnoPrimary key
order_shipping_idbigintnoFK to shipment
statusstring(32)noShipment status at this event
descriptiontextyesEvent description
locationstringyesLocation name
latitudedecimal(10,7)yesGPS latitude
longitudedecimal(10,7)yesGPS longitude
occurred_atdatetimeyesWhen the event occurred
metadatajsonyesAdditional event data

OrderItem Fulfillment Columns

ColumnTypeNullableDescription
order_shipping_idbigintyesFK to the shipment this item belongs to
fulfillment_statusstringyesCurrent fulfillment state of this item

Fulfillment Status

The FulfillmentStatus enum defines the lifecycle of each item:
use Shopper\Core\Enum\FulfillmentStatus;

FulfillmentStatus::Pending              // Awaiting processing
FulfillmentStatus::ForwardedToSupplier  // Sent to external supplier (dropshipping)
FulfillmentStatus::Processing           // Being prepared for shipment
FulfillmentStatus::Shipped              // In transit
FulfillmentStatus::Delivered            // Received by customer
FulfillmentStatus::Cancelled            // Fulfillment cancelled

Status Flow

FromPossible Transitions
PendingProcessing, ForwardedToSupplier, Cancelled
ForwardedToSupplierProcessing, Shipped, Cancelled
ProcessingShipped, Cancelled
ShippedDelivered
Delivered(terminal state)
Cancelled(terminal state)

Shipping Status (Order Level)

The ShippingStatus enum lives on the Order model and reflects the combined state of all its shipments. It answers “has this order been shipped/delivered?”.
use Shopper\Core\Enum\ShippingStatus;

ShippingStatus::Unfulfilled        // No items shipped yet
ShippingStatus::PartiallyShipped   // Some items shipped
ShippingStatus::Shipped            // All items shipped
ShippingStatus::PartiallyDelivered // Some packages delivered
ShippingStatus::Delivered          // All packages delivered
ShippingStatus::PartiallyReturned  // Some items returned
ShippingStatus::Returned           // All items returned

Shipment Status (Package Level)

The ShipmentStatus enum lives on the OrderShipping model and tracks the delivery progress of a single package. Each shipment goes through this lifecycle independently.
use Shopper\Core\Enum\ShipmentStatus;

ShipmentStatus::Pending         // Label created, waiting for pickup
ShipmentStatus::PickedUp        // Carrier collected the package
ShipmentStatus::InTransit       // On the way
ShipmentStatus::AtSortingCenter // At a sorting facility
ShipmentStatus::OutForDelivery  // With the local delivery driver
ShipmentStatus::Delivered       // Delivered to the customer
ShipmentStatus::DeliveryFailed  // Delivery attempt failed
ShipmentStatus::Returned        // Returned to sender

Shipment Transitions

The HasFulfillmentTransitions trait on OrderShipping enforces valid state transitions:
FromPossible Transitions
PendingPickedUp, Returned
PickedUpInTransit, DeliveryFailed, Returned
InTransitAtSortingCenter, OutForDelivery, DeliveryFailed, Returned
AtSortingCenterInTransit, OutForDelivery, DeliveryFailed, Returned
OutForDeliveryDelivered, DeliveryFailed, Returned
DeliveredReturned
DeliveryFailedInTransit, OutForDelivery, Returned
Returned(terminal state)
$shipment->canTransitionTo(ShipmentStatus::InTransit); // true or false

$shipment->allowedTransitions(); // Collection of valid next statuses

$shipment->canBeDelivered(); // shorthand for canTransitionTo(Delivered)

Transitioning Shipment Status

Use transitionTo() to update the shipment status. It validates the transition and automatically logs a tracking event:
$shipment->transitionTo(ShipmentStatus::PickedUp, [
    'description' => 'Package collected by carrier',
    'location' => 'Paris Distribution Center',
    'latitude' => 48.8566,
    'longitude' => 2.3522,
]);
This creates an OrderShippingEvent record with the status, timestamp, and optional location data.

Tracking Events

Each shipment has a timeline of events that record its journey:
$shipment->events; // Collection<OrderShippingEvent>

$shipment->events()->create([
    'status' => ShipmentStatus::InTransit,
    'description' => 'Package departed sorting facility',
    'location' => 'Lyon Hub',
    'occurred_at' => now(),
    'metadata' => ['facility_code' => 'LYS-01'],
]);

Relationships

Order → Shipments

// Get all shipments for an order
$order->shippings; // Collection<OrderShipping>

// Load shipments with their carrier and items
$order->load('shippings.carrier', 'shippings.items');

// Check if order is fully shipped
$allShipped = $order->items->every(
    fn (OrderItem $item) => $item->fulfillment_status === FulfillmentStatus::Delivered
);

OrderShipping → Items & Events

// Get all items in a shipment
$shipment->items; // Collection<OrderItem>

// Get the carrier for this shipment
$shipment->carrier; // Carrier model

// Get the parent order
$shipment->order; // Order model

// Get the tracking timeline
$shipment->events; // Collection<OrderShippingEvent>

OrderItem → Shipment

// Get the shipment for an item
$item->shipment; // OrderShipping (nullable)

// Get tracking info for an item
$item->shipment?->tracking_number;
$item->shipment?->tracking_url;
$item->shipment?->carrier?->name;

Creating Shipments

Single Shipment (All Items Together)

When all items ship in one package:
$order = Order::query()->find($orderId);

// Create the shipment
$shipment = $order->shippings()->create([
    'carrier_id' => $carrier->id,
    'tracking_number' => '1Z999AA10123456784',
    'tracking_url' => 'https://ups.com/track?num=1Z999AA10123456784',
    'shipped_at' => now(),
]);

// Link all items and update their status
$order->items()->update([
    'order_shipping_id' => $shipment->id,
    'fulfillment_status' => FulfillmentStatus::Shipped,
]);

Partial Shipment (Split Across Packages)

When items ship in multiple packages with different carriers:
$order = Order::query()
    ->with('items')
    ->find($orderId);

// First package: items 1 and 2 via Colissimo
$shipment1 = $order->shippings()->create([
    'carrier_id' => $colissimoId,
    'tracking_number' => 'LP123456789FR',
    'tracking_url' => 'https://laposte.fr/suivi/LP123456789FR',
    'shipped_at' => now(),
]);

$order->items()
    ->whereIn('id', [$item1->id, $item2->id])
    ->update([
        'order_shipping_id' => $shipment1->id,
        'fulfillment_status' => FulfillmentStatus::Shipped,
    ]);

// Second package: item 3 via UPS (ships later)
$shipment2 = $order->shippings()->create([
    'carrier_id' => $upsId,
    'tracking_number' => '1Z999AA10987654321',
    'tracking_url' => 'https://ups.com/track?num=1Z999AA10987654321',
    'shipped_at' => now(),
]);

$order->items()
    ->where('id', $item3->id)
    ->update([
        'order_shipping_id' => $shipment2->id,
        'fulfillment_status' => FulfillmentStatus::Shipped,
    ]);

Marking as Delivered

// Mark a specific shipment as delivered
$shipment->update(['received_at' => now()]);

$shipment->items()->update([
    'fulfillment_status' => FulfillmentStatus::Delivered,
]);

// Check if all items are delivered to complete the order
$allDelivered = $order->items()
    ->where('fulfillment_status', '!=', FulfillmentStatus::Delivered)
    ->doesntExist();

if ($allDelivered) {
    $order->update(['status' => OrderStatus::Completed]);
}

Dropshipping Workflow

For external products sourced from suppliers, the fulfillment flow includes an additional step where the order is forwarded to the supplier who ships directly to the customer.
Dropshipping requires the Supplier feature to be enabled. External products must be linked to a supplier.

Complete Dropshipping Flow

use Shopper\Core\Models\Contracts\Supplier;

$order = Order::query()
    ->with('items.product.supplier')
    ->find($orderId);

// Step 1: Forward items to their suppliers
foreach ($order->items as $item) {
    if ($item->product->supplier_id) {
        $item->update([
            'fulfillment_status' => FulfillmentStatus::ForwardedToSupplier,
        ]);
    }
}

// Step 2: When the supplier ships, create the shipment
$shipment = $order->shippings()->create([
    'carrier_id' => $carrierId,
    'tracking_number' => $trackingFromSupplier,
    'tracking_url' => $trackingUrlFromSupplier,
    'shipped_at' => now(),
]);

// Link supplier items to shipment
$order->items()
    ->where('fulfillment_status', FulfillmentStatus::ForwardedToSupplier)
    ->update([
        'order_shipping_id' => $shipment->id,
        'fulfillment_status' => FulfillmentStatus::Shipped,
    ]);

Mixed Orders (Own Products + Supplier Products)

An order can contain both your own products and dropshipped products:
$order = Order::query()
    ->with('items.product')
    ->find($orderId);

$ownItems = $order->items->filter(
    fn ($item) => ! $item->product->supplier_id
);

$supplierItems = $order->items->filter(
    fn ($item) => $item->product->supplier_id
);

// Ship your own products immediately
if ($ownItems->isNotEmpty()) {
    $shipment = $order->shippings()->create([
        'carrier_id' => $yourCarrierId,
        'tracking_number' => $yourTracking,
        'shipped_at' => now(),
    ]);

    $order->items()
        ->whereIn('id', $ownItems->pluck('id'))
        ->update([
            'order_shipping_id' => $shipment->id,
            'fulfillment_status' => FulfillmentStatus::Shipped,
        ]);
}

// Forward supplier items
if ($supplierItems->isNotEmpty()) {
    $order->items()
        ->whereIn('id', $supplierItems->pluck('id'))
        ->update([
            'fulfillment_status' => FulfillmentStatus::ForwardedToSupplier,
        ]);
}

Querying Fulfillment Data

Items by Status

// Get all unfulfilled items across orders
$unfulfilledItems = OrderItem::query()
    ->whereNull('fulfillment_status')
    ->orWhere('fulfillment_status', FulfillmentStatus::Pending)
    ->with('order.customer')
    ->get();

// Get items waiting for supplier
$awaitingSupplier = OrderItem::query()
    ->where('fulfillment_status', FulfillmentStatus::ForwardedToSupplier)
    ->with('product.supplier')
    ->get();

Orders Needing Attention

// Orders with items not yet shipped
$ordersToFulfill = Order::query()
    ->whereHas('items', fn ($q) => $q
        ->where('fulfillment_status', FulfillmentStatus::Pending)
        ->orWhereNull('fulfillment_status')
    )
    ->with('items', 'shippings')
    ->get();

// Orders partially shipped
$partiallyShipped = Order::query()
    ->whereHas('items', fn ($q) => $q->where('fulfillment_status', FulfillmentStatus::Shipped))
    ->whereHas('items', fn ($q) => $q->where('fulfillment_status', '!=', FulfillmentStatus::Shipped))
    ->get();

Shipment History

// Get all shipments for a customer
$customerShipments = OrderShipping::query()
    ->whereHas('order', fn ($q) => $q->where('customer_id', $customerId))
    ->with(['carrier', 'items', 'order'])
    ->latest('shipped_at')
    ->get();

Handling Returns

// Mark a shipment as returned
$shipment->update(['returned_at' => now()]);

// Update item statuses
$shipment->items()->update([
    'fulfillment_status' => FulfillmentStatus::Cancelled,
]);

Storefront Example

Order Tracking Page

namespace App\Http\Controllers;

use Shopper\Core\Models\Order;

class OrderTrackingController extends Controller
{
    public function show(Order $order)
    {
        abort_unless($order->customer_id === auth()->id(), 403);

        $order->load([
            'items.shipment.carrier',
            'shippings.carrier',
            'shippings.items',
        ]);

        return view('orders.tracking', [
            'order' => $order,
            'shipments' => $order->shippings->map(fn ($shipment) => [
                'carrier' => $shipment->carrier?->name,
                'tracking_number' => $shipment->tracking_number,
                'tracking_url' => $shipment->tracking_url,
                'shipped_at' => $shipment->shipped_at,
                'received_at' => $shipment->received_at,
                'items' => $shipment->items->map(fn ($item) => [
                    'name' => $item->name,
                    'quantity' => $item->quantity,
                    'status' => $item->fulfillment_status,
                ]),
            ]),
        ]);
    }
}

Best Practices

PracticeDescription
Update statuses atomicallyAlways update order_shipping_id and fulfillment_status together when shipping
Complete orders automaticallyCheck if all items are delivered and update the order status accordingly
Group items by carrierItems from the same supplier or warehouse should share a shipment
Store tracking URLsAlways provide tracking_url so customers can track packages directly
Handle mixed ordersProcess your own products and supplier products as separate shipments