NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Shops for Laravel

Products & categories

Products and categories belong to an optional shop, generate a URL slug from their name and use it as the route key. Every product gets one default variant when it is created, and $product->price reads the default (lowest-position) variant’s price:

use RoundlyConsulting\Shops\Products\Category;
use RoundlyConsulting\Shops\Products\Product;

$category = Category::create(['name' => 'Beverages', 'published_at' => now()]);
// $category->slug === 'beverages'

$product = Product::create(['name' => 'Sparkling Water', 'published_at' => now()]);
$product->categories()->attach($category);   // many-to-many, pivot with timestamps

$product->price;   // RoundlyConsulting\Money\Money — proxied from the default variant

Relations at a glance

$product->variants();          // HasMany<ProductVariant>
$product->defaultVariant();    // HasOne<ProductVariant> — lowest position
$product->options();           // HasMany<ProductOption>, ordered by position
$product->categories();        // BelongsToMany<Category>
$product->shop();              // BelongsTo — the configured tenant model
$product->variantFor([$id]);   // ?ProductVariant matching exactly these option-value ids

Publishing

Products and categories share the HasPublishing concern, driven by a nullable published_at — publish now, schedule for later, or keep a draft:

Product::query()->published();     // published_at set and not in the future
Product::query()->unpublished();   // no published_at, or scheduled for later
Product::query()->forShop($shop)->published()->get();

$product->isPublished();           // bool

$product->update(['published_at' => now()->addWeek()]);   // schedule
$product->update(['published_at' => null]);               // back to draft

Category::query()->published()->get();                   // categories publish the same way

Translations

name, slug and description are translatable, stored as native per-locale JSON. Reads return the current locale’s value, falling back to shops.locales.fallback; a plain-string write is stored under the current locale, so single-language stores need no extra work:

$product->setTranslation('name', 'en', 'Sparkling Water');
$product->setTranslation('name', 'sk', 'Perlivá voda');
$product->save();

app()->setLocale('sk');
$product->name;                          // "Perlivá voda"
$product->getTranslation('name', 'en');  // "Sparkling Water"
$product->getTranslations('name');       // ['en' => 'Sparkling Water', 'sk' => 'Perlivá voda']

// A plain string is stored under the current locale:
$product->update(['description' => 'Naturally carbonated.']);

// Or set several locales at once:
Product::create(['name' => ['en' => 'Chair', 'sk' => 'Stolička']]);

Slugs are generated per locale from the name’s translations — see Slugs & URLs. Shop names translate the same way.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.