Documentation Index
Fetch the complete documentation index at: https://docs.laravelshopper.dev/llms.txt
Use this file to discover all available pages before exploring further.
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
| Column | Type | Nullable | Description |
|---|
id | bigint | no | Primary key |
order_id | bigint | no | FK to order |
carrier_id | bigint | yes | FK to carrier |
status | string(32) | yes | Shipment status enum |
shipped_at | datetime | yes | When the shipment was dispatched |
received_at | datetime | yes | When the customer received it |
returned_at | datetime | yes | If the shipment was returned |
tracking_number | string | yes | Carrier tracking number |
tracking_url | string | yes | URL for customer tracking |
voucher | json | yes | Shipping label/voucher data |
OrderShippingEvent Table
| Column | Type | Nullable | Description |
|---|
id | bigint | no | Primary key |
order_shipping_id | bigint | no | FK to shipment |
status | string(32) | no | Shipment status at this event |
description | text | yes | Event description |
location | string | yes | Location name |
latitude | decimal(10,7) | yes | GPS latitude |
longitude | decimal(10,7) | yes | GPS longitude |
occurred_at | datetime | yes | When the event occurred |
metadata | jsonb | yes | Additional event data |
created_at | timestamp | yes | Creation timestamp |
OrderItem Fulfillment Columns
| Column | Type | Nullable | Description |
|---|
order_shipping_id | bigint | yes | FK to the shipment this item belongs to |
fulfillment_status | string | yes | Current 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
| From | Possible Transitions |
|---|
| Pending | Processing, ForwardedToSupplier, Cancelled |
| ForwardedToSupplier | Processing, Shipped, Cancelled |
| Processing | Shipped, Cancelled |
| Shipped | Delivered |
| 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:
| From | Possible Transitions |
|---|
| Pending | PickedUp, Returned |
| PickedUp | InTransit, DeliveryFailed, Returned |
| InTransit | AtSortingCenter, OutForDelivery, DeliveryFailed, Returned |
| AtSortingCenter | InTransit, OutForDelivery, DeliveryFailed, Returned |
| OutForDelivery | Delivered, DeliveryFailed, Returned |
| Delivered | Returned |
| DeliveryFailed | InTransit, 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,
]);
Actions
Shopper provides dedicated action classes for fulfillment operations. These handle side effects like syncing order statuses, updating item states, and dispatching events automatically.
RecordShipmentEventAction
The recommended way to record shipment events. It validates the transition, updates the shipment status, logs the event, and handles side effects based on the new status:
use Shopper\Core\Actions\RecordShipmentEventAction;
use Shopper\Core\Enum\ShipmentStatus;
$action = resolve(RecordShipmentEventAction::class);
$action->execute($shipment, ShipmentStatus::PickedUp, [
'description' => 'Package collected by carrier',
'location' => 'Paris Distribution Center',
'occurred_at' => now(),
]);
When a shipment transitions to PickedUp, the action automatically updates all items in the shipment to FulfillmentStatus::Shipped and changes the order status from New to Processing.
When a shipment transitions to Delivered, the action updates all items to FulfillmentStatus::Delivered and completes the order if all items across all shipments are delivered.
When a shipment transitions to Returned, the action updates all items to FulfillmentStatus::Cancelled.
MarkShipmentDeliveredAction
A convenience action for marking a shipment as delivered. It validates that the shipment can transition to Delivered and dispatches the OrderShipmentDelivered event:
use Shopper\Core\Actions\MarkShipmentDeliveredAction;
$action = resolve(MarkShipmentDeliveredAction::class);
$action->execute($shipment, [
'description' => 'Delivered to customer',
'location' => 'Customer address',
]);
SyncOrderShippingStatusAction
This action computes the order-level shipping_status from the fulfillment statuses of all its items. It runs automatically after every shipment event, but you can call it manually if you update item statuses directly:
use Shopper\Core\Actions\SyncOrderShippingStatusAction;
$action = resolve(SyncOrderShippingStatusAction::class);
$action->execute($order);
The algorithm counts items by fulfillment status and determines the order shipping status:
| Condition | Order Shipping Status |
|---|
| All items cancelled | Returned |
| Some cancelled, rest shipped/delivered | PartiallyReturned |
| No items shipped or delivered | Unfulfilled |
| All items delivered | Delivered |
| Some items delivered | PartiallyDelivered |
| All items shipped or delivered | Shipped |
| Otherwise | PartiallyShipped |
When the status transitions to Shipped, the OrderShipped event is dispatched.
Events
Fulfillment events are dispatched automatically by the actions above:
| Event | Dispatched When | Properties |
|---|
OrderShipmentCreated | A new shipment is created for an order | Order $order, OrderShipping $shipment |
OrderShipped | Order shipping_status transitions to Shipped | Order $order |
OrderShipmentDelivered | A shipment is marked as delivered | Order $order, OrderShipping $shipment |
use Shopper\Core\Events\Orders\OrderShipmentCreated;
use Shopper\Core\Events\Orders\OrderShipped;
use Shopper\Core\Events\Orders\OrderShipmentDelivered;
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
| Practice | Description |
|---|
| Update statuses atomically | Always update order_shipping_id and fulfillment_status together when shipping |
| Complete orders automatically | Check if all items are delivered and update the order status accordingly |
| Group items by carrier | Items from the same supplier or warehouse should share a shipment |
| Store tracking URLs | Always provide tracking_url so customers can track packages directly |
| Handle mixed orders | Process your own products and supplier products as separate shipments |