github shopperlabs/shopper v3.0.0-beta.4

pre-release2 hours ago

Caution

This is a beta release of the framework. Breaking changes may be introduced to v3 releases during the beta period.

This fourth 3.0 beta is the heaviest one yet. It adds outbound webhooks, pluggable sales channel and pricing seams for addons, passkey sign-in for the admin panel, and a cart that reads its totals from persisted state instead of recomputing them on every request. It also closes a wave of correctness gaps across orders, payments and tax: race-safe order numbers, guarded payment status transitions, frozen tax lines, and idempotent refunds. The official 3.0 release is coming this September.

Installation

"minimum-stability": "beta",
"prefer-stable": true
composer require shopper/framework:^3.0.0-beta

Highlights

Outbound webhooks

Shopper can now push events to external systems instead of making them poll. Merchants register an endpoint from Settings > Webhooks for any of ten event types (order.created, order.paid, order.shipped, product.updated, customer.registered, cart.completed, and more), and every delivery carries a signed, replay-protected header, X-Shopper-Signature: t=<timestamp>,v1=<hmac>, verified with a constant-time comparison. Delivery is SSRF-hardened: the URL is rejected unless it resolves to a public address at both subscription time and delivery time, private, loopback and link-local ranges are blocked (including the cloud metadata endpoint), and the resolved address is pinned for the actual HTTP call so DNS cannot be rebound in between. Failed deliveries retry on a dedicated webhooks queue with backoff up to 12 hours, a subscription disables itself after 15 straight failures, and shopper:webhooks:redispatch requeues deliveries an infrastructure blip left stranded.

Sales channel drivers

Channels move from a flat list of storefront records to a driver-based abstraction. The new ChannelManager resolves a ChannelDriver per channel through Channels::driver($name), ships a built-in web driver, and lets addons register their own with Channels::extend(), the same pattern payment and shipping providers already follow. The new Settings > Sales channels page shows each channel with its driver logo and a badge colored by whether isConfigured() reports it ready, so a marketplace, social or POS integration can plug in without touching the core channel model. The default channel can no longer be disabled, from the UI or programmatically.

Passkey authentication for the admin panel

Administrators can sign in without typing a password. Turned on with SHOPPER_ENABLED_PASSKEYS=true, the account page gets a passkey manager backed by laravel/passkeys, and the login page offers WebAuthn sign-in through browser autofill. Registering a passkey requires the current password to be reconfirmed first, and the passkey login flow runs through the same session regeneration and login response as the password flow, so a passkey session behaves identically everywhere else in the panel.

Swappable price resolver contract

Pricing now funnels through a single seam. Priceable models expose resolvePrice(PricingContext $context), where the context carries currency, customer, quantity, channel and zone, and the container binds a PriceResolver (CatalogPriceResolver by default) that addons can rebind for contextual pricing, price lists, or negotiated rates without touching the catalog tables. The cart resolves every line through this same contract on add, currency change, merge and checkout revalidation, so a swapped resolver applies consistently from the product page to the paid order.

Cart: persisted totals and guest cart merge

Reading a cart's totals no longer runs the full calculation pipeline (promotions, tax, discounts) on every request. CartManager::totals() now serves the numbers the last calculate() call persisted, as long as they are inside a configurable freshness window (totals_ttl_minutes, 15 minutes by default) and the cart was not mutated since; checkout still always recalculates under the cart lock, so the price actually charged is never served from the read-only cache. Signing in mid-session also no longer leaves the shopper with two carts: CartManager::merge() folds the guest cart into the customer's cart, summing quantities on shared lines, re-pricing carried-over lines in the target currency, merging applied coupons, and deleting the now-empty guest cart.

Order and payment integrity hardening

A dedicated wave of fixes closes gaps that could produce a wrong invoice or a stuck order under concurrency. Order numbers now derive from the row's own auto-increment id after insert instead of a max()+1 read, so two simultaneous checkouts can never collide. Tax lines are frozen onto the order exactly as the cart pipeline computed them under lock, rather than recomputed afterward, and checkout now throws a PriceChangedException if a line's live price rose since it was added to the cart, rather than silently charging the stale amount. Payment status changes go through a guarded transition map, refunds carry a stable idempotency key so a retried request collapses into a single gateway refund, and the payment reference is now journaled inside the same transaction as the order it belongs to. The Stripe driver also refuses to process a webhook when no signing secret is configured, instead of accepting an unverifiable payload.

New Features

  • feat(core): sales channel drivers by @mckenziearts in #624
  • feat(admin): passkey authentication for the admin panel by @mckenziearts in #621
  • feat(core): swappable price resolver contract by @mckenziearts in #620
  • feat(admin): reusable form and table components by @mckenziearts in #619
  • feat(discounts): extensible eligibility rules and reusable picker slide-overs by @mckenziearts in #618
  • feat(core): outbound webhooks by @mckenziearts in #617
  • feat(core): addon extension points for routes, customer events and cart pipeline by @mckenziearts in #616
  • feat(cart): merge the guest cart into the customer cart on sign-in by @mckenziearts in #613
  • feat(cart): serve cart reads from persisted totals instead of the write pipeline by @mckenziearts in #610
  • feat(api): cursor pagination and a page number cap on collection endpoints by @mckenziearts in #608
  • feat(orders): reclaim stale unpaid pending orders and schedule cart pruning by @mckenziearts in #607
  • feat(stock): snapshot stock levels alongside the inventory ledger by @mckenziearts in #606
  • feat(payment): guard payment status transitions and refund amounts by @mckenziearts in #604

Bug Fixes

  • fix(admin): guard fulfillment shipping address inside the card title slot by @mckenziearts in #623
  • fix(admin): align dashboard metrics with canonical sales definitions by @mckenziearts in #622
  • fix(fulfillment): throw on an invalid shipment status transition by @mckenziearts in #615
  • fix(checkout): revalidate the live line prices before placing the order by @mckenziearts in #614
  • fix(payment): journal the payment reference atomically with the order by @mckenziearts in #612
  • fix(admin): sargable dashboard queries and cached admin aggregates by @mckenziearts in #611
  • fix(catalog): sargable publish scope, indexed order search and async customer filter by @mckenziearts in #609
  • fix(orders): derive order numbers from the row id and enforce uniqueness by @mckenziearts in #605
  • fix(tax): freeze the cart tax lines on the order instead of recomputing by @mckenziearts in #603
  • fix(payment): guard the Stripe webhook against a missing secret and make refunds idempotent by @mckenziearts in #602
  • fix(security): restrict avatar, logo and cover uploads to safe image types by @mckenziearts in #601
  • fix(admin): enforce authorization on staff delete and create actions by @mckenziearts in #600
  • fix(settings): use the media disk for logo and cover uploads by @mckenziearts in #598

Breaking Changes

  • [BREAKING] Priceable contract gains resolvePrice(?PricingContext $context = null): ?ResolvedPrice. Models using the HasPrices trait get the new method automatically; a custom Priceable implementation must add it. (#620)
  • [BREAKING] Order contract gains canTransitionPaymentTo(PaymentStatus $status): bool and transitionPaymentTo(PaymentStatus $status): void. A custom Order model can adopt the new HasPaymentStatusTransitions trait. (#604)
  • [BREAKING] PaymentDriver::refundPayment() gains a fourth array $context = [] parameter carrying the refund's idempotency key. Drivers extending the abstract Driver class are unaffected; a driver implementing PaymentDriver directly must update its signature. (#602)
  • [BREAKING] TransferCartAction::execute() now returns the merged Cart instead of void, and the guest cart may be deleted during the merge. Code calling the action directly must use the returned cart and stop relying on the original guest cart id. (#613)

Upgrading

These breaks only affect projects that implement Shopper contracts directly or call the actions below. Projects using the default models and drivers inherit the new behavior automatically.

Custom Priceable model

use Shopper\Core\Models\Traits\HasPrices;

class YourPriceable extends Model implements Priceable
{
    use HasPrices;
}

Custom Order model

use Shopper\Core\Traits\HasPaymentStatusTransitions;

class Order extends Model implements OrderContract
{
    use HasPaymentStatusTransitions;
}

Custom PaymentDriver

public function refundPayment(string $reference, int $amount, ?string $reason = null, array $context = []): PaymentResult
{
    // ...
}

Cart transfer callers

$cart = app(TransferCartAction::class)->execute($guestCart, $customerId);

The returned cart is the customer cart that absorbed the guest lines. The guest cart may no longer exist after the call.

Behavior changes to review

  • Abandoned carts are now pruned automatically. shopper:prune-carts is scheduled daily and deletes carts older than shopper.cart.prune_after_days (30 by default). Raise the value in config/shopper/cart.php if you keep long-lived carts.
  • Stale pending orders can be reclaimed. shopper:orders:reclaim releases stock held by unpaid pending orders, gated by the new shopper.orders.reclaim_pending_after_hours key (off by default, set a number of hours to enable).
  • The default sales channel is always enabled. Disabling the default channel now silently re-enables it. Promote another channel to default first if you need the current one off.

Contributors

Full Changelog: v3.0.0-beta.3...v3.0.0-beta.4

Don't miss a new shopper release

NewReleases is sending notifications on new releases.