A multi-vendor cart looks like one purchase to the customer and is really three or four separate contracts underneath. Each seller ships from a different place, on a different carrier, with its own stock, its own return window and its own payout. Most marketplace bugs I have been asked to fix come from storing that as one order with a seller_id on each line, then trying to bolt on partial shipments, partial refunds and per-seller status later.
I run Loop Origin, where my team builds custom e-commerce and marketplace platforms, mostly on Laravel, Vue 3 and PostgreSQL. I wrote this with help from AI tools and checked every query and claim myself. The schema and code below are simplified from patterns I use in production.
How should a marketplace split one cart across multiple sellers?
Direct answer: Create one parent order for the customer's payment and one child order per seller for fulfillment. Quote shipping per seller group, allocate order-level discounts to child orders with a deterministic rounding rule, capture payment once, and let each child order move through its own state machine. Derive the customer-facing status from the children instead of storing it.
Three ways to model it
| Model | Payment | Fulfillment and status | Refunds | Where it breaks |
|---|---|---|---|---|
One order, seller_id per line |
One charge | One status for all sellers | Line-level math every time | "Shipped" is false when one of three sellers shipped |
| Parent order + seller sub-orders | One charge on the parent | Independent per seller | Scoped to a sub-order | Needs allocation logic up front |
| Separate checkout per seller | One charge per seller | Independent | Simple | Customer pays three times, more card declines and fees |
The second model costs more at the start and saves the most later. The rest of this post assumes it.
The schema
CREATE TABLE orders ( -- what the customer paid for
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL,
currency char(3) NOT NULL,
items_total bigint NOT NULL, -- minor units
shipping_total bigint NOT NULL,
discount_total bigint NOT NULL,
grand_total bigint NOT NULL,
payment_intent text UNIQUE,
idempotency_key uuid NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE seller_orders ( -- what one seller must fulfil
id bigserial PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id),
seller_id bigint NOT NULL,
ship_from_id bigint NOT NULL, -- seller warehouse
shipping_method text NOT NULL,
items_total bigint NOT NULL,
shipping_total bigint NOT NULL,
discount_alloc bigint NOT NULL, -- share of order-level coupon
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','packed','shipped','delivered','cancelled','refunded')),
accept_by timestamptz NOT NULL, -- seller SLA
UNIQUE (order_id, seller_id, ship_from_id)
);
CREATE TABLE order_lines (
id bigserial PRIMARY KEY,
seller_order_id bigint NOT NULL REFERENCES seller_orders(id),
sku_id bigint NOT NULL,
qty int NOT NULL CHECK (qty > 0),
unit_price bigint NOT NULL,
qty_cancelled int NOT NULL DEFAULT 0,
qty_refunded int NOT NULL DEFAULT 0
);
Two details carry a lot. The unique key includes ship_from_id, because one seller with two warehouses is two shipments, not one. And money is stored in minor units as integers, because the allocation step below has to add up to the exact cent.
Grouping the cart and quoting shipping
Group lines by seller and origin, then quote each group in parallel with a hard timeout. One slow carrier API should not block the whole checkout.
use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\Response;
$groups = $cart->lines->groupBy(fn ($l) => $l->seller_id.'-'.$l->shipFromId());
$responses = Http::pool(fn (Pool $pool) => $groups->map(fn ($lines, $key) =>
$pool->as((string) $key)->timeout(3)->post(
$this->carrierUrlFor($lines->first()->seller_id),
$this->quotePayload($lines, $address)
)
)->values()->all());
$quotes = $groups->map(function ($lines, $key) use ($responses) {
$r = $responses[$key] ?? null;
if ($r instanceof Response && $r->successful()) {
return ShippingQuote::fromCarrier($r->json());
}
Log::warning('shipping.flat_rate_fallback', ['group' => $key]);
return ShippingQuote::flatRate($lines->first()->seller);
});
Http::pool sends the requests concurrently, and a connection failure comes back as an exception object instead of a Response, which is why the check matters. The fallback is a business decision: a flat rate the seller agreed to in advance is better than a failed checkout, and the log line shows which sellers depend on it.
Show the customer one line per seller group ("Ships from Seller A, 3–5 days, Rs 250"). Splitting shipping per seller is honest and cuts disputes about why shipping costs more than expected.
Allocating discounts without losing a cent
An order-level coupon of Rs 500 across three sellers has to be split, because each seller's payout and each partial refund depends on it. Allocate in proportion to each seller's items total, then hand leftover units out by largest remainder:
function allocate(int $amount, array $weights): array {
$total = array_sum($weights);
$raw = array_map(fn ($w) => $amount * $w / $total, $weights);
$alloc = array_map('floor', $raw);
$left = $amount - array_sum($alloc);
$rema = array_map(fn ($r, $a) => $r - $a, $raw, $alloc);
arsort($rema);
foreach (array_keys($rema) as $i) { if ($left-- <= 0) break; $alloc[$i]++; }
return array_map('intval', $alloc);
}
// allocate(50000, [120000, 45000, 35000]) => [30000, 11250, 8750]
Store the result on seller_orders.discount_alloc at checkout and never recompute it. If you recompute after a line is cancelled, your refunds and payouts drift from what the customer actually paid.
One payment, many fulfilments
Capture once on the parent order, then record each seller's share in a ledger. I covered that side in detail in multi-vendor marketplace payments, ledgers and payouts; the important link here is that every ledger entry points at a seller_order_id, never only the parent.
Reserve stock per seller line at checkout with a short hold, and release it if payment fails. The hold-and-expire pattern is the same one in preventing double booking with locks, holds and expiry.
Each seller order then moves independently: pending → accepted → packed → shipped → delivered. The accept_by timestamp drives a scheduled job that auto-cancels and refunds a seller order that was never accepted, without touching the other sellers.
Deriving the customer status
Do not store a parent status. Derive it:
| Child statuses | Customer sees |
|---|---|
All pending or accepted
|
Processing |
Some shipped, some not |
Partially shipped |
All shipped or delivered (ignoring cancelled) |
Shipped |
All delivered (ignoring cancelled) |
Delivered |
All cancelled or refunded
|
Cancelled |
A stored parent status always ends up contradicting its children after a partial cancellation. A derived one cannot.
Failure modes and the trade-off each fix costs
| Failure | Symptom | Fix | Cost of the fix |
|---|---|---|---|
| One seller cannot fulfil | Whole order cancelled | Cancel only that seller order and refund its share | Customer gets a partial order; notify clearly |
| Coupon recomputed after cancellation | Refund larger than paid share | Freeze discount_alloc at checkout |
Slightly unfair edge cases on minimum-spend coupons |
| Carrier quote timeout | Checkout spinner, abandoned carts | Timeout plus seller flat-rate fallback | Occasional under- or over-charged shipping |
| Duplicate submit | Two parent orders |
idempotency_key unique on orders
|
One extra column and client-generated key |
| Return outside one seller's window | Support escalations | Store return policy per seller order at checkout | Policy snapshot per order |
| Seller ships late | Customer blames the platform |
accept_by and ship-by SLAs with auto-cancel |
Sellers must adopt the dashboard |
Implementation checklist
- [ ] Parent
ordersfor payment,seller_ordersper seller and origin for fulfilment - [ ] Money in integer minor units everywhere
- [ ] Parallel shipping quotes with a timeout and a flat-rate fallback
- [ ] Largest-remainder discount allocation, stored at checkout
- [ ] Stock holds per line, released on payment failure
- [ ] Ledger entries reference
seller_order_id - [ ] Per-seller state machine with an acceptance SLA
- [ ] Customer status derived, never stored
- [ ] Return and cancellation policy snapshotted per seller order
- [ ] Tests for partial cancel, partial refund and one-seller timeout
Frequently asked questions
Should the customer see one order number or several?
Show one order number and group shipments under it by seller. Customers think in purchases. Give each seller order its own suffix (for example 10452-A, 10452-B) for support conversations.
How do I handle free shipping thresholds with several sellers?
Decide whether the threshold is per seller or platform-wide, and say so at checkout. Per-seller is easier to fund, because each seller pays for its own shipping. A platform-wide threshold means the platform subsidizes the difference, which has to be booked in the ledger.
What if one seller's item goes out of stock after payment?
Cancel that seller order or just the affected lines, refund the line amounts minus their allocated discount, and keep the rest of the purchase moving. Never hold the other sellers' shipments.
Can sellers use different carriers in one order?
Yes, and they usually should. Shipping is quoted and tracked per seller order, so each can use its own carrier integration. A strategy-per-carrier pattern keeps that code clean.
How do taxes work when sellers ship from different places?
Tax often depends on where goods ship from and to, so calculate it per seller order, not per cart. Rules vary by country, so confirm them with an accountant before launch.
Is this overkill for a marketplace MVP?
The parent and child split is not. It is cheap on day one and painful to retrofit. You can skip the SLA jobs and parallel quoting until you have real sellers.
Follow & Connect
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)