Skip to main content

Contracts

IdentityEncoder

Polyslug\Contracts\IdentityEncoder — maps a model key to and from the opaque token in its URL.

interface IdentityEncoder
{
public function encode(int|string $id): string;
public function decode(string $token): int|string|null; // null → 404
}

encode() throws InvalidArgumentException when the key is not supported by the encoder. decode() returning null means not found — callers must turn it into a 404 and must never treat it as a cue to guess a nearby key. Reject non-canonical tokens (anything that does not round-trip) so each record has exactly one canonical URL.

Bind it globally through polyslug.encoder, or per model through the encoder attribute option. See Identity encoders.

Sluggable

Polyslug\Contracts\Sluggable — implemented by every model that carries Polyslug slugs. Pair it with the HasPolyslug trait, which provides every method, and override only the hooks you need. The full method list is in the model API.

Type-hint this interface rather than a concrete model wherever your code accepts "anything sluggable" — the package does the same.

ConfiguresPolyslug

Polyslug\Contracts\ConfiguresPolyslug — implement it on a sluggable model to compute its configuration at runtime.

interface ConfiguresPolyslug
{
public function polyslug(): PolyslugConfig;
}

The returned config takes precedence over the static #[Polyslug] attribute and is resolved fresh on every use, never cached, so it can vary per request and per tenant. Build one with PolyslugConfig::fromAttribute() to keep the option names identical to the attribute. See Dynamic configuration.

PolyslugUrlResolver

Polyslug\Contracts\PolyslugUrlResolver — turns a sluggable model plus a locale into its absolute canonical URL.

interface PolyslugUrlResolver
{
public function url(Sluggable $model, string $locale): string;
}

The package cannot know your URL structure, so bind an implementation in the container. Both the sitemap generator and the /go short-link controller use it, so binding it once enables both. Typically it wraps route() or url() with the model's polyslugRouteKeyForLocale($locale).

SlugGenerator

Polyslug\Contracts\SlugGenerator — produces a unique, URL-safe slug for a request and configuration.

interface SlugGenerator
{
public function generate(SlugRequest $request, PolyslugConfig $config): string;
}

Polyslug\Generators\DefaultSlugGenerator is bound by default and implements transliteration, the reserved list, the uniqueness suffix and the empty-source fallback. Rebind it only when you need a fundamentally different slug grammar; everything reachable through the attribute options is configuration, not a reason to replace the generator.

It throws Polyslug\Exceptions\CouldNotGenerateSlug when the source yields an empty slug and emptyFallback is 'throw'.

BulkIdentityEncoder

An optional companion to IdentityEncoder, for an encoder that keeps its mapping in a store and can therefore answer many keys in one round trip:

interface BulkIdentityEncoder extends IdentityEncoder
{
/** @param list<int|string> $ids @return array<string, string> */
public function encodeMany(array $ids): array;
}

It is a second interface rather than a method on IdentityEncoder, so an encoder you wrote yourself keeps satisfying its contract with no change. Callers check for it and fall back to encode() per key when it is absent — which is why the query-free encoders do not implement it.

encodeMany() must return exactly what encoding one key at a time would: the same tokens and the same behavior when a concurrent writer claims a key first. It optimizes the round trips, never the guarantees.

RandomTokenEncoder implements it, and Model::polyslugPreload() is what calls it for you.

StoresTokensPerRecord

A second optional companion to IdentityEncoder, for an encoder whose token is stored against a record rather than computed from its key:

interface StoresTokensPerRecord extends IdentityEncoder
{
public function encodeWithin(string $type, int|string $id): string;

public function decodeWithin(string $type, string $token): int|string|null;

/** @param list<int|string> $ids @return array<string, string> */
public function encodeManyWithin(string $type, array $ids): array;
}

The distinction it encodes is not academic. A computed token — Sqids, a UUID, the raw key — is a function of the key alone, so two models both holding id 1 unavoidably produce the same token and no encoder could prevent it. A stored token is a row, and a row can record who it belongs to. This interface is how an encoder says it can, and Polyslug then hands it the morph type so Page#1 and Wishlist#1 get different tokens.

decodeWithin() returns null when the token belongs to another type or to nothing, which makes a token borrowed from a different model a clean 404 rather than a lookup against this model's own ids.

It extends IdentityEncoder rather than replacing it, so an encoder written against the older contract keeps working untouched — it simply keeps one shared token space, which is what it always had. The inherited untyped methods stay meaningful here too: they address the untyped lane, where tokens issued before this contract existed live, and that is what a legacy decoder reaches through.

RandomTokenEncoder and SequentialTokenEncoder implement it. The query-free encoders cannot, for the reason in the first paragraph.

ProvidesAddressLocales

An optional contract for a model whose addresses are not the same set as its slugs:

interface ProvidesAddressLocales
{
/** @return list<string> */
public function polyslugAddressLocales(): array;
}

Polyslug normally derives every URL set — polyslugUrls(), the hreflang links, the <head> tags, the sitemap entries — from slugLocales(), the locales that hold a slug. For most models that is right: a record with a German and an English slug is reachable in German and English.

It stops being right when one slug is served under several addresses. A project may pin each slug to a single locale on purpose — slug sources are often single-language user content such as a name, and minting a second slug row for identical text only fragments the canonical URL — while still routing every record under a locale prefix:

/u/lena (en, also x-default)
/de/u/lena (de)

slugLocales() reports one entry there and always will, so the second address appears in no hreflang set and in no sitemap. Nothing fails; the address is simply never announced. That is the expensive direction, because hreflang in a page head is read only after a page has been fetched — an address that no sitemap names and no crawled page links may never be fetched.

Declaring the addresses fixes both at once, because the sitemap and the hreflang set are built from the same list:

final class Account extends Model implements ProvidesAddressLocales, Sluggable
{
use HasPolyslug;

public function polyslugAddressLocales(): array
{
return ['en', 'de'];
}
}

Like BulkIdentityEncoder, it is a separate interface rather than a method on Sluggable, so a model you already wrote keeps satisfying its contract with no change — without the interface, the locales still come from the slug rows.

A locale listed here still passes through polyslugIsRoutable(), so gating is unchanged, and its route key resolves through the ordinary missing-locale fallback (polyslug.locale.missing, fallback by default) — which is what lets one slug serve several addresses.

TokenScheme

Decides what a newly issued token looks like — the one thing that differs between an unguessable URL and a counted one:

interface TokenScheme
{
/** @param Closure(): int $issued */
public function draw(int $attempt, Closure $issued): string;

public function length(): int;

public function alphabet(): TokenAlphabet;
}

It is deliberately not an IdentityEncoder. An encoder answers which record is this token and must therefore be able to decode; a scheme only ever proposes a candidate, and the store it proposes to is what remembers the answer. That split is why the same two schemes serve both the stored-token encoders and the /go/{token} short link, which has its own token space.

$attempt is 0-based and counts how often this scheme's candidate lost a race to another token — a lost race for the same key returns the winner's token instead of looping — so it measures how contended the current output length is, and an implementation may use it to yield to a longer one. $issued is a lazy closure rather than a number, because answering it costs a query and a random scheme never needs to ask.

length() and alphabet() exist for diagnostics: they are what polyslug:doctor reads to say how full a token space is before it fills.

Bind it in a service provider to replace the scheme entirely:

$this->app->bind(TokenScheme::class, fn () => new MyScheme);

Polyslug — the route-key grammar

Not a contract to implement but the class that defines the grammar, and the one to reach for instead of hand-rolling strrpos() when you need to read a route key yourself:

use Polyslug\Polyslug;

Polyslug::DELIMITER; // '_' — what separates the slug from the token
Polyslug::SLUG_PATTERN; // the pattern a generated slug must match
Polyslug::split('my-page_aB3xK'); // ['my-page', 'aB3xK']
Polyslug::compose('my-page', 'aB3xK'); // 'my-page_aB3xK'
Polyslug::isValidSlug('my-page'); // bool

split() divides at the last delimiter, which is what makes a slug containing _ safe. Use these rather than reimplementing the split: the delimiter is one constant here and in the route-key builder, so a parser written against them cannot drift from the URLs the package produces.

SlugRequest — the generator's input

The value object SlugGenerator receives. Implementing your own generator means reading every property below, and two of them are easy to get wrong:

PropertyTypeMeaning
sourcestringThe raw text to slugify — already read from the model's configured source attribute.
sluggableTypestringThe model's morph class, i.e. the type half of the uniqueness key.
localestringThe locale this slug is being written for.
scopestringThe resolved uniqueness scope, '' when the model is not scoped.
exceptIdstring|nullExclude this model's own rows from the collision check. Without honoring it, a model renamed back to a slug it already holds collides with itself and the generator appends a suffix forever.
reservedlist<string>|nullThe reserved words this slug must not become, already resolved from the model's own reserved, the global list and — when enabled — the route-derived one. null means the caller did not resolve a list, not that the list is empty. A generator that ignores it happily issues a slug that shadows a route.