Marketplace Module Guide¶
Last reviewed: 2026-06-23
This guide is a how-to/reference for maintainers of the Marketplace module — a standalone goods/commerce surface where members list physical items for sale, swap-by-negotiation, or give away for free, and buyers order them with real money (Stripe Connect), time credits, or community delivery. It is completely separate from the timebanking Listings module (ListingService / listings table): different tables, different service layer, different Meilisearch index.
Real money is involved. When a tenant enables Stripe, marketplace orders are charged in real currency via Stripe Connect destination charges, held in escrow, and paid out to sellers' connected accounts. Treat the payment, escrow, and order-state code with the same rigour as the wallet ledger.
Audience & supported workflows¶
Use this guide when changing marketplace listings, the buyer/seller order lifecycle, payments/escrow, click-and-collect, or moderation.
Supported workflows:
- Listing management — a seller creates, edits, photographs, renews, promotes, and removes item listings (separate from timebanking offers/requests).
- Browse & discovery — public browse/search/nearby/featured/free, categories, saved listings, saved searches, and personal collections.
- Offers / negotiation — a buyer makes an offer on a listing; the seller accepts, declines, or counters; an accepted offer becomes an order.
- Order lifecycle — direct buy-now or offer-driven order, moving through
pending_payment → paid → shipped → delivered → completed, with cancel, dispute, and rating branches. - Payments & escrow — Stripe Connect payment intents / checkout sessions with a platform application fee, escrow hold, and auto/buyer-triggered release to the seller.
- Click-and-collect — sellers publish pickup slots; buyers reserve a slot and receive a QR collection code that the seller scans at handover.
- Community delivery — a peer deliverer offers to deliver an order and earns time credits from the buyer on confirmation.
- Moderation & DSA reports — optional listing moderation, user reports against listings, seller verification/suspension, and transparency stats.
Tenant & feature-gate rules¶
- Feature gate:
marketplace(default OFF —App\Services\TenantFeatureConfig::DEFAULTS['marketplace'] => false). The whole module is dark until a tenant explicitly enables it. - Backend enforcement is per-controller, not middleware. Every marketplace controller calls
TenantContext::hasFeature('marketplace')(via a privateensureFeature()helper) and returnsFEATURE_DISABLED(HTTP 403) when the feature is off. This applies to the public browse/search endpoints too —MarketplaceListingController::index()gates before serving. The accessible (GOV.UK) frontend gates the same way withabort_unless(TenantContext::hasFeature('marketplace'), 403). - React frontend: routes are wrapped in
<FeatureGate feature="marketplace" …>inreact-frontend/src/App.tsx(the mainmarketplaceroute shows a "coming soon" fallback; sub-routes redirect). - Tenant scoping is mandatory. Every marketplace table carries
tenant_idand every query is scoped byApp\Core\TenantContext::getId(). Order, escrow, and pickup operations re-pin tenant viaTenantContext::runForTenant()/setById()so cron and webhook paths (which boot without a tenant) operate under the correct tenant. - Module-level config lives in
App\Services\MarketplaceConfigurationService(per-tenant key/value withDEFAULTS), independent of themarketplacefeature flag. Notable defaults: Stripefalse, escrowfalse, platform fee5%, escrow auto-release14days, moderationfalse, free itemstrue, max active listings50, listing duration30days, max images20. A tenant can have the feature on but money off.
Key code & data locations¶
Routes are defined in routes/api.php under the "Marketplace Module — Authenticated routes" and "— Public routes" sections (/v2/marketplace/*), the admin section (/v2/admin/marketplace/*), and the Stripe webhook (/v2/marketplace/webhooks/stripe). Do not copy the endpoint table here — read the route file or the OpenAPI/docs/API.md reference for the live list. The module spans ~14 member-facing controllers, one admin controller, and ~18 services.
| Concern | Route prefix | Controller |
|---|---|---|
| Listing CRUD, images/video, browse, categories, saved, bulk/CSV | /v2/marketplace/listings/*, /v2/marketplace/categories/* |
MarketplaceListingController |
| Offers / negotiation | /v2/marketplace/listings/{id}/offers, /v2/marketplace/offers/*, /v2/marketplace/my-offers/* |
MarketplaceOfferController |
| Orders lifecycle | /v2/marketplace/orders/* |
MarketplaceOrderController |
| Payments / Stripe Connect / payouts | /v2/marketplace/payments/*, /v2/marketplace/seller/{payouts,balance,onboard} |
MarketplacePaymentController |
| Seller profile, dashboard, shipping options | /v2/marketplace/seller/*, /v2/marketplace/sellers/{id} |
MarketplaceSellerController |
| Discovery (saved searches, collections) | /v2/marketplace/saved-searches/*, /v2/marketplace/collections/* |
MarketplaceDiscoveryController |
| Promotions (paid bump/feature) | /v2/marketplace/promotions/*, /v2/marketplace/listings/{id}/promote |
MarketplacePromotionController |
| Click-and-collect pickup slots | /v2/marketplace/seller/pickup-slots/*, /v2/marketplace/seller/pickup-scan, /v2/marketplace/orders/{id}/pickup-reservation, /v2/marketplace/me/pickups |
MarketplacePickupSlotController |
| Community delivery (time-credit P2P) | /v2/marketplace/orders/{orderId}/delivery-offers/* |
MarketplaceCommunityDeliveryController |
| Inventory | /v2/marketplace/seller/listings/{id}/inventory |
MarketplaceInventoryController |
| Group-scoped marketplace | /v2/marketplace/groups/{groupId}/* |
MarketplaceGroupController |
| AI auto-reply / description | /v2/marketplace/listings/{id}/auto-reply, …/generate-description |
MarketplaceAiController, MarketplaceListingController |
| DSA user reports | /v2/marketplace/listings/{id}/report |
MarketplaceReportController |
| Admin moderation / sellers / reports | /v2/admin/marketplace/* |
AdminMarketplaceController |
| Stripe Connect webhooks | /v2/marketplace/webhooks/stripe |
StripeWebhookController |
Services (app/Services/):
MarketplaceListingService— listing read/create/update/remove/renew, Meilisearch sync, max-active-listings enforcement, geocoding.MarketplaceOrderService— order lifecycle (create from offer / direct purchase → ship → deliver → complete / cancel), order numbers (MKT-000001), idempotent multi-channel notifications.MarketplacePaymentService— Stripe Connect account creation/onboarding, payment intents and checkout sessions (application fee +transfer_data), payment confirmation, refunds.MarketplaceEscrowService— hold funds on payment, release on buyer confirm / auto-timeout / admin override / dispute resolution, refund on dispute.MarketplacePickupSlotService— click-and-collect slot CRUD, atomic reservation, QR (ULID) collection code, seller scan.MarketplaceCommunityDeliveryService— peer delivery offers settled in time credits (writes to thetransactionsledger).MarketplaceOfferService,MarketplaceSellerService,MarketplaceRatingService,MarketplaceReportService,MarketplaceConfigurationService,MarketplaceDiscoveryService,MarketplacePromotionService,MarketplaceInventoryService,MarketplaceShippingOptionService,MarketplaceGroupService,MarketplaceAiService.
Models / tables (all carry tenant_id):
marketplace_listings— item listings (App\Models\MarketplaceListing).marketplace_categories— hierarchical categories (parent_id, per-tenant or globaltenant_id = NULL).marketplace_images— listing photos (is_primary,sort_order); listings also have avideo_url.marketplace_offers— buyer/seller negotiation.marketplace_orders— the order record (order_number,status,payment_intent_id,escrow_released_at).marketplace_payments— Stripe payment record (amount,platform_fee,seller_payout,payout_status).marketplace_escrow— escrow hold (status,held_at,release_after,release_trigger,UNIQUE(order_id)).marketplace_seller_profiles— seller profile,stripe_account_id, verification,total_sales/total_revenuestats.marketplace_pickup_slots,marketplace_pickup_reservations— click-and-collect.marketplace_delivery_offers— community delivery offers (settled in time credits).marketplace_saved_listings,marketplace_disputes,marketplace_reports,marketplace_order_notification_deliveries(notification idempotency ledger).
Frontend entry points:
- React:
react-frontend/src/pages/marketplace/(MarketplacePage,MarketplaceListingPage,CreateMarketplaceListingPage,BuyerOrdersPage,SellerOrdersPage,StripeOnboardingPage,FreeItemsPage,MarketplaceCollectionsPage, seller sub-pages incl.SellerPickupSlotsPage, etc.), routed inreact-frontend/src/App.tsx. - Accessible (GOV.UK): commerce parity routes under
app/Http/Controllers/GovukAlpha/(CommerceParityconcern).
Item types & pricing¶
There is no separate "swap" listing type. Item pricing is driven by marketplace_listings.price_type, an enum of:
price_type |
Meaning |
|---|---|
fixed |
Fixed price (default). Buy-now creates an order at price. |
negotiable |
Price negotiable — buyers make offers; an accepted offer becomes the order amount. |
free |
Give-away / free item (gated by the marketplace.allow_free_items config, default on). Surfaced by GET /v2/marketplace/listings/free. |
auction |
Auction-style listing. |
contact |
"Contact for price". |
Additional pricing fields: price (decimal(10,2)), price_currency (default EUR), and time_credit_price (decimal(8,2)) — an optional time-credit price so an item can be acquired with time credits instead of money. Orders record time_credits_used when settled that way. condition is new | like_new | good | fair | poor; seller_type is private | business; delivery_method is pickup | shipping | both | community_delivery.
Listing lifecycle¶
marketplace_listings.status is draft | active | sold | reserved | expired | removed; moderation_status is pending | approved | rejected | flagged.
- Create (
MarketplaceListingService::create) — status defaults toactive(ordraft).moderation_statusisapprovedimmediately unlessMarketplaceConfigurationService::moderationEnabled()is on for the tenant, in which case it ispending.expires_atis set from the tenant's listing duration (default 30 days). Max active listings per seller is enforced (default 50). The listing is best-effort synced to themarketplace_listingsMeilisearch index. - Browse/search — only
status = active AND moderation_status = approvedlistings are publicly visible (see the Search guide). Search uses Meilisearch with an SQL fallback; price-range and posted-within facets force the SQL path. - Update / images / video / renew — owner-only; re-indexed on save.
renew()resetsstatus = active, extendsexpires_at, and bumpsrenewal_count. - Sold / reserved — placing an order marks the listing
sold(when inventory is untracked) or decrementsinventory_count(AG46) under a row lock. - Remove — soft-removes (
status = removed) and deletes the Meilisearch document.
Ownership is enforced in the controller (ensureOwner() → FORBIDDEN when listing.user_id !== auth user).
Categories¶
marketplace_categories is a per-tenant (or global, tenant_id = NULL) hierarchy with parent_id, slug (unique per tenant), icon, sort_order, and is_active. Listings reference category_id (FK ON DELETE SET NULL). Category templates (MarketplaceCategoryTemplate, served by categoryTemplate()) drive per-category structured fields stored in the listing's template_data JSON.
Buyer/seller order flow¶
marketplace_orders.status enum: pending_payment | paid | shipped | delivered | completed | disputed | refunded | cancelled.
- Create — either
createFromOffer()(from anacceptedoffer) orcreateDirectPurchase()(buy-now). Both run in aDB::transaction, generate anMKT-NNNNNNorder number, snapshotunit_price/total_price/currency, and apply an optional merchant coupon (AG63). Direct purchase locks the listing row and re-checksstatus = activeto prevent two buyers racing the same item; inventory is decremented atomically (AG46). Confirmation emails to both parties are sent outside the transaction. - Pay —
MarketplacePaymentService::confirmPayment()(driven by the Stripe webhook / confirm endpoint) locks the order, setsstatus = paid, writes amarketplace_paymentsrow, and — when escrow is enabled — callsMarketplaceEscrowService::holdFunds(). Paid notifications fire after commit. - Ship — seller-only (
markShipped):paid → shippedwith optional tracking; notifies the buyer. - Confirm delivery — buyer-only (
confirmDelivery): →delivered, setsauto_complete_at = now + 14 days; notifies the seller. - Complete —
complete()transitionsdelivered/paid/shipped → completedvia an atomic status-predicatedUPDATEso a buyer-confirm racing the auto-release cron (or a double-click) can only increment seller stats once. Setsescrow_released_at. Escrow release (releaseFunds) also callscomplete(), so completion and payout are coordinated. - Cancel — only before shipped (
pending_payment/paid); restores inventory or re-activates the listing; notifies both parties. - Dispute —
dispute()opens amarketplace_disputesrow; an open dispute blocks escrow auto-release (the escrow is markeddisputedinstead). Admin resolution releases or refunds. - Rate — after completion, buyer and seller can rate each other (
MarketplaceRatingService,rater_role, optional anonymity).
Click-and-collect (collection code)¶
For delivery_method involving pickup, sellers publish marketplace_pickup_slots (capacity, slot_start/slot_end, recurring patterns). A buyer reserves a slot for an order via reserve(), which under a row lock validates the slot is active/future/not-full, rejects a duplicate reservation per order, increments booked_count, and mints a QR collection code (qr_code, a URL-safe ULID) on the reservation. At handover the seller calls scanQr(), which verifies the code belongs to one of their orders, rejects already-picked-up/cancelled reservations, and marks the reservation picked_up. There is no separate human-typed short code — the QR/ULID is the collection token.
Money & credit handling¶
Three distinct settlement paths exist; be precise about which one applies:
- Real money (Stripe Connect). Gated by
marketplace.stripe_enabled.MarketplacePaymentServicecreates a Connect destination charge: the buyer is chargedtotal_price, anapplication_fee_amount(platform fee, default 5%, configurable) is routed to the platform account, and the remainder is transferred to the seller's connected account (stripe_account_idfrom their seller profile). Checkout sessions use an idempotency key derived from tenant + order + amount + fee. Refunds reverse the application fee proportionally for Connect refunds. Amounts aredecimal(10,2); fee/amount conversions go through cents. - Escrow. Gated by
marketplace.escrow_enabled. On payment,holdFunds()writes aheldescrow (amount = seller_payout,release_after = now + N days, default 14). Funds release to the seller onbuyer_confirmed,auto_timeout(hourlyprocessAutoReleases()cron),admin_override, ordispute_resolved; release/refund use atomic status-predicatedUPDATEs so a buyer-confirm cannot race the cron into a double-payout, and a refund cannot clobber an already-released escrow (which would pay the seller and refund the buyer). AUNIQUE(order_id)constraint backstops concurrentholdFunds(). - Time credits. Community delivery (
MarketplaceCommunityDeliveryService::confirmDelivery) is the path that moves time credits: on confirmation the buyer'susers.balanceis debited and the deliverer's credited inside oneDB::transaction(balance re-checked under lock; insufficient balance throws), writing atransactionsledger row withtransaction_type = 'community_delivery'. Items can also carry atime_credit_priceso the item itself is acquired with credits (orders.time_credits_used). Merchant coupons (AG63) discount the cash subtotal.
Moderation, reports & sellers¶
- Listing moderation is optional (
marketplace.moderation_enabled). When on, new listings arependingand must be approved by an admin (AdminMarketplaceController::approveListing/rejectListing/bulkReject) before they are searchable. With trusted auto-approve (marketplace.auto_approve_trusted) and DSA-compliance flags as additional config. - DSA user reports — any member can report a listing (
POST /v2/marketplace/listings/{id}/report→marketplace_reports, handled byMarketplaceReportService). Admins list/acknowledge/resolve reports and view transparency stats (/v2/admin/marketplace/{reports,transparency}). - Seller management — admins verify (
verifySeller) or suspend (suspendSeller, which also pulls the seller's live listings toremoved/rejected). Seller stats (total_sales,total_revenue) are incremented atomically on order completion.
Security & privacy invariants¶
- Every marketplace
SELECT/UPDATE/DELETEmust includetenant_id; cron/webhook entry points must set tenant context (runForTenant/setById) before any scoped query. - Listing mutation is owner-gated (
ensureOwner); ship is seller-only, confirm-delivery is buyer-only — keep these gated to prevent IDOR. - Money/escrow transitions must use atomic status-predicated
UPDATEs, never an unconditionalsave()on a status read into memory — this is what prevents double-payout, refund-after-release, and double stat increments under concurrency. - Direct purchase must lock the listing row and re-check availability/inventory inside the transaction.
- Email/notification rendering must wrap in
LocaleContext::withLocale($recipient, …)so order/payout emails render in the recipient'spreferred_language. Order notifications are de-duplicated per (order, event, user, channel) viamarketplace_order_notification_deliveries. - Notifications run outside the financial
DB::transactionso a notification failure can never roll back a payment, payout, or credit transfer. - Public browse endpoints only expose
active+approvedlistings; the feature gate is enforced even on unauthenticated reads.
Failure modes & recovery¶
| Failure | How it is handled |
|---|---|
| Feature disabled | All marketplace endpoints (incl. public browse) return FEATURE_DISABLED (403). |
| Two buyers race the same item | Direct purchase locks the listing row and re-checks status = active / inventory inside the transaction; the loser gets "no longer available". |
| Buyer-confirm races auto-release cron | Atomic status-predicated completion / escrow release: exactly one caller wins; the loser is a no-op (stats untouched, no second payout). |
| Concurrent escrow hold | UNIQUE(order_id) plus an exists-check; a concurrent holdFunds() returns the existing escrow. |
| Refund vs release race | refundEscrow() claims held\|disputed atomically; if the escrow already released, the refund throws rather than overwriting (prevents paying seller AND refunding buyer). |
| Open dispute at auto-release time | processAutoReleases() marks the escrow disputed (conditional on still being held) and skips payout. |
| Stripe webhook / payment confirm | confirmPayment is idempotent on stripe_payment_intent_id (UNIQUE); escrow hold is supplementary — a failed hold is logged but does not unwind a succeeded payment. |
| Duplicate pickup reservation / full slot / past slot | reserve() throws typed DomainException (DUPLICATE_RESERVATION, SLOT_FULL, SLOT_PAST, SLOT_INACTIVE). |
| Community delivery, buyer underfunded | Balance re-checked under lock at confirmDelivery; throws insufficient-balance and rolls back — no partial credit move. |
| Meilisearch unavailable | Listing index/sync no-ops; browse falls back to SQL LIKE (see Search guide). Backfill with php scripts/sync_search_index.php --tenant=<id> --type=marketplace. |
| Notification/email failure | Caught, logged, recorded in the delivery ledger; never rolls back a committed financial transaction. |
Recovery: financial and reservation operations are atomic, so a failed operation leaves no partial state — retry. For a stuck order, inspect marketplace_orders.status, the linked marketplace_payments/marketplace_escrow rows, and marketplace_order_notification_deliveries for what was delivered. Escrows past release_after are swept hourly by processAutoReleases().
Test commands & key regression tests¶
Run the backend suites (run heavy suites one at a time):
Targeted runs:
vendor/bin/phpunit --filter=Marketplace
vendor/bin/phpunit tests/Laravel/Unit/Services/MarketplaceEscrowServiceTest.php
vendor/bin/phpunit tests/Laravel/Unit/Services/MarketplaceOrderServiceTest.php
Important test files:
| Test | What it locks down |
|---|---|
tests/Laravel/Unit/Services/MarketplaceOrderServiceTest.php |
Order lifecycle, atomic completion / double-complete no-op, cancellation + inventory restore. |
tests/Laravel/Unit/Services/MarketplaceEscrowServiceTest.php |
Hold/release/refund races, auto-release, dispute blocking, no double-payout. |
tests/Laravel/Unit/Services/MarketplacePaymentServiceTest.php |
Stripe Connect intents/fees, refunds. |
tests/Laravel/Unit/Services/MarketplaceCommunityDeliveryServiceTest.php |
Time-credit settlement, insufficient-balance rollback. |
tests/Laravel/Unit/Services/MarketplaceListingServiceTest.php |
Create/update/renew, moderation gating, max-active enforcement. |
tests/Laravel/Unit/Services/MarketplaceOfferServiceTest.php |
Offer accept/decline/counter → order. |
tests/Laravel/Feature/Controllers/Marketplace*ControllerTest.php, AdminMarketplaceControllerTest.php |
Feature gating (incl. public routes), ownership/role gates, moderation, reports, seller verify/suspend. |
React: react-frontend/src/pages/marketplace/*.test.tsx (e.g. npm test -- BuyerOrdersPage).
Related references¶
- modules/listings.md — the timebanking Listings module (distinct table/service; do not conflate).
- modules/wallet-exchanges.md — the time-credit ledger that community delivery and
time_credit_pricesettle against. - modules/search.md — the
marketplace_listingsMeilisearch index and SQL fallback. routes/api.php— authoritative endpoint list (do not duplicate here).- ARCHITECTURE.md — runtime boundaries.