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-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.