Skip to main content

Uniqueness and scope

What a slug is unique against

Uniqueness is always isolated per model type and per locale, so a German slug never collides with an English one and a Page never collides with an Article. Within that, a collision appends a numeric suffix: hello-world, hello-world-2, hello-world-3, using the model's separator.

The guarantee is enforced by the database, not just by the generator: a unique index over (type, locale, scope, lower(slug)) covers current, non-deleted rows. Comparison is case-insensitive, so Hello-World cannot coexist with hello-world.

Scoping uniqueness

Scope uniqueness to any column(s) — most commonly a tenant — so two tenants can each own the same slug without collision:

#[Polyslug(source: 'title', scope: 'tenant_id')]
class Page extends Model implements Sluggable
{
use HasPolyslug;
}

scope accepts a string or an array of column names. A scope is about slug uniqueness, not about visibility: it lets two tenants own hello, but it does not stop one tenant's URL from resolving to the other's row. That is polyslugResolveQuery(), and a multi-tenant model wants both.

Scoping on a parent key is what makes nested slugs work: the same segment may repeat under different parents.

Preserving the writing

A slug is folded to lower case when it is generated, so Octo-Org is stored as octo-org. Resolution is unaffected either way, but the original writing is gone: the page cannot render Octo-Org, and neither can any URL built from the route key.

That matters when the record mirrors something that is case-preserving and case-insensitive at once — a GitHub handle is the usual example. github.com/Octo-Org and github.com/octo-org reach the same account, and the page shows the writing its owner chose.

#[Polyslug(source: 'handle', idLess: true, preserveCase: true)]
final class Account extends Model implements Sluggable
{
use HasPolyslug;
}

Octo-Org is now stored as Octo-Org. Nothing else moves:

  • Uniqueness is unchanged. Octo-Org and octo-org are still one name, so the second record gets a disambiguating suffix exactly as before. The unique index has always folded.
  • Resolution is unchanged. /Octo-Org, /octo-org and /OCTO-ORG all reach the record, because the lookup folds both sides.
  • Existing models are unchanged. The flag is opt-in, and a model that does not set it keeps producing lower-case slugs.

It shows up in the URL only where the slug IS the route key, so it goes together with idLess: true. On a slug_id key the id resolves the record and the canonical redirect will rewrite the slug half anyway.

warning
Not with unicode: 'native'

The two are refused together, and the reason is measurable rather than stylistic. Uniqueness is enforced by an index that folds with the database's lower(), and those do not agree: PostgreSQL folds non-ASCII letters, SQLite does not. A folded slug hides the difference because every stored value is already lower case. An unfolded native slug would not — Ärger would collide with ärger on PostgreSQL and not on SQLite, so the uniqueness guarantee would depend on where the application runs. unicode: 'ascii' transliterates first, so every stored slug is ASCII and every engine folds it identically.

Letting records share a slug

Sometimes a collision is not a problem to be solved. Two social posts titled "Hello World" should both be hello-world — a -2 suffix would be noise, because the URL carries an id that already disambiguates them.

#[Polyslug(source: 'title', unique: false)]
class Post extends Model implements Sluggable
{
use HasPolyslug;
}

With unique: false the generator returns the base slug with no suffix, and the row is written with enforce_unique = false so the uniqueness index skips it. Both posts get /p/hello-world_a1B and /p/hello-world_c9D, and each resolves to its own record by the encoded id.

Three things to know:

  • The one-current-slug guarantee is untouched. A separate index still enforces exactly one current row per (type, id, locale, scope), so a record can never end up with two live slugs.
  • The reserved list is not applied. unique: false opts out of the whole collision-resolution step, and reserved words are part of it — there is no suffix to escape into, so a reserved base would have nowhere to go. Reserve words on models that keep unique: true.
  • It cannot be combined with idLess. An id-less URL is the slug alone, so the slug must stay unique to resolve; the combination throws Polyslug\Exceptions\MisconfiguredPolyslug at configuration time. See Slug-only URLs.

The social and user-generated content recipe shows the shape in context.