Skip to main content

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,
Sqids is obfuscation, not security

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

EncoderKey typeWhat the URL reveals
SqidsEncoderintegerthe primary key (reversible), creation order, growth rate
UuidEncoderUUIDnothing (with a random UUIDv4)
UlidEncoderULIDcreation time (ULIDs are time-ordered)
RawIdEncoderintegerthe raw primary key — for internal tooling only
RandomTokenEncoder (default)anynothing — an unguessable random token stored in polyslug_tokens
SequentialTokenEncoderanythe 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.

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

A mixed-case alphabet needs 0.15.0 on MySQL

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.

One token per record, not per id

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.

Do not skip the migration on an application with published token URLs

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.