Slug-only URLs
Prefer /blog/hello-world over /blog/hello-world_aB3xK? Set idLess: true — the URL is
the slug alone, and resolution is by slug instead of by id:
#[Polyslug(source: 'title', idLess: true)]
class Article extends Model implements Sluggable
{
use HasPolyslug;
}
How id-less resolution stays safe
Dropping the id means the slug carries identity, so Polyslug tightens what may happen to it:
- A current slug resolves directly (no redirect).
- A superseded slug still resolves, then
301s to the current URL — old links never die. - A retired slug stays reserved, so it can never be reassigned to a different model. Without that rule, renaming one article and then titling another the same way would silently hijack the first article's published URL.
That last point is the practical difference from an id-carrying model, where a superseded slug is free to be reused because the id still disambiguates.
The cost
Because the slug is the identity here, it must be unique per (type, locale, scope).
Choose sources that stay unique, and expect a -2 suffix on genuine collisions.
idLess: true combined with unique: false is rejected at configuration time with
Polyslug\Exceptions\MisconfiguredPolyslug: there is no encoded id to disambiguate
records that share a slug, so the combination cannot resolve. Use one or the other — see
letting records share a slug.
Reclaim
By default a retired slug stays reserved forever. Rename api to api-v2 and a later
record asking for api gets api-2 instead — the old URL keeps pointing at the record that
published it.
That is the right guarantee for a name your application owns, and the reason it is the default: without it, renaming a record becomes a way to take over a URL somebody else has already published.
It is the wrong guarantee for a name your application only mirrors. If the slug comes from
an external source that reassigns names — a mirrored account, an upstream registry — then the
source has already given api to somebody else, and reserving it makes your canonical URL
disagree with the thing it mirrors.
#[Polyslug(source: 'name', idLess: true, reclaim: true)]
With it set, a retired slug no longer blocks the name: the newcomer gets api, the previous
owner keeps api-v2, and /api serves the new owner. The previous owner's retired row
stays as history and remains reachable under the name it moved to.
What you give up
reclaim: true deliberately drops the guarantee above. On app-owned slugs that is a
takeover vector: rename your record, wait for someone to claim the freed name, and their
content is served at your published URL.
Use it only where an external source is already the authority on who holds the name.
Without idLess it is refused outright — see
mutually exclusive combinations.
When the events arrive out of order
reclaim frees a retired name. It does nothing about a name another record still holds
actively — and that is the state a mirror lands in whenever its upstream events do not
arrive in the expected order.
Upstream renames A from x to y and gives x to B. That is two deliveries. In the
expected order A is already retired when B arrives, and reclaim handles it. Lose A's
delivery — and a webhook sender does not always retry — and A still holds x when B
arrives. B is named x-2, and your canonical URL disagrees with the source from then on.
The refusal surfaces as a constraint error on a webhook that retries forever, so the cost
lands in a queue rather than on a screen.
#[Polyslug(source: 'name', idLess: true, reclaim: true, reclaimActive: true)]
The newcomer now takes the name from whoever holds it. The previous owner's row is retired inside the same transaction as the insert — so the name is never owned by nobody — and stays as history, so its old URL still resolves and redirects.
Its row is retired, not deleted, so nothing 404s. But until its own source is synced it has no canonical URL of its own, because the package cannot know what it should be called instead — only its source can say.
Listen for SlugReclaimed, which names the claimant
and the displaced record by type and key, and re-sync from there.
reclaimActive requires reclaim (and therefore idLess); anything else is refused with
MisconfiguredPolyslug. On an app-owned name a takeover would simply be a way to seize a
published URL, which is why none of this is ever the default.
Seeding: the write that must NOT take
Taking a name is right for a webhook, because the source has already handed it over and that handover is the truth. It is wrong for a backfill: two records that already exist and both want one name are a conflict in your data, not a handover. Taking there decides who owns the address by the order the rows came back, and reports nothing.
So a second, named write path lets the holder keep the name:
$page->seedSlug($locale, $source); // yields — the holder keeps it, this record gets a suffix
$page->setSlug($locale, $source); // takes — reclaimActive applies
polyslugSeed() is the same distinction one level up, standing to polyslugSync() as
seedSlug() does to setSlug(). polyslug:backfill uses it, so the shipped backfill can
no longer move a name between two existing records.
On a model that is not reclaimActive the two are identical — which is what makes seeding
safe to use everywhere, without first checking how each model is configured.
reclaimActive requires reclaim, so a boolean could only ever turn the behavior off —
and a parameter whose true means "do whatever the model already said" is a trap. The one
place the difference matters is the call site, which is exactly what a name shows.
Scope
Uniqueness is per (type, locale, scope), so on a scoped model two records may hold the
same slug legitimately — /@alice/toolkit and /@bob/toolkit both being toolkit.
The lookup cannot infer which one you meant. The write path derives the scope from the model's own attributes, but a slug-only read has nothing except a string. The resolution gate does not close this either: it answers what the environment (session, tenant, request context) says is visible, while a scope sitting in a path segment is an argument of the resolution. A gate that never receives the scope cannot separate by it.
Hand it over on the model:
#[Polyslug(source: 'title', scope: 'owner_id', idLess: true)]
final class Repository extends Model implements Sluggable
{
use HasPolyslug;
/** @return array<string, mixed>|null */
public function polyslugResolutionScope(): ?array
{
$owner = request()->route('owner');
return $owner === null ? null : ['owner_id' => Owner::idFor($owner)];
}
}
The lookup is then filtered by exactly the key the write path stored — same columns, same builder.
Make the silent case loud
Returning null keeps the old behavior: no scope filter, first match wins. That is the
default so an update cannot break a model that does not answer yet — but on a scoped model
it is rarely what you want, and it fails quietly.
'resolution' => ['require_scope' => true],
With that set, a scoped model whose caller names no scope is refused rather than resolved across scopes. Turn it on once your scoped models answer: a refusal is visible, a wrong record is not.
Routing
Id-less models bind and self-heal like any other model, so the
Route::polyslug() macro is still the
way to wire the route. They also compose with
nested paths — an id-less leaf under a nested parent yields a fully
readable path such as /guides/routing/installation.
The other half
idLess drops the id and keeps the slug. slugless is its mirror
image — it drops the slug and keeps the id, so the URL is the token alone
(/lists/k3f9dlq7). Reach for that one when the URL is meant to be short and opaque rather
than descriptive: a share link, a QR target, anything nobody searches for by name. Setting
both is refused, since together they leave nothing for the URL to carry.