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

Slugs & URLs

Shop, product and category slugs come from sluggable-for-laravel. Each model stores one slug per locale in its slug column and uses it as the route key:

ModelUniqueExample
ShopAcross all shops, per localeacme, acme-2
ProductWithin its shop, per localeTwo shops may both sell chair; a second Chair in one shop is chair-2
CategoryWithin its shop, per localeSame as products
  • Generated per locale from name: Chair / Stolička becomes chair / stolicka.
  • Enforced by the database — the migrations add a unique index per supported locale (scoped by shop_id on products and categories), so even a race or a raw insert cannot duplicate one. Soft-deleted rows keep their slug reserved, so a restore never collides.
  • Manual slugs are normalised and made unique; a locale added later is filled in on the next save; an empty name gets a random slug.
  • Route binding matches the request locale, then shops.locales.fallback, then any locale — on every database engine, including tenant-scoped routes.
Route::get('/shops/{shop}/products/{product}', ShowProduct::class)->scopeBindings();
// /shops/acme/products/chair — another shop's "chair" is a 404

Every sluggable reader and scope is available on the three models:

$product->currentSlug();   // "stolicka" under sk
$product->slugFor('en');   // "chair"
$product->slugMap();       // ['en' => 'chair', 'sk' => 'stolicka']

Product::query()->forShop($shop)->whereSlug('chair')->first();

The default variant’s SKU derives from the fallback-locale slug (CHAIR-DEFAULT), so it is the same whatever locale the creating request ran under.

Slug history (opt-in)

Turn on slugs.history and publish sluggable’s migration — a shop, product or category whose slug changes keeps answering its old URL with a 301 to the new one. Renaming does not change the slug: a slug is generated once, from the first name, so links stay stable. To move the URL with a rename, set the new slug yourself ($product->setTranslation('slug', 'en', 'armchair')->save()) — with history on, the old one redirects:

# .env
SHOPS_SLUG_HISTORY=true

php artisan vendor:publish --tag="sluggable-migrations"
php artisan migrate

Upgrading an existing database

A database that ran the shops migrations before the sluggable integration has no slug indexes yet. Per model (Shops\Shop, Products\Product, Products\Category): find duplicates, preview and apply the rewrite, then add the indexes. Adding a locale later works the same way:

php artisan sluggable:duplicates "RoundlyConsulting\Shops\Products\Product"
php artisan sluggable:regenerate "RoundlyConsulting\Shops\Products\Product" --mode=all --dry-run
php artisan sluggable:regenerate "RoundlyConsulting\Shops\Products\Product" --mode=all
php artisan sluggable:indexes "RoundlyConsulting\Shops\Products\Product"

# Adding a locale later:
php artisan sluggable:regenerate "RoundlyConsulting\Shops\Products\Product" --locale=de
php artisan sluggable:indexes "RoundlyConsulting\Shops\Products\Product"

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.