agentsclimarketplace

Laravel product catalog

Skill aliziodev/laravel-product-catalog/skills/laravel-product-catalog

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

Install
npx -y skills add aliziodev/laravel-product-catalog --skill laravel-product-catalog

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

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.

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

21.7 KB, as published. Nobody here has run it

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   # interactive — publishes config + runs migrations

Or manually:

php artisan vendor:publish --tag=product-catalog-migrations
php artisan migrate
php artisan vendor:publish --tag=product-catalog-config  # optional

Key config (config/product-catalog.php):

'model'       => \App\Models\Product::class, // override when extending the base Product model
'table_prefix' => env('PRODUCT_CATALOG_TABLE_PREFIX', 'catalog_'),  // set BEFORE migrate
'inventory' => [
    'driver'           => env('PRODUCT_CATALOG_INVENTORY_DRIVER', 'database'), // 'database' | 'null' | custom
    'movement_reasons' => [],  // add app-specific reason strings here
],
'slug' => [
    'auto_generate'    => true,
    'route_key_length' => 8,   // random suffix length (4–32)
],
'search' => [
    'driver' => env('PRODUCT_CATALOG_SEARCH_DRIVER', 'database'), // 'database' | 'scout' | custom
],
'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;       // Simple | Variable
use Aliziodev\ProductCatalog\Enums\ProductStatus;     // Draft | Published | Private | Archived
use Aliziodev\ProductCatalog\Enums\InventoryPolicy;   // Track | Allow | Deny
use Aliziodev\ProductCatalog\Enums\InventoryReason;   // preset reason constants for audit trail
use Aliziodev\ProductCatalog\Facades\ProductCatalog;

Products

Creating a Product

// Simple product (single SKU)
$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
]);

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

Lifecycle

$product->publish();      // draft → published (fires ProductPublished event)
$product->unpublish();    // published → draft
$product->archive();      // → archived (fires ProductArchived event)
$product->makePrivate();  // → private (live but not publicly listed)

$product->isPublished();
$product->isDraft();
$product->isArchived();
$product->isPrivate();
$product->isLive();       // true for Published OR Private

// Status scopes
Product::published()->get();  // public storefront
Product::visible()->get();    // Published + Private (authenticated storefront)
Product::private()->get();    // Private only

Variants & Options

Variable Product — Options + Variants

// 1. Define option axes
$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]);

// 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,
    'is_default'    => true,
    'is_active'     => true,
    'meta'          => ['barcode' => '8991234567890'],
]);

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

// 4. Auto-generate SKU (must load optionValues first!)
$variant->load('optionValues');
$sku = $product->buildVariantSku($variant); // "RS-AIR-RED-42"
$variant->update(['sku' => $sku]);

Variant Helpers

$variant->displayName();         // "Red / 42" — human-readable label
$variant->isOnSale();            // true if compare_price > price
$variant->discountPercentage();  // 15 (integer percent)

⚠️ Pitfall: buildVariantSku() must be called after $variant->load('optionValues'). Without it the SKU is wrong.


Inventory

Setting Up InventoryItem

// ALWAYS create inventoryItem when creating a variant — even for Allow policy
$variant->inventoryItem()->create([
    'quantity'            => 100,
    'policy'              => InventoryPolicy::Track,  // Track | Allow | Deny
    'low_stock_threshold' => 10,
]);

Three policies:

PolicyBehaviour
TrackChecks actual stock; denies when quantity <= reserved_quantity
AllowAlways in stock — overselling permitted (digital, pre-order)
DenyAlways 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(); // resolves the active driver

// Read
$inventory->getQuantity($variant);         // available qty (total − reserved)
$inventory->isInStock($variant);           // bool
$inventory->canFulfill($variant, 10);      // bool

// Write (all run inside DB::transaction + lockForUpdate — race-condition safe)
$inventory->set($variant, 50, InventoryReason::STOCKTAKE);
$inventory->adjust($variant, -5, InventoryReason::SALE, $order);    // − deduct / + restock
$inventory->reserve($variant, 5, InventoryReason::ORDER_PLACED, $order);   // soft-hold
$inventory->release($variant, 5, InventoryReason::ORDER_CANCELLED, $order); // undo reserve
$inventory->commit($variant, 5, InventoryReason::ORDER_FULFILLED, $order);  // reserve → deduct

Reservation Lifecycle

reserve()  →  release()   (order cancelled / cart expired)
reserve()  →  commit()    (order fulfilled — quantity deducted permanently)
Operationquantityreserved_quantityavailable
reserve(5)unchanged+5−5
release(5)unchanged−5+5
commit(5)−5−5unchanged
adjust(-5)−5unchanged−5

InventoryItem Helpers (read-only)

$item = $variant->inventoryItem;

$item->availableQuantity();   // quantity - reserved_quantity
$item->isLowStock();          // availableQuantity <= low_stock_threshold

Built-in Inventory Drivers

DriverWhen to use
database (default)Stock tracked in DB with pessimistic locking — safe under concurrent requests
nullAlways 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 = Brand::create(['name' => 'Nike', 'slug' => 'nike']);
$product->update(['brand_id' => $brand->id]);

// Category (supports parent–child hierarchy)
$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]); // multiple categories

// Category tree
$tree = Category::whereNull('parent_id')->with('children')->orderBy('position')->get();

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

Querying

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

// Stock
Product::inStock()->get();          // has at least one purchasable active variant

// Local Eloquent scope search (name, code, description, SKU)
Product::search('RS-AIR')->get();

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

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

// Low stock alert
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

// config/product-catalog.php
'model' => \App\Models\Product::class,  // top-level — used by all subsystems
'search' => [
    'driver' => env('PRODUCT_CATALOG_SEARCH_DRIVER', 'database'),
],
// app/Models/Product.php
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');

// Scope
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

EventWhenKey payload
ProductPublished$product->publish()$event->product
ProductArchived$product->archive()$event->product
InventoryAdjustedadjust(), set(), commit()variant, previousQuantity, newQuantity, reason, movement
InventoryReservedreserve(), release()variant, type (MovementType), quantity, reservedBefore, reservedAfter, movement; helpers: isReserve(), isRelease()
InventoryLowStockWhen available crosses low_stock_thresholdvariant, availableQuantity, threshold, movement
InventoryOutOfStockWhen available drops to 0variant, 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;

// Register in EventServiceProvider
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

  1. Set table_prefix before migrating — changing it afterward requires manual table renames.
  2. Always create inventoryItem — variants without one are excluded from inStock() scope.
  3. buildVariantSku() requires load('optionValues') — call after syncing option values.
  4. Never update route_key — it is the permanent slug identifier. Changing it breaks all existing links.
  5. Soft-deleted Brand/Category$product->brand returns null. Handle gracefully: $product->brand?->name ?? 'No Brand'.
  6. Soft-deleted Tag pivot — the pivot row in catalog_product_tags persists after soft delete. Clean up in the Tag::forceDeleted event if needed.
  7. 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.
  8. 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.
  9. catalog:seed-demo is idempotent — safe to run multiple times; uses firstOrCreate and skips existing records.

Testing Setup

// tests/TestCase.php
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:

// ❌ N+1 — 2 queries per product (1 for minPrice, 1 for maxPrice)
$products = Product::published()->limit(10)->get();
foreach ($products as $p) {
    $p->minPrice(); // SELECT MIN(price) FROM ...
    $p->maxPrice(); // SELECT MAX(price) FROM ...
}

// ✅ 0 extra queries — in-memory collection is used
$products = Product::published()->with('variants')->limit(10)->get();
foreach ($products as $p) {
    $p->minPrice();   // no DB hit
    $p->maxPrice();   // no DB hit
    $p->priceRange(); // no DB hit
}

The helpers detect $this->relationLoaded('variants') and use the already-loaded collection when available.

Inventory access per variant

// ❌ N+1 — 1 query per variant (30 queries for 10 products × 3 variants)
$products = Product::published()->with('variants')->limit(10)->get();
foreach ($products as $p) {
    foreach ($p->variants as $v) {
        $_ = $v->inventoryItem; // lazy loaded
    }
}

// ✅ 3 queries total — products + variants + inventoryItems (batch)
$products = Product::published()->with('variants.inventoryItem')->limit(10)->get();
foreach ($products as $p) {
    foreach ($p->variants as $v) {
        $_ = $v->inventoryItem; // already in memory — 0 extra queries
    }
}

Product detail page (show endpoint)

Always include variants.inventoryItem — without it, every ->inventoryItem access in your view fires a query:

// ✅ Correct eager-load for a product detail page
Product::published()
    ->with(['brand', 'primaryCategory', 'tags', 'variants.inventoryItem', 'options.values'])
    ->bySlug($slug)
    ->firstOrFail();

Query Budget Reference

OperationQuery countNotes
Product::published()->with(['brand','primaryCategory','defaultVariant'])->paginate(15)≤ 5COUNT + SELECT + 3 eager-load batches
Product::published()->with('variants.inventoryItem')->limit(10)->get()3products + variants + inventoryItems
Product::published()->inStock()->paginate(15)2COUNT + SELECT (WHERE EXISTS, no JOIN)
Product detail page (all relations)≤ 8products + brand + category + tags + pivot + variants + inventoryItems + options + values
ProductSearchBuilder with 4 filters + paginate()≤ 5COUNT + SELECT + ≤3 eager-load batches

Tag Filter — Single Query

Multi-tag filtering uses a single IN (subquery) with GROUP BY / HAVINGnot N correlated WHERE EXISTS:

// Both resolve to a single SELECT
Product::withTag($tag->id)->get();                           // single WHERE EXISTS (1 tag)

ProductSearchBuilder::query('')
    ->withTags(['sale', 'new-arrival', 'featured'])
    ->get(); // WHERE id IN (SELECT product_id GROUP BY … HAVING COUNT(DISTINCT tag_id) = 3)

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):

TableColumnIndexUsage
catalog_productsnameidx_catalog_products_nameORDER BY name, prefix LIKE searches
catalog_inventory_itemspolicyidx_catalog_inventory_items_policyinStock() 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

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.