> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laravelshopper.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Products

> Complete reference for the Product model, types, relationships, stock, pricing, and media.

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.

| Type     | Shipping | Variants | Attributes | Stock                 | Use case                                    |
| -------- | -------- | -------- | ---------- | --------------------- | ------------------------------------------- |
| Standard | Yes      | No       | Yes        | On the product itself | A book, a poster, a mug                     |
| Variant  | Yes      | Yes      | Yes        | On each variant       | A t-shirt with sizes and colors             |
| Virtual  | No       | No       | Yes        | On the product itself | An ebook, a software license, a course      |
| External | No       | No       | No         | No                    | An 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](/v2/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:

```php theme={null}
$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:

```php theme={null}
namespace App\Models;

use Shopper\Models\Product as ShopperProduct;

class Product extends ShopperProduct
{
}
```

Update `config/shopper/models.php`:

```php theme={null}
return [
    'product' => \App\Models\Product::class,
];
```

## Database Schema

| Column            | Type          | Nullable | Default | Description                                      |
| ----------------- | ------------- | -------- | ------- | ------------------------------------------------ |
| `id`              | bigint        | no       | auto    | Primary key                                      |
| `name`            | string        | no       | -       | Product name                                     |
| `slug`            | string        | yes      | auto    | URL-friendly identifier (unique, auto-generated) |
| `sku`             | string        | yes      | null    | Stock Keeping Unit (unique)                      |
| `barcode`         | string        | yes      | null    | Product barcode (unique)                         |
| `description`     | longtext      | yes      | null    | Full product description (rich text)             |
| `summary`         | text          | yes      | null    | Short product summary                            |
| `type`            | string        | yes      | null    | Product type enum value                          |
| `is_visible`      | boolean       | no       | false   | Visibility on storefront                         |
| `featured`        | boolean       | no       | false   | Featured product flag                            |
| `allow_backorder` | boolean       | no       | false   | Allow adding to cart when out of stock           |
| `security_stock`  | integer       | yes      | 0       | Minimum stock alert threshold                    |
| `published_at`    | datetime      | no       | now()   | Publication date (supports scheduling)           |
| `seo_title`       | string(60)    | yes      | null    | SEO meta title                                   |
| `seo_description` | string(160)   | yes      | null    | SEO meta description                             |
| `weight_unit`     | string        | yes      | kg      | Weight unit (kg, g, lbs)                         |
| `weight_value`    | decimal(10,2) | yes      | 0.00    | Weight value                                     |
| `height_unit`     | string        | yes      | cm      | Height unit (m, cm, mm, ft, in)                  |
| `height_value`    | decimal(10,2) | yes      | 0.00    | Height value                                     |
| `width_unit`      | string        | yes      | cm      | Width unit                                       |
| `width_value`     | decimal(10,2) | yes      | 0.00    | Width value                                      |
| `depth_unit`      | string        | yes      | cm      | Depth unit                                       |
| `depth_value`     | decimal(10,2) | yes      | 0.00    | Depth value                                      |
| `volume_unit`     | string        | yes      | l       | Volume unit (l, ml, gal, floz)                   |
| `volume_value`    | decimal(10,2) | yes      | 0.00    | Volume value                                     |
| `external_id`     | string        | yes      | null    | External system reference                        |
| `metadata`        | json          | yes      | null    | Additional custom data                           |
| `brand_id`        | bigint        | yes      | null    | Foreign key to brands                            |
| `supplier_id`     | bigint        | yes      | null    | Foreign key to suppliers                         |
| `deleted_at`      | timestamp     | yes      | null    | Soft delete timestamp                            |
| `created_at`      | timestamp     | yes      | null    | Creation timestamp                               |
| `updated_at`      | timestamp     | yes      | null    | Last update timestamp                            |

## Relationships

### Brand

Each product can belong to a brand. This is a standard `BelongsTo` relationship.

```php theme={null}
$product->brand;
```

To query products by brand:

```php theme={null}
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.

```php theme={null}
$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.

```php theme={null}
$product->collections;

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

### Channels

Products can be assigned to multiple sales channels for multi-channel publishing.

```php theme={null}
$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.

```php theme={null}
$product->variants;

$product->variants()->create([
    'name' => 'Blue / Large',
    'sku' => 'TSHIRT-BLU-L',
]);
```

The `variants_stock` computed attribute returns the total stock across all variants.

```php theme={null}
$product->variants_stock;
```

See the [Product Variants](/v2/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.

```php theme={null}
$product->tags;

$product->tags()->attach([$tagId1, $tagId2]);
```

See the [Product Tags](/v2/product-tags) documentation for details.

### Supplier

Each product can optionally be linked to a supplier.

```php theme={null}
$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.

```php theme={null}
$product->options;
```

The pivot table stores the selected attribute value and an optional custom value for freeform input.

```php theme={null}
$product->options()->attach($attributeId, [
    'attribute_value_id' => $valueId,
    'attribute_custom_value' => 'Custom engraving text',
]);
```

### Related Products

You can link products together as related items for cross-selling on the storefront.

```php theme={null}
$product->relatedProducts;

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

### Discounts

Products can be associated with discounts through a polymorphic relationship.

```php theme={null}
$product->discounts;
```

See the [Discounts](/v2/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.

```php theme={null}
use Shopper\Models\Product;

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

### Filter by Channel

The `forChannel()` scope filters products assigned to one or more sales channels.

```php theme={null}
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.

```php theme={null}
$product->isPublished();
```

## Media

Products support three media collections through [Spatie MediaLibrary](https://spatie.be/docs/laravel-medialibrary). The collection names for the default gallery and thumbnail are defined in `config/shopper/media.php`.

| Collection      | Config key                                   | Behavior       | Description                             |
| --------------- | -------------------------------------------- | -------------- | --------------------------------------- |
| Default gallery | `shopper.media.storage.collection_name`      | Multiple files | Product gallery images                  |
| Thumbnail       | `shopper.media.storage.thumbnail_collection` | Single file    | Main product image for listings         |
| `files`         | -                                            | Multiple files | Downloadable files for virtual products |

To add a product thumbnail:

```php theme={null}
$collection = config('shopper.media.storage.thumbnail_collection');

$product->addMedia($file)->toMediaCollection($collection);
```

To add gallery images:

```php theme={null}
$collection = config('shopper.media.storage.collection_name');

$product->addMedia($file)->toMediaCollection($collection);
```

To retrieve the thumbnail URL with a specific conversion:

```php theme={null}
$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.

```php theme={null}
$product->addMedia($pdfPath)->toMediaCollection('files');

$downloadUrl = $product->getFirstMediaUrl('files');
```

To get all downloadable files for a product:

```php theme={null}
$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:

```php theme={null}
$price = $product->getPrice('USD');
```

To format the price for display, use the `shopper_money_format` helper. Prices are stored in cents.

```php theme={null}
shopper_money_format($price->amount, 'USD');
```

See the [Pricing](/v2/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:

```php theme={null}
$product->stock;
```

For variant products, use `variants_stock` to get the combined stock across all variants:

```php theme={null}
$product->variants_stock;
```

To get stock at a specific point in time, useful for reporting:

```php theme={null}
$product->getStock('2026-01-15');
$product->getStock(now()->subDays(7));
```

To get stock for a specific inventory location:

```php theme={null}
$product->stockInventory($warehouseId);
```

To check the stock alert threshold:

```php theme={null}
if ($product->stock <= $product->security_stock) {
    // low stock
}
```

### Modifying Stock

To increase stock (for example, when receiving a shipment from a supplier):

```php theme={null}
$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):

```php theme={null}
$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:

```php theme={null}
$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:

```php theme={null}
$product->clearStock(
    inventoryId: $inventory->id,
    newQuantity: 100,
    event: 'Stock reset',
);
```

<Warning>
  `clearStock()` deletes all inventory history records for the product. Use it only for resets or corrections, not for regular stock adjustments.
</Warning>

For variant products, stock is managed on each variant individually. See the [Product Variants stock management](/v2/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:

```php theme={null}
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:

```php theme={null}
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`.

```php theme={null}
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.

```php theme={null}
$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:

```php theme={null}
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](/v2/events#product-events) page.

## Creating Products

To create a standard product with a price and category:

```php theme={null}
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:

```php theme={null}
$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:

```php theme={null}
$product = Product::findBySlug('classic-t-shirt');
```

To get all published products with their relationships:

```php theme={null}
$products = Product::query()
    ->publish()
    ->with(['brand', 'categories', 'media'])
    ->get();
```

To get featured products:

```php theme={null}
$featured = Product::query()
    ->publish()
    ->where('featured', true)
    ->take(8)
    ->get();
```

To get products by category:

```php theme={null}
$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

| Permission        | Description            |
| ----------------- | ---------------------- |
| `browse_products` | View the products list |
| `read_products`   | View a single product  |
| `add_products`    | Create new products    |
| `edit_products`   | Edit existing products |
| `delete_products` | Delete products        |

### Product Variants

| Permission                | Description                |
| ------------------------- | -------------------------- |
| `browse_product_variants` | View variants on a product |
| `read_product_variants`   | View a single variant      |
| `add_product_variants`    | Create new variants        |
| `edit_product_variants`   | Edit existing variants     |
| `delete_product_variants` | Delete variants            |

## Components

You can publish the Livewire components to customize the admin UI for products:

```bash theme={null}
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.
