aliziodev / 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.
Package info
github.com/aliziodev/laravel-product-catalog
pkg:composer/aliziodev/laravel-product-catalog
Requires
- php: ^8.3
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
README
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.
Suitable For
| Use Case | Description |
|---|---|
| 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 & 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
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
- Full-text search across name, code, description, and variant SKUs
- Taxonomy: Brand, Category (parent–child hierarchy), Tag — all with soft delete
Variants & Options
ProductVariantas 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
metaJSON 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 - Low-stock threshold and alerts
- Append-only movement history (audit trail for every stock change)
- Driver pattern — swap the stock system without changing application code
- Built-in:
database(stock in DB) andnull(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
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.
ProductVariantis 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 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
- PHP ^8.3
- Laravel ^12.0 | ^13.0
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();
Table of Contents
- Suitable For
- Features
- Installation
- Configuration
- Basic Usage
- Slug Routing
- API Resources
- Events
- Inventory Policies
- Spatie Media Library Integration
- Custom Inventory Driver
- Use-Case Docs
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
// config/product-catalog.php return [ // 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), ], '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
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 & 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
use Aliziodev\ProductCatalog\Facades\ProductCatalog; $inventory = ProductCatalog::inventory(); // resolves configured driver // Set absolute quantity $inventory->set($variant, 50); // Adjust (positive = restock, negative = deduct) $inventory->adjust($variant, -5, 'sale', $order); // $order is optional reference model // Query $inventory->getQuantity($variant); // 45 $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 $item->release(3); // decrement reserved_quantity $item->isLowStock(); // true if availableQuantity <= low_stock_threshold
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
// 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();
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
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
| Event | Fired when |
|---|---|
ProductPublished |
$product->publish() |
ProductArchived |
$product->archive() |
InventoryAdjusted |
Stock changes via DatabaseInventoryProvider |
use Aliziodev\ProductCatalog\Events\ProductPublished; class SendNewProductNotification { public function handle(ProductPublished $event): void { // $event->product } }
Inventory Policies
Set per InventoryItem via policy column:
| Policy | Behaviour |
|---|---|
track |
Checks actual quantity; denies when quantity <= reserved_quantity |
allow |
Always in stock — overselling permitted (digital goods, pre-order) |
deny |
Always out of stock — variant is unavailable |
use Aliziodev\ProductCatalog\Enums\InventoryPolicy; $variant->inventoryItem()->create([ 'quantity' => 0, 'policy' => InventoryPolicy::Allow, // never runs out 'low_stock_threshold' => null, ]);
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:
<?php namespace App\Models; use Aliziodev\ProductCatalog\Models\Product as BaseProduct; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; class Product extends BaseProduct implements HasMedia { use InteractsWithMedia; public function registerMediaCollections(): void { $this->addMediaCollection('featured') ->singleFile() ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']); $this->addMediaCollection('gallery'); } public function registerMediaConversions(?Media $media = null): void { $this->addMediaConversion('thumb') ->width(400) ->height(400) ->sharpen(5); $this->addMediaConversion('webp') ->format('webp') ->quality(80); } }
Important: Laravel's service container
bind()does not affect Eloquent relationships. The package's$variant->product()is hardcoded toBaseProduct::classand will still return the base model. Choose one of the two approaches below.
Approach A — Simple (recommended for most projects)
Use App\Models\Product in all your app code. When you get a product through a variant relationship and need media, re-query with your model:
// In your controllers / services — always use your extended model use App\Models\Product; $product = Product::with('variants')->findOrFail($id); $product->getFirstMediaUrl('featured', 'thumb'); // ✓ works // When coming from a variant relationship, re-query: $product = App\Models\Product::find($variant->product_id); $product->getFirstMediaUrl('featured', 'thumb'); // ✓ works
Approach B — Override the relationship (complete solution)
Also extend ProductVariant to return your Product:
// app/Models/ProductVariant.php namespace App\Models; use Aliziodev\ProductCatalog\Models\ProductVariant as BaseVariant; use Illuminate\Database\Eloquent\Relations\BelongsTo; class ProductVariant extends BaseVariant { public function product(): BelongsTo { return $this->belongsTo(Product::class); // App\Models\Product } }
Then use App\Models\ProductVariant everywhere in your app code.
Upload and retrieve:
// Upload featured image $product->addMediaFromRequest('image')->toMediaCollection('featured'); // Upload gallery $product->addMediaFromRequest('gallery')->toMediaCollection('gallery'); // Get URLs $product->getFirstMediaUrl('featured', 'thumb'); $product->getMedia('gallery')->map->getUrl('webp');
Custom Inventory Driver
If your application already has its own stock system — your own inventories table, an ERP, or a third-party WMS — you don't have to use the package's catalog_inventory_items table. Implement InventoryProviderInterface and the package will talk to your system instead.
<?php namespace App\Inventory; use Aliziodev\ProductCatalog\Contracts\InventoryProviderInterface; use Aliziodev\ProductCatalog\Exceptions\InventoryException; use Aliziodev\ProductCatalog\Models\ProductVariant; use App\Models\Inventory; // your own inventory model use Illuminate\Database\Eloquent\Model; class AppInventoryProvider implements InventoryProviderInterface { public function getQuantity(ProductVariant $variant): int { return Inventory::where('sku', $variant->sku)->value('quantity') ?? 0; } public function isInStock(ProductVariant $variant): bool { return $this->getQuantity($variant) > 0; } public function canFulfill(ProductVariant $variant, int $quantity): bool { return $this->getQuantity($variant) >= $quantity; } public function adjust( ProductVariant $variant, int $delta, string $reason = '', ?Model $reference = null, ): void { $record = Inventory::where('sku', $variant->sku)->firstOrFail(); $newQty = $record->quantity + $delta; if ($newQty < 0) { throw InventoryException::insufficientStock($variant, abs($delta)); } $record->update(['quantity' => $newQty]); } public function set( ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null, ): void { Inventory::updateOrCreate( ['sku' => $variant->sku], ['quantity' => max(0, $quantity)] ); } }
Register the driver in a ServiceProvider:
use Aliziodev\ProductCatalog\Facades\ProductCatalog; public function boot(): void { ProductCatalog::extend('app', function ($app) { return new \App\Inventory\AppInventoryProvider; }); }
Activate via .env:
PRODUCT_CATALOG_INVENTORY_DRIVER=app
For more examples (ERP/WMS API, fallback strategy) see docs/custom-inventory-provider.md.
Testing
When writing tests for code that uses this package, set up your test case with migrations and factories:
// 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') ); } }
For tests that need the inventory facade, swap to the null driver so no DB records are required:
// tests/Feature/CheckoutTest.php use Aliziodev\ProductCatalog\Models\Product; use Aliziodev\ProductCatalog\Models\ProductVariant; use Aliziodev\ProductCatalog\Enums\InventoryPolicy; it('can add item to cart', function () { $variant = ProductVariant::factory()->create(['price' => 150000]); // Use Allow policy — no inventory record needed $variant->inventoryItem()->create(['quantity' => 0, 'policy' => InventoryPolicy::Allow]); // ... your test assertions });
To override the inventory driver in a specific test:
config(['product-catalog.inventory.driver' => 'null']);
Use-Case Docs
Detailed guides for specific scenarios:
| Guide | Description |
|---|---|
| Product Catalog | Read-only catalog with filtering, search, and slug routing |
| Online Store | Storefront with price display, stock badges, and cart-ready data |
| Simple Ecommerce | Minimal ecommerce setup with order integration |
| Internal Catalog | B2B / internal product database with cost price and meta fields |
| Digital & Physical Products | Mixed catalog with unlimited-stock and downloadable variants |
| Custom Inventory Provider | Connect ERP, WMS, Redis, or any external stock source |
| Configuration Reference | Deep dive into every config key with pitfalls and gotchas |
License
MIT — see LICENSE.
