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

Schema & unique indexes

Three Blueprint macros cover the columns and the simple string index:

$table->slug('slug');                                // string(255)
$table->localizedSlug('slug');                       // jsonb (pgsql) / json (mysql) / text (sqlite)
$table->uniqueSlug('slug', scope: ['clinic_id']);    // string slug, trashed rows count as taken

Locale maps, and string slugs that exclude trashed rows, need engine-specific DDL. SlugIndexes builds it — run it after Schema::create():

use RoundlyConsulting\Sluggable\DataTransferObjects\SlugIndexSpec;
use RoundlyConsulting\Sluggable\Schema\SlugIndexes;

// after Schema::create(…):
SlugIndexes::ensure(SlugIndexSpec::localeMap('topics', 'slug', scope: ['shop_id']));
SlugIndexes::ensure(SlugIndexSpec::string('articles', 'slug', includeTrashed: false));
SlugIndexes::forModel(Topic::class);       // specs derived from the definitions (= Slugs::model(Topic::class)->indexes())
SlugIndexes::plan($spec);                  // DDL only

In a migration

use App\Models\Topic;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use RoundlyConsulting\Sluggable\Schema\SlugIndexes;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('topics', function (Blueprint $table) {
            $table->id();
            $table->json('name');
            $table->localizedSlug('slug');
            $table->timestamps();
        });

        SlugIndexes::forModel(Topic::class);   // one unique index per supported locale
    }
};

ensure() and forModel() accept a dryRun flag and return a SlugIndexReport listing the created and skipped indexes plus the statements. Outside a migration, Slugs::model(Topic::class)->indexes() runs the same forModel() call.

Per engine

EngineLocale mapExclude-trashed string
PostgreSQLCREATE UNIQUE INDEX … (("slug"->>'en')) per locale, partial WHERE deleted_at IS NULL when neededPartial unique index
SQLiteCREATE UNIQUE INDEX … (json_extract("slug", '$."en"')), partial when neededPartial unique index
MySQL / MariaDBAn INVISIBLE virtual column slug__en (utf8mb4_bin, NULL for missing / JSON null / trashed) + a unique indexINVISIBLE slug__live with the source collation + a unique index
  • Indexes are idempotent and named {table}_{column}[_{locale}]_slug_unique; names over 60 characters are shortened with a crc32 hash.
  • Locales come from SlugLocales::supported() at migrate time; add a locale later with php artisan sluggable:indexes. Removed locales keep their index — drop it by hand.
  • includeTrashed on a spec defaults to true — mirror the definition, or use forModel(), which also skips non-unique, closure-scoped and across-locales definitions.
  • MySQL: locale-map indexes are binary (Café and cafe are different slugs, like the app-level check); string slug columns keep your collation. MySQL lookups are not accelerated by the generated columns — they enforce uniqueness only.
  • SQL Server: generation and querying work; the index DDL throws UnsupportedDriverException.

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.