Token-only URLs
Want /lists/k3f9dlq7 instead of /lists/my-shopping-list_k3f9dlq7xm2bv4tc? Set
slugless: true — the URL is the encoded identity alone, with no slug and no delimiter
in front of it:
#[Polyslug(slugless: true)]
class ShoppingList extends Model implements Sluggable
{
use HasPolyslug;
}
It is the mirror image of idLess: that one drops the id and keeps
the slug, this one drops the slug and keeps the id. Setting both is refused — together
they leave nothing for the URL to carry.
There is no source to declare, because nothing is built from the record's columns. That
also means renaming the record cannot change its URL, which is the property a shared
or printed link needs.
When this is the right shape
For records whose URL is meant to be short and opaque rather than descriptive:
- a share link, where the recipient never types the URL and no one searches for it by name
- a QR target, where every character is print area
- a per-user list, cart or draft that has no public name to begin with
- a link shortener, where the URL is the product
For anything a search engine should read as a topic, keep the slug. A descriptive URL is worth more than eight saved characters on a public article.
Choosing the token
With no slug in front of it, the token IS the URL — so its length and its predictability are now the two decisions that matter. Both are the encoder's, not this option's.
How long
The token length is a setting, and it is a floor rather than a fixed width: a length whose space fills up yields to one character more instead of failing to issue a URL. So a short length is a real choice, not one that surfaces months later as an error on a page.
// config/polyslug.php — every model
'random_token' => ['length' => 8],
// or one model only
#[Polyslug(slugless: true, encoderOptions: ['length' => 8])]
| Length | Tokens that exist | Example |
|---|---|---|
| 4 | ~1.7 million | /lists/k3f9 |
| 6 | ~2.2 billion | /lists/k3f9dl |
| 8 | ~2.8 trillion | /lists/k3f9dlq7 |
| 10 | ~3.7 quadrillion | /lists/k3f9dlq7xm |
| 16 | ~8.0 × 10²⁴ | /lists/k3f9dlq7xm2bv4tc (default) |
Which of those is right depends on what the URL has to withstand, and only you know that. Where the route is authorized anyway and the token exists to identify a record, a short one costs nothing. Where the link itself is the access control — an unlisted page, a share URL sent to one person — the length is that control, and the table above is the number of tries it takes.
Changing the setting is safe at any time. A token is looked up in polyslug_tokens, never
recomputed from the key, so every URL already issued keeps resolving and only new records
use the new length. The table will legitimately hold a mix.
How random
Two schemes ship, and they are opposite trades.
Random (the default) draws every token independently, so the URL says nothing about the record: not its key, not its age, not how many others exist.
Sequential hands out the shortest token not yet taken — 0, 1, … z, then 00 —
so the first record's URL is one character and a hundred records still fit in two. That is
the shortest a URL can be, and what a link shortener is usually after.
'encoder' => Polyslug\Encoders\SequentialTokenEncoder::class,
'sequential_token' => ['length' => 1],
What it costs is that the URL is completely predictable. The token after k3f8 is k3f9,
so the whole set can be walked, and the token itself reports how many records exist and
roughly when this one appeared. On public content nobody is hiding, that costs nothing.
Where the URL alone protects the content it is the wrong scheme — and a minimum length
does not change that, it only moves where the counting starts.
Counted tokens are numbered when a record's URL is first built, not when the record is
saved, because the token is claimed by the encoder rather than by the write path. Ordinary
traffic therefore numbers them roughly in creation order; a bulk import that never renders
a link numbers nothing. Call Model::polyslugPreload($records) over the records in the
order you want if the numbering matters.
The alphabet
Both schemes draw from 0-9a-z by default. Pass your own to change that — it must be made
of URL-unreserved characters
(A-Z a-z 0-9 - . _ ~) and must not repeat one:
'random_token' => ['alphabet' => 'abcdefghjkmnpqrstuvwxyz23456789'],
That example drops the characters people confuse when reading a code off paper — 0/o,
1/l/i — at the cost of a slightly smaller space per character. Whether that trade is
worth making is yours; the package only insists the alphabet can count.
Links that already exist keep working
Switching an existing model to slugless does not break its published URLs. A request for
the old descriptive form resolves through the token at the end of it, and the canonical
middleware then 301s to the short form:
GET /lists/my-shopping-list_k3f9dlq7 → 301 → /lists/k3f9dlq7
Old links self-heal exactly as they do across an encoder change, so print, inbound links and search-engine entries keep landing.
What slugless refuses
Four options do nothing on a slugless model, so they are rejected rather than ignored — an option that silently does nothing reads as a behavior you have changed:
| Option | Why |
|---|---|
idLess | drops the other half of the URL; together there is nothing left |
maxLength | trims the slug. The URL's length is the token's length — see How long |
reserved | keeps a generated slug from taking a name, and none is generated |
source | exists to build a slug; leaving it reads as if renaming still changed the URL |
maxLength is the one worth stating twice, because it is the natural wrong guess: the URL
is long, maxLength is the option that shortens things. It has always trimmed the slug
only, on every model — on an ordinary #[Polyslug(source: 'title', maxLength: 6)] the
slug becomes six characters and the token after it is untouched.
Watching the space fill
php artisan polyslug:doctor reports how full each token space is, so a width that is
running out is visible before it runs out:
! identity tokens: 400 of 1,296 2-character tokens are taken (31%).
New tokens widen to 3 characters as this fills.
It never fails the run — a filling space is not a fault, and on a counted scheme it is exactly what is supposed to happen. See Diagnostics.