Identity encoders
The token that stands in for the primary key is produced by a pluggable
IdentityEncoder. The default is RandomTokenEncoder: it stores an unguessable random
token per key, so the URL reveals nothing — not the key, not the row count, not how fast
the table grows. It costs one row in polyslug_tokens per record and one INSERT the
first time a record's URL is rendered.
Pick another one when it fits your keys better:
// config/polyslug.php
'encoder' => \Polyslug\Encoders\UuidEncoder::class,
SqidsEncoder is fully supported and is the right choice when you want short,
deterministic tokens over an id space that is not sensitive. But a Sqids token decodes
straight back to the primary key, so every URL leaks the key, the creation order and
the growth rate — and an unguessable URL becomes a constructible one. It was the default
until 0.5.0; it is now something you choose deliberately.
Need different schemes for different models? Override the encoder per model — the global default stays in place, and one model opts into another scheme:
#[Polyslug(source: 'username', encoder: \Polyslug\Encoders\UuidEncoder::class)]
class Profile extends Model implements Sluggable
{
use HasPolyslug;
}
The shipped encoders
| Encoder | Key type | What the URL reveals |
|---|---|---|
SqidsEncoder | integer | the primary key (reversible), creation order, growth rate |
UuidEncoder | UUID | nothing (with a random UUIDv4) |
UlidEncoder | ULID | creation time (ULIDs are time-ordered) |
RawIdEncoder | integer | the raw primary key — for internal tooling only |
RandomTokenEncoder (default) | any | nothing — an unguessable random token stored in polyslug_tokens |
SequentialTokenEncoder | any | the record count and roughly when this record appeared — the shortest token there is |
SqidsEncoder
Encodes a non-negative integer key with Sqids. A Sqids token is reversible by anyone who knows the alphabet, and the token space still leaks row count and growth — treat it as a tidy URL, not as a privacy control.
Its alphabet and minimum length are configured globally under
polyslug.sqids, or per model with
encoderOptions:
#[Polyslug(source: 'title', encoderOptions: ['alphabet' => 'k3G7...', 'min_length' => 8])]
A custom alphabet shuffles the token space, so set one once and keep it stable —
changing it changes every previously generated URL. Per-model options give one model its
own token space and are ignored unless the effective encoder is SqidsEncoder — the
stored-token encoders take length and alphabet instead, see
Token length.
Encoding a key it cannot represent throws InvalidArgumentException, and there are three
such keys: one that is not an integer, one that is negative, and one whose digits exceed
PHP_INT_MAX. The third is the one worth naming, because it used to be silent: a string of
digits is accepted when every character is a digit, and PHP's (int) cast saturates
rather than failing — so '9223372036854775808' and PHP_INT_MAX produced the same token,
and two records shared one URL. Leading zeros are unaffected: '007' is still the same key
as 7.
UuidEncoder and UlidEncoder
Pass-through encoders for models already keyed by a UUID or a ULID: the key is opaque and URL-safe, so it travels verbatim, and decoding validates the shape before any query runs.
A UUIDv4 reveals nothing. An ordered UUID (UUIDv7) and every ULID embed their creation
timestamp, so prefer UUIDv4 when creation time must stay private. Encoding a key that is
not a valid UUID or ULID throws InvalidArgumentException.
RawIdEncoder
The key appears in the URL verbatim. This leaks the sequential primary key — row count, growth rate, ordering — and it lets a visitor probe neighboring records by incrementing the number. It exists for parity with the framework default and for internal tooling; never put it on a public, enumeration-sensitive route.
RandomTokenEncoder
Each key maps to an unguessable random token kept in the polyslug_tokens table, 16
characters by default. The URL reveals nothing about the key — no count, order, or value —
which makes it the leak-free answer for integer-keyed tables that cannot be re-keyed to
UUIDs. The token is stable per key (one row), so each record keeps a single canonical URL.
The table ships with the package migrations and stays empty until a model uses this encoder.
SequentialTokenEncoder
The same stored mapping, filled differently: instead of drawing at random it hands out the
shortest token not yet taken — 0, 1, … z, then 00. A hundred records still fit in
two characters, which is the shortest a URL can be and what a link shortener is after.
It is predictable, and that is the whole trade. The token after k3f8 is k3f9, so
the set can be walked, and the token 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, RandomTokenEncoder is the one that makes it unguessable.
Because the mapping is stored rather than computed, switching to it over a table that already holds random tokens starts counting past them — every existing URL keeps resolving.
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 numbers them roughly in creation order; a bulk import that never renders a link
numbers nothing. Model::polyslugPreload($records) numbers a batch in the order given.
Token length
Both stored-token encoders take a length, globally or per model:
// config/polyslug.php
'random_token' => ['length' => 8, 'alphabet' => null],
'sequential_token' => ['length' => 1, 'alphabet' => null],
#[Polyslug(source: 'title', encoderOptions: ['length' => 8])]
It is a floor, not a fixed width. A length whose space fills up yields to one character
more rather than failing to issue a URL, so a short length is a real choice and not one
that surfaces months later as an error while a page is rendering. polyslug:doctor
reports how full each space is before that happens.
| Length | Tokens that exist |
|---|---|
| 4 | ~1.7 million |
| 6 | ~2.2 billion |
| 8 | ~2.8 trillion |
| 10 | ~3.7 quadrillion |
| 16 | ~8.0 × 10²⁴ |
Which is right depends on what the URL has to withstand. Where the route is authorized anyway, the token only identifies a record and a short one costs nothing. Where the link itself is the access control, the table is the number of tries it takes.
Changing the length is safe at any time: a token is looked up, never recomputed from the key, so URLs already issued keep resolving and only new records use the new length.
The alphabet defaults to 0-9a-z. Pass your own to change it — it must be made of
URL-unreserved characters (A-Z a-z 0-9 - . _ ~) and must not repeat one.
Adding A-Z doubles the space a token draws from, and from 0.15.0 it really does: a
migration pins the token columns to a byte-exact collation, so abc123 and AbC123 are
two tokens on every engine.
Before 0.15.0 that held on PostgreSQL and SQLite but not on MySQL. The column stated no
collation, so it inherited the connection's — case-insensitive by default. A 62-character
alphabet was counted as 36, which at the default length of 16 is a factor of roughly 6,000
— (62/36)^16, or 2^12.5 — and /go/ABC123 resolved to the record holding abc123.
That lookup no longer resolves, which is the correction rather than a side effect: it
never resolved on the other two engines, so the same request had two answers depending on
what was underneath.
The migration only ever splits equivalence classes, so a unique index that held before still holds and no row is rewritten. It is a no-op on PostgreSQL and SQLite, which already compare byte-exactly.
polyslug_tokens is keyed by (morph type, primary-key value), so Page#1 and
Wishlist#1 hold different tokens and a token addressed to one model type resolves to
null — a clean 404 — on another.
Until 0.11.0 the table was keyed by the id alone. Every pair of tables collided at id 1, so knowing one model's URL was enough to construct another model's URL for the same id, and the resolution gate was the only thing left deciding whether anything was disclosed. Scope that gate on anything that is not public regardless — it answers a different question, may this viewer see this record, and no amount of token separation answers that one.
Keeping tokens out of exception messages
A drawn token is the one value in polyslug_tokens worth keeping private — a leak-free URL is
the whole reason RandomTokenEncoder is the default. Tokens are bound into the insert, and a
QueryException on that statement interpolates its bindings into the message: deadlock,
connection loss, a constraint violation. That message is what failed_jobs.exception stores and
what most APM agents record on the span.
On Laravel 13.27 or newer the framework can leave the placeholders in place instead:
// config/database.php, on the connection that holds polyslug_tokens
'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', true),
It is the host application's setting, on the host application's connection, so Polyslug neither
ships nor publishes it — config/polyslug.php does not own your database connections. The
package's own floor is Laravel 13.0, so treat this as an operational hardening step available to
you, not something Polyslug can turn on.
Canonical tokens only
Non-canonical tokens — a wrong length, leading zeros, a re-encoded alias — resolve to a
clean 404 rather than silently pointing at the same record twice, so every record has
exactly one canonical URL. decode() returning null always means not found; it is
never a cue to guess a nearby key.
Writing your own
Implement Polyslug\Contracts\IdentityEncoder:
interface IdentityEncoder
{
public function encode(int|string $id): string;
public function decode(string $token): int|string|null; // null → 404
}
Then point polyslug.encoder (or a model's encoder option) at your class. See the
contracts reference.
Migrating encoders
Changing polyslug.encoder re-encodes every URL. List the previous encoder in
polyslug.legacy_decoders so old links keep resolving — the current encoder is tried
first, then each legacy decoder in order, and the canonical middleware 301s the resolved
model to its new-format URL on the next visit:
'encoder' => RandomTokenEncoder::class,
'legacy_decoders' => [SqidsEncoder::class], // old Sqid URLs still resolve, then self-heal
Keep the legacy entry in place for as long as old links are worth honoring; removing it
turns those URLs into 404s.
Upgrading from 0.4.x
The default changed to RandomTokenEncoder in 0.5.0. An application that published
config/polyslug.php keeps whatever that file says — nothing changes until you edit it.
An application that never published it picks up the new default on upgrade, and its
existing URLs would stop resolving. Pin the old encoder or take the migration:
// Keep the old URLs exactly as they are:
'encoder' => SqidsEncoder::class,
// …or move to the leak-free default and let old links self-heal:
'encoder' => RandomTokenEncoder::class,
'legacy_decoders' => [SqidsEncoder::class],
The second is the recommended path: old links keep working and are 301ed to the new
format as they are visited, so there is no flag day and no broken bookmark.
Upgrading to 0.11
polyslug_tokens gained a key_type column and its unique index widened from key_value
to (key_type, key_value). Run the migration — it ships with the package, so
php artisan migrate is the whole upgrade:
php artisan vendor:publish --tag=polyslug-migrations # only if you publish migrations
php artisan migrate
Existing tokens are migrated, not reissued. The migration reads each token's owner from
polyslug_slugs, which already records the (type, id) pair, so a record keeps the token
its published URLs contain. Where several model types claim one id — the very collision the
change removes — the oldest slug row wins, because whoever published first is the answer
that breaks the fewest links. The other model has no token of its own to keep; it was
borrowing that one, and it mints a fresh one the next time its URL is rendered.
A token whose model has no slug row cannot be attributed, so it stays in the untyped lane and keeps resolving. The first time that record's URL is rendered, the store adopts the row rather than minting a second token — which is what keeps that URL alive.
Nothing to change in your code, and nothing to configure. Reads fall back to the untyped lane, so a token minted before the upgrade resolves whether or not the backfill could attribute it.
Adding the column without the backfill would leave no row matching (type, id), so the
first render after deploying would mint a new token for every record and every published
URL would stop resolving. The backfill is the substance of that migration, not a courtesy.