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
Suggests
- laravel/scout: Required to use the ScoutSearchDriver (^10.0). Enables Algolia, Meilisearch, Typesense, and database full-text search.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-30 19:30:06 UTC
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.
Table of Contents
- Suitable For
- Features
- Installation
- Upgrading
- Configuration
- Basic Usage
- Product Specifications
- Slug Routing
- API Resources
- Events
- Inventory Policies
- Spatie Media Library Integration
- Custom Inventory Driver
- Use-Case Docs
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 - Scheduled publishing — a future
published_atkeeps a published product hidden until that moment - 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 statusfromRequest()— 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 & 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 - 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)
MovementTypeenum:Restock,Deduction,Adjustment,Set,Reserve,ReleaseInventoryReasonconstants for consistent audit trail reason strings- 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,InventoryReserved,InventoryLowStock,InventoryOutOfStock - 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 --migrate
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
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 use the installer. It publishes the config and the migrations; --migrate also runs php artisan migrate (without it, run migrate yourself):
php artisan catalog:install --migrate
AI Assistants
The package ships a Laravel Boost skill in resources/boost/skills/laravel-product-catalog. In an app that uses Laravel Boost, run php artisan boost:install (or php artisan boost:update if Boost is already set up) to make it available to your AI assistant.
Upgrading
Run php artisan migrate after every update.
To 1.7.1
Index names. The prefix_catalog_index_names migration renames idx_catalog_products_name to {prefix}products_name_index and idx_catalog_inventory_items_policy to {prefix}inventory_items_policy_index. The old names ignored table_prefix, so on PostgreSQL and SQLite, where index names must be unique across the schema, a second prefix in the same database failed to migrate. On MySQL and PostgreSQL the rename only changes metadata; SQLite rebuilds the two indexes. If your own code refers to the old names, for example in a migration that drops them or an index hint in a raw query, update it.
To 1.7.0
Wider price columns. The widen_catalog_variant_price_columns migration changes price, compare_price and cost_price from decimal(12,4) to decimal(19,4). The old type stopped at 99,999,999.9999, which is under Rp 100 juta. Only columns that still have the original definition are changed, and nothing happens on SQLite. On MySQL the change rebuilds the product_variants table, so run it at a quiet time if the table is large.
Behaviour changes
- A negative
adjust()throwsInventoryExceptionwhen it would take units held byreserve(), i.e. when the new quantity would fall belowreserved_quantity. Ship reserved orders withcommit().set()still accepts any quantity for stock counts. If you reserve and then deduct withadjust()(the flow of the old online-store guide), switch tocommit(); otherwise the last units of a variant can no longer be shipped. reserve()throwsInventoryExceptionfor a variant with thedenypolicy. It used to do nothing, so the order went through.- All package events (
InventoryAdjusted,InventoryReserved,InventoryLowStock,InventoryOutOfStock,ProductPublished,ProductArchived) are dispatched after the database transaction commits, and dropped if it rolls back. A listener that relied on running inside the same transaction now runs after it. Product::published()andProduct::visible()leave out products whosepublished_atis in the future. The default search listing and the API usepublished(), so they hide those products too. Products with a nullpublished_atare not affected. UseProduct::scheduled()to list scheduled products.GET /catalog/products/{slug}leaves out inactive variants, andProductResource/ProductVariantResourceno longer includemeta. To include it again, setPRODUCT_CATALOG_ROUTES_EXPOSE_META=true, or add'expose_meta' => trueunderroutesif you published the config.- The product relations on
Brand,Category,Tag,ProductVariantandProductOptionreturn the model set inproduct-catalog.modelinstead of the baseProduct.
Scout. The setup shown in earlier docs, use ScoutSearchable, Searchable;, stops with a fatal trait collision. Use the insteadof block from docs/scout-integration.md. toSearchableArray() now loads its own relations, so run php artisan scout:import "App\Models\Product" once to fill the brand, category, tag, SKU and price fields that earlier saves left empty.
To 1.6.1
Schema repair for older installs. Releases 1.4.1, 1.5.0 and 1.6.0 changed create migrations that had already shipped, so installs created before them never received:
brands.logo_path(renamed fromlogo_urlin 1.4.1)- the
idx_catalog_products_nameandidx_catalog_inventory_items_policyindexes (1.5.0) inventory_movements.reserved_before/reserved_after(1.6.0) — without them every stock change fails with Unknown column 'reserved_before'
The new sync_catalog_schema_for_upgraded_installs migration adds whatever is missing and does nothing on a fresh install.
MySQL. Two create migrations failed on MySQL 8 and are fixed in this release. If you published the migrations, publish them again with php artisan vendor:publish --tag=product-catalog-migrations --force. If migrate stopped at create_catalog_inventory_movements_table, drop the half-created catalog_inventory_movements table before running migrate again.
Behaviour changes
reserve(),release()andcommit()throwInventoryExceptionfor a quantity below 1. A negative amount used to invert the operation, e.g.reserve(-5)freed stock held by other orders.InventoryItem::reserve()/release()reject negative amounts.commit()throwsInventoryExceptioninstead of writing a negative quantity when stock was set below the reserved amount.ProductSearchBuilder::fromRequest()ignores thestatusquery parameter, so?status=draftcan no longer list unpublished products. Admin screens should call->withStatus()explicitly.
Reservations. Earlier versions of docs/online-store.md reserved with $variant->inventoryItem->reserve() and shipped with adjust(), which never released the hold. If you used that flow, reserved_quantity has grown over time. Switch to ProductCatalog::inventory()->reserve(), release() and commit(), then recompute reserved_quantity from your open orders.
To 1.6.0
InventoryProviderInterface gained reserve(), release() and commit(). A custom inventory driver written for 1.5 or earlier must implement them — see docs/custom-inventory-provider.md.
Configuration
// config/product-catalog.php return [ // The Eloquent model used throughout the package (search drivers, API controller, // and the product relations on Brand, Category, Tag, ProductVariant, ProductOption). // 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'], // Include product and variant `meta` in API responses. Off by default // because `meta` often holds internal data (supplier codes, reorder points). 'expose_meta' => env('PRODUCT_CATALOG_ROUTES_EXPOSE_META', false), ], ];
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 // Scheduled publishing — hidden from published() until published_at passes $product->update(['published_at' => now()->addDays(3)]); $product->publish(); // keeps the existing published_at; ProductPublished fires now, not at go-live // State checks $product->isPublished(); // status only — true for a scheduled product too $product->isScheduled(); // published, but published_at is still in the future $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; use Aliziodev\ProductCatalog\Enums\InventoryReason; $inventory = ProductCatalog::inventory(); // resolves configured driver // Set absolute quantity — unrestricted, use for stocktakes and corrections $inventory->set($variant, 50, InventoryReason::STOCKTAKE); // Adjust (positive = restock, negative = deduct) — for unreserved movements: // damage, walk-in sale, restock, return. A deduction can only take available // stock (quantity − reserved) and throws InventoryException otherwise. // Reserved units leave through commit() — see Reservation Lifecycle below. $inventory->adjust($variant, -2, InventoryReason::DAMAGE); // optional 4th argument: a 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 <= low_stock_threshold
Reservation Lifecycle
Use the inventory driver for reserve/release/commit — these methods record movements and fire events.
use Aliziodev\ProductCatalog\Facades\ProductCatalog; use Aliziodev\ProductCatalog\Enums\InventoryReason; $inventory = ProductCatalog::inventory(); // 1. Customer places order — hold stock $inventory->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 // All three throw InventoryException when the quantity is below 1 // reserve() throws InventoryException when available stock < requested, // or when the variant's policy is Deny (Allow is a no-op) // commit() throws InventoryException when reserved_quantity < requested, // or when total quantity < requested (stock was set below the reservation)
Ship reserved units with commit(), not adjust(). A negative adjust() can only take available stock and throws InventoryException when it would drop quantity below reserved_quantity, so pending orders keep their units. Use set() for stock counts.
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'
The reason argument is a free string, stored on the movement record as given. The inventory.movement_reasons list in config/product-catalog.php mirrors these constants for reference only: the package never reads it, so adding a string there neither registers nor validates it. For app-specific reasons ('promotion', 'gift', …) define your own constants and pass them the same way; list them in movement_reasons too if you want to validate against it in your own code.
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(); // live now: published_at is null or in the past Product::scheduled()->get(); // published, but published_at is still in the future Product::visible()->get(); // Published + Private, same published_at rule 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
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:
| Domain | Typical attributes |
|---|---|
| Fashion | Material, Fit, Care instructions |
| Electronics | Wattage, Resolution, Storage capacity |
| Books | Genre, Language, Page count |
| Internal catalog | May not need spec filtering at all |
Because 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
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);
Results are limited to Product::published() by default, so drafts and products scheduled for a future published_at stay hidden. ->withStatus('published') behaves the same way.
The default driver is database (Eloquent LIKE, no extra dependencies). Switch to scout driver for Meilisearch, Algolia, or Typesense — see 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. Both traits define toSearchableArray()
and makeAllSearchableUsing(), so the model needs an insteadof block — see
docs/scout-integration.md. This same key is read by the database
search driver, the API controller and the package's product relations — 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
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}
GET /catalog/categories
GET /catalog/brands
The product endpoints return only products that are live (Product::published()). GET /catalog/products/{slug} includes active variants only — a variant with is_active = false is left out.
The routes are unnamed, so route() cannot generate them. Use url(config('product-catalog.routes.prefix').'/products'), or keep enabled = false and register your own named routes — see docs/configuration.md.
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 (product and variant) is left out of ProductResource and ProductVariantResource unless product-catalog.routes.expose_meta (PRODUCT_CATALOG_ROUTES_EXPOSE_META) is true. When it is off — the default — the meta key is absent, not null. It is off because meta often holds internal data such as supplier codes or reorder points.
Events
| Event | Fired when | Payload |
|---|---|---|
ProductPublished |
$product->publish() |
product |
ProductArchived |
$product->archive() |
product |
InventoryAdjusted |
adjust(), set(), or commit() changes total quantity |
variant, previousQuantity, newQuantity, reason, movement; delta() |
InventoryReserved |
reserve() or release() changes reserved_quantity |
see the listener below |
InventoryLowStock |
adjust(), set() or reserve() brings available stock from above low_stock_threshold to or below it |
variant, availableQuantity, threshold, movement |
InventoryOutOfStock |
adjust(), set() or reserve() brings available stock to 0; fired instead of InventoryLowStock |
variant, movement |
The two stock alerts fire once, on crossing — not again on later operations that stay below the line.
All six events implement ShouldDispatchAfterCommit. Inside a database transaction, listeners run after the outermost transaction commits, and never if it rolls back; with no open transaction they run immediately. So a listener never runs inside your order transaction or while the inventory row lock is held. Event::fake() still records them immediately.
ProductPublished fires when publish() is called — for a scheduled product that is before it goes live.
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
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; reserve() throws InventoryException |
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); } }
Point the package at your model:
// config/product-catalog.php 'model' => \App\Models\Product::class,
The package's product relations then return your model — $variant->product, $option->product, $brand->products, $category->products and $tag->products — so media methods work wherever the product comes from:
$variant->product->getFirstMediaUrl('featured', 'thumb'); // ✓ App\Models\Product
The class name does not matter (CatalogProduct works too): variants(), defaultVariant() and options() always use the product_id foreign key.
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(abs($delta), $record->quantity); } $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)] ); } // This table has no reserved column, so holds are not stored: // reserve() only checks availability and commit() deducts the stock. public function reserve( ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null, ): void { if ($quantity < 1) { throw InventoryException::invalidQuantity($quantity); } if (! $this->canFulfill($variant, $quantity)) { throw InventoryException::insufficientStock($quantity, $this->getQuantity($variant)); } } public function release( ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null, ): void { if ($quantity < 1) { throw InventoryException::invalidQuantity($quantity); } } public function commit( ProductVariant $variant, int $quantity, string $reason = '', ?Model $reference = null, ): void { if ($quantity < 1) { throw InventoryException::invalidQuantity($quantity); } $this->adjust($variant, -$quantity, $reason, $reference); } }
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, dispatching the package events from your driver) 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 go through the inventory facade, swap to the null driver: every variant is in stock and the write methods are no-ops, so no inventory records are required:
// tests/Feature/CheckoutTest.php use Aliziodev\ProductCatalog\Facades\ProductCatalog; use Aliziodev\ProductCatalog\Models\ProductVariant; it('can add item to cart', function () { config(['product-catalog.inventory.driver' => 'null']); $variant = ProductVariant::factory()->create(['price' => 150000]); expect(ProductCatalog::inventory()->canFulfill($variant, 5))->toBeTrue(); // ... your test assertions });
The driver does not change Product::inStock(), which reads inventoryItem rows directly. When a test needs that scope, or needs the database driver, give the variant an always-available item instead:
use Aliziodev\ProductCatalog\Enums\InventoryPolicy; $variant->inventoryItem()->create(['quantity' => 0, 'policy' => InventoryPolicy::Allow]);
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 |
| Scout Integration | Full-text search with Algolia, Meilisearch, or Typesense via Laravel Scout |
| Configuration Reference | Deep dive into every config key with pitfalls and gotchas |
License
MIT — see LICENSE.
