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 number is the lowest one that is free. Finding it stays cheap for a title hundreds of records share: the generator asks about the suffixes in batches that double, so the 200th record with one title asks eight times rather than two hundred.

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.