Skip to main content

Documentation

No results found.
Features Members

Ecommerce

WebProCMS includes a built-in shop — sell physical products with variants (color, size, …), a session cart, and Stripe-powered checkout embedded right on your site. Stripe handles the payment form, the tax math, and the receipts; the CMS ha...

WebProCMS includes a built-in shop — sell physical products with variants (color, size, …), a session cart, and Stripe-powered checkout embedded right on your site. Stripe handles the payment form, the tax math, and the receipts; the CMS handles everything else: the product catalog, the storefront pages (fully editable in the page builder), orders, refunds, and customers.


What it does

  • Sell products with attributes: declare options like Color and Size per product; every combination becomes a variant with its own optional price override, SKU, and stock count. Attach an image to an option value (e.g. the color "Red") and the product photo swaps when a shopper selects it.
  • Digital products: flip a product to "Digital — delivered as a download", attach files (stored privately, never in the media library), and buyers get tokenized download links on the confirmation page, in the confirmation email, and on their member dashboard. Digital-only carts skip the shipping address and shipping options at checkout entirely.
  • Product subscriptions: give any product a billing interval (monthly or yearly) and its page swaps Add to cart for a Subscribe button — an embedded Stripe checkout that renews automatically. Every renewal lands as a normal paid order (so revenue reports include it), members see and manage their subscriptions from their account dashboard via the Stripe billing portal, and admins get a Shop → Subscriptions screen with cancel/resume controls.
  • Shipping methods: named checkout options (Standard / Express / "Free over $75") with business-day delivery estimates and per-country scoping, managed from Shop → Settings → Shipping. Shoppers pick one inside Stripe Checkout and the choice lands on the order.
  • Carrier shipping labels (Shippo / EasyPost): connect either provider and the order screen grows a rate-shop-and-buy-label flow — real carrier prices for the order's actual address and weight, a purchased label PDF to print, and tracking that pre-fills the fulfill modal. (See "Carrier shipping labels" below for why rates live on the order screen rather than at checkout.)
  • Checkout upsells: hand-pick "Offer at checkout" products per product; the cart page shows a one-click "Add before you check out" strip built from whatever is in the cart.
  • Product bundles: sell several products as one — bundle stock derives from the components, paying decrements each component's inventory, and the product page lists "What's inside" with a savings-vs-buying-separately line.
  • Multiple photos per product: a featured image plus a gallery, all picked from the media library; the detail page shows a thumbnail strip.
  • Structured spec sheets: optional label/value rows ("Material: 100% cotton") with grouped section headers ("Fabric", "Care"), rendered as a striped spec table on the product page via the shared <x-dl.spec-table> component and emitted as schema.org additionalProperty / PropertyValue JSON-LD for richer product snippets. The same capability is available to every custom content type as the specs field type.
  • Storefront layout pickers: choose how the catalog and product pages look without touching blades. Shop → Settings → Storefront picks the /shop list layout (Grid Cards / List / Masonry) and the store-default product-page layout (Stacked Sections / Tabbed — the tabbed variant groups Description / Specifications / Reviews into accessible WAI-ARIA tabs that collapse to an accordion on mobile). Each picker has a full-page "Browse designs" preview bound to your real products. Individual products can override the detail layout from a picker in the product editor — same materializer machinery the content-type layout pickers use.
  • A real storefront: /shop ships with search, category pills with live counts, tag pills (faceted, multi-select), attribute filters (Color, Size, … derived automatically from product options), price-range filters (multi-select — tick several ranges), sorting, and pagination — all crawlable plain-GET URLs (the same no-JS-safe collection pattern as the blog). /shop/{product} has the gallery, variant pickers, live price, add-to-cart, description, specs, and related products.
  • Merchandising: flat reusable product tags (comma-entered in the editor), hand-picked related products with automatic same-category fallback (the "You may also like" section hides entirely when there's nothing to show), a one-slot "Add this too?" cross-sell in the cart drawer driven by the in-cart items' related picks, and an automatic "New" badge on grid cards for recently published products (window configurable via shop.new_badge_days, default 30).
  • Products in site-wide search: published products join the public site search (their own "Products" group) and the dashboard cmd-K palette (linking straight to the product editor). Search understands the shopper's words — run-together names, dash-less SKUs, category paths and search synonyms (hand-typed, imported, or AI-generated at index time) — and a searched grid ranks by Best match; see "Product search".
  • SEO depth: renaming a product slug 301-redirects the old URL automatically (editor renames and CSV re-imports both); per-product Open Graph fields (OG title/description/image with media-library picker) fall back to meta title/description + featured image; product name/excerpt/description are translatable via the standard translations mechanism and render localized on non-English pages.
  • Embedded Stripe Checkout: the cart hands off to Stripe's payment form rendered on your own /shop/checkout page (no redirect to stripe.com). Apple Pay / Google Pay / Link come free.
  • Discounts managed in the dashboard: create promo codes (percentage or fixed amount, with optional expiry, redemption cap, minimum subtotal, and first-time-customer restriction) without leaving the CMS. Shoppers apply them in the cart; Stripe enforces the rules at checkout.
  • Product reviews with moderation: member-or-guest star reviews on every product page (honeypot + rate-limited), a Shop → Reviews approval queue, "Verified purchase" badges, grid-card stars, a Top-rated sort, and AggregateRating JSON-LD for star snippets in search results.
  • Automatic tax — and only automatic tax: the feature deliberately ships no manual tax rules. Checkout Sessions are created with automatic_tax enabled, so Stripe calculates the right tax from the shipper address. Turn on Stripe Tax in your Stripe dashboard (Settings → Tax) and you're done.
  • Orders with refunds: a full order screen with items, totals (subtotal / shipping / Stripe tax / total), the shipping address, and one-click full or partial refunds straight to Stripe. Refunds made directly in the Stripe dashboard sync back via webhook too.
  • Real fulfillment with tracking: marking an order fulfilled captures the carrier and tracking number (both optional); tracking links are generated automatically for UPS, USPS, FedEx, and DHL, or paste any carrier's link. An internal notes field lives on every order.
  • Order lifecycle emails from the CMS: order confirmation (off by default — Stripe's receipt covers it), order shipped (with the tracking link), and refund issued — sent through the same server/HTTP-API mail transport the Marketing feature uses, with per-email toggles and editable subject/heading/intro copy under Shop → Settings → Emails.
  • Abandoned-cart recovery: unfinished checkouts with a contact email get one recovery email (off by default) after a configurable delay, containing the cart items and a link that rebuilds the cart in one click. Member emails are captured the moment checkout starts; guest emails come from an optional "save your cart" field on the cart page or from what Stripe collected before the session expired. An Abandoned tab on Orders shows every recoverable cart with its recovery status, and a recovered order links back to the cart it rescued.
  • Customers without another database: the Customers screen aggregates order history by email — orders placed, total spent, first/last order — and links each person to their CRM contact and member account.
  • Marketing attribution built in: every order records where the buyer originally came from — Direct, Organic, Social, Referral, or Paid — derived from the visitor's first-touch referrer plus the Analytics feature's UTM/click-ID attribution when it's enabled.
  • CRM integration: every paid order creates/updates a CRM contact (source: ecommerce) and logs an order_placed interaction on their timeline (refunds log too) — so newsletters can target buyers and support can see purchase history.
  • CSV import/export: bulk-load products from a CSV (with a downloadable example file showing every column, including the specs and options syntax) and export the catalog back out.
  • One account system: shoppers check out as guests or sign in with the site-wide member account (shared with the Memberships feature — one login, one "My Account"). Members see their order history on the member dashboard. Social sign-in (Google, GitHub, Facebook, X, Microsoft, Apple, Discord) is available once providers are configured.

Find it under Dashboard → Shop (off by default; toggle under Settings → Features).

Storefront pages

Toggling the feature on materializes two editable pages into the page tree (same mechanism as Events):

  • /shop (pages/shop/⚡index.blade.php) — a collection-pattern grid bound to the products data preset; the store default is the Sidebar Filters layout (categories as a level-by-level list, then price ranges, tags, and attributes as checkboxes — several at once — in a sticky left column that collapses behind a Filters button on phones), switchable to the grid / list / masonry bar layouts under Shop → Settings. Filter pills, search, and pagination are stateless GET URLs, so every filtered view is shareable, cacheable, and works without JavaScript.
  • /shop/{slug} (pages/shop/⚡show.blade.php) — the product detail template, editable in the page builder with a "Preview as" dropdown over real products. The buy box (option selects, price, add-to-cart) is Livewire-driven; the surrounding content is normal builder rows with {item.*} tokens.

The cart (/shop/cart), checkout (/shop/checkout), and confirmation pages stay module-owned — they're transactional, per-visitor pages that are never response-cached and never appear in the page editor.

Product categories also have a data preset (product_categories) for category-tile rows, and the E-Commerce design-library category ships plenty of insertable product rows (featured, bestsellers, sale, categories, …) for any other page.

Storefront layouts

Both catalog pages are layout-swappable from Shop → Settings → Storefront, powered by the same list/detail layout materializers custom content types use:

  • Shop page layout — the single catalog row on /shop swaps in place between the shop-list design pool (Products - Grid Cards — the default, Products - List, Products - Masonry), leaving any sibling rows you added (a hero above, a CTA below) untouched. All three carry the same search / sort / category / price / tag / attribute filters, and keep the same collection prefix so filtered URLs stay valid across a swap.
  • Product page layout — the content below the buy box (description, specs, reviews) is a per-record gated layout row from the shop-detail pool: Product Detail - Stacked Sections (the default) or Product Detail - Tabbed. The store default is set on the Storefront tab; individual products can override it from the Detail Layout picker in the product editor — the chosen row materializes onto the page wrapped in a DetailLayoutGate guard, exactly like content-type detail layouts. The Livewire buy box (gallery, options, price, add-to-cart) and the related-products row sit outside the layout system and always render.

Every layout row lives in the design library (E-Commerce category, Shop List / Product Detail sub-categories), is tagged @pageDesign + @requiresFeature ecommerce (so it only surfaces on installs with the feature on), and remains fully editable in the page builder after it materializes.

The Storefront theme

For sites that are a shop (rather than sites that happen to sell something), the installable Storefront theme (resources/themes/ecommerce/) starts the whole site around the store: its homepage is a hand-composed shop landing page — hero with a Shop Now call-to-action, shop-by-category tiles, a product grid, a featured product, reviews, and a closing CTA banner. The theme declares "required_features": ["ecommerce"] in its manifest, so picking it at install time enables and migrates the E-Commerce feature automatically before any pages are scaffolded (see themes.md for the required_features mechanics).

Catalog data presets

  • products — filters: category (faceted counts), tags (multi-select, faceted counts), per-attribute facets (opt_{name}, derived from published products' options), price buckets, on-sale toggle, status, text search. Tokens include name, price (formatted), compare_at_price, featured_image, url, category.name, tags (comma list), on_sale, new, in_stock, body (description HTML) — translatable copy resolves through localized().
  • product_categories — tokens include name, url (pre-filtered shop link), products_count.

Three search surfaces read the same product data and must agree: the storefront header's typeahead + /search (Scout → PublicSearch::productResults()), the shop grid's in-page box (?products_q=, a filter on the products preset that composes with the sidebar facets), and the dashboard cmd-K palette.

What is indexed. Besides name, excerpt, description and sku, every product carries a derived, machine-owned search_terms bag built by Support\Search\ProductSearchTerms: the name split at CamelCase and letter/digit seams (ProComfortMax → pro comfort max), the SKU and every variant SKU as typed, without punctuation and split (PV-094 → pv094 pv 094), tag names, the category name plus every ancestor's, option names/values, spec-sheet labels/values, and the product's search synonyms. It is rebuilt on save whenever a source field changes (tags and variant SKUs refresh it from their writers) and in bulk by php artisan shop:rebuild-search-terms (no AI, idempotent — run after a category rename or a synonym import). The bag joins Scout's searchable array, the MySQL FULLTEXT index (search:install-fulltext, which the package updater runs post-update), and the grid's tokenized filter.

Ranking. With a query active the grid's sort select gains a Best match option and defaults to it: on MySQL with the products FULLTEXT index installed that is MATCH … AGAINST relevance, everywhere else (and until the index exists) a portable tiered order — whole query in the name, then a term in the name, then a SKU hit — with the store default as the tiebreak. Without a query the option is absent and the store default applies, so nothing changes for browsing. The header typeahead leads with the products matching every typed term (name / SKU / search_terms — the grid's own rule), then tops the group up with the engine's hits, so the suggestions and the grid a shopper lands on agree.

That ordering is not a preference, it is a correction: MySQL FULLTEXT relevance is length-normalised, so a match inside the long derived search_terms bag scores well below a short name that happens to share one weak token. Ranked purely by the engine, "pro comfort max" returned My Medic RECON Pro above ProComfortMax and "adult diapers" returned baby diapers above adult briefs. The derived bag makes matching better and fulltext ranking worse — worth remembering before adding more tokens to it.

Search synonyms (products.search_synonyms, comma-separated, never rendered) are the plain words shoppers type that are not in the name — "adult diapers" for briefs, a brand nickname, a common misspelling. Three owners write them, recorded in search_synonyms_source:

  • manual — the Search synonyms box in the product editor (with a Regenerate with AI button when a text provider is configured). A hand-edited product is never overwritten by an automated pass.
  • ai — php artisan shop:generate-search-synonyms {--limit=200} {--force} {--once}, batched 20 products per model call via the install's configured AI text provider (SiteKnowledge brief prepended, so "Never say" claims are honoured; only what the product is, no efficacy claims). The command is on the daily LazyCron but spends nothing unless Shop → Settings → General → Generate search synonyms with AI for new products is on; --once is the explicit one-off run (the settings page's Generate for N products missing synonyms button, an operator at the console). --force regenerates AI/imported ones too, never manual. The console output reports prompt + completion tokens per batch and per run, so the settings note's cost estimate can be checked against reality. New products — a WooCommerce sync adds them continuously — land with no synonyms and are picked up by the next daily pass. The model is consulted at index time only; a visitor's search never calls it.
  • import — php artisan shop:import-search-synonyms {file} {--source=import} loads a JSON array of {"id": 123, "synonyms": ["…"]} / {"sku": "PV-094", "synonyms": "walker, rollator"} rows, skips products whose current source is manual (unless imported as --source=manual), rebuilds each product's terms, and reports updated / kept / not-found / invalid counts. This is the catch-up channel for a catalog whose synonyms were written outside the install.

Products

Dashboard → Shop → Products. The list has configurable columns (Shop → Settings → Products Table), search, and status badges. "New Product" creates an auto-draft and opens the editor immediately (so variants and gallery — which attach to a saved product — work from the first second).

The editor covers: name, slug, excerpt, HTML description, specifications (label/value repeater with optional section header rows and up/down reorder — headers group the rows below them on the public table), options & variants (option builder with per-value images; a variants matrix with per-combination enable, price override, SKU, and stock), pricing (price + compare-at price for sale strikethroughs), inventory tracking (with an optional backorder toggle), category, featured image, gallery, and SEO (meta title/description, noindex).

Variants regenerate from the declared options on every save — combinations that still exist keep their per-variant overrides (matched by label).

Inventory

Tracking is opt-in per product. With options, stock lives per-variant; without, at the product level. A blank stock field means "untracked". Stock decrements when an order is paid, sold-out variants can't be added to the cart, and the affected product pages are evicted from the response cache so a cached "in stock" state never lingers.

  • Backorder: a per-product Allow backorder toggle keeps a tracked product purchasable at zero stock — the product page and cart show an "On backorder — ships later" note, checkout proceeds, and the paid decrement may push stock negative. The dashboard stock column reports that as Oversold by N, and the product's JSON-LD advertises BackOrder availability.
  • Low-stock alerts: set a threshold under Shop → Settings → General (empty = off). Tracked items at or under it surface behind a Low stock filter pill on the products list, and admins get a daily digest email (shop:low-stock-digest, LazyCron) listing each low product/variant with its SKU and remaining count. Drafts and untracked products never alert.
  • Restock notifications: an out-of-stock product page (a genuine stock-out — not a backorderable product or a disabled combination) shows a "Want an email when it's back?" capture, protected by a honeypot and per-IP rate limits and deduped per email per product/variant. When the item comes back — a stock edit, CSV import, inventory tracking turned off, or the product being republished — everyone waiting gets one Back in stock email (editable under Shop → Settings → Emails) and their request is cleared. Requests made against a product that later goes draft wait until it's publicly visible again, and a disabled Back-in-stock email keeps requests queued rather than dropping them.

CSV import

The Import button on the products list accepts a CSV (example file downloadable in the modal). Columns: name (required), slug, status, fulfillment_type (physical/digital), price, compare_at_price, sku, stock_quantity, allow_backorder (1/0), category (created if missing), excerpt, description, specifications (Label:Value;Label:Value; a [Section Name] segment inserts a grouped-section header row), options (Color:Red|Blue;Size:S|M — variants are generated automatically). Re-importing matches by slug (then exact name) and updates instead of duplicating. Export streams the same format back out. (Digital products' attached files aren't in the CSV — upload those in the product editor.)

WooCommerce import & sync

Shop → Settings → WooCommerce connects an existing WooCommerce store and pulls it in — the migration path for a shop moving onto the CMS, and a live mirror while the new site is being built beside the old one.

Setup: in WordPress go to WooCommerce → Settings → Advanced → REST API, add a key with Read permission, and paste the store URL, consumer key, and consumer secret. Both credentials are write-only (<x-secret-field>) and encrypted at rest. Test connection reads the store's product/order/customer counts.

What syncs (each toggleable):

  • Categories — the full tree, including parents. Product categories gained a parent_id for this; the shop's category filter includes a parent's sub-categories and its pills are tiered — the top level only, then one more row of children for each level of the selected category (led by an "All Parent" pill), with every pill's count covering its whole subtree and a category with no published products drawing no pill at all — the product_categories data preset has a Level filter (top-level only / children of X) plus parent_name / children_count tokens, and the Categories page nests and lets you pick a parent.
  • Products — name, slug, status (publish → published; hidden catalog visibility → unlisted; anything else → draft), price / sale price (→ compare-at), SKU, stock (managed quantity, or the plain out-of-stock / on-backorder flags), weight (converted to oz from the store's unit), featured flag, tags, non-variation attributes → the spec sheet, variation attributes → options, and every variation as a variant (own SKU / price / stock / image). A product is filed under the most specific of its WooCommerce categories. External/affiliate and grouped products are skipped. Images are copied into the media library (a Products category) once — the WooCommerce attachment id is baked into the filename, so re-syncs never re-download.
  • Orders — number, status (processing → paid, completed → fulfilled, cancelled/failed → canceled, on-hold → pending; refunds → partially/fully refunded), totals, shipping address and method, payment method label, customer note, line items linked to the imported products/variants, and the member account when the email matches. Imported orders are history: they never touch stock, send email, or mint downloads, and the order page shows an Imported from WooCommerce card in place of the Stripe payment card.
  • Customers — registered WooCommerce customers become member accounts (the site's one login) with an unguessable password; they sign in via Forgot password. No email is sent by the import. A staff login that shares a customer's email is never converted.

Every synced record keeps its WooCommerce id (woocommerce_id on products, variants, categories, orders, and member profiles), so re-running updates in place instead of duplicating; a pre-existing record with the same slug and no id yet is adopted.

How it runs — shop:sync-woocommerce, one resumable pass at a time:

  • The pass walks categories → products → customers → orders one API page at a time (customers first, so each order links to its member account) and saves its position after every page (shop.woocommerce.state). A LazyCron tick (every 15 minutes, off any public request — including the fleet's uptime probe) runs it with a ~25-second budget and stops mid-pass; the next tick resumes on the same page. From the CLI it runs to the end.
  • Import everything now (settings page) spawns a detached --full run in the background for the initial backfill (progress shown on the page); hosts that can't spawn get a bounded inline chunk and LazyCron carries on. Sync changes now runs a bounded incremental chunk inline.
  • Keep syncing automatically makes the LazyCron tick run incremental passes on its own; off, the tick only finishes a pass that's already under way. Incremental passes send modified_after (previous pass start minus a 5-minute overlap) for products and orders; customers have no such filter upstream and are re-walked (cheap, idempotent).
  • A --full pass also drafts products that no longer exist upstream (never deletes — order history stays intact). --entity=products limits a pass; --budget=N caps a run; --force runs with automatic sync off. Reset sync position clears the cursors so the next pass is a full one.
  • Runs are serialised by a runner heartbeat in the state (refreshed after every page; a runner silent for 5 minutes is presumed dead and the pass resumes), so a detached import and a LazyCron tick never overlap — even across a multi-hour backfill that would outlive a cache lock. Importing a customer also links any earlier orders with the same email to the member account, and a second WooCommerce customer record sharing an email is folded onto the existing account. Per-record failures are counted as skipped and logged, never fatal; a transport error stops the pass and surfaces on the settings page.

Not synced: coupons, reviews, product SEO plugins' meta, downloadable files, and customer addresses (Stripe collects a fresh address at checkout).

Cart and checkout

The cart is session-based — no account required. Line prices are re-resolved from the database on every read, so a stale tab can never check out at an old price, and lines whose product was unpublished or whose variant disappeared drop out automatically.

Checkout flow:

  1. /shop/checkout creates a pending order (an items snapshot with attribution stamped on it) and an embedded Stripe Checkout Session — automatic tax on, shipping address collection on, the configured shipping methods attached as pickable options (up to Stripe's five-option cap), and the signed-in member's email prefilled.
  2. Stripe's form renders in-page. Reloading reuses the same session while the cart is unchanged (no duplicate pending orders).
  3. On completion the order is finalized by whichever arrives first — the checkout.session.completed webhook or the confirmation page — idempotently: totals/tax/shipping address from Stripe, status paid, stock decremented, member linked by email, CRM contact + interaction written.

Abandoned checkouts stay as pending orders (visible under the Orders → Pending tab) and are marked canceled when their Stripe session expires.

Stripe configuration

  • API keys (publishable + secret) live on Settings → API Keys — the site-wide stripe.* pair shared by every payment-taking feature.
  • Webhook: point a Stripe webhook at /shop/stripe/webhook with events checkout.session.completed, checkout.session.expired, charge.refunded, and paste the signing secret into Shop → Settings → Stripe. (Local dev: stripe listen --forward-to your-site.test/shop/stripe/webhook.)
  • Tax: enable Stripe Tax in the Stripe dashboard. There is no tax UI in the CMS by design.
  • Shop → Settings → General: currency, price filter ranges, download expiry/limits.
  • Shop → Settings → Shipping: shipping countries (ISO-2 list offered in Stripe's address form) plus the shipping methods list — each method has a label, price, optional min/max business-day delivery estimate, optional country scope (excluded when it serves none of the store's shipping countries), and an optional "free over" subtotal threshold evaluated at session creation. Methods appear at checkout in your sort order (drag via up/down buttons); Stripe shows at most five. The shopper's pick is stored on the order as shipping_method_label. Upgrading from the old single flat rate is automatic — the migration seeds one method from the legacy setting, so existing installs checkout identically. With no methods, checkout still collects an address but charges nothing for shipping.

Discounts

Dashboard → Shop → Discounts creates and manages promo codes without a trip to the Stripe Dashboard. Each discount is a Stripe coupon + promotion code pair created through the API and mirrored into a local shop_discounts table, so the list renders instantly and survives an API key rotation. Stripe remains the source of truth for enforcement — expiry, redemption caps, minimum amounts, and the first-time-customer rule are all validated by Stripe when the Checkout Session is created.

  • Create: code (normalized to uppercase), percentage or fixed-amount value, optional expiry date, max redemptions, minimum subtotal, and a first-time-customers-only flag. Fixed amounts use the store currency at creation time.
  • List: value, restrictions, redemption counts (pulled lazily from Stripe after the page paints — an unconfigured key just leaves the mirrored counts), status badge (Active / Expired / Fully redeemed / Archived), and a copy-code button.
  • Archive / restore: archiving deactivates the Stripe promotion code so it stops redeeming immediately; past orders are unaffected. Restore re-activates it (Stripe refuses if the code has since expired or hit its cap).
  • How shoppers use it: the promo input in the cart (drawer and cart page) looks the code up on Stripe and carries it into the Checkout Session as an explicit discount. The applied code is snapshotted onto the order (promo_code) at checkout, so the order detail names the code next to the discount amount even after the discount is archived.

Free-shipping coupons aren't supported yet — a per-method "free over" subtotal threshold (Shop → Settings → Shipping) covers the common case without a code.

Orders

Dashboard → Shop → Orders — status tabs (All / Paid / Fulfilled / Refunded / Canceled / Pending / Abandoned), search by number or email. The detail screen shows items with photos, the totals breakdown ("Tax (Stripe automatic tax)"), the applied promo code, customer + shipping address with tracking details, an internal notes card, the attribution card (origin, referrer, UTM), and Stripe payment links.

Actions:

  • Mark fulfilled — opens a modal capturing the carrier and tracking number (both optional). Left empty, the tracking link is derived automatically for UPS, USPS, FedEx, and DHL; any other carrier's link can be pasted in. Fulfilling sends the "order shipped" email when that email is enabled and the order has an address on file.
  • Shipping label — when a carrier provider is configured (next section), a Shipping label card offers rate shopping + label purchase right on the order; the bought label fills in carrier/tracking so the fulfill modal comes pre-populated.
  • Refund — full or partial, sent to Stripe from the order screen; the order updates immediately and the charge.refunded webhook keeps it in sync (including refunds issued inside Stripe itself). Refunds log a CRM interaction and email the customer when the refund email is enabled.
  • Notes — a free-text internal field for special requests or support follow-ups; never shown to the customer.

Carrier shipping labels (Shippo / EasyPost)

Configure under Shop → Settings → Shipping → Carrier labels: pick a provider (Shippo or EasyPost), paste the API token/key, and fill in the ship-from address plus a default parcel (weight and dimensions). Once configured, any paid or fulfilled order with a shipping address and at least one physical line grows a Shipping label card on the order screen:

  1. Get shipping label opens a modal; Get rates rate-shops the shipment against the provider and lists the returned options — carrier · service · price · estimated days — cheapest first.
  2. Buy label purchases the selected rate. The label URL, label cost, and provider shipment id are stored on the order (shipping_label_url / shipping_label_cost_cents / shipping_provider_shipment_id), and the order's carrier, tracking number, and tracking URL fill in automatically — so the Mark-fulfilled modal comes pre-populated. Buying a label never auto-fulfills; sending the "shipped" email stays an explicit Mark-fulfilled action. Provider errors surface inside the modal.
  3. After purchase the card shows the carrier + tracking, the label cost, and a Print label button that opens the label file.

Why rates don't run at checkout: the store checks out through Stripe's embedded Checkout, which collects the shipping address inside Stripe's own iframe — the CMS never sees the address until the session completes, so true per-address live rates at checkout aren't feasible without Stripe's server-actions beta. Checkout therefore keeps charging the flat shipping_methods; the carrier integration lives where the address is known — the order screen.

Parcel & weight math: one parcel per order. Weight is the sum over physical lines of the product's Weight (oz) × quantity — products without a weight contribute the default parcel weight per unit, digital lines contribute nothing, and the total clamps to at least 1 oz. Dimensions come from the default parcel. API credentials resolve Settings-first (shop.shippo_token / shop.easypost_key) with a services.shippo.token / services.easypost.key config fallback — the same pattern as the Stripe keys.

Product weight

Physical products get an optional Weight (oz) field in the product editor's Fulfillment card (products.weight_oz, decimal ounces). It only feeds carrier rate quotes — the flat checkout shipping methods ignore it.

Checkout upsells

Every product's editor has an Offer at checkout picker (same UI as Related products) saved to products.upsell_product_ids. The cart page renders an "Add before you check out" strip between the line items and the summary: the union of the in-cart products' upsell lists, minus anything already in the cart, capped at three offers — image, name, price, one-click Add. Only simple products are offered: picks that are unpublished, out of stock, or subscriptions drop out, and products with options are skipped (they need a variant choice). Adding an offer re-renders the strip so the added product disappears from it immediately; the strip hides entirely when nothing applies. The cart drawer's related-products cross-sell is a separate, unchanged feature.

Product bundles

A product whose Bundle card (product editor sidebar) lists components — stored as [{product_id, quantity}] in products.bundle_items — sells as one line whose inventory flows through its components:

  • Stock: the bundle is in stock only while every component covers its per-bundle quantity (untracked and backorderable components always pass); a missing or unpublished component makes the bundle unavailable.
  • Paid decrement: paying an order decrements each component's stock by component quantity × line quantity (atomic SQL, with the same at-zero clamp unless the component allows backorder). The bundle row's own stock is never touched, and the component product pages are evicted from the response cache along with the bundle's.
  • Product page: bundles show a "What's inside" list (qty × linked component name) plus a "Save $X vs. buying separately" line when the components' combined price exceeds the bundle price (suppressed when it doesn't).
  • Constraints: bundles are physical-only, one-time products and can't use options/variants (the editor hides the Bundle card while options exist, and the save validates both directions). Components must be simple physical products too — digital, subscription, gift-card, bundle, and with-options products can't be components. Digital components are excluded deliberately: it keeps download-grant minting out of the bundle path entirely. The editor shows a live savings preview (components total vs. the bundle's price) while editing.

Order emails

Five emails, configured under Shop → Settings → Emails:

Email Default Trigger
Order confirmation Off Payment completes (webhook or confirmation page, whichever lands first)
Order shipped On Order marked fulfilled
Refund issued On Refund recorded — from the dashboard or a refund made inside Stripe
Abandoned cart recovery Off A started checkout sits unfinished past the configurable delay (see below)
Back in stock On A product/variant someone asked to be notified about returns to positive stock
  • Transport: sends through the same provider configured for Marketing campaigns (server mailer, Postmark, Resend, Mailgun, or SendGrid) — no extra dependencies, one provider config per install. The Marketing feature flag doesn't need to be on; only its provider settings are shared.
  • Sender: optional shop-specific from name/address; left empty it falls back to the Marketing sender settings, then the server default.
  • Copy: subject, heading, and intro text are editable per email with tokens ({{order_number}}, {{customer_name}}, {{site_name}}, {{total}}, {{carrier}}, {{tracking_number}}, {{refund_amount}} on the refund email, and {{product_name}} on the back-in-stock email). The item list, totals, shipping address, and tracking button are rendered automatically in an email-client-safe layout (inline styles, no Tailwind). Copy left at the default keeps tracking future default improvements.
  • Exactly-once delivery: every send claims a row in order_emails_log (unique per order + email type) before sending, so the webhook/confirmation-page finalize race and the dashboard-refund/webhook race each produce a single email. Refund emails are keyed by the cumulative refunded total — the same refund reported twice sends once, a second partial refund sends its own email with the new amount.
  • Transactional by design: the confirmation/shipped/refund emails skip the marketing suppression list and carry no unsubscribe link — they're triggered by the customer's own purchase. The abandoned-cart email is the exception: it's a nudge, not a receipt, so it does respect the Marketing unsubscribe list. Failures are logged and never break checkout, fulfillment, or refund flows.

Abandoned-cart recovery

Every checkout starts life as a pending order snapshotting the cart. When one sits unpaid past the recovery delay (Shop → Settings → Emails → Abandoned cart recovery → Send after, default 6 hours), the shopper gets one email with their items and a Complete your order button whose tokenized link (/shop/cart/recover/{token}) rebuilds their cart and lands them on /shop/cart — prices, publish state, and stock are re-validated on read, so the link restores the selection, never a stale price.

  • Email capture: members are captured the moment checkout starts. Guests are captured two ways — an optional "save your cart" field on the cart page (shown only while the recovery email is enabled), and the email Stripe collected in the payment form, which arrives with the checkout.session.expired webhook (~24 h) and is kept when the pending order is marked canceled.
  • Eligibility: unpaid (pending or expired-canceled) orders with an email, aged past the delay but no older than 7 days, that haven't been emailed and weren't themselves started from a recovery link (no follow-up chains). Anyone on the Marketing unsubscribe list is skipped.
  • Exactly once: sends run through the same order_emails_log claim as the other order emails plus a recovery_email_sent_at flag — the hourly shop:recover-carts LazyCron task can never double-send.
  • Dashboard: the Abandoned tab on Orders lists recoverable carts with a Recovery column — Not emailed, Email sent, or Recovered linking to the paid order. A recovered order's detail page badges the abandoned checkout it came from, and vice versa.

Digital products & downloads

Set a product's Fulfillment to Digital — delivered as a download in the product editor and attach one or more files. Files upload to the private disk (storage/app/private/product-files/…) — deliberately not the public media library — so the only way to reach them is the tokenized download endpoint. Up to 100 MB per file.

  • Checkout: a cart containing only digital products skips Stripe's shipping address form and the shipping rate; one physical item in the cart brings both back.
  • Delivery: when the order is paid, each digital line gets a download grant (/shop/download/{token}). Links appear on the order confirmation page, in the confirmation email (when enabled), and in a Your downloads panel on the member dashboard for member-linked orders. Guests keep access through the confirmation page + email — no account required.
  • Expiry & limits: Shop → Settings → General → download link expiry (default 30 days, 0 = never) and per-purchase download limit (default unlimited). Expired or exhausted links return 410 Gone; unknown tokens 404.
  • Refunds revoke access: fully refunding an order expires its download links immediately. Partial refunds don't — they may cover shipping goodwill or a different line.
  • Multiple files: a product can ship several files; each gets its own link (?file={id}, validated against the purchased product).

Product reviews

Customer star ratings with a moderation gate — nothing renders publicly until a manager approves it.

  • Member-or-guest submission: every product page ends with a "Write a review" form (star picker, optional title, review text). Signed-in members get name/email prefilled and the review linked to their account; guests submit with name + email. The form is a lazy Livewire island, so it works on the response-cached page — same mechanism as the cart drawer.
  • Spam defenses: a hidden honeypot field silently discards bot submissions (they still see "success"), per-IP rate limits (3/minute, 10/day) block bursts, and each email can review a product only once. A pending submission never clears the response cache, so bots can't use the form as a cache-stampede lever.
  • Moderation queue: Shop → Reviews (managers) lists Pending / Approved / Spam tabs with counts and search. Approve, unapprove, mark as spam, or permanently delete (modal-confirmed). Everything lands as pending — there is no auto-publish.
  • Verified purchase: submissions whose email matches a paid order containing the product get a "Verified purchase" badge, shown beside the review on the storefront and in the moderation queue.
  • Aggregates without queries: products.rating_avg + products.reviews_count are denormalized and resynced on every approve/unapprove/spam/delete, so grid stars, the "Top rated" sort, and JSON-LD never run an aggregate query on the hot path.
  • Storefront surfaces: approved reviews list on the product page (stars, title, text, name, verified badge, date) above the "You may also like" section; shop-grid cards show ★★★★☆ (12) under the product name (hidden at zero reviews); the products preset gains rating, rating_stars, and reviews_count tokens plus a Top rated sort option.
  • SEO: the product page's JSON-LD gains an AggregateRating block whenever approved reviews exist, so search results can show star snippets.
  • Design library: an E-Commerce Reviews – Live row (backed by the new product_reviews data preset — approved reviews of published products only, optional product-slug filter) drops real reviews onto any page, and a Review Stars item snippet adds a ★★★★★ element anywhere.

Customers

Dashboard → Shop → Customers aggregates paid orders by email: name, order count, total spent, last order, and a Member badge when the email matches a member account. The detail view shows lifetime stats and the full order list, with links to the CRM contact and member record.

Order attribution (Analytics tie-in)

A lightweight middleware captures each visitor's first touch (external referrer + UTM parameters) in their session the moment they land anywhere on the site. At checkout this is stamped onto the order, enriched — when the Analytics feature is enabled — by the visitor's visitor_attributions row (campaign first/last touch, click IDs, hashed visitor id). The origin column classifies every order as:

Origin Signal
Paid A click ID (gclid/fbclid/…) or a paid UTM medium (cpc, ppc, display, …)
Social Referrer or UTM source from a social network
Organic Referrer from a search engine
Referral Any other external referrer
Direct No referrer, no campaign

Works (referrer-only) even with Analytics disabled; no third-party scripts involved.

Reports

Dashboard → Shop → Reports — sales performance over a selectable range (7 / 30 / 90 days / year), computed live from the order data with no extra tables or tracking:

  • Summary cards: net revenue and orders (each with a change vs. the previous period), average order value, and refund rate (refunded amount as a share of gross, with the affected order count).
  • Charts: net revenue, order count, and AOV per day — the same dependency-free SVG line charts the Analytics feature uses.
  • Top products: units and line-item revenue per product, with edit links. Renamed products list their older name snapshots separately; deleted products keep their snapshot name.
  • Sales by origin / by campaign: orders and net revenue grouped by the stamped attribution origin and by UTM source/medium/campaign.
  • Discount usage: orders, total discounted amount, and net revenue per promo code.

The numbers count placed orders only (anything with a paid_at — paid, fulfilled, and refunded states); pending and canceled checkouts never appear. Refunds subtract from revenue on the order's paid day. AOV and top-product revenue are gross (before refunds), since refunds apply to whole orders rather than line items.

Product subscriptions

Any product can be sold as a recurring subscription: set Billing to Monthly or Yearly in the product editor's Pricing card (or via the CSV billing_interval column — month, year, or one-time to clear it).

How buying works. The product page shows the price as "$18.00 / month" and replaces Add to cart with a Subscribe button (variants still apply — the chosen variant's price becomes the recurring amount; quantity is always 1). Subscriptions require a member account: guests are sent through the member login/register flow and land right back on the subscribe checkout. The checkout itself is Stripe's embedded form at /shop/subscribe in mode=subscription — automatic tax applies, promotion codes are allowed, and physical subscription products collect a delivery address (Stripe doesn't support shipping rates on subscriptions, so price recurring shipping into the product). Subscription products never enter the cart, and inventory tracking doesn't apply to them.

Billing and orders. Each purchase is its own Stripe Subscription. Every paid invoice — the first charge and each renewal — is recorded as a normal paid order (idempotent on the Stripe invoice id), linked to the subscription and the member, so renewals flow into Orders, Customers, and Reports automatically. Renewal orders don't send the CMS order-confirmation email; Stripe's invoice receipts cover renewals.

Managing. Members get a Your product subscriptions panel on their account dashboard (status, renewal date, price) with a Manage billing button into the Stripe billing portal, where they cancel, resume, or update their card. Admins get Shop → Subscriptions: every subscription with status (Active / Past due / Canceled), renewal date, order count, and cancel-at-period-end / resume actions. Failed renewal payments show as Past due and recover automatically when Stripe retries successfully.

Coexistence with Memberships. Product subscriptions are completely separate from membership plans — separate table, separate Stripe subscriptions, and metadata-tagged webhook events so neither feature's webhook ever touches the other's subscriptions (a canceled coffee club never affects membership status). They share the member's Stripe customer, so one billing portal shows everything.

One account system (shared with Memberships)

Member accounts are the site-wide login: the same /members/login, /members/register, and member dashboard serve both Memberships and the Shop, and they're active when either feature is enabled (the feature: middleware accepts any-of lists). What that means in practice:

  • A visitor never needs an account to buy — guest checkout is the default.
  • A signed-in member gets a prefilled checkout, orders linked to their account (by id, and by email even for past guest orders), and an Your orders section on the member dashboard.
  • On a shop-only install (Memberships off), registration is a plain account signup — no plan picker, no Stripe subscription. On a Memberships install with the shop enabled, members may register with or without a plan.
  • Social sign-in providers configured under Settings → API Keys → Member Sign-In Providers appear as buttons on the member login/register pages (Google, GitHub, Facebook, X, Microsoft, Apple, Discord — the same Socialite pipeline the blog comments use).

Architecture notes (for developers)

  • Module lives at app/Features/Ecommerce/ following the standard feature-module layout (provider boots unconditionally; routes 404 via feature:ecommerce when disabled; disabling never drops tables).
  • All Stripe calls go through App\Features\Ecommerce\Services\EcommerceStripe — no \Stripe\* references elsewhere.
  • Support\OrderFinalizer is the single finalize/refund-sync path shared by the webhook and the confirmation page (idempotent).
  • Support\OrderEmails renders and sends the lifecycle emails through the Marketing module's CampaignMailer (via withFrom() for the shop sender override); the order_emails_log claim row is the idempotency guard.
  • Http\Controllers\DownloadController streams purchased files from the private disk; OrderDownload grants are minted in OrderFinalizer (unique per order item, race-safe) and the download route lives with the uncached commerce routes.
  • Support\Cart (session), Support\CheckoutStarter (pending order + session reuse), Support\ShopAttribution (capture + classify), Support\EcommercePageGenerator (theme feature-pages/ecommerce/ → pages/shop/ + route injection on enable).
  • Support\CarrierShipping is the single Shippo/EasyPost boundary — plain Http client calls (no SDK dependency, same driver pattern as Marketing's CampaignMailer), 15-second timeouts, every failure converted to a clean RuntimeException the order-screen modal can display. Rates normalize to one {id, shipment_id, carrier, service, amount_cents, currency, est_days} shape; label purchases fall back to the quoted amount when the provider's purchase response doesn't restate cost/carrier.
  • Subscriptions: Support\SubscriptionCheckoutStarter builds the mode=subscription embedded session (inline price_data with recurring — no Stripe Price objects to manage; reuses/mints members.stripe_customer_id); Support\SubscriptionSync is the idempotent establish/state-sync/invoice-order path shared by the webhook and the subscribe-return page. Sessions AND subscriptions carry shop_subscription metadata — the shop webhook only touches flagged subscriptions, and the Memberships webhook skips them (guards in its memberFromSubscription/invoice handlers), so the two features' customer.subscription.* / invoice.* events never cross-contaminate on the shared Stripe account.
  • Support\Search\* is product search: ProductSearchTerms (pure — builds the derived search_terms bag; TRIGGER_COLUMNS is what a Product::saving hook watches) and SearchSynonymGenerator (the batched AI pass, the manual/ai/import ownership rule, and the shared applyToProduct() every writer goes through). Commands: shop:rebuild-search-terms, shop:generate-search-synonyms (daily LazyCron, gated on shop.search_synonyms_ai), shop:import-search-synonyms.
  • Support\WooCommerce\* is the WooCommerce import: WooCommerceClient (wc/v3 over OutboundHttp::guarded(), Basic auth with a query-string retry for hosts that strip the header), WooCommerceSync (the resumable, lock-serialised, budgeted pass), and one importer per entity. The command is Console\SyncWooCommerceCommand; state lives in shop.woocommerce.state.
  • Money is integer cents everywhere; Support\Money::format() handles symbols and zero-decimal currencies.
  • Spec sheets: products.specifications shares one canonical shape with the content-type specs field — a flat ordered row list where a grouped-section header is just a special row ({label, header: true} interleaved with {label, value}), normalized/mapped by App\Support\SpecSheet (grouping is presentation-only; additionalProperty JSON-LD is flat, headers skipped). Rendering goes through the shared <x-dl.spec-table> component; <x-dl.tabs> / <x-dl.tab> are the stateless Alpine tabs primitives (desktop ARIA tablist, exclusive-accordion on mobile, DlTabs render-time registry pairs children with their container).
  • Storefront layouts: the shop is the one non-content-type entry in both PageLayoutMaterializer subclasses — a hardcoded shop branch in each dynamicConfigFor(), gated on Features::enabled('ecommerce') so every picker/reconcile no-ops when the feature is off. Store defaults live in the materializers' own Settings (index_layout.default.shop / detail_layout.default.shop); the per-product override is products.detail_layout (mirrors content_items.detail_layout, so DetailLayoutGate reads the LoopContext-bound Product unchanged), self-healed onto the page by a Product::saved hook. Pool rows carry the shop-list / shop-detail sub-categories (RowSubCategory::ShopList/ShopDetail, deliberately distinct from the generic list/detail subs so shop rows never leak into content-type pickers and vice versa).
  • Member auth routes moved to an any-of feature gate (feature:memberships,ecommerce); EnsureFeature treats multiple parameters as OR.

Current limitations / future work

  • Single currency per store; no multi-currency.
  • Shipping methods at checkout are flat-priced (with free-over thresholds) — live per-address carrier rates can't run inside Stripe's embedded checkout, so carrier rate shopping happens on the order screen instead (see "Carrier shipping labels"). Free-shipping promo codes aren't supported; a method's free-over threshold covers the common case without a code.
  • Abandoned-cart recovery sends a single email — no multi-touch drip sequence.
  • Fulfillment is all-or-nothing per order — no partial (per-item) fulfillment.
  • Subscriptions: one product per subscription (no multi-product recurring carts), quantity fixed at 1, no free trials, no mid-term plan/interval swaps (cancel and resubscribe instead), and recurring shipping must be priced into the product (Stripe checkout doesn't support shipping rates in subscription mode). Digital subscription products don't mint download grants on renewals.