Skip to main content
Products are the core of your e-commerce catalog. Each product has a type that determines its capabilities: a Standard product is a simple physical item, a Variant product has multiple options like size and color, a Virtual product is a digital download, and an External product links to an external URL.

How Products Work

Shopper uses a type-driven product system. When you create a product, you choose one of four types, and the system enables the right features automatically.
TypeShippingVariantsAttributesStockUse case
StandardYesNoYesOn the product itselfA book, a poster, a mug
VariantYesYesYesOn each variantA t-shirt with sizes and colors
VirtualNoNoYesOn the product itselfAn ebook, a software license, a course
ExternalNoNoNoNoAn affiliate product hosted on another site
Standard products hold their own price, stock, and media. They are the simplest product type and work for anything that does not need size/color options. Variant products are parents that hold the product description, images, and categories, but delegate pricing and stock to their child variants. Each variant represents a specific combination of options (e.g., “Blue / Large”). See the Product Variants documentation for details. Virtual products behave like Standard products except they skip shipping. The files media collection lets you attach downloadable files that customers receive after purchase. External products store a URL to an external site. They have no stock, no shipping, and no checkout flow in Shopper. You can check a product’s type and capabilities using the following methods:
$product->isStandard();
$product->isVariant();
$product->isVirtual();
$product->isExternal();

$product->canUseShipping();
$product->canUseAttributes();
$product->canUseVariants();

Model

The model used is Shopper\Models\Product, which extends Shopper\Core\Models\Product. The core model provides the business logic, relationships, scopes, stock management, pricing, and reviews. The admin model adds media collections and conversions through Spatie MediaLibrary. The product model uses SoftDeletes. Deleted products remain in the database and are excluded from queries automatically. The publish() scope filters by visibility and publication date, so storefront queries do not need to handle soft deletes manually.

Extending the Model

To add custom behavior, extend the admin model and update your configuration:
namespace App\Models;

use Shopper\Models\Product as ShopperProduct;

class Product extends ShopperProduct
{
}
Update config/shopper/models.php:
return [
    'product' => \App\Models\Product::class,
];

Database Schema

ColumnTypeNullableDefaultDescription
idbigintnoautoPrimary key
namestringno-Product name
slugstringyesautoURL-friendly identifier (unique, auto-generated)
skustringyesnullStock Keeping Unit (unique)
barcodestringyesnullProduct barcode (unique)
descriptionlongtextyesnullFull product description (rich text)
summarytextyesnullShort product summary
typestringyesnullProduct type enum value
is_visiblebooleannofalseVisibility on storefront
featuredbooleannofalseFeatured product flag
allow_backorderbooleannofalseAllow adding to cart when out of stock
security_stockintegeryes0Minimum stock alert threshold
published_atdatetimenonow()Publication date (supports scheduling)
seo_titlestring(60)yesnullSEO meta title
seo_descriptionstring(160)yesnullSEO meta description
weight_unitstringyeskgWeight unit (kg, g, lbs)
weight_valuedecimal(10,2)yes0.00Weight value
height_unitstringyescmHeight unit (m, cm, mm, ft, in)
height_valuedecimal(10,2)yes0.00Height value
width_unitstringyescmWidth unit
width_valuedecimal(10,2)yes0.00Width value
depth_unitstringyescmDepth unit
depth_valuedecimal(10,2)yes0.00Depth value
volume_unitstringyeslVolume unit (l, ml, gal, floz)
volume_valuedecimal(10,2)yes0.00Volume value
external_idstringyesnullExternal system reference
metadatajsonyesnullAdditional custom data
brand_idbigintyesnullForeign key to brands
supplier_idbigintyesnullForeign key to suppliers
deleted_attimestampyesnullSoft delete timestamp
created_attimestampyesnullCreation timestamp
updated_attimestampyesnullLast update timestamp

Relationships

Brand

Each product can belong to a brand. This is a standard BelongsTo relationship.
$product->brand;
To query products by brand:
Product::query()
    ->whereHas('brand', fn ($q) => $q->where('slug', 'nike'))
    ->get();

Categories

Products have a polymorphic many-to-many relationship with categories through the product_has_relations table.
$product->categories;

$product->categories()->attach([$categoryId1, $categoryId2]);
$product->categories()->sync([$categoryId1, $categoryId2]);

Collections

Products can belong to multiple collections. Collections can also be automatic (rule-based), in which case the ProductObserver dispatches a SyncProductWithCollectionsJob when a product is saved to evaluate collection rules.
$product->collections;

$product->collections()->attach($collectionId);

Channels

Products can be assigned to multiple sales channels for multi-channel publishing.
$product->channels;

$product->channels()->attach($channelId);

Variants

For products with type = ProductType::Variant, the variants() relationship returns all child variants. Each variant has its own SKU, price, stock, and dimensions.
$product->variants;

$product->variants()->create([
    'name' => 'Blue / Large',
    'sku' => 'TSHIRT-BLU-L',
]);
The variants_stock computed attribute returns the total stock across all variants.
$product->variants_stock;
See the Product Variants documentation for the full variant API.

Tags

Products can have multiple tags for cross-cutting organization. Unlike categories (hierarchical) or collections (rule-based), tags are simple flat labels.
$product->tags;

$product->tags()->attach([$tagId1, $tagId2]);
See the Product Tags documentation for details.

Supplier

Each product can optionally be linked to a supplier.
$product->supplier;

Attributes (Options)

Product attributes are accessed via the options() relationship. The name options is used instead of attributes to avoid collision with Eloquent’s $attributes property.
$product->options;
The pivot table stores the selected attribute value and an optional custom value for freeform input.
$product->options()->attach($attributeId, [
    'attribute_value_id' => $valueId,
    'attribute_custom_value' => 'Custom engraving text',
]);
You can link products together as related items for cross-selling on the storefront.
$product->relatedProducts;

$product->relatedProducts()->attach([$productId1, $productId2]);

Discounts

Products can be associated with discounts through a polymorphic relationship.
$product->discounts;
See the Discounts documentation for details on discount rules and conditions.

Query Scopes

Published Products

The publish() scope filters products that are visible and have a published_at date in the past or present. This is the primary scope for storefront queries.
use Shopper\Models\Product;

Product::query()->publish()->get();

Filter by Channel

The forChannel() scope filters products assigned to one or more sales channels.
Product::query()->forChannel($channelId)->get();

Product::query()->forChannel([$channel1, $channel2])->get();

Publication Check

The isPublished() method checks if a specific product is currently visible on the storefront.
$product->isPublished();

Media

Products support three media collections through Spatie MediaLibrary. The collection names for the default gallery and thumbnail are defined in config/shopper/media.php.
CollectionConfig keyBehaviorDescription
Default galleryshopper.media.storage.collection_nameMultiple filesProduct gallery images
Thumbnailshopper.media.storage.thumbnail_collectionSingle fileMain product image for listings
files-Multiple filesDownloadable files for virtual products
To add a product thumbnail:
$collection = config('shopper.media.storage.thumbnail_collection');

$product->addMedia($file)->toMediaCollection($collection);
To add gallery images:
$collection = config('shopper.media.storage.collection_name');

$product->addMedia($file)->toMediaCollection($collection);
To retrieve the thumbnail URL with a specific conversion:
$collection = config('shopper.media.storage.thumbnail_collection');

$url = $product->getFirstMediaUrl($collection, 'medium');

Downloadable Files

For virtual products (ebooks, software, courses), the files collection stores the downloadable assets that customers receive after purchase. Unlike the thumbnail and gallery collections, the files collection name is not configurable.
$product->addMedia($pdfPath)->toMediaCollection('files');

$downloadUrl = $product->getFirstMediaUrl('files');
To get all downloadable files for a product:
$product->getMedia('files');

Pricing

Products use the HasPrices trait for multi-currency pricing. Each product can have multiple prices, one per currency, stored in the prices table through a polymorphic relationship. To get the price for a specific currency:
$price = $product->getPrice('USD');
To format the price for display, use the shopper_money_format helper. Prices are stored in cents.
shopper_money_format($price->amount, 'USD');
See the Pricing documentation for details on creating and managing prices.

Stock Management

Products use the HasStock trait for inventory management. Stock is not stored as a column on the product. Instead, every stock change creates a record in the inventory_histories table, and the current stock is computed as the sum of all mutations. This gives you a complete audit trail of every stock movement. Every stock mutation is tied to an inventory location (warehouse, store, fulfillment center), so you must always provide an $inventoryId when modifying stock.

Querying Stock

To get the current total stock across all locations:
$product->stock;
For variant products, use variants_stock to get the combined stock across all variants:
$product->variants_stock;
To get stock at a specific point in time, useful for reporting:
$product->getStock('2026-01-15');
$product->getStock(now()->subDays(7));
To get stock for a specific inventory location:
$product->stockInventory($warehouseId);
To check the stock alert threshold:
if ($product->stock <= $product->security_stock) {
    // low stock
}

Modifying Stock

To increase stock (for example, when receiving a shipment from a supplier):
$product->mutateStock(
    inventoryId: $inventory->id,
    quantity: 50,
    event: 'Shipment received',
    description: 'PO #12345 - Spring collection restock',
);
To decrease stock (for example, when fulfilling an order):
$product->decreaseStock(
    inventoryId: $inventory->id,
    quantity: 2,
    event: 'Order fulfilled',
    description: 'Order #1042',
);
To set stock to an exact quantity (for example, after a physical inventory count). This calculates the delta from the current stock and creates a single mutation:
$product->setStock(
    newQuantity: 75,
    inventoryId: $inventory->id,
    event: 'Inventory count',
    description: 'Q1 2026 physical count adjustment',
);
To clear all stock history and optionally set a new starting quantity:
$product->clearStock(
    inventoryId: $inventory->id,
    newQuantity: 100,
    event: 'Stock reset',
);
clearStock() deletes all inventory history records for the product. Use it only for resets or corrections, not for regular stock adjustments.
For variant products, stock is managed on each variant individually. See the Product Variants stock management section.

Avoiding N+1 Queries

Accessing $product->stock triggers an individual SUM(quantity) query on the inventory_histories table for each product. When iterating over a collection, this results in N separate queries. Use loadCurrentStock() to batch-load stock for an entire collection in a single query:
use Shopper\Models\Product;
use Shopper\Models\ProductVariant;

$products = Product::query()->with('variants')->get();

Product::loadCurrentStock($products);

$variants = $products
    ->filter(fn ($p) => $p->canUseVariants())
    ->flatMap->variants;

ProductVariant::loadCurrentStock($variants);
You can also batch-load stock for a specific inventory location:
Product::loadStockForInventory($products, $inventoryId);

Preventing Lazy Stock Loading

Following the same pattern as Laravel’s Model::preventLazyLoading(), you can catch unoptimized stock access during development. When enabled, accessing $product->stock without prior batch-loading throws a LazyStockLoadingException.
use Shopper\Models\Product;

Product::preventLazyStockLoading(! app()->isProduction());

Dimensions

The HasDimensions trait provides computed attributes that return formatted dimension strings combining the value and unit.
$product->weight;
$product->height;
$product->width;
$product->depth;
$product->volume;
For a product with weight_value = 1.50 and weight_unit = Weight::KG, $product->weight returns "1.50 kg".

Events

Product lifecycle events are dispatched automatically:
use Shopper\Core\Events\Products\ProductCreated;
use Shopper\Core\Events\Products\ProductUpdated;
use Shopper\Core\Events\Products\ProductDeleted;
The ProductObserver also dispatches a SyncProductWithCollectionsJob every time a product is saved, which evaluates automatic collection rules to add or remove the product from rule-based collections. For the full events reference including payload details, see the Events page.

Creating Products

To create a standard product with a price and category:
use Shopper\Models\Product;
use Shopper\Core\Enum\ProductType;

$product = Product::query()->create([
    'name' => 'Classic T-Shirt',
    'sku' => 'TSHIRT-001',
    'type' => ProductType::Standard,
    'description' => 'A comfortable cotton t-shirt available in multiple colors.',
    'is_visible' => true,
    'published_at' => now(),
    'brand_id' => $brandId,
]);

$product->prices()->create([
    'amount' => 2999,
    'currency_id' => $currencyId,
]);

$product->categories()->attach([$categoryId]);
To add a thumbnail image:
$collection = config('shopper.media.storage.thumbnail_collection');

$product->addMediaFromUrl('https://example.com/tshirt.jpg')
    ->toMediaCollection($collection);

Retrieving Products

The findBySlug static method looks up a product by its slug and throws a ModelNotFoundException if not found:
$product = Product::findBySlug('classic-t-shirt');
To get all published products with their relationships:
$products = Product::query()
    ->publish()
    ->with(['brand', 'categories', 'media'])
    ->get();
To get featured products:
$featured = Product::query()
    ->publish()
    ->where('featured', true)
    ->take(8)
    ->get();
To get products by category:
$products = Product::query()
    ->publish()
    ->whereHas('categories', fn ($q) => $q->where('slug', 'clothing'))
    ->get();

Permissions

The admin panel generates permissions for product management and its related features:

Products

PermissionDescription
browse_productsView the products list
read_productsView a single product
add_productsCreate new products
edit_productsEdit existing products
delete_productsDelete products

Product Variants

PermissionDescription
browse_product_variantsView variants on a product
read_product_variantsView a single variant
add_product_variantsCreate new variants
edit_product_variantsEdit existing variants
delete_product_variantsDelete variants

Components

You can publish the Livewire components to customize the admin UI for products:
php artisan shopper:component:publish product
This creates config/shopper/components/product.php where you can replace any page or form component with your own implementation.