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

Uniqueness & concurrency

Slugs are unique globally by default. Narrow or drop the rule per definition:

use Illuminate\Database\Eloquent\Builder;

SlugDefinition::for('slug')->from('name')->unique();                          // global (default)
SlugDefinition::for('slug')->from('name')->uniqueWithin('clinic_id');         // per clinic — DB-indexable
SlugDefinition::for('slug')->from('name')->uniqueWithin('shop_id', 'category_id');
SlugDefinition::for('slug')->from('name')->uniqueWhere(
    fn (Builder $query, Post $post) => $query->where('type', $post->type),   // application-level only
);
SlugDefinition::for('code')->from('sku')->notUnique();
SlugDefinition::for('slug')->from('name')->excludeTrashed();                  // trashed rows free their slug

How collisions are checked

  • Without global scopes — a tenancy, published or SoftDeletes scope never hides a taken slug — then with the definition’s scope columns or closure, the trashed rule and the model’s own key excluded. NULL is a scope value to the check.
  • Soft-deleted rows keep their slug reserved by default (include_trashed), so restores are safe and a plain unique index agrees; excludeTrashed() frees them, and a restore then re-checks and re-suffixes if needed.
  • Reserved words (config and reserved()) count as taken.
  • Changing a scope column on update re-checks uniqueness and re-suffixes (or throws for Strict).
  • uniqueAcrossLocales() and uniqueWhere() closures are enforced by the application only.

Bounded probing

Probing is bounded and batched: the base first (one query in the common case), then sequential candidates in batches of 10 — at most six queries for 50 collisions — then 10 random suffixes, then SlugGenerationException::exhausted(). The limits.* config keys tune every bound.

Concurrency

Two writers can pick the same free slug. The unique index rejects the loser; sluggable catches the UniqueConstraintViolationException, re-probes with a locking read, re-suffixes and retries (concurrency.retries, default 3). Inside an outer transaction each attempt runs in a SAVEPOINT, so the transaction stays usable on PostgreSQL.

  • A violation that names no slug index is rethrown untouched.
  • A re-probe that finds nothing taken throws SlugGenerationException::constraintMismatch() — your index disagrees with the definition; run sluggable:indexes --dry-run.
  • A Strict manual value that loses the race throws SlugAlreadyTakenException; used-up retries throw retriesExhausted().
  • Each retry dispatches SlugCollisionRetried and re-fires creating/updating for your own listeners — use retries(0) on models with non-idempotent listeners.

Only sluggable’s own values are re-suffixed. Inside Slugs::withoutGeneration() the retry is off too: a duplicate slug you write yourself fails with the database’s UniqueConstraintViolationException. Under ManualSlugPolicy::Strict, a value no generation pass produced in this save — a manual value, or one saved with saveQuietly() without Slugs::apply() — throws SlugAlreadyTakenException instead of being rewritten.

SlugDefinition::for('slug')->from('name')->retries(5);   // more race retries
SlugDefinition::for('slug')->from('name')->retries(0);   // rethrow the violation untouched

Uniqueness without a database index — closure scopes, across-locales, or tables without the index — is best-effort under concurrency. Create the engine-native indexes to make it airtight.

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.