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-Organdocto-orgare 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-organd/OCTO-ORGall 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.
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: falseopts 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 keepunique: 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 throwsPolyslug\Exceptions\MisconfiguredPolyslugat configuration time. See Slug-only URLs.
The social and user-generated content recipe shows the shape in context.