Sluggable models
Add the #[Polyslug] attribute, the HasPolyslug trait, and the Sluggable interface.
The attribute declares which column(s) the slug is built from:
#[Polyslug(source: 'title')]
class Page extends Model implements Sluggable
{
use HasPolyslug;
}
A slug is generated automatically when the model is saved. Changing the source supersedes the old slug, keeping the previous one as history.
Prefer to scaffold? Generate a pre-wired model:
php artisan make:polyslug Page
A model that uses the trait but carries neither the attribute nor a
polyslug() override throws
Polyslug\Exceptions\MissingPolyslugConfig — the configuration is required, not optional.
Attribute options
| Option | Default | Description |
|---|---|---|
source | — | Column(s) the slug is built from (string or array; arrays join with a space). Required, except on a slugless model. |
separator | '-' | Word separator within the slug. |
transliterate | Simple | TransliterationProfile::Simple (ü→u) or Din (ü→ue). See Transliteration and Unicode. |
maxLength | null | Trim the slug to at most this many characters (never mid-separator). It does not shorten the _token after it — that is the encoder's length. |
unique | true | Append -2, -3, … on a collision. Set to false to let records share a slug. |
scope | null | Column(s) that scope uniqueness, e.g. tenant_id. See Uniqueness and scope. |
reserved | [] | Slugs that may never be assigned (matched case-insensitively). |
immutable | false | Freeze the slug after first generation. See History, events and immutability. |
encoder | (global) | Override the identity encoder for this model only (a fully-qualified encoder class). |
onDelete | 'keep' | On soft-delete, 'keep' reserves the slug; 'release' frees it for reuse. A hard or force delete always cascades the slug rows. |
emptyFallback | 'id-only' | When the source has no sluggable characters (a CJK or emoji-only title), 'id-only' stores an empty slug so the URL is just _{id} and the save never fails; 'throw' raises CouldNotGenerateSlug. |
encoderOptions | [] | Per-model encoder settings — a dedicated token space for this model. SqidsEncoder: alphabet, min_length. RandomTokenEncoder / SequentialTokenEncoder: length, alphabet. A key the effective encoder does not understand is ignored. |
unicode | 'ascii' | 'native' keeps Unicode letters and numbers instead of transliterating them away. See Transliteration and Unicode. |
idLess | false | Drop the id suffix — the URL is the slug alone. See Slug-only URLs. |
slugless | false | Drop the slug — the URL is the token alone. The mirror image of idLess; setting both is refused. See Token-only URLs. |
The same list, with types, is in the attribute reference.
Reserved slugs
App-wide reserved slugs — merged with each model's own reserved — live in
polyslug.reserved.global. Use it so generated slugs never shadow sensitive words like
login, admin, or api:
// config/polyslug.php
'reserved' => [
'global' => ['login', 'admin', 'api'],
'from_routes' => false,
],
Set polyslug.reserved.from_routes to true to additionally reserve the static first
segment of every registered route, so a slug can never shadow a real route such as
/login or /admin. Segments that are route parameters are skipped.
A reserved base is not rejected — it is suffixed like any other collision, so a page
titled "Admin" becomes admin-2.
Opting a model out
Those lists only ever add. A model that sits behind a prefix by construction —
/@{owner}/{repo} — cannot shadow a route at all, because the prefix separates the
namespaces completely, so every inherited reservation there is a false positive. And it
fails quietly: the generator appends a counter suffix rather than refusing, so a
legitimately named record ends up as api-2. For externally assigned identifiers, api,
docs, demo and media are not the edge case — they are the middle.
Override polyslugReservedWords() to filter, replace or clear the inherited list:
// Take the name exactly as the upstream source assigned it.
public function polyslugReservedWords(array $inherited): array
{
return [];
}
// Or keep the list and edit it, which an on/off flag could not express.
public function polyslugReservedWords(array $inherited): array
{
return [
...array_values(array_filter($inherited, fn (string $w) => $w !== 'api')),
'house-rule',
];
}
It is offered everything the model inherits — its own reserved,
polyslug.reserved.global, and the route-derived words when from_routes is on. That last
part is the half a per-model reserved: [...] could never reach. Returning the argument
unchanged is the default, so a model that does not override it behaves exactly as before.
Methods on a sluggable model
$page->currentSlug(); // "a-deep-dive-into-laravel-routing"
$page->currentSlug('de'); // the German current slug, or null
$page->getRouteKey(); // "a-deep-dive-into-laravel-routing_aB3xK"
$page->slugLocales(); // ['en', 'de']
$page->slugHistory(); // superseded slugs, newest first
$page->setSlug('de', 'Titel'); // set/override a locale's slug explicitly
The full set — including polyslugRouteKeyForLocale(), polyslugPath(), shortLink()
and polyslugSync() — is in the model API reference.
Rendering a list of links
Eager-load the slugs relation and the links on a listing page cost one query for the whole
page instead of one per row:
$pages = Page::query()->with('slugs')->paginate();
foreach ($pages as $page) {
route('pages.show', $page); // no query per link
}
currentSlug(), polyslugRouteKey(), slugLocales() and everything built on them —
polyslugUrls(), hreflangLinks(), hreflangTags(), sitemapAlternateTags() and the
<head> integration — read the loaded collection when there is one.
Two things this deliberately does not do:
-
Writes never read it.
save(),polyslugSync()andsetSlug()decide against a row read fresh from the database, never against a collection loaded before the request started — a write must decide against what is current now. Where one of them has already taken that fresh read, it hands it on rather than asking twice; a retry after a failed write always asks again, because the retry exists precisely because the row moved.What that costs, measured: one indexed read per save — including a save that leaves the slug source untouched, because "is there a current row?" is exactly the question that decides whether the write may be skipped, and a model whose row is missing has to get one. Creating a model costs four queries in total, renaming one five, and a save that changes nothing else costs two.
It does not amortise across saves: four saves of the same model cost four reads. So the lever is the number of saves, not the package — a world-building routine that saves each model four times pays four times, and collapsing those into one save removes three reads that no configuration can.
-
slugHistory()keeps querying. History lives in the non-current rows, and a constrained eager load (with(['slugs' => fn ($q) => $q->where('is_current', true)])) would leave it looking at an empty past. A fast wrong answer is worse than a slow right one.
The default identity encoder stores its mapping, so
it reads its token table once per row the first time each model is encoded. polyslugPreload()
does that read for the whole set at once:
$pages = Page::query()->with('slugs')->paginate();
Page::polyslugPreload($pages); // one round trip for every token on the page
With both in place a rendered list issues no query per row. On an encoder that derives its
token from the key alone — Sqids, UUID, ULID, the raw key — polyslugPreload() is a no-op, and
silently so on purpose: you can write it without first knowing which encoder is configured.
Dynamic (per-tenant) configuration
For rules that vary at runtime — per-tenant reserved words, a per-environment encoder —
implement Polyslug\Contracts\ConfiguresPolyslug and return a PolyslugConfig from
polyslug(). It is resolved fresh on every use and takes precedence over the
#[Polyslug] attribute:
use Polyslug\Contracts\ConfiguresPolyslug;
use Polyslug\PolyslugConfig;
class Page extends Model implements Sluggable, ConfiguresPolyslug
{
use HasPolyslug;
public function polyslug(): PolyslugConfig
{
return PolyslugConfig::fromAttribute(new Polyslug(
source: 'title',
reserved: currentTenant()->reservedSlugs(),
));
}
}
Because the override is never cached, it can vary per request and per tenant. The attribute path stays cached, so a model that does not need dynamic rules pays nothing.