Skip to main content

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])]
LengthTokens that existExample
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.

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:

OptionWhy
idLessdrops the other half of the URL; together there is nothing left
maxLengthtrims the slug. The URL's length is the token's length — see How long
reservedkeeps a generated slug from taking a name, and none is generated
sourceexists 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.