| name | laravel-product-catalog |
| license | MIT |
| description | Complete guide for the `aliziodev/laravel-product-catalog` package — a variant-centric Laravel product catalog with pluggable inventory management. Use this skill whenever working with this package: installation & setup, creating Products and ProductVariants, inventory management (adjust, reserve, release), taxonomy (Brand, Category, Tag), slug routing, search setup (database driver, ScoutSearchDriver, ProductSearchBuilder), custom inventory drivers, API Resources, or any question about the laravel-product-catalog package. Also trigger when user asks about: ProductCatalog facade, InventoryPolicy, inStock scope, buildVariantSku, priceRange, displayName, InventoryProviderInterface, ProductSearchBuilder, ScoutSearchDriver, `product-catalog.model`, or catalog:install artisan command.
|
laravel-product-catalog — Skill Guide
aliziodev/laravel-product-catalog is a variant-centric product catalog for Laravel 12+.
Core philosophy: Product is a presentation entity, ProductVariant is the sellable unit.
Inventory is pluggable — swap drivers without changing application code.
Installation & Setup
composer require aliziodev/laravel-product-catalog
php artisan catalog:install
Or manually:
php artisan vendor:publish --tag=product-catalog-migrations
php artisan migrate
php artisan vendor:publish --tag=product-catalog-config
Key config (config/product-catalog.php):
'model' => \App\Models\Product::class,
'table_prefix' => env('PRODUCT_CATALOG_TABLE_PREFIX', 'catalog_'),
'inventory' => [
'driver' => env('PRODUCT_CATALOG_INVENTORY_DRIVER', 'database'),
'movement_reasons' => [],
],
'slug' => [
'auto_generate' => true,
'route_key_length' => 8,
],
'search' => [
'driver' => env('PRODUCT_CATALOG_SEARCH_DRIVER', 'database'),
],
'routes' => [
'enabled' => env('PRODUCT_CATALOG_ROUTES_ENABLED', false),
'prefix' => 'catalog',
'middleware' => ['api'],
],
⚠️ Pitfall: Change table_prefix before running migrate. Changing it afterward orphans the old tables — manual rename required.
⚠️ Scout pitfall: ScoutSearchDriver does not use the package base Product
model directly. When enabling Scout, set the top-level product-catalog.model to your
application Product model that extends the package base model and uses both
Laravel\Scout\Searchable and the package Concerns\Searchable trait. This same key
is also used by the database search driver and the API controller.
Core Models & Namespaces
use Aliziodev\ProductCatalog\Models\Product;
use Aliziodev\ProductCatalog\Models\ProductVariant;
use Aliziodev\ProductCatalog\Models\InventoryItem;
use Aliziodev\ProductCatalog\Models\Brand;
use Aliziodev\ProductCatalog\Models\Category;
use Aliziodev\ProductCatalog\Models\Tag;
use Aliziodev\ProductCatalog\Enums\ProductType;
use Aliziodev\ProductCatalog\Enums\ProductStatus;
use Aliziodev\ProductCatalog\Enums\InventoryPolicy;
use Aliziodev\ProductCatalog\Enums\InventoryReason;
use Aliziodev\ProductCatalog\Facades\ProductCatalog;
Products
Creating a Product
$product = Product::create([
'name' => 'Wireless Mouse',
'code' => 'WM-001', // parent SKU (optional)
'type' => ProductType::Simple,
'short_description' => 'Ergonomic wireless mouse, 2.4 GHz',
'meta_title' => 'Wireless Mouse — Best Price',
'meta' => ['warranty' => '1 year'], // free-form JSON
]);
$product = Product::create([
'name' => 'Running Shoes',
'code' => 'RS-AIR',
'type' => ProductType::Variable,
]);
Lifecycle
$product->publish();
$product->unpublish();
$product->archive();
$product->makePrivate();
$product->isPublished();
$product->isDraft();
$product->isArchived();
$product->isPrivate();
$product->isLive();
Product::published()->get();
Product::visible()->get();
Product::private()->get();
Variants & Options
Variable Product — Options + Variants
$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]);
$s42 = $sizeOption->values()->create(['value' => '42', 'position' => 1]);
$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,
'is_default' => true,
'is_active' => true,
'meta' => ['barcode' => '8991234567890'],
]);
$variant->optionValues()->sync([$red->id, $s42->id]);
$variant->load('optionValues');
$sku = $product->buildVariantSku($variant);
$variant->update(['sku' => $sku]);
Variant Helpers
$variant->displayName();
$variant->isOnSale();
$variant->discountPercentage();
⚠️ Pitfall: buildVariantSku() must be called after $variant->load('optionValues'). Without it the SKU is wrong.
Inventory
Setting Up InventoryItem
$variant->inventoryItem()->create([
'quantity' => 100,
'policy' => InventoryPolicy::Track, // Track | Allow | Deny
'low_stock_threshold' => 10,
]);
Three policies:
| Policy | Behaviour |
|---|
Track | Checks actual stock; denies when quantity <= reserved_quantity |
Allow | Always in stock — overselling permitted (digital, pre-order) |
Deny | Always out of stock — variant unavailable |
⚠️ Critical pitfall: Product::inStock() uses whereHas('inventoryItem', ...).
Variants without an inventoryItem row are excluded from this scope,
even if the intent is Allow policy. Always create inventoryItem!
Inventory Operations via Facade
$inventory = ProductCatalog::inventory();
$inventory->getQuantity($variant);
$inventory->isInStock($variant);
$inventory->canFulfill($variant, 10);
$inventory->set($variant, 50, InventoryReason::STOCKTAKE);
$inventory->adjust($variant, -5, InventoryReason::SALE, $order);
$inventory->reserve($variant, 5, InventoryReason::ORDER_PLACED, $order);
$inventory->release($variant, 5, InventoryReason::ORDER_CANCELLED, $order);
$inventory->commit($variant, 5, InventoryReason::ORDER_FULFILLED, $order);
Reservation Lifecycle
reserve() → release() (order cancelled / cart expired)
reserve() → commit() (order fulfilled — quantity deducted permanently)
| Operation | quantity | reserved_quantity | available |
|---|
reserve(5) | unchanged | +5 | −5 |
release(5) | unchanged | −5 | +5 |
commit(5) | −5 | −5 | unchanged |
adjust(-5) | −5 | unchanged | −5 |
InventoryItem Helpers (read-only)
$item = $variant->inventoryItem;
$item->availableQuantity();
$item->isLowStock();
Built-in Inventory Drivers
| Driver | When to use |
|---|
database (default) | Stock tracked in DB with pessimistic locking — safe under concurrent requests |
null | Always in stock, no DB writes — for unlimited/digital across the whole app |
null driver vs InventoryPolicy::Allow:
null driver: all variants app-wide are always in stock
InventoryPolicy::Allow: only the variants you explicitly configure this way are unlimited
Taxonomy
$brand = Brand::create(['name' => 'Nike', 'slug' => 'nike']);
$product->update(['brand_id' => $brand->id]);
$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]);
$product->categories()->sync([$apparel->id, $shoes->id]);
$tree = Category::whereNull('parent_id')->with('children')->orderBy('position')->get();
$tag = Tag::create(['name' => 'new-arrival', 'slug' => 'new-arrival']);
$product->tags()->attach($tag);
Querying
Product::published()->get();
Product::draft()->get();
Product::inStock()->get();
Product::search('RS-AIR')->get();
Product::forBrand($brand)->published()->get();
Product::withTag($tag)->inStock()->get();
$product->priceRange();
$product->minPrice();
$product->maxPrice();
InventoryItem::lowStock()->with('variant.product')->get();
ProductSearchBuilder
use Aliziodev\ProductCatalog\Search\ProductSearchBuilder;
ProductSearchBuilder::query('kemeja')
->inCategory('t-shirts')
->withTags(['sale', 'new-arrival'])
->forBrand('stylehouse')
->priceBetween(50_000, 500_000)
->onlyInStock()
->withStatus('published')
->sortBy('price')
->sortAscending()
->paginate(24);
ScoutSearchDriver
'model' => \App\Models\Product::class,
'search' => [
'driver' => env('PRODUCT_CATALOG_SEARCH_DRIVER', 'database'),
],
use Aliziodev\ProductCatalog\Concerns\Searchable;
use Aliziodev\ProductCatalog\Models\Product as BaseProduct;
use Laravel\Scout\Searchable as ScoutSearchable;
class Product extends BaseProduct
{
use ScoutSearchable, Searchable;
}
use Aliziodev\ProductCatalog\Search\ProductSearchBuilder;
use Aliziodev\ProductCatalog\Search\ScoutSearchDriver;
ProductSearchBuilder::query('kemeja')
->usingDriver(app(ScoutSearchDriver::class))
->paginate(24);
ScoutSearchDriver delegates text search to Laravel Scout, then applies catalog
filters and optional sort via the Eloquent query() callback. Without an explicit
sort_by, Scout engine relevance is preserved.
Slug Routing
Slugs use a permanent random suffix — when a product is renamed, the slug prefix changes but the suffix stays the same. Old URLs remain valid.
/catalog/wireless-mouse-a1b2c3d4 ← original
/catalog/ergonomic-mouse-a1b2c3d4 ← after rename — same suffix, still resolves
$product = Product::findBySlug('ergonomic-mouse-a1b2c3d4');
$product = Product::findBySlugOrFail('ergonomic-mouse-a1b2c3d4');
Product::published()->bySlug($slug)->firstOrFail();
Enable built-in routes:
PRODUCT_CATALOG_ROUTES_ENABLED=true
# GET /catalog/products
# GET /catalog/products/{slug}
API Resources
use Aliziodev\ProductCatalog\Http\Resources\ProductResource;
$product = Product::with(['brand', 'primaryCategory', 'tags', 'variants'])->findOrFail($id);
return ProductResource::make($product);
Extend to add custom fields:
class CatalogProductResource extends ProductResource
{
public function toArray($request): array
{
return array_merge(parent::toArray($request), [
'price_range' => $this->resource->priceRange(),
]);
}
}
Events
| Event | When | Key payload |
|---|
ProductPublished | $product->publish() | $event->product |
ProductArchived | $product->archive() | $event->product |
InventoryAdjusted | adjust(), set(), commit() | variant, previousQuantity, newQuantity, reason, movement |
InventoryReserved | reserve(), release() | variant, type (MovementType), quantity, reservedBefore, reservedAfter, movement; helpers: isReserve(), isRelease() |
InventoryLowStock | When available crosses low_stock_threshold | variant, availableQuantity, threshold, movement |
InventoryOutOfStock | When available drops to 0 | variant, movement |
InventoryLowStock and InventoryOutOfStock fire on crossing only, not on every subsequent operation below the threshold. InventoryOutOfStock takes precedence — both never fire for the same operation.
use Aliziodev\ProductCatalog\Events\ProductPublished;
use Aliziodev\ProductCatalog\Events\InventoryLowStock;
use Aliziodev\ProductCatalog\Events\InventoryOutOfStock;
ProductPublished::class => [SendNewProductNotification::class],
InventoryLowStock::class => [NotifyPurchasingTeamListener::class],
InventoryOutOfStock::class => [DisableVariantListener::class],
Custom Inventory Driver
If stock is already managed in your own table / ERP / WMS, implement this interface:
use Aliziodev\ProductCatalog\Contracts\InventoryProviderInterface;
class AppInventoryProvider implements InventoryProviderInterface
{
public function getQuantity(ProductVariant $variant): int { ... }
public function isInStock(ProductVariant $variant): bool { ... }
public function canFulfill(ProductVariant $variant, int $quantity): bool { ... }
public function adjust(ProductVariant $variant, int $delta, string $reason = '', ?Model $reference = null): void { ... }
public function set(ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null): void { ... }
public function reserve(ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null): void { ... }
public function release(ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null): void { ... }
public function commit(ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null): void { ... }
}
Register in ServiceProvider:
ProductCatalog::extend('app', fn ($app) => new \App\Inventory\AppInventoryProvider);
Activate via .env:
PRODUCT_CATALOG_INVENTORY_DRIVER=app
See references/inventory.md for full examples (ERP API, fallback strategy).
Common Gotchas
- Set
table_prefix before migrating — changing it afterward requires manual table renames.
- Always create
inventoryItem — variants without one are excluded from inStock() scope.
buildVariantSku() requires load('optionValues') — call after syncing option values.
- Never update
route_key — it is the permanent slug identifier. Changing it breaks all existing links.
- Soft-deleted Brand/Category —
$product->brand returns null. Handle gracefully: $product->brand?->name ?? 'No Brand'.
- Soft-deleted Tag pivot — the pivot row in
catalog_product_tags persists after soft delete. Clean up in the Tag::forceDeleted event if needed.
Product::search() is not the Scout entrypoint by itself — for Scout integration, use your app Product model with ScoutSearchable and set the top-level product-catalog.model correctly.
- Manual slug override can throw
ProductCatalogException — setting slug explicitly on create or update will throw ProductCatalogException::duplicateSlug() if the slug is already taken. Auto-generated slugs (no slug field set) are always unique and never throw.
catalog:seed-demo is idempotent — safe to run multiple times; uses firstOrCreate and skips existing records.
Testing Setup
use Orchestra\Testbench\TestCase as OrchestraTestCase;
use Aliziodev\ProductCatalog\ProductCatalogServiceProvider;
abstract class TestCase extends OrchestraTestCase
{
use \Illuminate\Foundation\Testing\RefreshDatabase;
protected function getPackageProviders($app): array
{
return [ProductCatalogServiceProvider::class];
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(
base_path('vendor/aliziodev/laravel-product-catalog/database/migrations')
);
}
}
Override the driver in a specific test:
config(['product-catalog.inventory.driver' => 'null']);
Query Performance
N+1 Patterns to Avoid
Price helpers (minPrice / maxPrice / priceRange)
Without eager loading, each call hits the DB:
$products = Product::published()->limit(10)->get();
foreach ($products as $p) {
$p->minPrice();
$p->maxPrice();
}
$products = Product::published()->with('variants')->limit(10)->get();
foreach ($products as $p) {
$p->minPrice();
$p->maxPrice();
$p->priceRange();
}
The helpers detect $this->relationLoaded('variants') and use the
already-loaded collection when available.
Inventory access per variant
$products = Product::published()->with('variants')->limit(10)->get();
foreach ($products as $p) {
foreach ($p->variants as $v) {
$_ = $v->inventoryItem;
}
}
$products = Product::published()->with('variants.inventoryItem')->limit(10)->get();
foreach ($products as $p) {
foreach ($p->variants as $v) {
$_ = $v->inventoryItem;
}
}
Product detail page (show endpoint)
Always include variants.inventoryItem — without it, every ->inventoryItem access in your view fires a query:
Product::published()
->with(['brand', 'primaryCategory', 'tags', 'variants.inventoryItem', 'options.values'])
->bySlug($slug)
->firstOrFail();
Query Budget Reference
| Operation | Query count | Notes |
|---|
Product::published()->with(['brand','primaryCategory','defaultVariant'])->paginate(15) | ≤ 5 | COUNT + SELECT + 3 eager-load batches |
Product::published()->with('variants.inventoryItem')->limit(10)->get() | 3 | products + variants + inventoryItems |
Product::published()->inStock()->paginate(15) | 2 | COUNT + SELECT (WHERE EXISTS, no JOIN) |
| Product detail page (all relations) | ≤ 8 | products + brand + category + tags + pivot + variants + inventoryItems + options + values |
ProductSearchBuilder with 4 filters + paginate() | ≤ 5 | COUNT + SELECT + ≤3 eager-load batches |
Tag Filter — Single Query
Multi-tag filtering uses a single IN (subquery) with GROUP BY / HAVING — not N correlated WHERE EXISTS:
Product::withTag($tag->id)->get();
ProductSearchBuilder::query('')
->withTags(['sale', 'new-arrival', 'featured'])
->get();
Price Sort — Documented Limitation
->sortBy('price') adds a scalar correlated subquery in ORDER BY:
ORDER BY (SELECT MIN(price) FROM catalog_product_variants
WHERE product_id = catalog_products.id AND is_active = 1) ASC
This is evaluated once per row in the result set. Acceptable for catalogs up to ~50k products.
For larger catalogs, add a denormalized min_price column to catalog_products and keep it
synced via a model observer.
Database Indexes
The package ships these indexes out of the box (migration 2026_04_01_000013):
| Table | Column | Index | Usage |
|---|
catalog_products | name | idx_catalog_products_name | ORDER BY name, prefix LIKE searches |
catalog_inventory_items | policy | idx_catalog_inventory_items_policy | inStock() scope, lowStock() scope |
Reference Files
Read these when you need deeper coverage on a topic:
references/inventory.md — Full custom driver guide (own table, ERP API, fallback strategy)
references/use-cases.md — Patterns for: Public Catalog, Online Store, Simple Ecommerce, Internal Catalog, Digital+Physical
references/api-reference.md — Complete list of scopes, methods, enums, events, exceptions, DB tables