Skip to main content

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

OptionDefaultDescription
sourceColumn(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.
transliterateSimpleTransliterationProfile::Simple (ü→u) or Din (ü→ue). See Transliteration and Unicode.
maxLengthnullTrim 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.
uniquetrueAppend -2, -3, … on a collision. Set to false to let records share a slug.
scopenullColumn(s) that scope uniqueness, e.g. tenant_id. See Uniqueness and scope.
reserved[]Slugs that may never be assigned (matched case-insensitively).
immutablefalseFreeze 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.
idLessfalseDrop the id suffix — the URL is the slug alone. See Slug-only URLs.
sluglessfalseDrop 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.

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() and setSlug() 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.