PHPackages                             aliziodev/laravel-product-catalog - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. aliziodev/laravel-product-catalog

ActiveLibrary[API Development](/categories/api)

aliziodev/laravel-product-catalog
=================================

A professional, variant-centric product catalog package for Laravel. Covers product catalog, online store, ecommerce, internal catalog, digital &amp; physical products, and custom inventory integration.

v1.6.0(1mo ago)365MITPHPPHP ^8.3CI passing

Since Apr 22Pushed 1mo agoCompare

[ Source](https://github.com/aliziodev/laravel-product-catalog)[ Packagist](https://packagist.org/packages/aliziodev/laravel-product-catalog)[ Docs](https://github.com/aliziodev)[ RSS](/packages/aliziodev-laravel-product-catalog/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (10)Dependencies (7)Versions (12)Used By (0)

[![Laravel Product Catalog](https://raw.githubusercontent.com/aliziodev/laravel-product-catalog/refs/heads/main/docs/art.png)](https://raw.githubusercontent.com/aliziodev/laravel-product-catalog/refs/heads/main/docs/art.png)

 [![codecov](https://camo.githubusercontent.com/360ce32fe4ee6170fb5d16ab0c7b063e464a18250655a5a5cdaf1a4c7d398803/68747470733a2f2f636f6465636f762e696f2f67682f616c697a696f6465762f6c61726176656c2d70726f647563742d636174616c6f672f67726170682f62616467652e7376673f746f6b656e3d52434a54394343584138)](https://codecov.io/gh/aliziodev/laravel-product-catalog) [![Tests](https://github.com/aliziodev/laravel-product-catalog/workflows/Tests/badge.svg)](https://github.com/aliziodev/laravel-product-catalog/actions) [![Latest Version on Packagist](https://camo.githubusercontent.com/abc96437dbf4bee107541e8e703c9a563b8a6d210e0d73b4477eb98317bd7284/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f616c697a696f6465762f6c61726176656c2d70726f647563742d636174616c6f672e737667)](https://packagist.org/packages/aliziodev/laravel-product-catalog)
 [![Total Downloads](https://camo.githubusercontent.com/96b54936c38a9add6357118866033b6f320dd5a67f3fe87581058f186209e61c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f616c697a696f6465762f6c61726176656c2d70726f647563742d636174616c6f672e737667)](https://packagist.org/packages/aliziodev/laravel-product-catalog) [![PHP Version](https://camo.githubusercontent.com/9419b972c8b171394db19095b8237cc5e9548eda59f820d8c219d32c7aed8876/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f616c697a696f6465762f6c61726176656c2d70726f647563742d636174616c6f672e737667)](https://packagist.org/packages/aliziodev/laravel-product-catalog) [![Laravel Version](https://camo.githubusercontent.com/9d7b877f4b01967d5a2beee08a196c8bec9247518f42bf903d692b5e74ce620e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c61726176656c2d31322e302532422d6f72616e67652e737667)](https://laravel.com/) [![Ask DeepWiki](https://camo.githubusercontent.com/0f5ae213ac378635adeb5d7f13cef055ad2f7d9a47b36de7b1c67dbe09f609ca/68747470733a2f2f6465657077696b692e636f6d2f62616467652e737667)](https://deepwiki.com/aliziodev/laravel-product-catalog)

A professional, variant-centric product catalog package for Laravel 12+. Designed to be a stable foundation for any application that needs structured product data — from a simple internal catalog to a full ecommerce storefront — without locking you into a specific architecture.

---

Table of Contents
-----------------

[](#table-of-contents)

- [Suitable For](#suitable-for)
- [Features](#features)
- [Installation](#installation)
- [Configuration](#configuration)
- [Basic Usage](#basic-usage)
    - [Products](#products)
    - [Variants &amp; Options](#variants--options)
    - [Inventory](#inventory)
    - [Taxonomy](#taxonomy)
    - [Querying](#querying)
- [Product Specifications](#product-specifications)
- [Slug Routing](#slug-routing)
- [API Resources](#api-resources)
- [Events](#events)
- [Inventory Policies](#inventory-policies)
- [Spatie Media Library Integration](#spatie-media-library-integration)
- [Custom Inventory Driver](#custom-inventory-driver)
- [Use-Case Docs](#use-case-docs)

---

Suitable For
------------

[](#suitable-for)

Use CaseDescription**Product Catalog**Display products with filtering, search, and SEO-friendly slug routing**Online Store**Storefront with prices, discount badges, per-variant stock, and cart-ready data**Simple Ecommerce**Order integration with reserve/release stock and an audit trail of stock movements**Internal Catalog**Internal product database with product codes, cost prices, and custom metadata**Digital &amp; Physical**Mixed catalog — physical variants (tracked stock) and digital (unlimited) in one product**Custom Inventory**Stock already managed externally (ERP, WMS, your own table) — connect it via a single interface---

Features
--------

[](#features)

**Catalog**

- Products with lifecycle status: `draft` → `published` → `archived`
- Product code (`code`) as the parent SKU; per-variant SKUs for child variants
- Permanent slug routing — URLs stay valid even when the product name changes
- `ProductSearchBuilder` — fluent catalog-aware search with filters for category, brand, tags, price range, and stock status
- `fromRequest()` — map HTTP query-string params to the builder in one line
- Text search via LIKE (zero config) or MySQL FULLTEXT (opt-in via `search.fulltext = true`)
- Scout integration — plug in Algolia, Meilisearch, or Typesense via `ScoutSearchDriver`
- Taxonomy: Brand, Category (parent–child hierarchy), Tag — all with soft delete

**Variants &amp; Options**

- `ProductVariant` as the primary sellable unit, not Product
- String-based options (Color, Size, etc.) with no separate master table required
- Auto-generated label from combined option values: `"Red / XL"`
- Auto-generate SKU from product code + option values
- Sale price, compare price (discount), and cost price per variant
- Physical dimensions (weight, length, width, height) for shipping calculation
- `meta` JSON for custom attributes without additional migrations

**Inventory**

- Three policies per variant: `track` (deduct stock), `allow` (always available), `deny` (unavailable)
- Soft-reserve (`reserved_quantity`) to hold stock while awaiting payment
- Full reservation lifecycle via the driver: `reserve()` → `release()` / `commit()`
- Low-stock threshold and alerts
- Append-only movement history (audit trail for every stock change, including reservations)
- `MovementType` enum: `Restock`, `Deduction`, `Adjustment`, `Set`, `Reserve`, `Release`
- `InventoryReason` constants for consistent audit trail reason strings
- Driver pattern — swap the stock system without changing application code
- Built-in: `database` (stock in DB) and `null` (always in stock, for digital products)

**Extensibility**

- Custom inventory driver — integrate your own stock system via a single interface
- No forced image schema — integrate spatie/laravel-medialibrary or any media solution you prefer
- Events: `ProductPublished`, `ProductArchived`, `InventoryAdjusted`
- Configurable table prefix — safe to install alongside any existing schema

---

Why This Package
----------------

[](#why-this-package)

Most ecommerce packages bundle payment, cart, and order management alongside the catalog. This package does one thing well: **product catalog with variant-centric inventory**. You own the order flow.

- Product is a presentation entity. `ProductVariant` is the sellable unit.
- Inventory is pluggable — connect your own stock system via a single interface without touching your existing code.
- No forced image schema — integrate [spatie/laravel-medialibrary](https://spatie.be/docs/laravel-medialibrary) or your own solution.
- Configurable table prefix — safe to install alongside any existing schema.
- Slug routing that survives product renames (Shopee-style permanent route key).

---

Requirements
------------

[](#requirements)

- PHP **^8.3**
- Laravel **^12.0 | ^13.0**

---

Quick Start
-----------

[](#quick-start)

```
composer require aliziodev/laravel-product-catalog
```

```
php artisan catalog:install
```

```
use Aliziodev\ProductCatalog\Models\Product;
use Aliziodev\ProductCatalog\Models\ProductVariant;
use Aliziodev\ProductCatalog\Enums\ProductType;
use Aliziodev\ProductCatalog\Enums\InventoryPolicy;
use Aliziodev\ProductCatalog\Facades\ProductCatalog;

// 1. Create product
$product = Product::create(['name' => 'T-Shirt', 'code' => 'TS-001', 'type' => ProductType::Simple]);

// 2. Create variant
$variant = $product->variants()->create(['sku' => 'TS-001-WHT', 'price' => 150000, 'is_default' => true]);

// 3. Set stock
$variant->inventoryItem()->create(['quantity' => 100, 'policy' => InventoryPolicy::Track]);

// 4. Publish
$product->publish();

// 5. Query
Product::published()->inStock()->with('variants')->get();
```

Installation
------------

[](#installation)

```
composer require aliziodev/laravel-product-catalog
```

Publish and run the migrations:

```
php artisan vendor:publish --tag=product-catalog-migrations
php artisan migrate
```

Optionally publish the config:

```
php artisan vendor:publish --tag=product-catalog-config
```

Or run the interactive installer:

```
php artisan catalog:install
```

---

Configuration
-------------

[](#configuration)

```
// config/product-catalog.php
return [

    // The Eloquent model used throughout the package (search drivers, API controller).
    // Override when extending the base Product model in your application.
    // Your model must extend Aliziodev\ProductCatalog\Models\Product.
    'model' => \Aliziodev\ProductCatalog\Models\Product::class,

    // Prefix for all package tables. Change BEFORE running migrations.
    'table_prefix' => env('PRODUCT_CATALOG_TABLE_PREFIX', 'catalog_'),

    'inventory' => [
        // Built-in: 'database' (tracks stock in DB), 'null' (always in stock).
        // Register custom drivers via ProductCatalog::extend().
        'driver' => env('PRODUCT_CATALOG_INVENTORY_DRIVER', 'database'),
    ],

    'slug' => [
        // Regenerate the slug prefix when the product name changes.
        'auto_generate'    => true,
        'separator'        => '-',
        // Length of the permanent random suffix (4–32). Recommended: 8.
        'route_key_length' => (int) env('PRODUCT_CATALOG_ROUTE_KEY_LENGTH', 8),
    ],

    'search' => [
        // Built-in: 'database' (default) or 'scout'.
        'driver' => env('PRODUCT_CATALOG_SEARCH_DRIVER', 'database'),
    ],

    'routes' => [
        // Set true to register the built-in read-only catalog API routes.
        'enabled'    => env('PRODUCT_CATALOG_ROUTES_ENABLED', false),
        'prefix'     => env('PRODUCT_CATALOG_ROUTES_PREFIX', 'catalog'),
        'middleware' => ['api'],
    ],
];
```

---

Basic Usage
-----------

[](#basic-usage)

### Products

[](#products)

```
use Aliziodev\ProductCatalog\Models\Product;
use Aliziodev\ProductCatalog\Enums\ProductType;

// Simple product (single SKU)
$product = Product::create([
    'name'              => 'Wireless Mouse',
    'code'              => 'WM-001',        // optional parent SKU / product code
    'type'              => ProductType::Simple,
    'short_description' => 'Ergonomic wireless mouse, 2.4 GHz.',
    'meta_title'        => 'Wireless Mouse — Best Price',
    'meta'              => ['warranty' => '1 year'],
]);

// Lifecycle
$product->publish();    // draft → published, fires ProductPublished event
$product->unpublish();  // published → draft
$product->archive();    // → archived, fires ProductArchived event

// State checks
$product->isPublished();
$product->isDraft();
$product->isArchived();
$product->isSimple();
$product->isVariable();
```

### Variants &amp; Options

[](#variants--options)

```
use Aliziodev\ProductCatalog\Models\ProductVariant;
use Aliziodev\ProductCatalog\Enums\ProductType;

// Variable product
$product = Product::create([
    'name' => 'Running Shoes',
    'code' => 'RS-AIR',
    'type' => ProductType::Variable,
]);

// Define options
$colorOption = $product->options()->create(['name' => 'Color', 'position' => 1]);
$red  = $colorOption->values()->create(['value' => 'Red',  'position' => 1]);
$blue = $colorOption->values()->create(['value' => 'Blue', 'position' => 2]);

$sizeOption = $product->options()->create(['name' => 'Size', 'position' => 2]);
$size42 = $sizeOption->values()->create(['value' => '42', 'position' => 1]);
$size43 = $sizeOption->values()->create(['value' => '43', 'position' => 2]);

// Create variant
$variant = ProductVariant::create([
    'product_id'    => $product->id,
    'sku'           => 'RS-AIR-RED-42',
    'price'         => 850000,
    'compare_price' => 1000000,    // original price (for sale badge)
    'cost_price'    => 500000,     // internal cost
    'weight'        => 0.350,
    'length'        => 30,
    'width'         => 15,
    'height'        => 12,
    'is_default'    => true,
    'is_active'     => true,
    'meta'          => ['barcode' => '8991234567890'],
]);

// Attach option values to variant
$variant->optionValues()->sync([$red->id, $size42->id]);

// Auto-generate SKU from product code + option values
$variant->load('optionValues');
$suggested = $product->buildVariantSku($variant); // "RS-AIR-RED-42"

// Human-readable label
$variant->displayName();        // "Red / 42"

// Pricing helpers
$variant->isOnSale();           // true — compare_price > price
$variant->discountPercentage(); // 15 (int)
```

### Inventory

[](#inventory)

```
use Aliziodev\ProductCatalog\Facades\ProductCatalog;
use Aliziodev\ProductCatalog\Enums\InventoryReason;

$inventory = ProductCatalog::inventory(); // resolves configured driver

// Set absolute quantity
$inventory->set($variant, 50);

// Adjust (positive = restock, negative = deduct)
$inventory->adjust($variant, -5, InventoryReason::SALE, $order); // $order is optional reference model

// Query
$inventory->getQuantity($variant);        // available quantity (total − reserved)
$inventory->isInStock($variant);          // true
$inventory->canFulfill($variant, 10);     // true

// Built-in drivers:
// 'database' (default) — tracks stock in catalog_inventory_items
// 'null'               — always in stock, no DB writes (digital/unlimited goods)
// To use null driver: PRODUCT_CATALOG_INVENTORY_DRIVER=null in .env
// For per-variant unlimited stock use InventoryPolicy::Allow instead (more granular)

// Direct model helpers (InventoryItem)
$item = $variant->inventoryItem;
$item->availableQuantity();  // quantity - reserved_quantity
$item->reserve(3);           // increment reserved_quantity (no audit trail)
$item->release(3);           // decrement reserved_quantity (no audit trail)
$item->isLowStock();         // true if availableQuantity reserve($variant, 3, InventoryReason::ORDER_PLACED, $order);
// reserved_quantity: +3, total quantity: unchanged, available: −3

// 2a. Order cancelled — release the hold
$inventory->release($variant, 3, InventoryReason::ORDER_CANCELLED, $order);
// reserved_quantity: −3, total quantity: unchanged, available: +3

// 2b. Order fulfilled — convert reservation to permanent deduction
$inventory->commit($variant, 3, InventoryReason::ORDER_FULFILLED, $order);
// reserved_quantity: −3, total quantity: −3, available: unchanged

// reserve() throws InventoryException when available stock < requested
// commit() throws InventoryException when reserved_quantity < requested
```

### InventoryReason

[](#inventoryreason)

Use `InventoryReason` constants to keep reason strings consistent across your application.

```
use Aliziodev\ProductCatalog\Enums\InventoryReason;

// Restock
InventoryReason::PURCHASE        // 'purchase'
InventoryReason::RETURN_ITEM     // 'return'

// Deduction
InventoryReason::SALE            // 'sale'
InventoryReason::DAMAGE          // 'damage'
InventoryReason::EXPIRY          // 'expiry'

// Adjustment / Set
InventoryReason::CORRECTION      // 'correction'
InventoryReason::STOCKTAKE       // 'stocktake'

// Reserve
InventoryReason::ORDER_PLACED    // 'order_placed'
InventoryReason::CART_HOLD       // 'cart_hold'

// Release
InventoryReason::ORDER_CANCELLED // 'order_cancelled'
InventoryReason::CART_RELEASED   // 'cart_released'
InventoryReason::TIMEOUT         // 'timeout'

// Commit
InventoryReason::ORDER_FULFILLED // 'order_fulfilled'
```

Add your own reason strings to `config/product-catalog.php`:

```
'inventory' => [
    'movement_reasons' => [
        // built-in reasons ...
        'promotion',     // custom reason for your app
        'gift',
    ],
],
```

### Taxonomy

[](#taxonomy)

```
use Aliziodev\ProductCatalog\Models\Brand;
use Aliziodev\ProductCatalog\Models\Category;
use Aliziodev\ProductCatalog\Models\Tag;

// Brand
$brand = Brand::create(['name' => 'Nike', 'slug' => 'nike']);
$product->update(['brand_id' => $brand->id]);

// Category (supports parent–child nesting)
$apparel  = Category::create(['name' => 'Apparel',  'slug' => 'apparel']);
$shoes    = Category::create(['name' => 'Shoes',    'slug' => 'shoes', 'parent_id' => $apparel->id]);

$product->update(['primary_category_id' => $shoes->id]);

// Assign multiple categories
$product->categories()->sync([$apparel->id, $shoes->id]);

// Tags
$tag = Tag::create(['name' => 'new-arrival', 'slug' => 'new-arrival']);
$product->tags()->attach($tag);
```

### Querying

[](#querying)

```
// Status scopes
Product::published()->get();
Product::draft()->get();

// Price range (active variants only)
$product->minPrice();      // float|null
$product->maxPrice();      // float|null
$product->priceRange();    // ['min' => 850000.0, 'max' => 1200000.0] | null

// Stock scope — products with at least one purchasable active variant
// NOTE: variants without an inventoryItem record are excluded from this scope.
// Always create an inventoryItem when creating a variant, even for Allow policy.
Product::inStock()->get();

// Search across name, code, short_description, and variant SKUs
Product::search('RS-AIR')->get();

// Filter
Product::forBrand($brand)->published()->get();
Product::withTag($tag)->inStock()->get();

// Low stock alert
use Aliziodev\ProductCatalog\Models\InventoryItem;

InventoryItem::lowStock()->with('variant.product')->get();
```

---

Product Specifications
----------------------

[](#product-specifications)

Both `Product` and `ProductVariant` have a `meta` JSON column for storing arbitrary key-value data — including product specifications.

```
$product = Product::create([
    'name' => 'Kaos Polo',
    'meta' => [
        'material'    => 'Katun 100%',
        'origin'      => 'Indonesia',
        'care'        => 'Cuci maks 30°C',
        'weight_gram' => 200,
    ],
]);

$product->meta['material']; // 'Katun 100%'
```

**Why there is no dedicated `product_attributes` table in this package**

A filterable attribute system is a common need — but not a *universal* one:

DomainTypical attributesFashionMaterial, Fit, Care instructionsElectronicsWattage, Resolution, Storage capacityBooksGenre, Language, Page countInternal catalogMay not need spec filtering at allBecause attribute schemas differ across domains, a single built-in implementation would either be too opinionated (forcing a schema that doesn't fit your domain) or too generic (adding migration weight for every consumer, even those that don't need it). The `meta` JSON column is the right default for specs that are display-only.

**When to add filterable attributes at the application level**

If your application needs to filter or search products by specification value, add a `product_attributes` table in your own migration:

```
Schema::create('product_attributes', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('product_id'); // references catalog_products.id
    $table->string('key');
    $table->string('value');
    $table->index(['product_id', 'key']);
});
```

Extend the `Product` model in your application:

```
// app/Models/Product.php
class Product extends BaseProduct
{
    public function attributes(): HasMany
    {
        return $this->hasMany(ProductAttribute::class);
    }
}
```

Filter by attribute:

```
Product::published()
    ->whereHas('attributes', fn ($q) =>
        $q->where('key', 'material')->where('value', 'Katun')
    )
    ->get();
```

This keeps the package lean and gives your application full control over the attribute schema, indexing strategy, and query patterns.

---

Search
------

[](#search)

```
use Aliziodev\ProductCatalog\Search\ProductSearchBuilder;

// Fluent API
ProductSearchBuilder::query('kemeja')
    ->inCategory('t-shirts')           // slug or ID
    ->withTags(['sale', 'new-arrival']) // AND logic, slug or ID
    ->forBrand('stylehouse')            // slug or ID
    ->priceBetween(50_000, 500_000)
    ->onlyInStock()
    ->sortBy('price')->sortAscending()
    ->paginate(24);

// Build from HTTP request — maps q, category, brand, tag/tags[],
// min_price, max_price, in_stock, type, sort_by, sort_direction
ProductSearchBuilder::fromRequest($request)->paginate(24);

// Control which relations are eager-loaded
ProductSearchBuilder::query('laptop')
    ->withRelations(['brand', 'primaryCategory', 'tags', 'defaultVariant'])
    ->paginate(20);
```

The default driver is `database` (Eloquent LIKE, no extra dependencies). Switch to `scout` driver for Meilisearch, Algolia, or Typesense — see [docs/scout-integration.md](docs/scout-integration.md).

```
# .env
PRODUCT_CATALOG_SEARCH_DRIVER=database  # or: scout

# Optional — MySQL/MariaDB only, requires FULLTEXT index
PRODUCT_CATALOG_SEARCH_FULLTEXT=false
```

```
// config/product-catalog.php
'model' => \App\Models\Product::class,  // top-level — used by all subsystems
'search' => [
    'driver' => env('PRODUCT_CATALOG_SEARCH_DRIVER', 'database'),
],
```

When using `scout`, set the top-level `model` key to your application Product model that extends the package base model and uses both `Laravel\Scout\Searchable` and `Aliziodev\ProductCatalog\Concerns\Searchable`. This same key is read by the database search driver and the API controller — configure your extended model once, everywhere.

```
// Custom search driver
use Aliziodev\ProductCatalog\Facades\ProductCatalog;

ProductCatalog::extendSearch('typesense', function ($app) {
    return new \App\Search\TypesenseSearchDriver;
});
```

```
PRODUCT_CATALOG_SEARCH_DRIVER=typesense
```

---

Slug Routing
------------

[](#slug-routing)

Slugs use a permanent random `route_key` suffix (Shopee-style). Renaming a product regenerates the slug prefix but keeps the same route key, so old URLs still resolve.

```
/catalog/wireless-mouse-a1b2c3d4   ← original slug
/catalog/ergonomic-mouse-a1b2c3d4  ← after rename — same route_key suffix

```

```
// Find by slug (both old and new slugs resolve)
$product = Product::findBySlug('ergonomic-mouse-a1b2c3d4');
$product = Product::findBySlugOrFail('ergonomic-mouse-a1b2c3d4');

// Scope variant
Product::published()->bySlug($slug)->firstOrFail();
```

Enable the built-in read-only API routes:

```
// config/product-catalog.php
'routes' => [
    'enabled' => true,
    'prefix'  => 'catalog',
],
```

```
GET /catalog/products
GET /catalog/products/{slug}

```

---

API Resources
-------------

[](#api-resources)

```
use Aliziodev\ProductCatalog\Http\Resources\ProductResource;
use Aliziodev\ProductCatalog\Http\Resources\ProductVariantResource;

$product = Product::with(['brand', 'primaryCategory', 'tags', 'variants'])->findOrFail($id);

return ProductResource::make($product);
```

Response shape:

```
{
  "id": 1,
  "name": "Running Shoes",
  "code": "RS-AIR",
  "slug": "running-shoes-a1b2c3d4",
  "type": "variable",
  "status": "published",
  "featured_image_path": null,
  "brand": { "id": 1, "name": "Nike" },
  "variants": [
    {
      "id": 1,
      "sku": "RS-AIR-RED-42",
      "price": 850000,
      "compare_price": 1000000,
      "is_on_sale": true,
      "discount_percentage": 15,
      "weight": 0.35,
      "length": 30,
      "width": 15,
      "height": 12,
      "meta": { "barcode": "8991234567890" }
    }
  ]
}
```

---

Events
------

[](#events)

EventFired when`ProductPublished``$product->publish()``ProductArchived``$product->archive()``InventoryAdjusted``adjust()`, `set()`, or `commit()` changes total quantity`InventoryReserved``reserve()` or `release()` changes `reserved_quantity````
use Aliziodev\ProductCatalog\Events\ProductPublished;
use Aliziodev\ProductCatalog\Events\InventoryReserved;

class SendNewProductNotification
{
    public function handle(ProductPublished $event): void
    {
        // $event->product
    }
}

class HandleStockReservation
{
    public function handle(InventoryReserved $event): void
    {
        // $event->variant
        // $event->type          — MovementType::Reserve or MovementType::Release
        // $event->quantity      — positive for reserve, negative for release
        // $event->reservedBefore
        // $event->reservedAfter
        // $event->reason
        // $event->movement      — the InventoryMovement record
        // $event->isReserve()   — true when type is Reserve
        // $event->isRelease()   — true when type is Release
    }
}
```

---

Inventory Policies
------------------

[](#inventory-policies)

Set per `InventoryItem` via `policy` column:

PolicyBehaviour`track`Checks actual quantity; denies when `quantity inventoryItem()->create([
    'quantity'           => 0,
    'policy'             => InventoryPolicy::Allow,  // never runs out
    'low_stock_threshold' => null,
]);
```

---

Spatie Media Library Integration
--------------------------------

[](#spatie-media-library-integration)

This package intentionally excludes a built-in image gallery to stay compatible with any media solution your application already uses.

Install spatie/laravel-medialibrary:

```
composer require spatie/laravel-medialibrary
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate
```

Extend the `Product` model in your application:

```
