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).
canAddress() — optional, and deliberately not on the interface
public function canAddress(Sluggable $model): bool
Implement it to say this record has no public address; leave it out and nothing changes.
url() returns a string, so a resolver that cannot address a record has no way to say so
except by throwing — and polyslug:sitemap walks every configured type, so one throw used
to end the whole run: no document at all, rather than one entry fewer. Declaring the method
on the interface would break every existing implementation, so the command reaches it
through method_exists() instead — the same additive shape the model trait uses for
polyslugRobotsDirective() and polyslugLastModified().
A throw is still survived, because a resolver can fail for reasons nobody declared. What differs is what the operator is told: a declared refusal is silent, a throw is counted and reported. Only one of the two is a decision somebody made.
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.
BulkIdentityDecoder
The counterpart of BulkIdentityEncoder: an optional companion for
an encoder that can turn many tokens back into keys in one round trip.
interface BulkIdentityDecoder extends IdentityEncoder
{
/** @param list<string> $tokens @return array<array-key, int|string> */
public function decodeMany(array $tokens): array;
/** @param list<string> $tokens @return array<array-key, int|string> */
public function decodeManyWithin(string $type, array $tokens): array;
}
Both return the key of each token, keyed by the token. They must answer what decode() and
decodeWithin() answer one token at a time, and a token those answer with null is absent from
the result, so an unknown token and one of another model type are missing. PHP keeps a
numeric token as an integer key; a lookup by the token string finds it either way.
RandomTokenEncoder and SequentialTokenEncoder implement it, and
Model::polyslugResolveMany()
is what calls it for you. An encoder that does not implement it is asked one token at a time.
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 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 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);
SitemapSource
Polyslug\Contracts\SitemapSource — the records a sitemap service
lists, each with the key that orders it:
interface SitemapSource
{
public function name(): string;
/** @return iterable<int, SitemapRecord> */
public function records(string $from = '', ?string $until = null): iterable;
}
name() names the parts of the source, so products writes products-1.xml. records() yields
the records whose keys run from $from, inclusive, up to $until, exclusive, in the byte order of
their keys. A SitemapRecord carries the key, every address of the record by locale or hreflang
code (x-default among them where there is one) and an optional lastmod. A key never changes.
Polyslug\Sitemaps\SluggableSource is the implementation for sluggable models.
SitemapStorage
Polyslug\Contracts\SitemapStorage — where the parts of a sitemap are kept. A sitemap writes in
one go: begin(), put() for every part it writes and trim() for every source whose parts it
settled, then commit() or rollBack().
interface SitemapStorage
{
/** @return list<SitemapPart>|null */
public function parts(): ?array;
public function body(string $source, int $number): ?string;
public function name(string $source, int $number): string;
public function begin(): void;
public function put(SitemapPart $part): void;
public function trim(string $source, int $parts): void;
/** @param Closure(list<SitemapPart>): string $entry */
public function commit(Closure $entry): void;
public function rollBack(): void;
}
parts() lists every part without its body, or answers null for a storage that keeps no record
of where each part starts, whose sitemap every rebuild then writes whole. Read inside a write, it is
what that write plans from, so a storage that several processes write makes the second write wait
there. name() is the name a part is served under, relative to the sitemap's root. trim() settles
a source at a number of parts and removes the rest. $entry renders the document a sitemap of the
given parts is entered by, for a storage that keeps that document itself, as files do.
FileSitemapStorage and DatabaseSitemapStorage are the two the package ships.
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 default shape of a slug
Polyslug::split('my-page_aB3xK'); // ['my-page', 'aB3xK']
Polyslug::compose('my-page', 'aB3xK'); // 'my-page_aB3xK'
Polyslug::isValidSlug('my-page'); // bool: has the default shape
SLUG_PATTERN is the shape a model generates under the attribute's defaults: lowercase ASCII
words joined by -. A model that sets unicode: 'native', preserveCase: true or another
separator generates slugs outside it (über-uns, Hello-World, hello.world), and so does
a slug-only model whose empty source falls back to its encoded identity. isValidSlug()
refuses those, so it is no test of whether a slug could belong to such a model.
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:
| Property | Type | Meaning |
|---|---|---|
source | string | The raw text to slugify — already read from the model's configured source attribute. |
sluggableType | string | The model's morph class, i.e. the type half of the uniqueness key. |
locale | string | The locale this slug is being written for. |
scope | string | The resolved uniqueness scope, '' when the model is not scoped. |
exceptId | string|null | Exclude 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. |
reserved | list<string>|null | The 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. |