Skip to content

v3.0.0-beta.6

Pre-release
Pre-release

Choose a tag to compare

@mckenziearts mckenziearts released this 17 Aug 17:32
021efbe

Caution

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

This sixth 3.0 beta builds out the public surface of the Store API: a storefront listing endpoint with category trees and tag filters, price sorting and filtering, catalog scoping by sales channel, product reviews, and public settings and legal pages endpoints. It also lets a project replace any store resource wholesale, adds a webhook registry addons can register events against, and closes a privilege escalation gap in team settings. 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

Storefront listing API

The Store API now carries what a headless storefront needs to render category pages, filters and navigation without custom queries. Product listings gain filter[in_stock] (branched by product type), filter[category_tree] resolved against a cached category tree, filter[tag] and filter[option] guarded by a 50 value breadth ceiling, and include=tags. GET /store/categories/tree returns the full nested visible tree in one call and one query, with depth and an opt-in products_count. Disabled categories and every descendant of a disabled ancestor are hidden consistently across listings, detail, filters and includes.

const tree = await sdk.store.category.tree()
const { data } = await sdk.store.tag.list()

GET /store/tags and sdk.store.tag.list() join the SDK alongside CategoryTreeNode and the new listing meta types.

Catalog scoped to a sales channel

A ChannelResolver contract, resolved through config, reads the X-Shopper-Channel header and matches an enabled channel by its slug, mirroring how a pricing zone is already resolved:

'channel' => [
    'resolver' => Shopper\Http\Channel\DefaultChannelResolver::class,
    'header' => 'X-Shopper-Channel',
    'default_slug' => env('SHOPPER_API_DEFAULT_CHANNEL'),
    'cache_ttl' => 60,
],

The resolver is swappable, and the channel is a soft context rather than an access gate: when nothing resolves, the catalog stays unfiltered. Every products relation exposed through an include (categories, brands, collections, related products) now goes through a single published, channel scoped constraint, closing a gap where a relationship include bypassed it entirely. A storefront sets the channel once on the SDK client:

const sdk = new Shopper({ baseUrl: 'https://my-store.com', channel: 'webstore' })

Nothing changes for a shop that sets neither the header nor SHOPPER_API_DEFAULT_CHANNEL. Once a default channel is configured, products need a channel attached to keep serving, see Upgrading below.

Price sort, filters and price range

GET /store/products gains sort=price / sort=-price (a variant product aggregates its variants' prices, every other type its own rows), filter[price_min] / filter[price_max] in minor units, filter[featured], filter[currency], and price_range: { currency_code, min, max } on listings and detail.

await sdk.store.product.list({
  sort: ['price'],
  filter: { price_min: 2500, currency: 'EUR' },
})

Currency resolution falls through filter[currency], the X-Shopper-Zone zone currency, then the shop default; an unknown or disabled code is a 422, never a silent fallback. The prices table gains a unique constraint on (priceable_type, priceable_id, currency_id) after deduplicating existing rows, and the variant save path moves from delete-then-create to updateOrCreate so concurrent saves can no longer corrupt price rows.

Store resources become replaceable through a manifest

A ResourceManifest singleton and the ApiResource facade let a project swap any store resource for its own:

use Shopper\Api\Facades\ApiResource;

ApiResource::replace(ProductResource::class, CustomProductResource::class);

The replacement must extend the resource it replaces, validated when replace() runs so a wrong wiring fails at boot instead of returning broken payloads. JsonApiResource::make() and collection() resolve the replacement transparently everywhere a resource serializes: controllers, nested includes, cart lines. The 27 store resources lose final so they can be extended, while toType() stays locked since the JSON:API type is the contract sparse fieldsets and included deduplication rely on. Without a registered replacement, every payload is byte for byte identical to today.

Webhook registry for addon events

Webhook events are no longer limited to the hardcoded core list. An addon registers its own events and payload serializers through the Webhooks facade, from its service provider:

use Shopper\Core\Webhooks\Facades\Webhooks;

Webhooks::register(SubscriptionRenewed::class, 'subscription.renewed');
Webhooks::register(InvoiceGenerated::class, 'invoice.generated', InvoicePayloadSerializer::class);

Registered events show up in the admin webhooks settings next to the core ones, and deliveries flow through the same queue, retry and redispatch machinery. Entries in config/shopper/webhooks.php keep working unchanged and act as overridable defaults, while conflicting registrations between addons fail fast at boot instead of silently misrouting payloads.

Security: privilege escalation through team settings closed

3.x carried the same privilege escalation already fixed on 2.x: the general purpose system.settings permission was enough to create arbitrary permissions, self-grant them, open and edit the administrator role, and create a team member assigned to it. AuthorizesTeamManagement now enforces that a non administrator can never target the administrator role, can only grant a permission they hold themselves, and can never create or delete a permission definition. Administrators are unaffected, and no existing permission assignment is modified.

New Features

  • feat(core): webhook registry so addons can register their own webhook events and payload serializers at runtime by @mckenziearts in #655
  • feat(api): make the store resources replaceable through a manifest by @mckenziearts in #652
  • feat: storefront listing API, category tree, tags and catalog filters by @mckenziearts in #651
  • feat(api): expose the generated image conversions on media payloads by @mckenziearts in #649
  • feat(api): public store settings and legal pages endpoints by @mckenziearts in #648
  • feat(api): scope the store catalog to a sales channel by @mckenziearts in #647
  • feat(api): store product reviews endpoints by @mckenziearts in #646
  • feat(api): price sort, filters and price range on store products by @mckenziearts in #645
  • feat(sdk): per-request fetch options and shopper-types re-export by @mckenziearts in #644
  • feat(core): add Scout search indexing foundation by @mckenziearts in #643
  • feat(core): currency column support on product import by @mckenziearts in #635

Bug Fixes

  • fix(security): prevent privilege escalation through team settings by @mckenziearts in #656
  • fix(api): harden the store api includes and identifiers by @mckenziearts in #650

Upgrading

Audit duplicate prices before migrating. The unique constraint migration on prices deletes duplicate rows for the same priceable and currency, keeping the most recent one. Check what would be removed first and back up the table if anything shows up:

SELECT priceable_type, priceable_id, currency_id, COUNT(*)
FROM prices
GROUP BY priceable_type, priceable_id, currency_id
HAVING COUNT(*) > 1;

Run your migrations. This release adds the unique constraint on prices, a composite index on reviews, and public id backfills:

php artisan migrate

Run composer update. laravel/scout is a new dependency of the core package. It ships with the collection driver, so no search service is required, and Brand, Category, Collection, Order and Product are now indexable.

Published api.php config files. The storefront listing filters (#651) and the public settings and legal endpoints (#648) add new resources keys. A config/shopper/api.php published before this release keeps its own stale resources array, since resources is replaced wholesale rather than merged, and the new filters, sorts and includes stay inert until the file is republished.

Published http.php config files. The sales channel resolver (#647) is configured under the new channel key. A published config/shopper/http.php needs it added by hand, or the file republished, before the X-Shopper-Channel header has any effect.

Breaking changes

  • The Category contract gains enabledSubtreeIds(). A custom category model bound through shopper.models must implement it, or extend the default model to inherit the implementation.
  • Product::search() is now Laravel Scout. The previous LIKE based scope was renamed to matching(). Calls to Product::search($term) keep compiling but return a Laravel\Scout\Builder running a full text query instead of the old Eloquent builder. Switch to Product::query()->matching($term) to keep the previous behavior.
  • prices on variant capable products is now empty. The top level prices array on a product that manages variants returns []. Read pricing from variants[].prices through include=variants, or from the new price_range attribute. Prices in disabled currencies are also no longer serialized on any product.
  • Category.parent_id is now the public id, not the primary key. It used to leak the internal auto-increment key with no endpoint to resolve it against. It now carries the parent's public id, and null when the parent is disabled.
  • shopper-types reshapes Review and Category. Review.author becomes a required nullable { name, avatar } display identity and the moderation fields (approved, reviewrateable_*, author_id, author_type) are gone from the public type. Category.slug_path is removed, replaced by ancestors, depth and products_count. TypeScript consumers need to update those reads.
  • Filters are capped at 50 values. Any store API filter with more than 50 comma separated values now returns a 422 instead of building an unbounded query. Batch wider ID lists.

Behavior changes to review

  • Products need a channel attached once a default channel is configured. Products carry channels through an opt-in relation, and only the admin product form pre-fills the default channel. A catalog created by CSV import, a seeder or the API has no channel attached, and those products stop being served the moment SHOPPER_API_DEFAULT_CHANNEL or the resolver header is in play:

    $channelId = Channel::query()->where('slug', 'webstore')->value('id');
    
    Product::query()
        ->whereDoesntHave('channels')
        ->each(fn (Product $product) => $product->channels()->attach($channelId));
  • The store API no longer leaks hidden records through includes. include=brand, include=categories, include=collections on a single product, include=children on a category and include=zones on a country used to bypass the model's visibility scope entirely. They now resolve through the same scope the listings already applied. A storefront relying on the old, unfiltered behavior will stop seeing disabled or unpublished related records.

  • Category visibility follows the ancestor chain. An enabled category nested under a disabled parent is no longer served. Enable the full chain, not just the leaf.

  • A non administrator can no longer manage permissions or the administrator role through system.settings. Everything else in the settings area is unchanged.

  • Shared caches keyed before this release are not partitioned by sales channel. Purge /store/* when rolling this release out behind a CDN.

Contributors

Full Changelog: v3.0.0-beta.5...v3.0.0-beta.6