Important
This is the first release candidate of Shopper 3.0. The public surface is frozen: only bug fixes land between this release and the stable 3.0 tag, coming this September.
This release candidate closes the operational loop of a headless store. Shipments are tracked end to end with real carriers, provider payment events are settled whatever order they arrive in, and the Store API serializes on Laravel's native JSON:API resources with no stability flag left in the dependency tree. Around that: UPS, FedEx and USPS move to opt-in packages, the storefront authentication flow attaches the guest cart and expires tokens, every API error carries a machine code, the payment session and login endpoints are hardened, and every split package now requires its siblings at the same version so a 2.x store cannot upgrade half way.
Installation
"minimum-stability": "RC",
"prefer-stable": truecomposer require shopper/framework:^3.0.0-rcHighlights
Carrier tracking, end to end
ShippingDriver::track() finally has callers. A carrier pushes delivery updates to POST /store/webhooks/shipping/{driver}, the way payment providers already did, and carriers without webhooks are polled every thirty minutes by shopper:shipments:sync-tracking, one unique job per open shipment. Both paths feed ApplyTrackingInfoAction, which resolves the shipment from the carrier reference and records the timeline through a single locked write path. Carrier feeds use a rank based, forward only state machine, so out of order scans, duplicates and corrections are absorbed while manual actions keep the strict transition graph.
'tracking' => [
'sync' => env('SHIPPING_TRACKING_SYNC', true),
'queue' => env('SHIPPING_TRACKING_QUEUE'),
'backoff' => [60, 300, 900],
],Delivery completes the order once every item is delivered and the order is paid. An order paid after delivery completes as well through the new OrderPaid listener, and auto-completion now dispatches OrderCompleted. Four domain events (OrderShipmentCreated, OrderShipmentEventRecorded, OrderShipmentDeliveryFailed, OrderShipmentReturned) and four outgoing webhooks (shipment.created, shipment.delivered, shipment.delivery_failed, shipment.returned) expose the lifecycle, and every shipment event records its source and the acting user. The timeline is served on GET /store/customers/me/orders/{id}?include=shippings,shippings.events.
UPS, FedEx and USPS as opt-in packages
Every install used to carry three carrier integrations and ext-soap. The drivers now live in shopper/ups, shopper/fedex and shopper/usps, the way shopper/stripe sits outside shopper/payment. shopper/shipping keeps the manual driver, and ivanmitrikeski/laravel-shipping leaves the monorepo.
composer require shopper/ups
php artisan vendor:publish --tag=shopper-ups-configEach package merges a flat config at shopper.<carrier> with the same env names as before, and registers its driver through Shipping::extend(). UPS and FedEx implement track(). FedEx and USPS rates are rewritten against the carriers' REST APIs over Http: the previous implementations called accessors the vendor objects never had, so neither carrier could return a single quote.
Payment events settled whatever order they arrive in
In the Store API flow the provider confirms the payment in the browser before POST /store/carts/{id}/complete creates the order, so payment_intent.succeeded usually lands first. The event was journalized as processed and dropped, the order stayed pending forever, and a Stripe redelivery was rejected as a duplicate.
The webhook ledger is now a transactional inbox. IngestPaymentEvent journalizes the event unprocessed and ApplyPaymentEvent claims it in the same transaction that applies it, so a rollback hands it back. Completion replays the stored events for its payment reference through SettlePayment, then queues SyncPendingPaymentJob to ask the provider through the new PaymentDriver::retrievePayment() when the payment is still pending. shopper:payments:reconcile --pull runs every fifteen minutes to catch what both missed.
'reconciliation' => [
'pull_on_completion' => env('PAYMENT_PULL_ON_COMPLETION', true),
'schedule' => env('PAYMENT_RECONCILE_SCHEDULE', true),
'queue' => env('PAYMENT_RECONCILE_QUEUE'),
'backoff' => [60, 300, 900],
'prune_after_days' => env('PAYMENT_WEBHOOK_EVENTS_PRUNE_AFTER_DAYS', 90),
],A failed webhook no longer cancels the order, the customer may retry on the same intent, and unpaid orders release their stock after 24 hours. Stripe refunds are ingested from refund.created and refund.updated with the amount and id of that refund, so a refund issued from the admin and its confirming webhook collapse onto one transaction.
Native JSON:API resources
shopper/api serializes on Illuminate\Http\Resources\JsonApi and drops timacdonald/json-api. Installing shopper/api no longer needs a stability flag, and the framework constraint is ^12.68|^13.27 across the monorepo, the releases that ship the requestedResourceRelationships() hook the base resource builds on.
use Shopper\Api\Http\Resources\JsonApiResource;
final class ProductResource extends JsonApiResource
{
public function toRelationships(Request $request): array
{
return [
'brand' => BrandResource::class,
'categories' => CategoryResource::class,
];
}
}Relationship keys are the Eloquent relation names everywhere, included is deduplicated by type:id, and a resource only exposes the relationships named on its own include path. The migration surfaced four visibility gaps, all closed: eager loads overwrote the include constraints on listings, detail endpoints accepted any include, three detail endpoints validated none, and a category lost its depth once it was included as a parent or child.
Storefront authentication and error codes
POST /store/auth/register and POST /store/auth/login accept a cart_id. The guest cart is attached under a row lock and folded into the cart the customer already owns, and meta.cart_id answers the surviving cart, so a retried login and a fresh device both learn the cart they should use. Customer tokens expire after shopper.api.token_expiration minutes, 30 days by default.
const customer = await sdk.auth.login({ email, password, cart_id: guestCartId })
const cartId = sdk.auth.getCartId()Every JSON:API error object now carries a code, snake_case and never translated. Validation failures name the failed rule next to the field pointer, the errors raised by the API carry a domain code (credentials_invalid, stock_insufficient, cart_completed, promotion_limit_reached, payment_session_required and the others from the ErrorCode enum), and HTTP errors carry unauthenticated, forbidden, not_found, rate_limited.
try {
await sdk.store.cart.createLineItem(cartId, { purchasable_id, purchasable_type: 'variant', quantity: 2 })
} catch (error) {
if (error instanceof ShopperApiError && error.code === 'stock_insufficient') {
// ...
}
}ShopperApiError.fields() and codeFor(field) read the new contract, ShopperConfig.onUnauthorized fires once on a 401 answered to an authenticated request, and the SDK gets its first test suite.
Payment session and login hardening
A provider failure on the payment session endpoint answered a bare 500 and a lost driver configuration a 422 that storefronts rendered as a field error. Both are now a 503 with Retry-After (payment_provider_unavailable, payment_method_not_configured). The idempotency key is unique per attempt instead of a persisted counter, since Stripe replays the saved outcome of a key for 24 hours, errors included. Session creation is serialized per cart with a cache lock spanning the provider round trip, so a double submit resumes the intent the first call opened instead of leaving a live intent no order records. A cart whose method collects through a provider refuses to complete without a session (payment_session_required).
POST /store/auth/login counts failed attempts per email and origin, the key Laravel's own throttling uses. After five failures within a minute (shopper.http.login_failures) the endpoint answers 429 with Retry-After, even with the right password, and an attacker cannot lock a customer out from another address. A storefront calling from its own server must forward the visitor IP in X-Forwarded-For and be trusted through trustProxies(), otherwise every visitor shares one quota.
Cross origin requests on the store routes
Laravel's HandleCors only answers on the paths listed in cors.paths, and the default list is api/*. shopper/http now appends {prefix}/* at boot, so a storefront served from another origin receives the CORS headers on every /store route. Origins, methods and headers stay under the application's own CORS configuration.
Product options carried inline
GET /store/products?include=options returned the wrong option values for every product after the first one sharing an attribute: scoped values and per-product swatches lived in an attributes resource identified by the attribute id, and the compound document deduplicated them. They now ride inline in data.attributes.options, the way rating and price_range already do, with the same fields. Product.options in @shopperlabs/shopper-types becomes ProductOption[].
Security: forged bulk selection on the staff list
A forged bulk selection could delete the signed in administrator or the last one, locking everyone out of the panel: checkIfRecordIsSelectableUsing() only filtered the checkboxes, and Filament 5.0 to 5.6 handed the selected keys to the bulk action without applying it again. Filament 5.7 applies isRecordSelectable() when resolving the selection, so shopper/framework requires filament/filament at ^5.8 and a test locks the invariant.
Split packages move in lockstep
The split packages required each other with "*", so an application pinned to shopper/core: ^2.11 running composer require shopper/api:^3.0 got shopper/api 3.x on top of shopper/core 2.x without a warning. Every shopper/* package now requires its siblings at self.version, the convention Filament and Lunar follow, and Composer refuses the half upgrade and names the way out: the whole shopper/* line moves in one command with -W.
New Features
- feat(shipping): extract the carrier drivers into opt-in packages by @mckenziearts in #668
- feat(api): attach the cart at authentication, expire tokens and code every error by @mckenziearts in #663
- feat(http): answer cross origin requests on the store routes by @mckenziearts in #662
- feat(shipping): carrier tracking with inbound webhooks and scheduled sync by @mckenziearts in #657
Bug Fixes
- fix(sdk): publish against the shopper-types release candidate by @mckenziearts in #670
- fix(api): carry product options inline so listings keep each product's values by @mckenziearts in #666
- fix(api): report payment session failures as 503 and throttle failed logins per email by @mckenziearts in #665
- fix(build): require Tailwind 4.3 for the scrollbar gutter utility Filament 5.8 applies by @mckenziearts in #664
- fix(security): require Filament 5.8 so a forged bulk selection cannot delete an administrator by @mckenziearts in #661
- fix(packages): require sibling packages at self.version by @mckenziearts in #659
- fix(payment): settle provider events that arrive before the order exists by @mckenziearts in #658
Refactoring
- refactor(api): serialize on Laravel's native JSON:API resources by @mckenziearts in #660
Chores
- docs(packages): add a README to every split package by @mckenziearts in #669
Upgrading
Laravel 12.68 or 13.27 and Filament 5.8 are required. Update the framework and Filament before updating shopper/*.
Move the whole shopper/* line in one command. The packages now require each other at self.version, so a partial update is refused:
composer require shopper/framework:^3.0.0-rc shopper/api:^3.0.0-rc shopper/stripe:^3.0.0-rc -WRequire the carriers you ship with. UPS, FedEx and USPS are no longer part of shopper/shipping:
composer require shopper/ups shopper/fedex shopper/uspsRun your migrations. This release adds external_id, source and causer_id on order_shipping_events with a unique index per shipment and indexes on tracking_number and status, and a reference column on payment_webhook_events. That migration rewrites data as well: processed_at used to mean received and now means applied to an order, so it is backfilled from the payload and reset to null on every event that never reached a transaction, which the reconcile command then replays. Re-applying an already settled event is a no-op, but back the table up first on a large store:
php artisan migrateUpdate the Stripe webhook endpoint. The driver no longer handles charge.refunded. Enable refund.created and refund.updated on the endpoint, next to the payment_intent.* events.
Use a cache store that supports locks. The payment session endpoint takes a cache lock per cart across the provider round trip. The default cache store must be redis, database, memcached or dynamodb, shared by every app server. file and array accept the lock but serialize nothing across processes.
Trust your storefront server. A storefront calling the API from its own server must forward the visitor IP in X-Forwarded-For and be listed in bootstrap/app.php with ->trustProxies(at: [...]), otherwise the rate limiters and the login throttle see one visitor. Never trust * on an API reachable from the public internet.
Published config files. Each of these files keeps working, but the new keys stay inert until added or the file republished:
config/shopper/payment.php: add thereconciliationblock, otherwise the scheduled reconcile stays off. Remove thedriversblock, the Stripe credentials now live inconfig/shopper/stripe.phpundershopper.stripe.*.config/shopper/shipping.php: remove thedriversblock and publish the per carrier files withvendor:publish --tag=shopper-ups-config,shopper-fedex-configorshopper-usps-config. Env names are unchanged, but the keys are flat:shopper.ups.client_idreplacesshopper.shipping.drivers.ups.credentials.client_id. Add thetrackingblock to tune the scheduler.config/shopper/webhooks.php: addshipment.created,shipment.delivered,shipment.delivery_failedandshipment.returnedto theeventsmap.config/shopper/api.php: pointoptionstoScopedOptions::classandancestorstoEnabledAncestors::classin the product includes, otherwise option values are no longer scoped and the ancestors order is not guaranteed. A publishedresources.order.includesstays authoritative, so addshippingAddress,billingAddress,paymentMethod,shippings,shippings.eventsandrefundto expose them.resources.cartandtoken_expirationtake the package default when absent.config/shopper/http.php:login_failuresis optional, the controller falls back to five.
Breaking changes
- Config keys of the Stripe driver moved.
config('shopper.payment.drivers.stripe.credentials.secret_key')becomesconfig('shopper.stripe.secret_key'). Thedriversblock leavespayment.phpandshipping.php, along with thepaypal,canada_postandpurolatorentries that never had a driver. - Carrier driver classes moved.
Shopper\Shipping\Drivers\UpsDriver,FedExDriverandUspsDriverbecomeShopper\Ups\UpsDriver,Shopper\FedEx\FedExDriverandShopper\Usps\UspsDriver, in their own packages. ShippingDrivergainssupportsWebhooks(): boolandhandleWebhook(Request): ?TrackingInfo. The baseDriveranswers false and not supported.Driver::supportsTracking()now defaults tofalse: a driver implementingtrack()must opt in.TrackingInfo::statusandTrackingEvent::statusare typedShipmentStatus, andTrackingEventgainsexternalId,latitudeandlongitude.PaymentDrivergainssupportsRetrieval(): boolandretrievePayment()semantics are strict. The baseDriveranswers false.retrievePayment()must answersuccess: falseonly once the payment can no longer be confirmed; a driver reporting an unconfirmed intent as unsuccessful is never resumed.WebhookResult::$actionis typedWebhookAction. Drivers return the enum cases instead of strings.PaymentProcessingService::processWebhook()is removed. UseIngestPaymentEventfor a raw provider event orPaymentProcessingService::apply()when the order is already known.- Manager creators registered through
extend()receive the container, not the driver name, and closures are rebound to the manager.ShippingManager,PaymentManager,ChannelManagerandImportManagerextendIlluminate\Support\Manager. RecordShipmentEventAction::execute()no longer mutates the passed instance. Callrefresh()if you read the shipment afterwards.TiMacDonald\JsonApi\*imports are gone. ExtendShopper\Api\Http\Resources\JsonApiResourceorJsonApiResourceCollection. A custom resource registered throughResourceManifest::replace()must return class-strings fromtoRelationships(), a closure now ends in a 500.- Relationship keys are the Eloquent relation names.
include=payment_method,shipping_addressandbilling_addressbecomepaymentMethod,shippingAddressandbillingAddress, in the parameter, in the documents and in@shopperlabs/shopper-types. - Product options are inline. A storefront reading
data.relationships.optionsand theattributesentries ofincludedmust readdata.attributes.options.Product.optionsisProductOption[], without an id. swatch_urlleavesGET /store/attributes. A swatch belongs to the product and attribute pair, so it is only served inline onproduct.options[].values[].swatch_url.AttributeValue.swatch_urlis removed from@shopperlabs/shopper-types, andsort=positionis no longer accepted on/store/attributes, it answers 400: sort on each value's ownpositionclient side.- Unknown includes answer 400 on every endpoint. Detail and cart endpoints now validate
includeagainst their allowlist as the listings did. A nested cart path must be declared in full inshopper.api.resources.cart.includes, and a path deeper than what an endpoint loads is truncated instead of lazy loaded. - Every error object carries a
code, and the pointer of a nested field uses slashes:/data/attributes/filter/currency. - Payment session errors changed status.
payment_method_not_configuredmoves from a 422 with a pointer to a 503 without one. New codes:payment_provider_unavailable(503),payment_session_in_progress(409),payment_session_required(422).data.idempotency_keyis nowcart_{cart}_{ulid}, treat it as opaque, and theversionkey ofcarts.payment_sessionis gone. - Login answers 429 after repeated failures. A storefront that only handled 200 and 422 on login must handle the 429 and its
Retry-Afterheader. MissingPriceExceptionreplaces the line at 0. Adding a line or switching the cart currency throws when a purchasable has no price in the target currency, answered as 422price_missing.CartManager::merge()callers must handle it, and the merge now resets the target's shipping option, shipping amount and payment session once lines moved in. Transferring a completed guest cart answers 409.CreateOrderFromCartAction::execute()reloads the cart it receives under lock instead of fetching a second copy, so callbacks see the locked row.- The SDK clears its token storage on a 401 answered to an authenticated request, whether or not
onUnauthorizedis configured.
Behavior changes to review
- The tracking scheduler is on by default. Set
SHIPPING_TRACKING_SYNC=falseto opt out. The command excludes carriers that push webhooks. - Unpaid orders are reclaimed after 24 hours.
shopper.orders.reclaim_pending_after_hoursdefaults to 24 instead of null; set it to null to keep unpaid orders indefinitely. - A
failedpayment webhook no longer cancels the order. It records the failed attempt and dispatchesPaymentFailed; the customer may retry on the same intent. - Customer tokens expire after 30 days. Tokens issued before this release keep no expiration.
sanctum:prune-expiredis scheduled daily by the package. - Changing the payment method drops the payment session and cancels its intent, the way a currency change already did.
- A resource key omitted from a published
resourcesentry takes the package default. Set it to[]explicitly to disable it. estimatedDaysis unset on FedEx and USPS rates. Neither reply carries a transit field the previous implementation ever read.
Contributors
Full Changelog: v3.0.0-beta.6...v3.0.0-rc.1